Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/forge/src/generated/stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export async function runForgeEngineAttempt(opts: {engineId:string,prompt:string
// Forge is a code-changing workflow. API-only engines must run through
// the tool-using agent loop; plain API chat can only return text and will
// never modify the worktree.
const useAgent = !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api);
const shouldUseAgentLoop = forgeMode === 'implement' || forgeMode === 'improve' || requireDiff;
const useAgent = shouldUseAgentLoop && !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api);
const onSpawn = (pid: number) => emitForgeEvent(opts.onEvent, 'engine:pid', opts.engineId, { engineId: opts.engineId, pid, phase: opts.metricPhase });
try {
if (useAgent) {
Expand Down Expand Up @@ -298,7 +299,7 @@ export async function runForgeEngineAttempt(opts: {engineId:string,prompt:string
return { result, metric, dispatchResult, worktreePath: wtPath };
}

// @kern-source: stages:276
// @kern-source: stages:277
export async function runBaseline(opts: {cwd:string, baseSha:string, fitnessCmd:string, fitnessTimeout:number, forgeDir:string, onEvent?:ForgeEventCallback}): Promise<boolean> {
opts.onEvent?.({ type: 'baseline:start' });

Expand All @@ -322,7 +323,7 @@ export async function runBaseline(opts: {cwd:string, baseSha:string, fitnessCmd:
}
}

// @kern-source: stages:300
// @kern-source: stages:301
export async function runStage1(opts: {starter:string, forgePrompt:string, fitnessCmd:string, config:Required<AgonConfig>, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, taskClass?:string, enginePrompts?:Map<string,string>, forgeMode?:'implement'|'improve'|'validate', requireDiff?:boolean, baselinePasses?:boolean, acceptReviewOutput?:boolean}): Promise<StageResult> {
opts.onEvent?.({ type: 'stage1:start', engineId: opts.starter });
const root = repoRoot(opts.cwd);
Expand Down Expand Up @@ -352,7 +353,7 @@ export async function runStage1(opts: {starter:string, forgePrompt:string, fitne
return { engineResults, accepted, winner: result?.pass ? result.engineId : null, metrics };
}

// @kern-source: stages:330
// @kern-source: stages:331
export async function runStage2(opts: {challengers:string[], forgePrompt:string, enginePrompts?:Map<string,string>, fitnessCmd:string, config:Required<AgonConfig>, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, existingResults:Map<string,EngineResult>, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, onResult?:(engineId:string,result:EngineResult,metric:DispatchMetric)=>'continue'|'finalize'|void, abortControllers?: Map<string,AbortController>, forgeMode?: 'implement'|'improve'|'validate', requireDiff?: boolean, baselinePasses?: boolean, acceptReviewOutput?: boolean, earlyFinalizeCount?: number}): Promise<StageResult> {
opts.onEvent?.({ type: 'stage2:start' });

Expand Down Expand Up @@ -551,7 +552,7 @@ export async function runStage2(opts: {challengers:string[], forgePrompt:string,
return { engineResults: allResults, accepted: false, winner: null, metrics };
}

// @kern-source: stages:529
// @kern-source: stages:530
export async function runStage2WithPeek(opts: {challengers:string[], forgePrompt:string, enginePrompts?:Map<string,string>, fitnessCmd:string, config:Required<AgonConfig>, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, existingResults:Map<string,EngineResult>, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, forgeMode?:'implement'|'improve'|'validate', requireDiff?:boolean, baselinePasses?:boolean, acceptReviewOutput?:boolean}): Promise<StageResult> {
if (opts.challengers.length <= 1) {
// Only one challenger — no peek possible, use normal stage2
Expand Down
3 changes: 2 additions & 1 deletion packages/forge/src/kern/stages.kern
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ fn name=runForgeEngineAttempt async=true params="opts:{engineId:string,prompt:st
// Forge is a code-changing workflow. API-only engines must run through
// the tool-using agent loop; plain API chat can only return text and will
// never modify the worktree.
const useAgent = !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api);
const shouldUseAgentLoop = forgeMode === 'implement' || forgeMode === 'improve' || requireDiff;
const useAgent = shouldUseAgentLoop && !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api);
const onSpawn = (pid: number) => emitForgeEvent(opts.onEvent, 'engine:pid', opts.engineId, { engineId: opts.engineId, pid, phase: opts.metricPhase });
try {
if (useAgent) {
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/forge-finalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,110 @@ function makeRegistry(): EngineRegistry {
return registry;
}

describe('forge validate dispatch routing', () => {
it('uses plain dispatch, not agent tools, for validate mode without required diff', async () => {
process.env.AGON_FINALIZE_TEST_KEY = 'test';
const repoDir = makeRepo();
const forgeDir = join(tmpdir(), `agon-forge-validate-route-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDirs.push(forgeDir);
mkdirSync(forgeDir, { recursive: true });

const registry = new EngineRegistry();
registry.register({
id: 'api-agent',
displayName: 'api-agent',
api: { apiKeyEnv: 'AGON_FINALIZE_TEST_KEY' },
agent: { args: [] },
exec: { args: [] },
review: { args: [] },
timeout: 120,
tier: 'user',
schemaVersion: 3,
isLocal: false,
} as any);

const dispatch = vi.fn(async () => ({
exitCode: 0,
stdout: 'VALIDATE_ROUTE_OK '.repeat(8),
stderr: '',
timedOut: false,
}));
const dispatchAgent = vi.fn(async () => {
throw new Error('dispatchAgent should not run for validate/no-diff');
});

const manifest = await runForge(
{
task: 'validate without editing',
fitnessCmd: 'true',
cwd: repoDir,
forgeDir,
engines: ['api-agent'],
mode: 'validate',
requireDiff: false,
acceptReviewOutput: true,
baselineMayPass: true,
healthCheckEnabled: false,
} as any,
registry,
{ isAvailable: async () => true, getVersion: async () => 'test', dispatch, dispatchAgent } as any,
);

expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatchAgent).not.toHaveBeenCalled();
expect(manifest.results['api-agent']).toMatchObject({ pass: true, status: 'no_diff_but_report' });
});

it('still uses agent tools for validate mode when a diff is required', async () => {
process.env.AGON_FINALIZE_TEST_KEY = 'test';
const repoDir = makeRepo();
const forgeDir = join(tmpdir(), `agon-forge-validate-agent-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDirs.push(forgeDir);
mkdirSync(forgeDir, { recursive: true });

const registry = new EngineRegistry();
registry.register({
id: 'api-agent',
displayName: 'api-agent',
api: { apiKeyEnv: 'AGON_FINALIZE_TEST_KEY' },
agent: { args: [] },
exec: { args: [] },
review: { args: [] },
timeout: 120,
tier: 'user',
schemaVersion: 3,
isLocal: false,
} as any);

const dispatch = vi.fn(async () => ({ exitCode: 0, stdout: 'plain', stderr: '', timedOut: false }));
const dispatchAgent = vi.fn(async ({ cwd }: any) => {
writeFileSync(join(cwd, 'agent-change.txt'), 'changed by agent\n');
return { exitCode: 0, stdout: 'agent changed files', stderr: '', timedOut: false };
});

const manifest = await runForge(
{
task: 'validate but require a diff',
fitnessCmd: 'test -f agent-change.txt',
cwd: repoDir,
forgeDir,
engines: ['api-agent'],
mode: 'validate',
requireDiff: true,
acceptReviewOutput: true,
baselineMayPass: true,
healthCheckEnabled: false,
} as any,
registry,
{ isAvailable: async () => true, getVersion: async () => 'test', dispatch, dispatchAgent } as any,
);

expect(dispatchAgent).toHaveBeenCalledTimes(1);
expect(dispatch).not.toHaveBeenCalled();
expect(manifest.results['api-agent']).toMatchObject({ pass: true });
});
});

describe('caller-driven forge finalize', () => {
it('aborts in-flight stage2 engines when onResult requests finalize', async () => {
process.env.AGON_FINALIZE_TEST_KEY = 'test';
Expand Down