From 81f711db3c1f98a3e444834fcb14efb6591b2080 Mon Sep 17 00:00:00 2001 From: HexaField <10372036+HexaField@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:40:25 +1100 Subject: [PATCH] Checkpoint from VS Code for coding agent session --- src/client/src/lib/codingAgentClient.test.ts | 4 +- src/modules/agent/agent-orchestrator.ts | 318 ++++++++----------- src/modules/agent/workflow-schema.ts | 2 +- src/modules/workflowAgentExecutor.test.ts | 11 +- src/runner/workflowRunner.ts | 7 +- 5 files changed, 155 insertions(+), 187 deletions(-) diff --git a/src/client/src/lib/codingAgentClient.test.ts b/src/client/src/lib/codingAgentClient.test.ts index e4e0eaf..5f40377 100644 --- a/src/client/src/lib/codingAgentClient.test.ts +++ b/src/client/src/lib/codingAgentClient.test.ts @@ -79,11 +79,11 @@ describe('coding agent client helpers', () => { it('posts messages to sessions', async () => { const detail = { run: { id: 'ses_test', agents: [], log: [], createdAt: 'now', updatedAt: 'now' } } fetchJsonMock.mockResolvedValue(detail) - await postCodingAgentMessage('ses_test', { text: 'Hello' }) + await postCodingAgentMessage('/repo', 'ses_test', { text: 'Hello' }) expect(fetchJsonMock).toHaveBeenCalledWith('/api/coding-agent/sessions/ses_test/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'user', text: 'Hello' }) + body: JSON.stringify({ workspacePath: '/repo', role: 'user', text: 'Hello' }) }) }) }) diff --git a/src/modules/agent/agent-orchestrator.ts b/src/modules/agent/agent-orchestrator.ts index a9e4372..70b7afb 100644 --- a/src/modules/agent/agent-orchestrator.ts +++ b/src/modules/agent/agent-orchestrator.ts @@ -10,39 +10,18 @@ import { recordUserMessage, saveRunMeta } from '../provenance/provenance' -import { - AgentRunResponse, - AgentStreamCallback, - coerceString, - invokeStructuredJsonCall, - parseJsonPayload -} from './agent' +import { AgentRunResponse, AgentStreamCallback, invokeStructuredJsonCall, parseJsonPayload } from './agent' import { createSession, getSession, getSessionDiff } from './opencode' import { AgentWorkflowDefinition, WorkflowCondition, WorkflowFieldCondition, WorkflowOutcomeTemplate, - WorkflowRoleParser, WorkflowStepDefinition, WorkflowTransitionDefinition, workflowDefinitionSchema } from './workflow-schema' -export type WorkerStructuredResponse = { - status: 'working' | 'done' | 'blocked' - plan: string - work: string - requests: string -} - -export type VerifierStructuredResponse = { - verdict: 'instruct' | 'approve' | 'fail' - critique: string - instructions: string - priority: number -} - export type AgentWorkflowRunOptions = { userInstructions: string sessionDir: string @@ -52,12 +31,6 @@ export type AgentWorkflowRunOptions = { onStream?: AgentStreamCallback } -type ParserOutputMap = { - worker: WorkerStructuredResponse - verifier: VerifierStructuredResponse - passthrough: unknown -} - export type WorkflowRoleName = keyof TDefinition['roles'] & string type ParserLookup = { @@ -67,43 +40,47 @@ type ParserLookup = { type RoundStepDefinition = TDefinition['flow']['round']['steps'][number] type RoundStepKey = RoundStepDefinition['key'] type BootstrapStepDefinition = - TDefinition['flow']['bootstrap'] extends WorkflowStepDefinition ? TDefinition['flow']['bootstrap'] : never + NonNullable extends WorkflowStepDefinition + ? NonNullable + : never type ParserForRole< TDefinition extends AgentWorkflowDefinition, Role extends WorkflowRoleName > = ParserLookup[Role] -type ParserOutputForRole< +/** @todo fix this */ +// type ParserOutputForRole< +// TDefinition extends AgentWorkflowDefinition, +// Role extends WorkflowRoleName +// > = ParserOutputMap[ParserForRole] + +type WorkflowTurnForStep< TDefinition extends AgentWorkflowDefinition, - Role extends WorkflowRoleName -> = ParserOutputMap[ParserForRole] - -type RoundStepTurn = - RoundStepDefinition extends infer Step - ? Step extends WorkflowStepDefinition - ? { - key: Step['key'] - role: Step['role'] - round: number - raw: string - parsed: ParserOutputForRole> - } - : never - : never + TStep extends WorkflowStepDefinition +> = TStep extends WorkflowStepDefinition + ? { + key: TStep['key'] + role: TStep['role'] + round: number + raw: string + parsed: unknown //ParserOutputForRole> + } + : never -type BootstrapTurn = - BootstrapStepDefinition extends infer Step - ? Step extends WorkflowStepDefinition - ? { - key: Step['key'] - role: Step['role'] - round: number - raw: string - parsed: ParserOutputForRole> - } - : never - : never +type RoundStepTurn = WorkflowTurnForStep< + TDefinition, + RoundStepDefinition +> + +type BootstrapTurn = WorkflowTurnForStep< + TDefinition, + BootstrapStepDefinition +> + +type AnyWorkflowTurn = + | RoundStepTurn + | BootstrapTurn export type AgentWorkflowTurn = | RoundStepTurn @@ -121,26 +98,6 @@ export type AgentWorkflowResult> } -type RuntimeWorkflowTurn = { - key: string - role: string - round: number - raw: string - parsed: any -} - -type RuntimeWorkflowRound = { - round: number - steps: Record -} - -type RuntimeWorkflowResult = { - outcome: string - reason: string - bootstrap?: RuntimeWorkflowTurn - rounds: RuntimeWorkflowRound[] -} - export function loadWorkflowDefinition(filePath: string): AgentWorkflowDefinition { const resolved = path.isAbsolute(filePath) ? filePath : path.join(__dirname, filePath) const contents = fs.readFileSync(resolved, 'utf8') @@ -212,23 +169,30 @@ async function ensureWorkflowSessions( return sessions } -type TemplateScope = { +type StepDictionary = Partial< + Record, RoundStepTurn> +> + +type TemplateScope = { user: { instructions: string } run: { id: string } state: Record - steps: Record - bootstrap?: RuntimeWorkflowTurn + steps: StepDictionary + bootstrap?: BootstrapTurn round: number maxRounds: number } -type StepAwareScope = TemplateScope & { - current?: RuntimeWorkflowTurn - parsed?: any +type StepAwareScope = TemplateScope & { + current?: AnyWorkflowTurn + parsed?: AnyWorkflowTurn['parsed'] raw?: string } -const cloneScope = (scope: TemplateScope, additions: Partial = {}): TemplateScope => ({ +const cloneScope = ( + scope: TemplateScope, + additions: Partial> = {} +): TemplateScope => ({ user: scope.user, run: scope.run, state: scope.state, @@ -239,7 +203,10 @@ const cloneScope = (scope: TemplateScope, additions: Partial = {} ...additions }) -const scopeWithStep = (scope: TemplateScope, step: RuntimeWorkflowTurn): StepAwareScope => ({ +const scopeWithStep = ( + scope: TemplateScope, + step: AnyWorkflowTurn +): StepAwareScope => ({ ...scope, current: step, parsed: step.parsed, @@ -262,7 +229,10 @@ const getValueAtPath = (source: any, pathExpression: string): any => { return current } -const evaluateExpression = (expression: string, scope: StepAwareScope | TemplateScope): string => { +const evaluateExpression = ( + expression: string, + scope: StepAwareScope | TemplateScope +): string => { const fallbacks = expression.split('||').map((segment) => segment.trim()) for (const segment of fallbacks) { if (!segment) continue @@ -296,18 +266,27 @@ const evaluateExpression = (expression: string, scope: StepAwareScope | Template return '' } -const renderTemplateString = (template: string, scope: StepAwareScope | TemplateScope): string => { +const renderTemplateString = ( + template: string, + scope: StepAwareScope | TemplateScope +): string => { return template.replace(/{{\s*([^}]+)\s*}}/g, (_, expr: string) => evaluateExpression(expr, scope)) } -const renderPrompt = (sections: ReadonlyArray, scope: TemplateScope): string => { +const renderPrompt = ( + sections: ReadonlyArray, + scope: TemplateScope +): string => { return sections .map((section) => renderTemplateString(section, scope).trim()) .filter(Boolean) .join('\n\n') } -const initializeState = (initial: Record | undefined, scope: TemplateScope): Record => { +const initializeState = ( + initial: Record | undefined, + scope: TemplateScope +): Record => { if (!initial) return {} const state: Record = {} for (const [key, value] of Object.entries(initial) as Array<[string, string]>) { @@ -317,8 +296,8 @@ const initializeState = (initial: Record | undefined, scope: Tem return state } -type StepExecutionContext = { - definition: AgentWorkflowDefinition +type StepExecutionContext = { + definition: TDefinition sessions: SessionMap model: string directory: string @@ -326,17 +305,11 @@ type StepExecutionContext = { onStream?: AgentStreamCallback } -const builtInParsers: Record any> = { - worker: (role, res) => parseWorkerResponse(role, res), - verifier: (role, res) => parseVerifierResponse(role, res), - passthrough: (role, res) => parseJsonPayload(role, res) -} - -const executeStep = async ( - step: WorkflowStepDefinition, - scope: TemplateScope, - ctx: StepExecutionContext -): Promise => { +const executeStep = async ( + step: TStep, + scope: TemplateScope, + ctx: StepExecutionContext +): Promise> => { const roleConfig = ctx.definition.roles[step.role] if (!roleConfig) { throw new Error(`Workflow role ${step.role} missing definition`) @@ -346,7 +319,7 @@ const executeStep = async ( throw new Error(`No session available for role ${step.role}`) } const prompt = renderPrompt(step.prompt, scope) - const parser = builtInParsers[roleConfig.parser] + const parser = parseJsonPayload(step.role, roleConfig.parser) const { raw, parsed } = await invokeStructuredJsonCall({ role: step.role, systemPrompt: roleConfig.systemPrompt, @@ -358,12 +331,18 @@ const executeStep = async ( onStream: ctx.onStream, parseResponse: (response) => parser(step.role, response) }) - return { key: step.key, role: step.role, round: scope.round, raw, parsed } + return { + key: step.key, + role: step.role, + round: scope.round, + raw, + parsed + } as WorkflowTurnForStep } -const applyStateUpdates = ( +const applyStateUpdates = ( updates: Record | undefined, - scope: StepAwareScope, + scope: StepAwareScope, state: Record ) => { if (!updates) return @@ -373,30 +352,33 @@ const applyStateUpdates = ( } } -type RoundNavigator = { - start: string - fallbackNext: Map - stepMap: Map +type RoundNavigator = { + start: RoundStepKey + fallbackNext: Map, RoundStepKey | undefined> + stepMap: Map, RoundStepDefinition> } -const createRoundNavigator = (round: AgentWorkflowDefinition['flow']['round']): RoundNavigator => { - const stepMap = new Map() - const fallbackNext = new Map() +const createRoundNavigator = ( + round: TDefinition['flow']['round'] +): RoundNavigator => { + const stepMap = new Map, RoundStepDefinition>() + const fallbackNext = new Map, RoundStepKey | undefined>() round.steps.forEach((step, index) => { - stepMap.set(step.key, step) - const fallback = round.steps[index + 1]?.key - fallbackNext.set(step.key, step.next ?? fallback) + const key = step.key as RoundStepKey + stepMap.set(key, step) + const fallback = round.steps[index + 1]?.key as RoundStepKey | undefined + fallbackNext.set(key, (step.next as RoundStepKey | undefined) ?? fallback) }) - const startKey = round.start ?? round.steps[0]?.key + const startKey = (round.start ?? round.steps[0]?.key) as RoundStepKey | undefined if (!startKey) { throw new Error('Workflow round must define at least one step.') } return { start: startKey, fallbackNext, stepMap } } -type TransitionResolution = +type TransitionResolution = | { type: 'outcome'; outcome: WorkflowOutcomeTemplate; stateUpdates?: Record } - | { type: 'next'; nextStep: string; stateUpdates?: Record } + | { type: 'next'; nextStep: RoundStepKey; stateUpdates?: Record } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null @@ -468,10 +450,10 @@ const valueMatchesPattern = (actual: unknown, pattern: string, caseSensitive?: b } } -const evaluateFieldCondition = ( +const evaluateFieldCondition = ( condition: WorkflowFieldCondition, - scope: StepAwareScope, - step: RuntimeWorkflowTurn + scope: StepAwareScope, + step: AnyWorkflowTurn ): boolean => { const targetBase = condition.field.startsWith('@') ? scope : step const expression = condition.field.startsWith('@') ? condition.field.slice(1) : condition.field @@ -511,7 +493,11 @@ const evaluateFieldCondition = ( return true } -const matchesCondition = (condition: WorkflowCondition, scope: StepAwareScope, step: RuntimeWorkflowTurn): boolean => { +const matchesCondition = ( + condition: WorkflowCondition, + scope: StepAwareScope, + step: AnyWorkflowTurn +): boolean => { if (condition === 'always') return true if (isAnyCondition(condition)) { return condition.any.some((child) => matchesCondition(child, scope, step)) @@ -525,10 +511,10 @@ const matchesCondition = (condition: WorkflowCondition, scope: StepAwareScope, s return false } -const resolveTransition = ( +const resolveTransition = ( transitions: ReadonlyArray | undefined, - stepScope: StepAwareScope -): TransitionResolution | null => { + stepScope: StepAwareScope +): TransitionResolution | null => { if (!transitions?.length) return null for (const transition of transitions) { if (!matchesCondition(transition.condition, stepScope, stepScope.current!)) { @@ -546,7 +532,11 @@ const resolveTransition = ( } } if (transition.nextStep) { - return { type: 'next', nextStep: transition.nextStep, stateUpdates: updates } + return { + type: 'next', + nextStep: transition.nextStep as RoundStepKey, + stateUpdates: updates + } } } return null @@ -567,13 +557,13 @@ export async function runAgentWorkflow => { + const resultPromise = (async (): Promise> => { const state: Record = {} - const baseScope: TemplateScope = { + const baseScope: TemplateScope = { user: { instructions: options.userInstructions }, run: { id: runId }, state, - steps: {}, + steps: {} as StepDictionary, round: 0, maxRounds, bootstrap: undefined @@ -581,7 +571,7 @@ export async function runAgentWorkflow = { definition, sessions, model, @@ -590,25 +580,31 @@ export async function runAgentWorkflow | undefined if (definition.flow.bootstrap) { - const bootstrapScope = cloneScope(baseScope, { round: 0, steps: {} }) - bootstrapTurn = await executeStep(definition.flow.bootstrap, bootstrapScope, execCtx) - baseScope.bootstrap = bootstrapTurn - const bootstrapStepScope = scopeWithStep(bootstrapScope, bootstrapTurn) + const bootstrapScope = cloneScope(baseScope, { round: 0, steps: {} as StepDictionary }) + const bootstrapDefinition = definition.flow.bootstrap as BootstrapStepDefinition + const executedBootstrap = (await executeStep( + bootstrapDefinition, + bootstrapScope, + execCtx + )) as BootstrapTurn + bootstrapTurn = executedBootstrap + baseScope.bootstrap = executedBootstrap + const bootstrapStepScope = scopeWithStep(bootstrapScope, executedBootstrap) applyStateUpdates(definition.flow.bootstrap.stateUpdates, bootstrapStepScope, state) } - const rounds: RuntimeWorkflowRound[] = [] + const rounds: AgentWorkflowRound[] = [] let finalOutcome: WorkflowOutcomeTemplate | null = null const roundDefinition = definition.flow.round - const navigator = createRoundNavigator(roundDefinition) + const navigator = createRoundNavigator(definition.flow.round) const maxStepIterations = Math.max(roundDefinition.steps.length * 3, roundDefinition.steps.length + 1) for (let roundNumber = 1; roundNumber <= maxRounds && !finalOutcome; roundNumber++) { - const roundSteps: Record = {} + const roundSteps: StepDictionary = {} const roundScope = cloneScope(baseScope, { round: roundNumber, steps: roundSteps }) - let currentStepKey: string | undefined = navigator.start + let currentStepKey: RoundStepKey | undefined = navigator.start let stepIterations = 0 while (currentStepKey && !finalOutcome) { @@ -624,8 +620,9 @@ export async function runAgentWorkflow + const stepKey = stepDefinition.key as RoundStepKey + roundSteps[stepKey] = stepResult const currentScope = scopeWithStep(roundScope, stepResult) applyStateUpdates(stepDefinition.stateUpdates, currentScope, state) @@ -665,16 +662,15 @@ export async function runAgentWorkflow> } + return { runId, result: resultPromise } } export async function getWorkflowRunDiff( @@ -707,35 +703,3 @@ export async function getWorkflowRunDiff( } return [] } - -function normalizeWorkerStatus(value: unknown): 'working' | 'done' | 'blocked' { - const asString = typeof value === 'string' ? value.toLowerCase() : 'working' - if (asString === 'done' || asString === 'blocked') { - return asString - } - return 'working' -} - -function parseWorkerResponse(role: string, res: string) { - const obj = parseJsonPayload(role, res) - const status = normalizeWorkerStatus(obj.status) - return { - status, - plan: coerceString(obj.plan ?? obj.analysis ?? obj.summary ?? obj.work ?? ''), - work: coerceString(obj.work ?? obj.output ?? obj.result ?? obj.answer ?? ''), - requests: coerceString(obj.requests ?? obj.questions ?? obj.blockers ?? '') - } -} - -function parseVerifierResponse(role: string, res: string) { - const obj = parseJsonPayload(role, res) - const verdictRaw = typeof obj.verdict === 'string' ? obj.verdict.toLowerCase() : coerceString(obj.status, 'instruct') - const verdict = ['approve', 'fail', 'instruct'].includes(verdictRaw) ? verdictRaw : 'instruct' - const priority = Number.isInteger(obj.priority) ? obj.priority : 3 - return { - verdict, - critique: coerceString(obj.critique ?? obj.feedback ?? ''), - instructions: coerceString(obj.instructions ?? obj.next_steps ?? obj.plan ?? ''), - priority: priority as number - } -} diff --git a/src/modules/agent/workflow-schema.ts b/src/modules/agent/workflow-schema.ts index 69a6019..f28f67e 100644 --- a/src/modules/agent/workflow-schema.ts +++ b/src/modules/agent/workflow-schema.ts @@ -137,7 +137,7 @@ const workflowSessionRoleSchema = z.object({ const workflowRoleDefinitionSchema = z.object({ systemPrompt: z.string().min(1), - parser: z.enum(['worker', 'verifier', 'passthrough']) + parser: z.string() }) const workflowRolesSchema = z diff --git a/src/modules/workflowAgentExecutor.test.ts b/src/modules/workflowAgentExecutor.test.ts index 2634db9..859b03b 100644 --- a/src/modules/workflowAgentExecutor.test.ts +++ b/src/modules/workflowAgentExecutor.test.ts @@ -3,7 +3,12 @@ import os from 'os' import path from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentRunResponse } from './agent/agent' -import type { AgentWorkflowRunOptions, AgentWorkflowTurn } from './agent/agent-orchestrator' +import type { + AgentWorkflowRunOptions, + AgentWorkflowTurn, + VerifierStructuredResponse, + WorkerStructuredResponse +} from './agent/agent-orchestrator' import { type VerifierWorkerWorkflowDefinition, type VerifierWorkerWorkflowResult } from './agent/workflows' import { createAgentWorkflowExecutor } from './workflowAgentExecutor' import type { AgentExecutorArgs } from './workflows' @@ -116,13 +121,13 @@ function buildExecutorArgs(sessionDir: string): AgentExecutorArgs { } function buildWorkflowResult(outcome: VerifierWorkerWorkflowResult['outcome']): VerifierWorkerWorkflowResult { - const verifierParsed = { + const verifierParsed: VerifierStructuredResponse = { verdict: outcome === 'approved' ? 'approve' : 'fail', critique: 'Looks good', instructions: outcome === 'approved' ? 'Ship it.' : 'Fix the failing tests.', priority: 1 } - const workerParsed = { + const workerParsed: WorkerStructuredResponse = { status: 'done', plan: 'Plan the refactor', work: 'Implemented the parser updates.', diff --git a/src/runner/workflowRunner.ts b/src/runner/workflowRunner.ts index f16a85e..d262b5d 100644 --- a/src/runner/workflowRunner.ts +++ b/src/runner/workflowRunner.ts @@ -431,11 +431,10 @@ const createFallbackAgentExecutor = (behavior: string): AgentExecutor => { behavior === 'invalid-json' ? 'verifier returned invalid JSON: SyntaxError: Unterminated string in JSON at position 1803' : 'workflow agent provider unavailable during test fallback' - const failingRunLoop = async () => { - throw new Error(message) - } return createAgentWorkflowExecutor({ - runLoop: failingRunLoop, + runWorkflow: async () => { + throw new Error(message) + }, maxRounds: 1 }) }