From 113301fdc913365ebe2f074397ea3964452ccb37 Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 19 Aug 2026 14:25:04 +0200 Subject: [PATCH 1/7] feat(workflows): pluggable custom transition handlers via plugin API Add a transition handler registry so plugins can contribute custom workflow transition conditions. A workflow step declares a transition with when: { type: 'custom', handler: '', config? } and the executor dispatches to the registered async handler to decide whether it fires. - TransitionHandlerRegistry + TransitionHandlerContext (workflows/transition-handlers) - Add 'custom' variant to TransitionCondition union - Async evaluateConditionAsync / findMatchingTransitionAsync in executor; built-in condition types stay synchronous (unchanged fast path) - Expose registerTransitionHandler on ProviderPluginRegistry; ProviderRegistry holds the registry and exposes getTransitionHandlers() - Thread transitionHandlers through OrchestratorOptions -> launch -> executeWorkflow - Loader + plugins route forward the new hook and track diagnostics Deterministic built-in transitions remain the default; custom handlers are opt-in. A missing handler degrades gracefully (non-firing, fallthrough). --- src/provider/index.ts | 8 + src/server/index.ts | 1 + src/server/providers/plugins/loader.ts | 6 + src/server/providers/plugins/registry.test.ts | 15 ++ src/server/providers/plugins/registry.ts | 12 ++ src/server/routes/plugins.ts | 5 + src/server/runner/launch.ts | 5 + src/server/runner/types.ts | 7 + src/server/workflows/executor.ts | 83 ++++++++- .../workflows/transition-eval-async.test.ts | 158 ++++++++++++++++++ .../workflows/transition-handlers.test.ts | 46 +++++ src/server/workflows/transition-handlers.ts | 59 +++++++ .../transition-plugin-integration.test.ts | 81 +++++++++ src/server/workflows/types.ts | 1 + 14 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 src/server/workflows/transition-eval-async.test.ts create mode 100644 src/server/workflows/transition-handlers.test.ts create mode 100644 src/server/workflows/transition-handlers.ts create mode 100644 src/server/workflows/transition-plugin-integration.test.ts diff --git a/src/provider/index.ts b/src/provider/index.ts index d846b161f..1f9e637a4 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,5 +1,6 @@ import type { ModelConfig } from '../shared/types.js' import type { LLMCompletionRequest, LLMCompletionResponse, LLMStreamEvent } from '../server/llm/types.js' +import type { TransitionHandler } from '../server/workflows/transition-handlers.js' // ============================================================================ // Auth Types @@ -105,6 +106,12 @@ export interface ProviderPluginRegistry { registerAuth(adapter: ProviderAuthAdapter): void registerTransport(adapter: ProviderTransportAdapter): void registerPreset(preset: ProviderPreset): void + /** + * Register a custom workflow transition handler. A workflow step declares a + * transition with `when: { type: 'custom', handler: '' }` and the + * executor invokes the registered handler to decide whether it fires. + */ + registerTransitionHandler(handlerId: string, handler: TransitionHandler): void readonly runtime: ProviderPluginRuntime } @@ -170,3 +177,4 @@ export type { LLMToolDefinition, } from '../server/llm/types.js' export type { ModelConfig, ToolCall } from '../shared/types.js' +export type { TransitionHandler, TransitionHandlerContext } from '../server/workflows/transition-handlers.js' diff --git a/src/server/index.ts b/src/server/index.ts index 7633ca346..8413bca0e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3382,6 +3382,7 @@ export async function createServerHandle(config: Config): Promise ...(statsEffort ? { reasoningEffort: statsEffort } : {}), }, broadcastForSession: wssExports.broadcastForSession, + transitionHandlers: providerAdapters.getTransitionHandlers(), }, { workflowId: launch.workflowId, diff --git a/src/server/providers/plugins/loader.ts b/src/server/providers/plugins/loader.ts index 4984a9f6c..55f3f209c 100644 --- a/src/server/providers/plugins/loader.ts +++ b/src/server/providers/plugins/loader.ts @@ -17,6 +17,7 @@ export interface ProviderPluginDiagnostic { authAdapters: string[] transportAdapters: string[] presets: string[] + transitionHandlers: string[] error?: string } @@ -85,6 +86,7 @@ export async function loadProviderPlugins(options: { authAdapters: [], transportAdapters: [], presets: [], + transitionHandlers: [], } diagnostics.push(diagnostic) if (manifest.openfox?.apiVersion !== 1) { @@ -106,6 +108,10 @@ export async function loadProviderPlugins(options: { options.registry.registerPreset(preset) diagnostic.presets.push(preset.id) }, + registerTransitionHandler(handlerId, handler) { + options.registry.registerTransitionHandler(handlerId, handler) + diagnostic.transitionHandlers.push(handlerId) + }, } try { const module = (await import(pathToFileURL(join(packageDir, plugin)).href)) as { diff --git a/src/server/providers/plugins/registry.test.ts b/src/server/providers/plugins/registry.test.ts index 13607e4ff..63289e289 100644 --- a/src/server/providers/plugins/registry.test.ts +++ b/src/server/providers/plugins/registry.test.ts @@ -212,4 +212,19 @@ describe('ProviderRegistry', () => { models: [{ id: 'custom-model' }], }) }) + + it('registers transition handlers and exposes them via getTransitionHandlers()', async () => { + const value = registry() + const handler = async () => true + value.registerTransitionHandler('llm_decision', handler) + const registryHandlers = value.getTransitionHandlers() + expect(registryHandlers.has('llm_decision')).toBe(true) + expect(registryHandlers.get('llm_decision')).toBe(handler) + }) + + it('starts with an empty transition handler registry', () => { + const value = registry() + const registryHandlers = value.getTransitionHandlers() + expect(registryHandlers.list()).toEqual([]) + }) }) diff --git a/src/server/providers/plugins/registry.ts b/src/server/providers/plugins/registry.ts index 5a5bf8bb0..9395e7bb4 100644 --- a/src/server/providers/plugins/registry.ts +++ b/src/server/providers/plugins/registry.ts @@ -5,15 +5,27 @@ import type { ProviderPluginRuntime, ProviderPreset, ProviderTransportAdapter, + TransitionHandler, } from '../../../provider/index.js' +import { TransitionHandlerRegistry } from '../../workflows/transition-handlers.js' export class ProviderRegistry implements ProviderPluginRegistry { private readonly authAdapters = new Map() private readonly transportAdapters = new Map() private readonly presets = new Map() + private readonly transitionHandlers = new TransitionHandlerRegistry() constructor(readonly runtime: ProviderPluginRuntime) {} + registerTransitionHandler(handlerId: string, handler: TransitionHandler): void { + this.transitionHandlers.register(handlerId, handler) + } + + /** The registry of custom workflow transition handlers contributed by plugins. */ + getTransitionHandlers(): TransitionHandlerRegistry { + return this.transitionHandlers + } + registerAuth(adapter: ProviderAuthAdapter): void { this.register(this.authAdapters, adapter.id, adapter, 'auth adapter') } diff --git a/src/server/routes/plugins.ts b/src/server/routes/plugins.ts index fea655d34..2f2402898 100644 --- a/src/server/routes/plugins.ts +++ b/src/server/routes/plugins.ts @@ -145,6 +145,7 @@ export function createPluginRoutes(options: { authAdapters: [], transportAdapters: [], presets: [], + transitionHandlers: [], } const trackingRegistry: ProviderPluginRegistry = { runtime: providerAdapters.runtime, @@ -160,6 +161,10 @@ export function createPluginRoutes(options: { providerAdapters.registerPreset(preset) diagnostic.presets.push(preset.id) }, + registerTransitionHandler(handlerId, handler) { + providerAdapters.registerTransitionHandler(handlerId, handler) + diagnostic.transitionHandlers.push(handlerId) + }, } await mod.register(trackingRegistry) diagnostic.loaded = true diff --git a/src/server/runner/launch.ts b/src/server/runner/launch.ts index 15d7465ef..fd70d835c 100644 --- a/src/server/runner/launch.ts +++ b/src/server/runner/launch.ts @@ -14,6 +14,7 @@ import type { ServerMessage } from '../../shared/protocol.js' import { createServerMessage } from '../../shared/protocol.js' import type { LLMClientWithModel } from '../llm/client.js' import type { SessionManager } from '../session/index.js' +import type { TransitionHandlerRegistry } from '../workflows/transition-handlers.js' import { runOrchestrator } from './index.js' import { normalizeWorkflowScope } from '../workflows/registry.js' import { logger } from '../utils/logger.js' @@ -45,6 +46,8 @@ export interface LaunchWorkflowRunDeps { broadcastForSession: (sessionId: string, msg: ServerMessage) => void /** Turn-bookkeeping cleanup after the run settles (queue drain, restart, …). */ onFinished?: () => void + /** Custom workflow transition handlers contributed by plugins. */ + transitionHandlers?: TransitionHandlerRegistry } const activeRuns = new Map() @@ -67,6 +70,7 @@ export function launchWorkflowRun(deps: LaunchWorkflowRunDeps, payload: Workflow statsIdentity, broadcastForSession, onFinished, + transitionHandlers, } = deps const signal = controller.signal @@ -93,6 +97,7 @@ export function launchWorkflowRun(deps: LaunchWorkflowRunDeps, payload: Workflow ...(payload.resumeFrom ? { resumeFromStep: payload.resumeFrom } : {}), ...(payload.stepOutput ? { initialStepOutput: payload.stepOutput } : {}), ...(payload.userChoice ? { userChoice: payload.userChoice } : {}), + ...(transitionHandlers ? { transitionHandlers } : {}), ...(payload.resumeFrom ? (() => { const exec = sessionManager.getLatestWorkflowExecution(sessionId) diff --git a/src/server/runner/types.ts b/src/server/runner/types.ts index 301d38b44..807a93f5e 100644 --- a/src/server/runner/types.ts +++ b/src/server/runner/types.ts @@ -10,6 +10,7 @@ import type { ServerMessage } from '../../shared/protocol.js' import type { LLMClientWithModel } from '../llm/client.js' import type { StreamTiming } from '../llm/streaming.js' import type { SessionManager } from '../session/index.js' +import type { TransitionHandlerRegistry } from '../workflows/transition-handlers.js' // ============================================================================ // Decision Types - What the state machine decides to do next @@ -59,6 +60,12 @@ export interface OrchestratorOptions { getSessionLLMClient?: () => LLMClientWithModel /** Overrides for the LLM-failure retry backoff policy. */ llmRetryPolicy?: Partial + /** + * Registry of custom transition handlers contributed by plugins. Enables + * `when: { type: 'custom', handler: '' }` transitions in workflows. + * Undefined = only built-in condition types are evaluated. + */ + transitionHandlers?: TransitionHandlerRegistry } export interface OrchestratorResult { diff --git a/src/server/workflows/executor.ts b/src/server/workflows/executor.ts index 952007e93..800b8bccb 100644 --- a/src/server/workflows/executor.ts +++ b/src/server/workflows/executor.ts @@ -18,6 +18,7 @@ import type { ShellStep, UserStep, } from './types.js' +import type { TransitionHandlerRegistry, TransitionHandlerContext } from './transition-handlers.js' import { TERMINAL_DONE, TERMINAL_BLOCKED } from './types.js' import { getEventStore, getCurrentContextWindowId } from '../events/index.js' import { createChatMessageMessage } from '../ws/protocol.js' @@ -148,6 +149,10 @@ export function evaluateCondition( case 'always': return true + // `custom` conditions are resolved asynchronously via the handler registry + // (see evaluateConditionAsync). Synchronous callers see them as non-firing. + default: + return false } } @@ -172,6 +177,58 @@ export function evaluateTransitions( return findMatchingTransition(transitions, stepOutcome, metadataEntries)?.goto ?? TERMINAL_BLOCKED } +// ---------------------------------------------------------------------------- +// Async evaluation — supports `custom` conditions backed by the handler registry +// ---------------------------------------------------------------------------- + +/** + * Async condition evaluator. Built-in condition types delegate to the + * synchronous {@link evaluateCondition} (unchanged fast path). `custom` + * conditions are dispatched to the handler registered under `condition.handler` + * in the provided registry. A `custom` condition with no registered handler + * is treated as non-firing (logged), so a missing plugin never blocks the + * workflow — the next transition (typically an `always` fallback) wins. + */ +export async function evaluateConditionAsync( + condition: TransitionCondition, + stepOutcome: StepOutcome | null, + metadataEntries: Record | undefined, + registry: TransitionHandlerRegistry | undefined, + handlerCtx: TransitionHandlerContext, +): Promise { + if (condition.type === 'custom') { + const handler = registry?.get(condition.handler) + if (!handler) { + logger.warn('Custom transition handler not registered', { handler: condition.handler }) + return false + } + const ctx: TransitionHandlerContext = { + ...handlerCtx, + stepOutcome, + metadataEntries, + config: condition.config, + } + return handler(ctx) + } + return evaluateCondition(condition, stepOutcome, metadataEntries) +} + +/** Async counterpart of {@link findMatchingTransition} — first match wins. */ +export async function findMatchingTransitionAsync( + transitions: Transition[], + stepOutcome: StepOutcome | null, + metadataEntries: Record | undefined, + registry: TransitionHandlerRegistry | undefined, + handlerCtx: TransitionHandlerContext, +): Promise { + for (const transition of transitions) { + if (await evaluateConditionAsync(transition.when, stepOutcome, metadataEntries, registry, handlerCtx)) { + return transition + } + } + return null +} + // ============================================================================ // User-Step Choices // ============================================================================ @@ -281,7 +338,7 @@ export async function executeWorkflow( options: OrchestratorOptions, subGroup?: string, ): Promise { - const { sessionManager, sessionId, llmClient, signal, onMessage } = options + const { sessionManager, sessionId, llmClient, signal, onMessage, transitionHandlers } = options const eventStore = getEventStore() const startTime = performance.now() let iterations = 0 @@ -353,10 +410,18 @@ export async function executeWorkflow( // Evaluate start condition if present if (workflow.startCondition && workflow.startCondition.type !== 'always') { const session = sessionManager.requireSession(sessionId) - const conditionMet = evaluateCondition( + const conditionMet = await evaluateConditionAsync( workflow.startCondition as TransitionCondition, null, session.metadataEntries, + transitionHandlers, + { + stepOutcome: null, + metadataEntries: session.metadataEntries, + workflowId: workflow.metadata.id, + stepId: workflow.entryStep, + signal, + }, ) if (!conditionMet) { logger.debug('Workflow start condition not met', { sessionId, condition: workflow.startCondition.type }) @@ -818,7 +883,19 @@ export async function executeWorkflow( const candidates = subGroup ? step.transitions.filter((t) => !t.subGroup || activeSubGroups.has(t.subGroup)) : step.transitions - const fired = findMatchingTransition(candidates, stepOutcome, refreshedSession.metadataEntries) + const fired = await findMatchingTransitionAsync( + candidates, + stepOutcome, + refreshedSession.metadataEntries, + transitionHandlers, + { + stepOutcome, + metadataEntries: refreshedSession.metadataEntries, + workflowId: workflow.metadata.id, + stepId: step.id, + signal, + }, + ) let nextStepId = fired ? fired.goto : TERMINAL_BLOCKED // When running a sub-group, a transition leaving the active set either: diff --git a/src/server/workflows/transition-eval-async.test.ts b/src/server/workflows/transition-eval-async.test.ts new file mode 100644 index 000000000..3ebd60f2e --- /dev/null +++ b/src/server/workflows/transition-eval-async.test.ts @@ -0,0 +1,158 @@ +/** + * Async transition evaluation — `custom` condition type via the handler registry. + * + * Built-in condition types stay synchronous (covered by executor.test.ts); + * this file covers the async path that lets plugins contribute custom + * transition conditions. + */ + +import { describe, it, expect } from 'vitest' +import type { MetadataEntry } from '../../shared/types.js' +import type { Transition } from './types.js' +import { TERMINAL_BLOCKED } from './types.js' +import { TransitionHandlerRegistry, type TransitionHandlerContext } from './transition-handlers.js' +import { evaluateConditionAsync, findMatchingTransitionAsync, type StepOutcome } from './executor.js' + +function makeMetadataEntry(overrides: Partial = {}): MetadataEntry { + return { id: 'c1', description: 'Test', status: 'pending', ...overrides } +} + +const baseHandlerCtx = (overrides: Partial = {}): TransitionHandlerContext => ({ + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'step', + config: undefined, + ...overrides, +}) + +describe('evaluateConditionAsync', () => { + it('delegates built-in types to the synchronous evaluator (step_result)', async () => { + const registry = new TransitionHandlerRegistry() + const outcome: StepOutcome = { result: 'success', output: {} } + await expect( + evaluateConditionAsync( + { type: 'step_result', result: 'success' }, + outcome, + undefined, + registry, + baseHandlerCtx(), + ), + ).resolves.toBe(true) + await expect( + evaluateConditionAsync( + { type: 'step_result', result: 'success' }, + { result: 'failure', output: {} }, + undefined, + registry, + baseHandlerCtx(), + ), + ).resolves.toBe(false) + }) + + it('delegates built-in types (always)', async () => { + const registry = new TransitionHandlerRegistry() + await expect(evaluateConditionAsync({ type: 'always' }, null, undefined, registry, baseHandlerCtx())).resolves.toBe( + true, + ) + }) + + it('invokes a registered custom handler and forwards context', async () => { + const registry = new TransitionHandlerRegistry() + const seen: TransitionHandlerContext[] = [] + registry.register('llm_decision', async (ctx) => { + seen.push(ctx) + return ctx.config?.['decide'] === 'proceed' + }) + const ctx = baseHandlerCtx({ config: { decide: 'proceed' }, stepId: 'verify' }) + await expect( + evaluateConditionAsync( + { type: 'custom', handler: 'llm_decision', config: { decide: 'proceed' } }, + null, + undefined, + registry, + ctx, + ), + ).resolves.toBe(true) + expect(seen).toHaveLength(1) + expect(seen[0]?.stepId).toBe('verify') + expect(seen[0]?.config).toEqual({ decide: 'proceed' }) + }) + + it('returns false when no handler is registered for the custom id (graceful)', async () => { + const registry = new TransitionHandlerRegistry() + await expect( + evaluateConditionAsync({ type: 'custom', handler: 'missing' }, null, undefined, registry, baseHandlerCtx()), + ).resolves.toBe(false) + }) + + it('passes metadata entries through to the handler', async () => { + const registry = new TransitionHandlerRegistry() + let received: MetadataEntry[] | undefined + registry.register('all_passed', async (ctx) => { + received = ctx.metadataEntries?.['criteria'] + return received?.every((e) => e.status === 'passed') ?? false + }) + const entries = { criteria: [makeMetadataEntry({ status: 'passed' })] } + await expect( + evaluateConditionAsync( + { type: 'custom', handler: 'all_passed' }, + null, + entries, + registry, + baseHandlerCtx({ metadataEntries: entries }), + ), + ).resolves.toBe(true) + expect(received).toHaveLength(1) + }) +}) + +describe('findMatchingTransitionAsync', () => { + it('returns the first transition whose custom handler fires', async () => { + const registry = new TransitionHandlerRegistry() + registry.register('llm_decision', async (ctx) => ctx.config?.['go'] === 'retry') + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'llm_decision', config: { go: 'retry' } }, goto: 'build' }, + { when: { type: 'always' }, goto: '$done' }, + ] + const fired = await findMatchingTransitionAsync(transitions, null, undefined, registry, baseHandlerCtx()) + expect(fired?.goto).toBe('build') + }) + + it('falls back to a built-in transition when the custom handler does not fire', async () => { + const registry = new TransitionHandlerRegistry() + registry.register('llm_decision', async () => false) + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'llm_decision' }, goto: 'build' }, + { when: { type: 'always' }, goto: '$done' }, + ] + const fired = await findMatchingTransitionAsync(transitions, null, undefined, registry, baseHandlerCtx()) + expect(fired?.goto).toBe('$done') + }) + + it('returns null when no transition matches and no handler is registered', async () => { + const registry = new TransitionHandlerRegistry() + const transitions: Transition[] = [{ when: { type: 'custom', handler: 'missing' }, goto: 'build' }] + const fired = await findMatchingTransitionAsync(transitions, null, undefined, registry, baseHandlerCtx()) + expect(fired).toBeNull() + }) + + it('evaluates transitions in order — first match wins', async () => { + const registry = new TransitionHandlerRegistry() + registry.register('pick', async (ctx) => ctx.config?.['n'] === 1) + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'pick', config: { n: 1 } }, goto: 'first' }, + { when: { type: 'custom', handler: 'pick', config: { n: 1 } }, goto: 'second' }, + ] + const fired = await findMatchingTransitionAsync(transitions, null, undefined, registry, baseHandlerCtx()) + expect(fired?.goto).toBe('first') + }) + + it('returns null for an empty transition list (caller maps to $blocked)', async () => { + const registry = new TransitionHandlerRegistry() + const fired = await findMatchingTransitionAsync([], null, undefined, registry, baseHandlerCtx()) + expect(fired).toBeNull() + // sanity: the caller's $blocked constant + expect(TERMINAL_BLOCKED).toBe('$blocked') + }) +}) diff --git a/src/server/workflows/transition-handlers.test.ts b/src/server/workflows/transition-handlers.test.ts new file mode 100644 index 000000000..90e0fa650 --- /dev/null +++ b/src/server/workflows/transition-handlers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { TransitionHandlerRegistry, type TransitionHandlerContext } from './transition-handlers.js' + +describe('TransitionHandlerRegistry', () => { + it('registers and retrieves a handler by id', () => { + const registry = new TransitionHandlerRegistry() + const handler = async () => true + registry.register('llm_decision', handler) + expect(registry.has('llm_decision')).toBe(true) + expect(registry.get('llm_decision')).toBe(handler) + }) + + it('reports has()=false for an unknown handler id', () => { + const registry = new TransitionHandlerRegistry() + expect(registry.has('llm_decision')).toBe(false) + expect(registry.get('llm_decision')).toBeUndefined() + }) + + it('lists registered handler ids', () => { + const registry = new TransitionHandlerRegistry() + registry.register('llm_decision', async () => true) + registry.register('cost_guard', async () => false) + expect(registry.list().sort()).toEqual(['cost_guard', 'llm_decision']) + }) + + it('overwrites a handler registered twice under the same id', async () => { + const registry = new TransitionHandlerRegistry() + registry.register('llm_decision', async () => true) + registry.register('llm_decision', async () => false) + const handler = registry.get('llm_decision') + expect(handler).toBeDefined() + const ctx: TransitionHandlerContext = { + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'step', + config: undefined, + } + expect(await handler?.(ctx)).toBe(false) + }) + + it('rejects an empty handler id', () => { + const registry = new TransitionHandlerRegistry() + expect(() => registry.register(' ', async () => true)).toThrow() + }) +}) diff --git a/src/server/workflows/transition-handlers.ts b/src/server/workflows/transition-handlers.ts new file mode 100644 index 000000000..9a4771818 --- /dev/null +++ b/src/server/workflows/transition-handlers.ts @@ -0,0 +1,59 @@ +/** + * Transition Handler Registry + * + * Extension point for custom workflow transition conditions. Plugins register + * async handlers keyed by an id; a workflow step declares a transition with + * `when: { type: 'custom', handler: '', config?: {...} }` and the executor + * invokes the matching handler to decide whether the transition fires. + * + * Built-in condition types (step_result, metadata_all_match, metadata_all_in, + * always) remain evaluated synchronously by the executor. Only `custom` + * conditions go through this registry, keeping the common path unchanged. + */ + +import type { MetadataEntry } from '../../shared/types.js' + +/** Structural twin of executor's StepOutcome — avoids a circular import. */ +export interface StepOutcomeLike { + result: string + output: Record +} + +export interface TransitionHandlerContext { + /** Result + output of the step whose transitions are being evaluated. */ + stepOutcome: StepOutcomeLike | null + /** Session metadata entries (criteria, review_findings, …). */ + metadataEntries?: Record | undefined + /** Workflow being executed. */ + workflowId: string + /** Step whose transitions are being evaluated. */ + stepId: string + /** Free-form config declared on the `custom` condition. */ + config?: Record | undefined + /** Abort signal forwarded from the orchestrator. */ + signal?: AbortSignal | undefined +} + +/** A transition handler returns true to fire the transition, false to skip it. */ +export type TransitionHandler = (ctx: TransitionHandlerContext) => Promise + +export class TransitionHandlerRegistry { + private readonly handlers = new Map() + + register(handlerId: string, handler: TransitionHandler): void { + if (!handlerId.trim()) throw new Error('Transition handler id cannot be empty') + this.handlers.set(handlerId, handler) + } + + get(handlerId: string): TransitionHandler | undefined { + return this.handlers.get(handlerId) + } + + has(handlerId: string): boolean { + return this.handlers.has(handlerId) + } + + list(): string[] { + return [...this.handlers.keys()] + } +} diff --git a/src/server/workflows/transition-plugin-integration.test.ts b/src/server/workflows/transition-plugin-integration.test.ts new file mode 100644 index 000000000..a6a0cbab0 --- /dev/null +++ b/src/server/workflows/transition-plugin-integration.test.ts @@ -0,0 +1,81 @@ +/** + * Phase 0 integration — plugin contract for custom transition handlers. + * + * Proves the end-to-end wiring: a plugin calls + * `ProviderRegistry.registerTransitionHandler(id, handler)`, the registry + * exposes it via `getTransitionHandlers()`, and the executor's async + * transition resolver uses it to route a workflow step with a + * `when: { type: 'custom', handler }` transition. + */ + +import { describe, it, expect } from 'vitest' +import { ProviderRegistry } from '../providers/plugins/registry.js' +import { findMatchingTransitionAsync } from './executor.js' +import type { Transition } from './types.js' + +describe('Plugin custom transition handler — integration', () => { + it('routes a workflow step via a plugin-registered handler', async () => { + const providerRegistry = new ProviderRegistry({ mode: 'production', configDirectory: '/tmp/openfox' }) + + // A plugin's register() would call this: + providerRegistry.registerTransitionHandler('decide_next', async (ctx) => { + // Fire only when the orchestrator signals "more work" in the config. + return ctx.config?.['verdict'] === 'retry' + }) + + // The server threads this into OrchestratorOptions.transitionHandlers: + const transitionHandlers = providerRegistry.getTransitionHandlers() + expect(transitionHandlers.has('decide_next')).toBe(true) + + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'decide_next', config: { verdict: 'retry' } }, goto: 'build' }, + { when: { type: 'always' }, goto: '$done' }, + ] + + // Handler fires → first transition wins → routes back to 'build'. + const fired = await findMatchingTransitionAsync(transitions, null, undefined, transitionHandlers, { + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'verify', + }) + expect(fired?.goto).toBe('build') + }) + + it('falls through to the always-transition when the plugin handler does not fire', async () => { + const providerRegistry = new ProviderRegistry({ mode: 'production', configDirectory: '/tmp/openfox' }) + providerRegistry.registerTransitionHandler('decide_next', async (ctx) => ctx.config?.['verdict'] === 'retry') + const transitionHandlers = providerRegistry.getTransitionHandlers() + + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'decide_next', config: { verdict: 'done' } }, goto: 'build' }, + { when: { type: 'always' }, goto: '$done' }, + ] + + const fired = await findMatchingTransitionAsync(transitions, null, undefined, transitionHandlers, { + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'verify', + }) + expect(fired?.goto).toBe('$done') + }) + + it('survives an unregistered handler id (graceful fallthrough)', async () => { + const providerRegistry = new ProviderRegistry({ mode: 'production', configDirectory: '/tmp/openfox' }) + const transitionHandlers = providerRegistry.getTransitionHandlers() + + const transitions: Transition[] = [ + { when: { type: 'custom', handler: 'never_registered' }, goto: 'build' }, + { when: { type: 'always' }, goto: '$done' }, + ] + + const fired = await findMatchingTransitionAsync(transitions, null, undefined, transitionHandlers, { + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'verify', + }) + expect(fired?.goto).toBe('$done') + }) +}) diff --git a/src/server/workflows/types.ts b/src/server/workflows/types.ts index fbeef0eb6..782ef4f57 100644 --- a/src/server/workflows/types.ts +++ b/src/server/workflows/types.ts @@ -117,6 +117,7 @@ export type TransitionCondition = | { type: 'metadata_all_match'; key: string; field: string; value: string } | { type: 'metadata_all_in'; key: string; field: string; values: string[] } | { type: 'always' } + | { type: 'custom'; handler: string; config?: Record } // ============================================================================ // Terminal state constants From 9cd546ab617ec9642da87396b41c4cbfb85d5899 Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 19 Aug 2026 14:35:36 +0200 Subject: [PATCH 2/7] feat(agents): per-step model override (workflowId:stepId) Add step-level model override resolution with precedence step > agent > session. Each workflow step can pin a distinct provider+model, enabling N-step / N-model teams (no 4-role cap). - STEP_MODEL_OVERRIDES setting key + parse/get/set helpers - resolveLLMClientForStep: step override wins; a broken step override is a hard error (no silent fallthrough to agent) - Thread StepContext through runAgentTurn -> createClientForAgent and executeSubAgent; executor sets it per agent/sub_agent step - TDD: 14 unit tests + 2 executor integration tests, all green; full unit suite 4368 pass, 0 fail --- src/server/agents/model-overrides.ts | 81 ++++++- .../agents/step-model-overrides.test.ts | 219 ++++++++++++++++++ src/server/chat/orchestrator.ts | 8 + src/server/db/settings.ts | 1 + src/server/runner/types.ts | 9 + src/server/session/manager.ts | 16 +- src/server/sub-agents/manager.ts | 20 +- src/server/workflows/executor-mode.test.ts | 41 ++++ src/server/workflows/executor.ts | 2 + 9 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 src/server/agents/step-model-overrides.test.ts diff --git a/src/server/agents/model-overrides.ts b/src/server/agents/model-overrides.ts index d4426d8e9..e95b6f4fe 100644 --- a/src/server/agents/model-overrides.ts +++ b/src/server/agents/model-overrides.ts @@ -12,6 +12,7 @@ import type { LLMClientWithModel } from '../llm/client.js' import type { ProviderManager } from '../provider-manager.js' export const AGENT_MODEL_OVERRIDES_KEY = SETTINGS_KEYS.AGENT_MODEL_OVERRIDES +export const STEP_MODEL_OVERRIDES_KEY = SETTINGS_KEYS.STEP_MODEL_OVERRIDES const overrideSchema = z.object({ providerId: z.string().min(1), @@ -21,8 +22,23 @@ const overrideSchema = z.object({ export type AgentModelOverride = z.infer export type AgentModelOverrides = Record +export type StepModelOverrides = Record + +/** Compose the storage key for a per-step override: `${workflowId}:${stepId}`. */ +export function stepOverrideKey(workflowId: string, stepId: string): string { + return `${workflowId}:${stepId}` +} export function parseAgentModelOverrides(raw: string | null | undefined): AgentModelOverrides { + return parseOverridesMap(raw) +} + +export function parseStepModelOverrides(raw: string | null | undefined): StepModelOverrides { + return parseOverridesMap(raw) +} + +/** Shared parser for an override map keyed by an arbitrary string id. */ +function parseOverridesMap(raw: string | null | undefined): Record { if (!raw) return {} let parsed: unknown try { @@ -32,11 +48,11 @@ export function parseAgentModelOverrides(raw: string | null | undefined): AgentM } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} - const result: AgentModelOverrides = {} - for (const [agentId, value] of Object.entries(parsed)) { + const result: Record = {} + for (const [id, value] of Object.entries(parsed)) { const validated = overrideSchema.safeParse(value) if (validated.success) { - result[agentId] = validated.data + result[id] = validated.data } } return result @@ -60,6 +76,25 @@ export function setAgentModelOverride(agentId: string, override: AgentModelOverr setSetting(AGENT_MODEL_OVERRIDES_KEY, JSON.stringify(overrides)) } +export function getStepModelOverrides(): StepModelOverrides { + return parseStepModelOverrides(getSetting(STEP_MODEL_OVERRIDES_KEY)) +} + +export function getStepModelOverride(workflowId: string, stepId: string): AgentModelOverride | undefined { + return getStepModelOverrides()[stepOverrideKey(workflowId, stepId)] +} + +export function setStepModelOverride(workflowId: string, stepId: string, override: AgentModelOverride | null): void { + const overrides = getStepModelOverrides() + const key = stepOverrideKey(workflowId, stepId) + if (override === null) { + delete overrides[key] + } else { + overrides[key] = override + } + setSetting(STEP_MODEL_OVERRIDES_KEY, JSON.stringify(overrides)) +} + export interface AgentClientResolution { client: LLMClientWithModel usedOverride: boolean @@ -106,3 +141,43 @@ export function resolveLLMClientForAgent( override: effectiveEffort ? { ...override, reasoningEffort: effectiveEffort } : override, } } + +/** + * Resolve the LLM client for a single workflow step. Precedence: + * 1. step override (`workflowId:stepId`) — wins over everything when present. + * A configured-but-unresolvable step override is a hard error for that + * step: it falls back to the session model with a warning and does NOT + * silently pick up the agent override. + * 2. agent override (`agentId`) — delegated to `resolveLLMClientForAgent`. + * 3. session/global fallback. + */ +export function resolveLLMClientForStep( + workflowId: string, + stepId: string, + agentId: string, + fallbackClient: LLMClientWithModel, + providerManager: ProviderManager, + pinnedEffort?: string, +): AgentClientResolution { + const stepOverride = getStepModelOverride(workflowId, stepId) + if (!stepOverride) { + return resolveLLMClientForAgent(agentId, fallbackClient, providerManager, pinnedEffort) + } + + const effectiveEffort = pinnedEffort ?? stepOverride.reasoningEffort + const client = providerManager.createClient(stepOverride.providerId, stepOverride.model, effectiveEffort) + if (!client) { + return { + client: fallbackClient, + usedOverride: false, + override: stepOverride, + warning: `Step '${stepOverrideKey(workflowId, stepId)}' is configured to use model '${stepOverride.model}' from provider '${stepOverride.providerId}', but it is no longer available. Falling back to the session model.`, + } + } + + return { + client, + usedOverride: true, + override: effectiveEffort ? { ...stepOverride, reasoningEffort: effectiveEffort } : stepOverride, + } +} diff --git a/src/server/agents/step-model-overrides.test.ts b/src/server/agents/step-model-overrides.test.ts new file mode 100644 index 000000000..917586f02 --- /dev/null +++ b/src/server/agents/step-model-overrides.test.ts @@ -0,0 +1,219 @@ +/** + * Step Model Overrides Tests + * + * Per-step model overrides keyed by `${workflowId}:${stepId}`. Stored in DB + * settings as JSON under `step.modelOverrides`. Precedence when resolving the + * LLM client for a step: step override > agent override > session fallback. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { LLMClientWithModel } from '../llm/client.js' +import type { ProviderManager } from '../provider-manager.js' + +const { getSettingMock, setSettingMock } = vi.hoisted(() => ({ + getSettingMock: vi.fn(), + setSettingMock: vi.fn(), +})) + +vi.mock('../db/settings.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + getSetting: getSettingMock, + setSetting: setSettingMock, + } +}) + +import { + parseStepModelOverrides, + getStepModelOverride, + setStepModelOverride, + resolveLLMClientForStep, + STEP_MODEL_OVERRIDES_KEY, +} from './model-overrides.js' + +function fakeClient(model: string): LLMClientWithModel { + return { getModel: () => model } as unknown as LLMClientWithModel +} + +function fakeProviderManager(createResult?: LLMClientWithModel): ProviderManager { + return { + createClient: vi.fn(() => createResult), + } as unknown as ProviderManager +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseStepModelOverrides', () => { + it('returns empty map for null/undefined/invalid JSON', () => { + expect(parseStepModelOverrides(null)).toEqual({}) + expect(parseStepModelOverrides(undefined)).toEqual({}) + expect(parseStepModelOverrides('not json')).toEqual({}) + expect(parseStepModelOverrides('[]')).toEqual({}) + expect(parseStepModelOverrides('"str"')).toEqual({}) + }) + + it('parses valid overrides keyed by workflowId:stepId and drops malformed entries', () => { + const raw = JSON.stringify({ + 'wf:build': { providerId: 'p1', model: 'm1' }, + bad1: { providerId: 'p1' }, + bad2: { model: 'm1' }, + bad3: 'nope', + 'wf:verify': { providerId: 'p2', model: 'm2' }, + }) + expect(parseStepModelOverrides(raw)).toEqual({ + 'wf:build': { providerId: 'p1', model: 'm1' }, + 'wf:verify': { providerId: 'p2', model: 'm2' }, + }) + }) + + it('preserves an optional reasoningEffort', () => { + const raw = JSON.stringify({ + 'wf:build': { providerId: 'p1', model: 'm1', reasoningEffort: 'high' }, + 'wf:verify': { providerId: 'p2', model: 'm2', reasoningEffort: '' }, + }) + expect(parseStepModelOverrides(raw)).toEqual({ + 'wf:build': { providerId: 'p1', model: 'm1', reasoningEffort: 'high' }, + }) + }) +}) + +describe('getStepModelOverride', () => { + it('returns undefined when no setting stored', () => { + getSettingMock.mockReturnValue(null) + expect(getStepModelOverride('wf', 'build')).toBeUndefined() + expect(getSettingMock).toHaveBeenCalledWith(STEP_MODEL_OVERRIDES_KEY) + }) + + it('returns the override for a known workflowId:stepId', () => { + getSettingMock.mockReturnValue(JSON.stringify({ 'wf:build': { providerId: 'p1', model: 'm1' } })) + expect(getStepModelOverride('wf', 'build')).toEqual({ providerId: 'p1', model: 'm1' }) + expect(getStepModelOverride('wf', 'verify')).toBeUndefined() + expect(getStepModelOverride('other', 'build')).toBeUndefined() + }) +}) + +describe('setStepModelOverride', () => { + it('writes a new override under the workflowId:stepId key', () => { + getSettingMock.mockReturnValue(null) + setStepModelOverride('wf', 'build', { providerId: 'p1', model: 'm1' }) + expect(setSettingMock).toHaveBeenCalledWith( + STEP_MODEL_OVERRIDES_KEY, + JSON.stringify({ 'wf:build': { providerId: 'p1', model: 'm1' } }), + ) + }) + + it('merges with existing overrides without clobbering others', () => { + getSettingMock.mockReturnValue(JSON.stringify({ 'wf:verify': { providerId: 'p2', model: 'm2' } })) + setStepModelOverride('wf', 'build', { providerId: 'p1', model: 'm1' }) + expect(setSettingMock).toHaveBeenCalledWith( + STEP_MODEL_OVERRIDES_KEY, + JSON.stringify({ + 'wf:verify': { providerId: 'p2', model: 'm2' }, + 'wf:build': { providerId: 'p1', model: 'm1' }, + }), + ) + }) + + it('removes the override when passed null', () => { + getSettingMock.mockReturnValue( + JSON.stringify({ + 'wf:verify': { providerId: 'p2', model: 'm2' }, + 'wf:build': { providerId: 'p1', model: 'm1' }, + }), + ) + setStepModelOverride('wf', 'build', null) + expect(setSettingMock).toHaveBeenCalledWith( + STEP_MODEL_OVERRIDES_KEY, + JSON.stringify({ 'wf:verify': { providerId: 'p2', model: 'm2' } }), + ) + }) +}) + +describe('resolveLLMClientForStep', () => { + const fallback = fakeClient('global-model') + + it('returns fallback when no step override and no agent override exist', () => { + getSettingMock.mockReturnValue(null) + const pm = fakeProviderManager(fakeClient('other')) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(fallback) + expect(result.usedOverride).toBe(false) + expect(result.warning).toBeUndefined() + }) + + it('step override wins over agent override', () => { + // agent overrides + step overrides both populated; step must win. + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'step.modelOverrides') + return JSON.stringify({ 'wf:build': { providerId: 'ps', model: 'step-model' } }) + return null + }) + const dedicated = fakeClient('step-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.usedOverride).toBe(true) + expect(result.override).toEqual({ providerId: 'ps', model: 'step-model' }) + expect(pm.createClient).toHaveBeenCalledWith('ps', 'step-model', undefined) + }) + + it('falls back to agent override when no step override exists', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + return null + }) + const dedicated = fakeClient('agent-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.usedOverride).toBe(true) + expect(result.override).toEqual({ providerId: 'pa', model: 'agent-model' }) + expect(pm.createClient).toHaveBeenCalledWith('pa', 'agent-model', undefined) + }) + + it('passes pinned effort to the step override createClient', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'step.modelOverrides') + return JSON.stringify({ 'wf:build': { providerId: 'ps', model: 'm1', reasoningEffort: 'low' } }) + return null + }) + const dedicated = fakeClient('m1') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm, 'max') + expect(result.usedOverride).toBe(true) + expect(result.override).toEqual({ providerId: 'ps', model: 'm1', reasoningEffort: 'max' }) + expect(pm.createClient).toHaveBeenCalledWith('ps', 'm1', 'max') + }) + + it('falls back with warning when step override provider no longer exists', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'step.modelOverrides') return JSON.stringify({ 'wf:build': { providerId: 'gone', model: 'm1' } }) + return null + }) + const pm = fakeProviderManager(undefined) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(fallback) + expect(result.usedOverride).toBe(false) + expect(result.warning).toContain('gone') + expect(result.warning).toContain('m1') + }) + + it('step override missing provider does NOT fall through to agent override', () => { + // A configured-but-broken step override is a hard error for that step: + // it must not silently pick up the agent override. Surface the warning + fallback. + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'step.modelOverrides') return JSON.stringify({ 'wf:build': { providerId: 'gone', model: 'm1' } }) + return null + }) + const pm = fakeProviderManager(undefined) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(fallback) + expect(result.usedOverride).toBe(false) + expect(result.warning).toContain('gone') + }) +}) diff --git a/src/server/chat/orchestrator.ts b/src/server/chat/orchestrator.ts index c67185152..f561abe4f 100644 --- a/src/server/chat/orchestrator.ts +++ b/src/server/chat/orchestrator.ts @@ -117,6 +117,13 @@ export interface OrchestratorOptions { /** When true, the agent-definition reminder is not re-injected at turn start * (already present in history — used for workflow retries/resumes). */ skipAgentReminder?: boolean + /** + * When set, the agent turn resolves a per-step model override + * (`workflowId:stepId`) before falling back to the agent override. Set by + * the workflow executor for agent/sub-agent steps so each step can pin a + * different model. Undefined on the plain chat path (no step scope). + */ + stepContext?: import('../runner/types.js').StepContext } function resolveStatsIdentity(options: OrchestratorOptions): StatsIdentity { @@ -366,6 +373,7 @@ export async function runAgentTurn( options.sessionId, agentId, options.getSessionLLMClient ? options.getSessionLLMClient() : options.llmClient, + options.stepContext, ) const agentLlmClient = resolveAgentClient() const statsIdentity = resolveStatsIdentity({ ...options, llmClient: agentLlmClient }) diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index b0d3afc5b..b8b073f1f 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -42,6 +42,7 @@ export const SETTINGS_KEYS = { PROXY_URL: 'network.proxyUrl', DEFAULT_AGENT: 'agent.defaultAgent', AGENT_MODEL_OVERRIDES: 'agent.modelOverrides', + STEP_MODEL_OVERRIDES: 'step.modelOverrides', } as const export const SETTINGS_DEFAULTS: Record = { diff --git a/src/server/runner/types.ts b/src/server/runner/types.ts index 807a93f5e..40c09047d 100644 --- a/src/server/runner/types.ts +++ b/src/server/runner/types.ts @@ -68,6 +68,15 @@ export interface OrchestratorOptions { transitionHandlers?: TransitionHandlerRegistry } +/** + * Identifies a workflow step for per-step model override resolution. + * The override key is `${workflowId}:${stepId}`. + */ +export interface StepContext { + workflowId: string + stepId: string +} + export interface OrchestratorResult { finalAction: NextAction iterations: number diff --git a/src/server/session/manager.ts b/src/server/session/manager.ts index 33dcdf2b3..c3db218a8 100644 --- a/src/server/session/manager.ts +++ b/src/server/session/manager.ts @@ -64,7 +64,8 @@ import { logger } from '../utils/logger.js' import { EventEmitter, type Unsubscribe } from '../utils/async.js' import { getLspManager as getOrCreateLspManager, shutdownLspManager, type LspManager } from '../lsp/index.js' import { devServerManager } from '../dev-server/manager.js' -import { resolveLLMClientForAgent, getAgentModelOverride } from '../agents/model-overrides.js' +import { resolveLLMClientForAgent, resolveLLMClientForStep, getAgentModelOverride } from '../agents/model-overrides.js' +import type { StepContext } from '../runner/types.js' import { parseDefaultModelSelection } from '../provider-manager.js' import { getEventStore } from '../events/store.js' import { @@ -155,13 +156,24 @@ export class SessionManager { sessionId: string, agentId: string, preferredFallback?: import('../llm/client.js').LLMClientWithModel, + stepContext?: StepContext, ): import('../llm/client.js').LLMClientWithModel { const fallback = preferredFallback ?? this.providerManager.getLLMClient() const pinnedEffort = dbGetSession(sessionId)?.providerPinnedEffort ?? undefined - const resolved = resolveLLMClientForAgent(agentId, fallback, this.providerManager, pinnedEffort) + const resolved = stepContext + ? resolveLLMClientForStep( + stepContext.workflowId, + stepContext.stepId, + agentId, + fallback, + this.providerManager, + pinnedEffort, + ) + : resolveLLMClientForAgent(agentId, fallback, this.providerManager, pinnedEffort) if (resolved.warning) { logger.warn('Agent model override unavailable, falling back', { agentId, + ...(stepContext ? { stepId: stepContext.stepId, workflowId: stepContext.workflowId } : {}), warning: resolved.warning, }) } diff --git a/src/server/sub-agents/manager.ts b/src/server/sub-agents/manager.ts index 7d543e976..e3d8f5435 100644 --- a/src/server/sub-agents/manager.ts +++ b/src/server/sub-agents/manager.ts @@ -18,7 +18,7 @@ import type { AgentDefinition } from '../agents/types.js' import { readFile, access } from 'node:fs/promises' import { join, dirname, isAbsolute } from 'node:path' import { loadAllAgentsDefault, findAgentById } from '../agents/registry.js' -import { resolveLLMClientForAgent } from '../agents/model-overrides.js' +import { resolveLLMClientForAgent, resolveLLMClientForStep } from '../agents/model-overrides.js' import { buildBasePrompt } from '../chat/prompts.js' import { TurnMetrics, createMessageStartEvent } from '../chat/stream-pure.js' import { runTopLevelAgentLoop } from '../chat/agent-loop.js' @@ -55,6 +55,12 @@ export interface SubAgentExecutionOptions { providerManager?: ProviderManager | undefined signal?: AbortSignal onMessage?: (msg: ServerMessage) => void + /** + * When set, resolve a per-step model override (`workflowId:stepId`) before + * falling back to the sub-agent override. Set by the workflow executor for + * sub-agent steps. + */ + stepContext?: import('../runner/types.js').StepContext } export interface SubAgentResult { @@ -147,6 +153,7 @@ export async function executeSubAgent(options: SubAgentExecutionOptions): Promis providerManager, signal, onMessage, + stepContext, } = options const agentDef = await resolveAgentDef(subAgentType, sessionManager.getProjectWorkdir(sessionId)) @@ -165,7 +172,16 @@ export async function executeSubAgent(options: SubAgentExecutionOptions): Promis // A session-pinned effort ("Keep current reasoning effort") wins over the // sub-agent override's own effort, mirroring the top-level agent path. const pinnedEffort = session.providerPinnedEffort ?? undefined - const resolved = resolveLLMClientForAgent(subAgentType, parentLlmClient, providerManager, pinnedEffort) + const resolved = stepContext + ? resolveLLMClientForStep( + stepContext.workflowId, + stepContext.stepId, + subAgentType, + parentLlmClient, + providerManager, + pinnedEffort, + ) + : resolveLLMClientForAgent(subAgentType, parentLlmClient, providerManager, pinnedEffort) if (resolved.usedOverride && resolved.override) { hasOverride = true llmClient = resolved.client diff --git a/src/server/workflows/executor-mode.test.ts b/src/server/workflows/executor-mode.test.ts index 6c03b166c..50d27bb55 100644 --- a/src/server/workflows/executor-mode.test.ts +++ b/src/server/workflows/executor-mode.test.ts @@ -86,6 +86,8 @@ vi.mock('../git/diff.js', () => ({ import { executeWorkflow, userStepChoices } from './executor.js' import { runAgentTurn } from '../chat/orchestrator.js' +import { executeSubAgent } from '../sub-agents/manager.js' +import { findAgentById } from '../agents/registry.js' describe('userStepChoices', () => { it('maps step_result transitions to choices and appends a Continue choice for always', () => { @@ -726,4 +728,43 @@ describe('executeWorkflow mode changes', () => { // Should complete successfully (reach $done) expect(result.finalAction).toHaveProperty('type', 'DONE') }) + + it('threads stepContext into runAgentTurn for agent steps', async () => { + await executeWorkflow(workflow, options) + + const opts = vi.mocked(runAgentTurn).mock.calls[0]?.[0] as + { stepContext?: { workflowId: string; stepId: string } } | undefined + expect(opts?.stepContext).toEqual({ workflowId: 'test', stepId: 'build' }) + }) + + it('threads stepContext into executeSubAgent for sub_agent steps', async () => { + // findAgentById defaults to undefined (mock) which short-circuits the + // sub-agent step before executeSubAgent is reached. Return a minimal def + // so the executor proceeds to the executeSubAgent call. + vi.mocked(findAgentById).mockReturnValue({ + metadata: { id: 'verifier', name: 'Verifier', subagent: true }, + } as any) + + const subAgentWorkflow: WorkflowDefinition = { + metadata: { id: 'test', name: 'Test', description: '', version: '1' }, + entryStep: 'verify', + settings: { maxIterations: 10 }, + steps: [ + { + id: 'verify', + name: 'Verifier', + type: 'sub_agent', + phase: 'verification', + subAgentType: 'verifier', + transitions: [{ when: { type: 'always' }, goto: '$done' }], + }, + ], + } + + await executeWorkflow(subAgentWorkflow, options) + + const opts = vi.mocked(executeSubAgent).mock.calls[0]?.[0] as + { stepContext?: { workflowId: string; stepId: string } } | undefined + expect(opts?.stepContext).toEqual({ workflowId: 'test', stepId: 'verify' }) + }) }) diff --git a/src/server/workflows/executor.ts b/src/server/workflows/executor.ts index 800b8bccb..688e18cad 100644 --- a/src/server/workflows/executor.ts +++ b/src/server/workflows/executor.ts @@ -643,6 +643,7 @@ export async function executeWorkflow( sessionManager, sessionId, llmClient, + stepContext: { workflowId: workflow.metadata.id, stepId: step.id }, ...(options.getSessionLLMClient ? { getSessionLLMClient: options.getSessionLLMClient } : {}), ...(options.statsIdentity ? { statsIdentity: options.statsIdentity } : {}), ...(signal ? { signal } : {}), @@ -743,6 +744,7 @@ export async function executeWorkflow( llmClient, toolRegistry: filteredToolRegistry, turnMetrics, + stepContext: { workflowId: workflow.metadata.id, stepId: step.id }, statsIdentity: options.statsIdentity ?? { providerId: '', providerName: '', From f2dea901f38ff5897a59b0aad02ab2ecb1348d1b Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 19 Aug 2026 16:41:14 +0200 Subject: [PATCH 3/7] feat(teams): workflow team presets (N roles, arbitrary models) A team is a named bundle of per-step model assignments bound to a workflow. Binding a workflow to a team makes every run resolve each step's model from the team transparently, no per-run apply. Precedence in resolveLLMClientForStep: step override > team assignment > agent override > session. A broken team assignment is a hard error for that step (no silent fallthrough to the agent override). - TEAMS + WORKFLOW_TEAM settings keys, teams.ts CRUD (parse/get/set, workflow binding) - Team-aware resolution in resolveLLMClientForStep - REST API /api/teams (CRUD) + /api/teams/bindings/:workflowId - Shared override-schema.ts + settings-json.ts helpers (0% jscpd) - TDD: 10 teams unit tests, 7 team-resolution tests, 8 route tests; full unit suite 4393 pass, 0 fail --- src/server/agents/model-overrides.ts | 82 ++++++---- src/server/agents/override-schema.ts | 18 +++ src/server/agents/settings-json.ts | 19 +++ .../agents/step-model-overrides.test.ts | 109 +++++++++++++ src/server/agents/teams.test.ts | 144 +++++++++++++++++ src/server/agents/teams.ts | 98 ++++++++++++ src/server/db/settings.ts | 2 + src/server/index.ts | 2 + src/server/routes/teams.test.ts | 147 ++++++++++++++++++ src/server/routes/teams.ts | 81 ++++++++++ 10 files changed, 669 insertions(+), 33 deletions(-) create mode 100644 src/server/agents/override-schema.ts create mode 100644 src/server/agents/settings-json.ts create mode 100644 src/server/agents/teams.test.ts create mode 100644 src/server/agents/teams.ts create mode 100644 src/server/routes/teams.test.ts create mode 100644 src/server/routes/teams.ts diff --git a/src/server/agents/model-overrides.ts b/src/server/agents/model-overrides.ts index e95b6f4fe..e4b245f50 100644 --- a/src/server/agents/model-overrides.ts +++ b/src/server/agents/model-overrides.ts @@ -6,21 +6,19 @@ * Absence of an override = agent uses the session/global model. */ -import { z } from 'zod' import { getSetting, setSetting, SETTINGS_KEYS } from '../db/settings.js' +import { getTeam, getWorkflowTeam } from './teams.js' +import { parseJsonObject } from './settings-json.js' +import { overrideSchema, type AgentModelOverride } from './override-schema.js' import type { LLMClientWithModel } from '../llm/client.js' import type { ProviderManager } from '../provider-manager.js' +export { overrideSchema } +export type { AgentModelOverride } + export const AGENT_MODEL_OVERRIDES_KEY = SETTINGS_KEYS.AGENT_MODEL_OVERRIDES export const STEP_MODEL_OVERRIDES_KEY = SETTINGS_KEYS.STEP_MODEL_OVERRIDES -const overrideSchema = z.object({ - providerId: z.string().min(1), - model: z.string().min(1), - reasoningEffort: z.string().min(1).optional(), -}) - -export type AgentModelOverride = z.infer export type AgentModelOverrides = Record export type StepModelOverrides = Record @@ -39,14 +37,8 @@ export function parseStepModelOverrides(raw: string | null | undefined): StepMod /** Shared parser for an override map keyed by an arbitrary string id. */ function parseOverridesMap(raw: string | null | undefined): Record { - if (!raw) return {} - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return {} - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + const parsed = parseJsonObject(raw) + if (!parsed) return {} const result: Record = {} for (const [id, value] of Object.entries(parsed)) { @@ -147,9 +139,12 @@ export function resolveLLMClientForAgent( * 1. step override (`workflowId:stepId`) — wins over everything when present. * A configured-but-unresolvable step override is a hard error for that * step: it falls back to the session model with a warning and does NOT - * silently pick up the agent override. - * 2. agent override (`agentId`) — delegated to `resolveLLMClientForAgent`. - * 3. session/global fallback. + * silently pick up the team/agent override. + * 2. team assignment — when the workflow is bound to a team that has an + * assignment for this step. A broken team assignment is likewise a hard + * error for that step (no fallthrough to the agent override). + * 3. agent override (`agentId`) — delegated to `resolveLLMClientForAgent`. + * 4. session/global fallback. */ export function resolveLLMClientForStep( workflowId: string, @@ -160,24 +155,45 @@ export function resolveLLMClientForStep( pinnedEffort?: string, ): AgentClientResolution { const stepOverride = getStepModelOverride(workflowId, stepId) - if (!stepOverride) { - return resolveLLMClientForAgent(agentId, fallbackClient, providerManager, pinnedEffort) + if (stepOverride) { + const effectiveEffort = pinnedEffort ?? stepOverride.reasoningEffort + const client = providerManager.createClient(stepOverride.providerId, stepOverride.model, effectiveEffort) + if (!client) { + return { + client: fallbackClient, + usedOverride: false, + override: stepOverride, + warning: `Step '${stepOverrideKey(workflowId, stepId)}' is configured to use model '${stepOverride.model}' from provider '${stepOverride.providerId}', but it is no longer available. Falling back to the session model.`, + } + } + return { + client, + usedOverride: true, + override: effectiveEffort ? { ...stepOverride, reasoningEffort: effectiveEffort } : stepOverride, + } } - const effectiveEffort = pinnedEffort ?? stepOverride.reasoningEffort - const client = providerManager.createClient(stepOverride.providerId, stepOverride.model, effectiveEffort) - if (!client) { + // Team assignment: workflow bound to a team that carries this step. + const teamId = getWorkflowTeam(workflowId) + const team = teamId ? getTeam(teamId) : undefined + const teamAssignment = team?.assignments[stepId] + if (teamAssignment) { + const effectiveEffort = pinnedEffort ?? teamAssignment.reasoningEffort + const client = providerManager.createClient(teamAssignment.providerId, teamAssignment.model, effectiveEffort) + if (!client) { + return { + client: fallbackClient, + usedOverride: false, + override: teamAssignment, + warning: `Step '${stepOverrideKey(workflowId, stepId)}' (team '${teamId}') is configured to use model '${teamAssignment.model}' from provider '${teamAssignment.providerId}', but it is no longer available. Falling back to the session model.`, + } + } return { - client: fallbackClient, - usedOverride: false, - override: stepOverride, - warning: `Step '${stepOverrideKey(workflowId, stepId)}' is configured to use model '${stepOverride.model}' from provider '${stepOverride.providerId}', but it is no longer available. Falling back to the session model.`, + client, + usedOverride: true, + override: effectiveEffort ? { ...teamAssignment, reasoningEffort: effectiveEffort } : teamAssignment, } } - return { - client, - usedOverride: true, - override: effectiveEffort ? { ...stepOverride, reasoningEffort: effectiveEffort } : stepOverride, - } + return resolveLLMClientForAgent(agentId, fallbackClient, providerManager, pinnedEffort) } diff --git a/src/server/agents/override-schema.ts b/src/server/agents/override-schema.ts new file mode 100644 index 000000000..aec2a29c4 --- /dev/null +++ b/src/server/agents/override-schema.ts @@ -0,0 +1,18 @@ +/** + * Shared override schema. + * + * The `{ providerId, model, reasoningEffort? }` shape is reused by agent + * overrides, step overrides, and team assignments. Centralizing it avoids a + * circular import between model-overrides.ts and teams.ts (both consume it, + * and model-overrides imports team getters from teams). + */ + +import { z } from 'zod' + +export const overrideSchema = z.object({ + providerId: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) + +export type AgentModelOverride = z.infer diff --git a/src/server/agents/settings-json.ts b/src/server/agents/settings-json.ts new file mode 100644 index 000000000..fac56e01a --- /dev/null +++ b/src/server/agents/settings-json.ts @@ -0,0 +1,19 @@ +/** + * Shared JSON-settings parse helper. + * + * Settings are stored as a JSON string of an object keyed by id. Several + * override/team maps share the same "parse + guard it's a plain object" + * preamble; this centralizes it so jscpd stops flagging the boilerplate. + */ + +export function parseJsonObject(raw: string | null | undefined): Record | null { + if (!raw) return null + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null + return parsed as Record +} diff --git a/src/server/agents/step-model-overrides.test.ts b/src/server/agents/step-model-overrides.test.ts index 917586f02..e5b3efa54 100644 --- a/src/server/agents/step-model-overrides.test.ts +++ b/src/server/agents/step-model-overrides.test.ts @@ -217,3 +217,112 @@ describe('resolveLLMClientForStep', () => { expect(result.warning).toContain('gone') }) }) + +describe('resolveLLMClientForStep — team assignment', () => { + const fallback = fakeClient('global-model') + + function teamStore(teamId: string, assignments: Record): string { + return JSON.stringify({ [teamId]: { id: teamId, name: teamId, assignments } }) + } + + it('uses the team assignment when the workflow is bound to a team', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'teams') return teamStore('team-a', { build: { providerId: 'pt', model: 'team-model' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const dedicated = fakeClient('team-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.usedOverride).toBe(true) + expect(result.override).toEqual({ providerId: 'pt', model: 'team-model' }) + expect(pm.createClient).toHaveBeenCalledWith('pt', 'team-model', undefined) + }) + + it('explicit step override wins over team assignment', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'step.modelOverrides') + return JSON.stringify({ 'wf:build': { providerId: 'ps', model: 'step-model' } }) + if (key === 'teams') return teamStore('team-a', { build: { providerId: 'pt', model: 'team-model' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const dedicated = fakeClient('step-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.override).toEqual({ providerId: 'ps', model: 'step-model' }) + }) + + it('team assignment wins over agent override', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'teams') return teamStore('team-a', { build: { providerId: 'pt', model: 'team-model' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const dedicated = fakeClient('team-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.override).toEqual({ providerId: 'pt', model: 'team-model' }) + }) + + it('falls back to agent override when the team has no assignment for the step', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'teams') return teamStore('team-a', { verify: { providerId: 'pt', model: 'team-model' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const dedicated = fakeClient('agent-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.override).toEqual({ providerId: 'pa', model: 'agent-model' }) + }) + + it('falls back to agent override when the bound team no longer exists', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'teams') return JSON.stringify({}) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-gone' }) + return null + }) + const dedicated = fakeClient('agent-model') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(dedicated) + expect(result.override).toEqual({ providerId: 'pa', model: 'agent-model' }) + }) + + it('team assignment with a missing provider falls back with warning (no fallthrough to agent)', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'agent.modelOverrides') return JSON.stringify({ builder: { providerId: 'pa', model: 'agent-model' } }) + if (key === 'teams') return teamStore('team-a', { build: { providerId: 'gone', model: 'm1' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const pm = fakeProviderManager(undefined) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm) + expect(result.client).toBe(fallback) + expect(result.usedOverride).toBe(false) + expect(result.warning).toContain('gone') + }) + + it('pinned effort is applied to the team assignment', () => { + getSettingMock.mockImplementation((key: string) => { + if (key === 'teams') + return teamStore('team-a', { build: { providerId: 'pt', model: 'm1', reasoningEffort: 'low' } }) + if (key === 'workflow.team') return JSON.stringify({ wf: 'team-a' }) + return null + }) + const dedicated = fakeClient('m1') + const pm = fakeProviderManager(dedicated) + const result = resolveLLMClientForStep('wf', 'build', 'builder', fallback, pm, 'max') + expect(result.usedOverride).toBe(true) + expect(result.override).toEqual({ providerId: 'pt', model: 'm1', reasoningEffort: 'max' }) + expect(pm.createClient).toHaveBeenCalledWith('pt', 'm1', 'max') + }) +}) diff --git a/src/server/agents/teams.test.ts b/src/server/agents/teams.test.ts new file mode 100644 index 000000000..8af9ed5e4 --- /dev/null +++ b/src/server/agents/teams.test.ts @@ -0,0 +1,144 @@ +/** + * Teams Tests + * + * A team is a named map of stepId -> model override, bound to a workflow via + * `workflow.team` (workflowId -> teamId). Resolution is lazy in + * resolveLLMClientForStep: step override > team assignment > agent override. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { getSettingMock, setSettingMock } = vi.hoisted(() => ({ + getSettingMock: vi.fn(), + setSettingMock: vi.fn(), +})) + +vi.mock('../db/settings.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + getSetting: getSettingMock, + setSetting: setSettingMock, + } +}) + +import { + parseTeams, + getTeam, + setTeam, + getWorkflowTeam, + setWorkflowTeam, + TEAMS_KEY, + WORKFLOW_TEAM_KEY, +} from './teams.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseTeams', () => { + it('returns empty map for null/undefined/invalid JSON', () => { + expect(parseTeams(null)).toEqual({}) + expect(parseTeams(undefined)).toEqual({}) + expect(parseTeams('not json')).toEqual({}) + expect(parseTeams('[]')).toEqual({}) + expect(parseTeams('"str"')).toEqual({}) + }) + + it('parses valid teams and drops malformed ones', () => { + const raw = JSON.stringify({ + 'team-a': { + id: 'team-a', + name: 'Team A', + assignments: { + build: { providerId: 'p1', model: 'm1' }, + verify: { providerId: 'p2', model: 'm2', reasoningEffort: 'high' }, + }, + }, + bad1: { id: 'bad1', name: 'Bad', assignments: 'nope' }, + bad2: { id: 'bad2', assignments: {} }, + bad3: 'nope', + }) + expect(parseTeams(raw)).toEqual({ + 'team-a': { + id: 'team-a', + name: 'Team A', + assignments: { + build: { providerId: 'p1', model: 'm1' }, + verify: { providerId: 'p2', model: 'm2', reasoningEffort: 'high' }, + }, + }, + }) + }) +}) + +describe('getTeam / setTeam', () => { + it('returns undefined when no teams stored', () => { + getSettingMock.mockReturnValue(null) + expect(getTeam('team-a')).toBeUndefined() + expect(getSettingMock).toHaveBeenCalledWith(TEAMS_KEY) + }) + + it('returns the team for a known id', () => { + getSettingMock.mockReturnValue( + JSON.stringify({ + 'team-a': { id: 'team-a', name: 'Team A', assignments: { build: { providerId: 'p1', model: 'm1' } } }, + }), + ) + expect(getTeam('team-a')?.name).toBe('Team A') + expect(getTeam('team-b')).toBeUndefined() + }) + + it('writes a new team without clobbering others', () => { + getSettingMock.mockReturnValue( + JSON.stringify({ + 'team-b': { id: 'team-b', name: 'Team B', assignments: {} }, + }), + ) + setTeam('team-a', { id: 'team-a', name: 'Team A', assignments: { build: { providerId: 'p1', model: 'm1' } } }) + const [, value] = setSettingMock.mock.calls[0]! + const parsed = JSON.parse(value as string) + expect(parsed['team-a'].name).toBe('Team A') + expect(parsed['team-b'].name).toBe('Team B') + }) + + it('removes a team when passed null', () => { + getSettingMock.mockReturnValue( + JSON.stringify({ + 'team-a': { id: 'team-a', name: 'Team A', assignments: {} }, + 'team-b': { id: 'team-b', name: 'Team B', assignments: {} }, + }), + ) + setTeam('team-a', null) + const [, value] = setSettingMock.mock.calls[0]! + const parsed = JSON.parse(value as string) + expect(parsed['team-a']).toBeUndefined() + expect(parsed['team-b']).toBeDefined() + }) +}) + +describe('getWorkflowTeam / setWorkflowTeam', () => { + it('returns undefined when no binding stored', () => { + getSettingMock.mockReturnValue(null) + expect(getWorkflowTeam('wf')).toBeUndefined() + expect(getSettingMock).toHaveBeenCalledWith(WORKFLOW_TEAM_KEY) + }) + + it('returns the bound team id for a workflow', () => { + getSettingMock.mockReturnValue(JSON.stringify({ wf: 'team-a' })) + expect(getWorkflowTeam('wf')).toBe('team-a') + expect(getWorkflowTeam('other')).toBeUndefined() + }) + + it('sets a workflow binding, merging with existing bindings', () => { + getSettingMock.mockReturnValue(JSON.stringify({ other: 'team-b' })) + setWorkflowTeam('wf', 'team-a') + expect(setSettingMock).toHaveBeenCalledWith(WORKFLOW_TEAM_KEY, JSON.stringify({ other: 'team-b', wf: 'team-a' })) + }) + + it('removes a workflow binding when passed null', () => { + getSettingMock.mockReturnValue(JSON.stringify({ wf: 'team-a', other: 'team-b' })) + setWorkflowTeam('wf', null) + expect(setSettingMock).toHaveBeenCalledWith(WORKFLOW_TEAM_KEY, JSON.stringify({ other: 'team-b' })) + }) +}) diff --git a/src/server/agents/teams.ts b/src/server/agents/teams.ts new file mode 100644 index 000000000..dae8308f0 --- /dev/null +++ b/src/server/agents/teams.ts @@ -0,0 +1,98 @@ +/** + * Teams + * + * A team is a named bundle of per-step model assignments (`stepId -> override`) + * that can be bound to a workflow. Binding a workflow to a team makes every + * run of that workflow resolve each step's model from the team, transparently, + * without writing individual step overrides. + * + * Resolution precedence (in resolveLLMClientForStep): + * explicit step override > team assignment > agent override > session. + * + * Stored in DB settings: + * `teams` -> { [teamId]: Team } + * `workflow.team` -> { [workflowId]: teamId } + */ + +import { z } from 'zod' +import { getSetting, setSetting, SETTINGS_KEYS } from '../db/settings.js' +import { overrideSchema, type AgentModelOverride } from './override-schema.js' +import { parseJsonObject } from './settings-json.js' + +export const TEAMS_KEY = SETTINGS_KEYS.TEAMS +export const WORKFLOW_TEAM_KEY = SETTINGS_KEYS.WORKFLOW_TEAM + +/** A per-step assignment within a team: same shape as an agent/step override. */ +export type TeamAssignment = AgentModelOverride + +const teamSchema = z.object({ + id: z.string().min(1), + name: z.string(), + assignments: z.record(z.string(), overrideSchema), +}) + +export type Team = z.infer +export type Teams = Record + +export function parseTeams(raw: string | null | undefined): Teams { + const parsed = parseJsonObject(raw) + if (!parsed) return {} + + const result: Teams = {} + for (const [id, value] of Object.entries(parsed)) { + const validated = teamSchema.safeParse(value) + if (validated.success) { + result[id] = validated.data + } + } + return result +} + +export function getTeams(): Teams { + return parseTeams(getSetting(TEAMS_KEY)) +} + +export function getTeam(teamId: string): Team | undefined { + return getTeams()[teamId] +} + +export function setTeam(teamId: string, team: Team | null): void { + const teams = getTeams() + if (team === null) { + delete teams[teamId] + } else { + teams[teamId] = team + } + setSetting(TEAMS_KEY, JSON.stringify(teams)) +} + +// ---------------------------------------------------------------------------- +// Workflow -> Team binding +// ---------------------------------------------------------------------------- + +function parseWorkflowTeamMap(raw: string | null | undefined): Record { + const parsed = parseJsonObject(raw) + if (!parsed) return {} + + const result: Record = {} + for (const [workflowId, teamId] of Object.entries(parsed)) { + if (typeof teamId === 'string' && teamId.length > 0) { + result[workflowId] = teamId + } + } + return result +} + +export function getWorkflowTeam(workflowId: string): string | undefined { + return parseWorkflowTeamMap(getSetting(WORKFLOW_TEAM_KEY))[workflowId] +} + +export function setWorkflowTeam(workflowId: string, teamId: string | null): void { + const map = parseWorkflowTeamMap(getSetting(WORKFLOW_TEAM_KEY)) + if (teamId === null) { + delete map[workflowId] + } else { + map[workflowId] = teamId + } + setSetting(WORKFLOW_TEAM_KEY, JSON.stringify(map)) +} diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index b8b073f1f..0f2e0c74b 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -43,6 +43,8 @@ export const SETTINGS_KEYS = { DEFAULT_AGENT: 'agent.defaultAgent', AGENT_MODEL_OVERRIDES: 'agent.modelOverrides', STEP_MODEL_OVERRIDES: 'step.modelOverrides', + TEAMS: 'teams', + WORKFLOW_TEAM: 'workflow.team', } as const export const SETTINGS_DEFAULTS: Record = { diff --git a/src/server/index.ts b/src/server/index.ts index 8413bca0e..9cdd4573a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -39,6 +39,7 @@ import { setRuntimeConfig } from './runtime-config.js' import { createSkillRoutes } from './routes/skills.js' import { createCommandRoutes } from './routes/commands.js' import { createAgentRoutes } from './routes/agents.js' +import { createTeamRoutes } from './routes/teams.js' import { loadAllAgentsDefault, getTopLevelAgents } from './agents/registry.js' import { createWorkflowRoutes } from './routes/workflows.js' import { createDevServerRoutes } from './routes/dev-server.js' @@ -3114,6 +3115,7 @@ export async function createServerHandle(config: Config): Promise app.use('/api/commands', createCommandRoutes(configDir, projectDir)) app.use('/api/agents', createAgentRoutes(configDir, projectDir)) app.use('/api/workflows', createWorkflowRoutes(configDir, config, projectDir)) + app.use('/api/teams', createTeamRoutes()) app.use('/api/dev-server', createDevServerRoutes()) app.use('/api/workspace', createWorkspaceConfigRoutes(sessionManager)) app.use('/api/terminals', createTerminalRoutes()) diff --git a/src/server/routes/teams.test.ts b/src/server/routes/teams.test.ts new file mode 100644 index 000000000..fcb5e868e --- /dev/null +++ b/src/server/routes/teams.test.ts @@ -0,0 +1,147 @@ +/** + * Team Routes Tests + * + * In-memory DB integration: CRUD for teams + workflow->team binding. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import { type Server } from 'node:http' +import { closeDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' +import { getTeam, getWorkflowTeam } from '../agents/teams.js' +import { createTeamRoutes } from './teams.js' + +describe('Team routes', () => { + let server: Server + let baseUrl: string + + beforeEach(async () => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + + const app = express() + app.use(express.json()) + app.use('/api/teams', createTeamRoutes()) + + await new Promise((resolve) => { + server = app.listen(0, () => { + baseUrl = `http://localhost:${(server.address() as { port: number }).port}` + resolve() + }) + }) + }) + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())) + closeDatabase() + }) + + it('lists an empty team map initially', async () => { + const res = await fetch(`${baseUrl}/api/teams`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ teams: {} }) + }) + + it('creates and retrieves a team', async () => { + const create = await fetch(`${baseUrl}/api/teams/team-a`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Team A', + assignments: { + build: { providerId: 'p1', model: 'm1' }, + verify: { providerId: 'p2', model: 'm2', reasoningEffort: 'high' }, + }, + }), + }) + expect(create.status).toBe(200) + const team = (await create.json()) as { id: string; name: string } + expect(team.id).toBe('team-a') + expect(team.name).toBe('Team A') + + const res = await fetch(`${baseUrl}/api/teams/team-a`) + expect(res.status).toBe(200) + expect(getTeam('team-a')?.assignments['build']).toEqual({ providerId: 'p1', model: 'm1' }) + }) + + it('rejects a team with an invalid reasoningEffort', async () => { + const res = await fetch(`${baseUrl}/api/teams/bad`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Bad', + assignments: { build: { providerId: 'p1', model: 'm1', reasoningEffort: 'nope' } }, + }), + }) + expect(res.status).toBe(400) + }) + + it('rejects a team missing a name', async () => { + const res = await fetch(`${baseUrl}/api/teams/bad`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignments: {} }), + }) + expect(res.status).toBe(400) + }) + + it('deletes a team', async () => { + await fetch(`${baseUrl}/api/teams/team-a`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Team A', assignments: {} }), + }) + const res = await fetch(`${baseUrl}/api/teams/team-a`, { method: 'DELETE' }) + expect(res.status).toBe(200) + expect(getTeam('team-a')).toBeUndefined() + }) + + it('sets and reads a workflow->team binding', async () => { + await fetch(`${baseUrl}/api/teams/team-a`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Team A', assignments: {} }), + }) + + const bind = await fetch(`${baseUrl}/api/teams/bindings/wf`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ teamId: 'team-a' }), + }) + expect(bind.status).toBe(200) + expect(getWorkflowTeam('wf')).toBe('team-a') + + const read = await fetch(`${baseUrl}/api/teams/bindings/wf`) + expect(read.status).toBe(200) + expect(await read.json()).toEqual({ teamId: 'team-a' }) + }) + + it('rejects a binding to a non-existent team', async () => { + const res = await fetch(`${baseUrl}/api/teams/bindings/wf`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ teamId: 'ghost' }), + }) + expect(res.status).toBe(404) + }) + + it('clears a workflow->team binding', async () => { + await fetch(`${baseUrl}/api/teams/team-a`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Team A', assignments: {} }), + }) + await fetch(`${baseUrl}/api/teams/bindings/wf`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ teamId: 'team-a' }), + }) + + const res = await fetch(`${baseUrl}/api/teams/bindings/wf`, { method: 'DELETE' }) + expect(res.status).toBe(200) + expect(getWorkflowTeam('wf')).toBeUndefined() + }) +}) diff --git a/src/server/routes/teams.ts b/src/server/routes/teams.ts new file mode 100644 index 000000000..6022aed91 --- /dev/null +++ b/src/server/routes/teams.ts @@ -0,0 +1,81 @@ +/** + * Team Routes + * + * Teams are named per-step model bundles stored in DB settings. A workflow + * binds to a team via `/api/teams/bindings/:workflowId`; the executor then + * resolves each step's model from the team (precedence: step override > team + * > agent override > session). + */ + +import { Router } from 'express' +import { getTeams, getTeam, setTeam, getWorkflowTeam, setWorkflowTeam } from '../agents/teams.js' +import type { Team } from '../agents/teams.js' +import { isReasoningEffortValue } from '../providers/model-catalog.js' + +function validateAssignments(assignments: unknown): string | null { + if (assignments === undefined) return null + if (typeof assignments !== 'object' || assignments === null || Array.isArray(assignments)) + return 'assignments must be an object' + for (const [stepId, a] of Object.entries(assignments as Record)) { + if (typeof a !== 'object' || a === null) return `assignment for '${stepId}' must be an object` + const { providerId, model, reasoningEffort } = a as Record + if (typeof providerId !== 'string' || !providerId) return `assignment '${stepId}' missing providerId` + if (typeof model !== 'string' || !model) return `assignment '${stepId}' missing model` + if (reasoningEffort !== undefined && !isReasoningEffortValue(String(reasoningEffort))) + return `assignment '${stepId}' has unsupported reasoningEffort` + } + return null +} + +export function createTeamRoutes(): Router { + const router = Router() + + // --- Team CRUD --- + + router.get('/', (_req, res) => { + res.json({ teams: getTeams() }) + }) + + router.get('/:id', (req, res) => { + const team = getTeam(req.params.id) + if (!team) return res.status(404).json({ error: 'Team not found' }) + res.json(team) + }) + + router.put('/:id', (req, res) => { + const id = req.params.id + const { name, assignments } = req.body as { name?: string; assignments?: Record } + if (!name || typeof name !== 'string') return res.status(400).json({ error: 'Missing name' }) + const err = validateAssignments(assignments) + if (err) return res.status(400).json({ error: err }) + const team: Team = { id, name, assignments: (assignments as Team['assignments']) ?? {} } + setTeam(id, team) + res.json(team) + }) + + router.delete('/:id', (req, res) => { + setTeam(req.params.id, null) + res.json({ success: true }) + }) + + // --- Workflow -> Team binding --- + + router.get('/bindings/:workflowId', (req, res) => { + res.json({ teamId: getWorkflowTeam(req.params.workflowId) ?? null }) + }) + + router.put('/bindings/:workflowId', (req, res) => { + const { teamId } = req.body as { teamId?: string } + if (!teamId || typeof teamId !== 'string') return res.status(400).json({ error: 'Missing teamId' }) + if (!getTeam(teamId)) return res.status(404).json({ error: 'Team not found' }) + setWorkflowTeam(req.params.workflowId, teamId) + res.json({ success: true }) + }) + + router.delete('/bindings/:workflowId', (req, res) => { + setWorkflowTeam(req.params.workflowId, null) + res.json({ success: true }) + }) + + return router +} From 2d9f7b939a3a062d0ab8a07a12d0c65e88958d8e Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 19 Aug 2026 17:11:20 +0200 Subject: [PATCH 4/7] feat(workflows): add built-in llm_decision transition handler (Phase 3) Dynamic orchestration: a step whose transitions use when: { type: 'custom', handler: 'llm_decision', config } asks the step's resolved LLM to pick the next step. One LLM call per (workflow, step, outcome) is shared across sibling transitions via a module-level cache; only the transition whose thisGoto matches the LLM's choice fires. - New TransitionHandlerContext.llmClient: for step transitions it is the step's resolved client (honoring per-step/team overrides), so which LLM orchestrates is configurable per step; for the start condition it is the session client. - executor threads llmClient into the handler context at both sites (start condition + step transitions). - Handler gracefully returns false on missing client, failed call, or unparseable response so an always-fallback wins (never blocks). - Registered as a built-in before plugins load (plugin can override). - Update executor test mocks to expose createClientForAgent. --- src/server/index.ts | 5 + src/server/workflows/executor-mode.test.ts | 1 + .../workflows/executor-resume-nudge.test.ts | 1 + src/server/workflows/executor-retry.test.ts | 1 + .../workflows/executor-subgroup.test.ts | 1 + src/server/workflows/executor.ts | 13 ++ .../workflows/llm-decision-handler.test.ts | 206 ++++++++++++++++++ src/server/workflows/llm-decision-handler.ts | 197 +++++++++++++++++ src/server/workflows/transition-handlers.ts | 8 + 9 files changed, 433 insertions(+) create mode 100644 src/server/workflows/llm-decision-handler.test.ts create mode 100644 src/server/workflows/llm-decision-handler.ts diff --git a/src/server/index.ts b/src/server/index.ts index 9cdd4573a..300ad9027 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -53,6 +53,7 @@ import { createProviderAuthRoutes } from './routes/provider-auth.js' import { devServerManager } from './dev-server/manager.js' import { getGlobalConfigDir } from '../cli/paths.js' import { ProviderRegistry, loadProviderPlugins } from './providers/plugins/index.js' +import { createLlmDecisionHandler } from './workflows/llm-decision-handler.js' import { createPluginRoutes } from './routes/plugins.js' import { registerSessionFavoriteRoute } from './routes/session-favorite.js' import { logger, setLogLevel } from './utils/logger.js' @@ -121,6 +122,10 @@ export async function createServerHandle(config: Config): Promise mode: config.mode === 'development' ? 'development' : 'production', configDirectory: configDir, }) + // Register the built-in `llm_decision` transition handler before plugins + // load, so a plugin registering the same id still wins (last write). + providerAdapters.registerTransitionHandler('llm_decision', createLlmDecisionHandler()) + const pluginDiagnostics = await loadProviderPlugins({ registry: providerAdapters, configDirectory: configDir }) for (const diagnostic of pluginDiagnostics) { if (!diagnostic.loaded) logger.warn('Provider plugin failed to load', { ...diagnostic }) diff --git a/src/server/workflows/executor-mode.test.ts b/src/server/workflows/executor-mode.test.ts index 50d27bb55..80010a514 100644 --- a/src/server/workflows/executor-mode.test.ts +++ b/src/server/workflows/executor-mode.test.ts @@ -185,6 +185,7 @@ describe('executeWorkflow mode changes', () => { })), setMode, setPhase, + createClientForAgent: vi.fn((_sessionId: string, _agentId: string, fallbackClient: unknown) => fallbackClient), getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/test'), getProjectWorkdir: vi.fn().mockReturnValue('/tmp/test'), addMessage: vi.fn(), diff --git a/src/server/workflows/executor-resume-nudge.test.ts b/src/server/workflows/executor-resume-nudge.test.ts index f680f3d39..35d6f51d6 100644 --- a/src/server/workflows/executor-resume-nudge.test.ts +++ b/src/server/workflows/executor-resume-nudge.test.ts @@ -121,6 +121,7 @@ function createMockOptions(extra?: Partial): OrchestratorOp })), setMode: vi.fn(), setPhase: vi.fn(), + createClientForAgent: vi.fn((_s: string, _a: string, fb: unknown) => fb), getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/test'), getProjectWorkdir: vi.fn().mockReturnValue('/tmp/test'), addMessage: vi.fn(), diff --git a/src/server/workflows/executor-retry.test.ts b/src/server/workflows/executor-retry.test.ts index 0f5288a69..57f88f0d3 100644 --- a/src/server/workflows/executor-retry.test.ts +++ b/src/server/workflows/executor-retry.test.ts @@ -120,6 +120,7 @@ function createMockOptions(extra?: Partial): OrchestratorOp })), setMode: vi.fn(), setPhase: vi.fn(), + createClientForAgent: vi.fn((_s: string, _a: string, fb: unknown) => fb), getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/test'), getProjectWorkdir: vi.fn().mockReturnValue('/tmp/test'), addMessage: vi.fn(), diff --git a/src/server/workflows/executor-subgroup.test.ts b/src/server/workflows/executor-subgroup.test.ts index 9a2ccd57f..4bdb0a71d 100644 --- a/src/server/workflows/executor-subgroup.test.ts +++ b/src/server/workflows/executor-subgroup.test.ts @@ -163,6 +163,7 @@ function makeHarness(criteria: MetadataEntry[]) { })), setMode, setPhase, + createClientForAgent: vi.fn((_s: string, _a: string, fb: unknown) => fb), getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/test'), getProjectWorkdir: vi.fn().mockReturnValue('/tmp/test'), addMessage: vi.fn(), diff --git a/src/server/workflows/executor.ts b/src/server/workflows/executor.ts index 688e18cad..0fa101ea0 100644 --- a/src/server/workflows/executor.ts +++ b/src/server/workflows/executor.ts @@ -421,6 +421,7 @@ export async function executeWorkflow( workflowId: workflow.metadata.id, stepId: workflow.entryStep, signal, + llmClient, }, ) if (!conditionMet) { @@ -885,6 +886,17 @@ export async function executeWorkflow( const candidates = subGroup ? step.transitions.filter((t) => !t.subGroup || activeSubGroups.has(t.subGroup)) : step.transitions + // Resolve the step's LLM client so custom handlers (e.g. llm_decision) can + // route with the same model the step ran under (per-step/team overrides). + const decisionClient = + step.type === 'agent' + ? sessionManager.createClientForAgent( + sessionId, + (step as AgentStep).agentId ?? resolveDefaultAgentId(), + llmClient, + { workflowId: workflow.metadata.id, stepId: step.id }, + ) + : llmClient const fired = await findMatchingTransitionAsync( candidates, stepOutcome, @@ -896,6 +908,7 @@ export async function executeWorkflow( workflowId: workflow.metadata.id, stepId: step.id, signal, + llmClient: decisionClient, }, ) let nextStepId = fired ? fired.goto : TERMINAL_BLOCKED diff --git a/src/server/workflows/llm-decision-handler.test.ts b/src/server/workflows/llm-decision-handler.test.ts new file mode 100644 index 000000000..5c620c04c --- /dev/null +++ b/src/server/workflows/llm-decision-handler.test.ts @@ -0,0 +1,206 @@ +/** + * Phase 3 tests — built-in `llm_decision` transition handler (dynamic orchestration). + * + * The handler lets a workflow step ask an LLM "which step next?" and route + * accordingly. Each sibling transition declares the shared `candidates` list + * plus its own `thisGoto`; the handler makes ONE LLM call per (workflow, step, + * outcome) and fires only the transition whose `thisGoto` matches the LLM's + * chosen candidate. The orchestrator LLM is ctx.llmClient — the step's resolved + * model, honoring per-step/team overrides. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { LLMCompletionResponse } from '../llm/types.js' +import type { TransitionHandlerContext } from './transition-handlers.js' +import { createLlmDecisionHandler, __resetLlmDecisionCache } from './llm-decision-handler.js' + +/** Minimal mock LLM client — only `complete` is exercised. */ +function mockClient(content: string) { + const complete = vi.fn().mockResolvedValue({ + id: 'resp-1', + content, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + } as LLMCompletionResponse) + return { complete } +} + +function ctx( + overrides: Partial & { config: Record }, +): TransitionHandlerContext { + return { + stepOutcome: null, + metadataEntries: undefined, + workflowId: 'wf', + stepId: 'verify', + signal: undefined, + llmClient: undefined, + ...overrides, + } as TransitionHandlerContext +} + +const CANDIDATES = [ + { goto: 'build', label: 'Retry build', description: 'Code did not compile' }, + { goto: 'review', label: 'Send to review', description: 'Build passed' }, + { goto: '$done', label: 'Finish', description: 'Nothing left to do' }, +] + +describe('llm_decision transition handler', () => { + beforeEach(() => __resetLlmDecisionCache()) + + it('fires only the transition whose thisGoto matches the LLM choice, with one LLM call', async () => { + const client = mockClient('Send to review') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + + const retryCtx = ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'build', prompt: 'pick next' } }) + const reviewCtx = ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'review', prompt: 'pick next' } }) + const doneCtx = ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: '$done', prompt: 'pick next' } }) + + expect(await handler(retryCtx)).toBe(false) + expect(await handler(reviewCtx)).toBe(true) + expect(await handler(doneCtx)).toBe(false) + expect(client.complete).toHaveBeenCalledTimes(1) + }) + + it('caches the decision across sibling transitions (one LLM call per outcome)', async () => { + const client = mockClient('Finish') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + const buildCtx = ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'build' } }) + const doneCtx = ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: '$done' } }) + + await handler(buildCtx) + await handler(doneCtx) + await handler(buildCtx) + expect(client.complete).toHaveBeenCalledTimes(1) + }) + + it('re-queries the LLM for a different step outcome (cache keyed by outcome)', async () => { + const client = mockClient('Retry build') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + const passCtx = ctx({ + ...shared, + stepOutcome: { result: 'success', output: {} }, + config: { candidates: CANDIDATES, thisGoto: 'build' }, + }) + const failCtx = ctx({ + ...shared, + stepOutcome: { result: 'failure', output: { stderr: 'boom' } }, + config: { candidates: CANDIDATES, thisGoto: 'build' }, + }) + + await handler(passCtx) + await handler(failCtx) + expect(client.complete).toHaveBeenCalledTimes(2) + }) + + it('parses the chosen label out of a verbose response (substring fallback)', async () => { + const client = mockClient('I think the best option here is to Retry build because tests failed.') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + const r = await handler(ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'build' } })) + expect(r).toBe(true) + }) + + it('returns false for every transition when the response is unparseable', async () => { + const client = mockClient('banana') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + expect(await handler(ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'build' } }))).toBe(false) + expect(await handler(ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'review' } }))).toBe(false) + expect(client.complete).toHaveBeenCalledTimes(1) + }) + + it('falls through gracefully (returns false) when no llmClient is available', async () => { + const handler = createLlmDecisionHandler() + const r = await handler(ctx({ llmClient: undefined, config: { candidates: CANDIDATES, thisGoto: 'build' } })) + expect(r).toBe(false) + }) + + it('falls through gracefully when the LLM call rejects', async () => { + const complete = vi.fn().mockRejectedValue(new Error('upstream down')) + const client = { complete } + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + const r = await handler(ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: 'build' } })) + expect(r).toBe(false) + }) + + it('forwards ctx.signal and temperature to the LLM client', async () => { + const client = mockClient('Finish') + const handler = createLlmDecisionHandler() + const controller = new AbortController() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + signal: controller.signal, + } + await handler(ctx({ ...shared, config: { candidates: CANDIDATES, thisGoto: '$done', temperature: 0.2 } })) + + expect(client.complete).toHaveBeenCalledTimes(1) + const req = client.complete.mock.calls[0]![0] + expect(req.signal).toBe(controller.signal) + expect(req.temperature).toBe(0.2) + }) + + it('includes the step outcome and candidate labels in the prompt sent to the LLM', async () => { + const client = mockClient('Finish') + const handler = createLlmDecisionHandler() + + const shared = { + workflowId: 'wf', + stepId: 'verify', + llmClient: client as unknown as TransitionHandlerContext['llmClient'], + } + await handler( + ctx({ + ...shared, + stepOutcome: { result: 'failure', output: { stderr: 'syntax error' } }, + config: { candidates: CANDIDATES, thisGoto: '$done', prompt: 'What should we do next?' }, + }), + ) + + const req = client.complete.mock.calls[0]![0] + const userMessage = req.messages.find((m: { role: string }) => m.role === 'user') + const userText: string = userMessage!.content + expect(userText).toContain('What should we do next?') + expect(userText).toContain('Retry build') + expect(userText).toContain('Send to review') + expect(userText).toContain('failure') + expect(userText).toContain('syntax error') + }) +}) diff --git a/src/server/workflows/llm-decision-handler.ts b/src/server/workflows/llm-decision-handler.ts new file mode 100644 index 000000000..97f95859a --- /dev/null +++ b/src/server/workflows/llm-decision-handler.ts @@ -0,0 +1,197 @@ +/** + * Built-in `llm_decision` transition handler — dynamic orchestration. + * + * A workflow step whose transitions carry `when: { type: 'custom', handler: + * 'llm_decision', config }` asks the orchestrator LLM to pick the next step. + * Each sibling transition declares the shared `candidates` list plus its own + * `thisGoto`; the handler makes ONE LLM call per (workflow, step, outcome) and + * fires only the transition whose `thisGoto` matches the LLM's choice. + * + * Config shape (per transition): + * { + * prompt?: string, // optional custom question + * candidates: Array<{ goto: string, label: string, description?: string }>, + * thisGoto: string, // the goto this transition routes to + * temperature?: number // optional sampling override + * } + * + * The orchestrator LLM is `ctx.llmClient`: for step transitions this is the + * step's resolved model (honoring per-step/team overrides), so "which LLM + * orchestrates" is configurable per step. When no client is available or the + * call fails, the handler returns false so a following `always` fallback can + * win — the workflow never blocks on a routing decision. + */ + +import type { LLMMessage, LLMCompletionRequest } from '../llm/types.js' +import type { TransitionHandler, TransitionHandlerContext } from './transition-handlers.js' + +interface DecisionCandidate { + goto: string + label: string + description?: string +} + +interface LlmDecisionConfig { + prompt?: string + candidates: DecisionCandidate[] + thisGoto: string + temperature?: number +} + +/** Sentinel cached when the LLM produces no parseable choice. */ +const NO_CHOICE = '__no_choice__' + +/** + * Decision cache keyed by (workflow, step, outcome, candidate set). Lets all + * sibling transitions of one step share a single LLM call. Module-level by + * design: a routing decision for a given outcome must be stable within a run. + */ +const decisionCache = new Map() + +/** Test-only reset hook. */ +export function __resetLlmDecisionCache(): void { + decisionCache.clear() +} + +function outcomeToken(stepOutcome: TransitionHandlerContext['stepOutcome']): string { + if (!stepOutcome) return 'null' + const output = stepOutcome.output ?? {} + const keys = Object.keys(output).sort() + const out = keys.map((k) => `${k}=${output[k] ?? ''}`).join('|') + return `${stepOutcome.result ?? ''}|${out}` +} + +function candidatesToken(candidates: DecisionCandidate[]): string { + return candidates + .map((c) => `${c.label}::${c.goto}`) + .sort() + .join('||') +} + +function cacheKey(ctx: TransitionHandlerContext, candidates: DecisionCandidate[]): string { + return `${ctx.workflowId}:${ctx.stepId}:${outcomeToken(ctx.stepOutcome)}:${candidatesToken(candidates)}` +} + +function readConfig(raw: Record | undefined): LlmDecisionConfig | null { + if (!raw) return null + const thisGoto = typeof raw['thisGoto'] === 'string' ? (raw['thisGoto'] as string) : undefined + if (!thisGoto) return null + const candidatesRaw = raw['candidates'] + if (!Array.isArray(candidatesRaw)) return null + const candidates: DecisionCandidate[] = [] + for (const c of candidatesRaw) { + if (typeof c !== 'object' || c === null) continue + const obj = c as Record + const goto = typeof obj['goto'] === 'string' ? (obj['goto'] as string) : undefined + const label = typeof obj['label'] === 'string' ? (obj['label'] as string) : undefined + if (!goto || !label) continue + const candidate: DecisionCandidate = { goto, label } + if (typeof obj['description'] === 'string') candidate.description = obj['description'] as string + candidates.push(candidate) + } + if (candidates.length === 0) return null + const cfg: LlmDecisionConfig = { candidates, thisGoto } + if (typeof raw['prompt'] === 'string') cfg.prompt = raw['prompt'] as string + if (typeof raw['temperature'] === 'number') cfg.temperature = raw['temperature'] as number + return cfg +} + +function buildMessages(cfg: LlmDecisionConfig, ctx: TransitionHandlerContext): LLMMessage[] { + const lines = cfg.candidates.map((c) => `- ${c.label}${c.description ? `: ${c.description}` : ''} (goto: ${c.goto})`) + const question = cfg.prompt ?? 'Choose the best next step for this workflow.' + const outcome = ctx.stepOutcome + ? `result="${ctx.stepOutcome.result}" output=${JSON.stringify(ctx.stepOutcome.output ?? {})}` + : 'no step outcome yet (start of workflow)' + const user = [ + question, + '', + 'Available next steps:', + ...lines, + '', + `Current step: ${ctx.stepId} (workflow: ${ctx.workflowId})`, + `Step outcome: ${outcome}`, + '', + 'Reply with ONLY the label of the chosen step, nothing else.', + ].join('\n') + + return [ + { + role: 'system', + content: + 'You are a workflow orchestrator. You decide which step runs next. ' + + 'Reply with ONLY the label of exactly one candidate step, nothing else.', + }, + { role: 'user', content: user }, + ] +} + +function parseChoice(content: string, candidates: DecisionCandidate[]): string | null { + const text = content.trim().toLowerCase() + if (!text) return null + // Exact label match first. + for (const c of candidates) { + if (text === c.label.toLowerCase()) return c.goto + } + // Exact goto match (some models echo the id). + for (const c of candidates) { + if (text === c.goto.toLowerCase()) return c.goto + } + // Substring fallback: first candidate label appearing in the response. + for (const c of candidates) { + if (text.includes(c.label.toLowerCase())) return c.goto + } + return null +} + +/** + * Resolve the LLM's choice for the given context, using the cache when the + * same (workflow, step, outcome, candidates) has been seen. Returns the chosen + * goto, NO_CHOICE when the response was unparseable, or null when no decision + * could be made (no client, call failure, bad config). + */ +async function resolveChoice( + cfg: LlmDecisionConfig, + ctx: TransitionHandlerContext, + key: string, +): Promise { + const cached = decisionCache.get(key) + if (cached !== undefined) return cached + + if (!ctx.llmClient) { + return null + } + + const request: LLMCompletionRequest = { + messages: buildMessages(cfg, ctx), + temperature: cfg.temperature ?? 0, + skipClientReasoningEffort: true, + } + if (ctx.signal) request.signal = ctx.signal + + let choice: string | null = null + try { + const response = await ctx.llmClient.complete(request) + choice = parseChoice(response.content, cfg.candidates) + } catch { + // A failed call leaves choice at null → cached as NO_CHOICE below. + } + + // Always cache so sibling transitions share the single call: a parse failure + // (or a thrown call) is cached as NO_CHOICE so a dead upstream isn't queried + // once per transition. + decisionCache.set(key, choice ?? NO_CHOICE) + return choice +} + +/** Factory: a fresh handler closure (shares the module-level cache). */ +export function createLlmDecisionHandler(): TransitionHandler { + return async (ctx: TransitionHandlerContext): Promise => { + const cfg = readConfig(ctx.config) + if (!cfg) return false + + const key = cacheKey(ctx, cfg.candidates) + const choice = await resolveChoice(cfg, ctx, key) + if (choice === null) return false + return choice === cfg.thisGoto + } +} diff --git a/src/server/workflows/transition-handlers.ts b/src/server/workflows/transition-handlers.ts index 9a4771818..84bc117f7 100644 --- a/src/server/workflows/transition-handlers.ts +++ b/src/server/workflows/transition-handlers.ts @@ -12,6 +12,7 @@ */ import type { MetadataEntry } from '../../shared/types.js' +import type { LLMClientWithModel } from '../llm/client.js' /** Structural twin of executor's StepOutcome — avoids a circular import. */ export interface StepOutcomeLike { @@ -32,6 +33,13 @@ export interface TransitionHandlerContext { config?: Record | undefined /** Abort signal forwarded from the orchestrator. */ signal?: AbortSignal | undefined + /** + * LLM client a custom handler may call to make a routing decision (e.g. the + * built-in `llm_decision` handler). For step transitions this is the + * step's resolved client (honoring per-step/team overrides); for the start + * condition it is the session client. Undefined when no client is available. + */ + llmClient?: LLMClientWithModel | undefined } /** A transition handler returns true to fire the transition, false to skip it. */ From 0c86a084421b611763b2d49f27da73d346c2349b Mon Sep 17 00:00:00 2001 From: Gerald Date: Wed, 19 Aug 2026 19:04:40 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(workflows):=20Phase=204=20=E2=80=94=20?= =?UTF-8?q?dream=20orchestration=20demo=20+=20validation=20+=20step=20over?= =?UTF-8?q?ride=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the four-phase 'dream' composition end-to-end and makes Phase 1 fully configurable via HTTP. - phase4-integration.test.ts: proves the step-resolved (team/step) LLM client is the one llm_decision consults to route, the session client is not consulted, routing follows the LLM's choice, and an unparseable response falls through to the always-fallback. - step-model-overrides.ts (+7 tests): HTTP surface for per-step overrides (GET/PUT/DELETE /api/workflows/:workflowId/steps/:stepId/model). Phase 1 only had the resolver; this completes the configuration loop. - override-route-helpers.ts: shared body parser for both override PUT routes (agent + step), keeping jscpd at 0%. - .openfox/workflows/dream.workflow.json: loadable demo workflow using llm_decision to route build/verify/review. - docs/DREAM-ORCHESTRATION.md: setup guide (team binding, per-step override, llm_decision config). --- .gitignore | 4 +- .openfox/workflows/dream.workflow.json | 105 +++++++ docs/DREAM-ORCHESTRATION.md | 96 ++++++ src/server/index.ts | 2 + src/server/routes/agents.ts | 12 +- src/server/routes/override-route-helpers.ts | 29 ++ .../routes/step-model-overrides.test.ts | 125 ++++++++ src/server/routes/step-model-overrides.ts | 48 +++ .../workflows/phase4-integration.test.ts | 276 ++++++++++++++++++ 9 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 .openfox/workflows/dream.workflow.json create mode 100644 docs/DREAM-ORCHESTRATION.md create mode 100644 src/server/routes/override-route-helpers.ts create mode 100644 src/server/routes/step-model-overrides.test.ts create mode 100644 src/server/routes/step-model-overrides.ts create mode 100644 src/server/workflows/phase4-integration.test.ts diff --git a/.gitignore b/.gitignore index 5a78b2736..2fbb0388f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,6 @@ e2e-test-project/ tmp/ worktrees/ -plugins/ \ No newline at end of file +plugins/ +# jscpd JSON report (generated, .md is the tracked stub) +report/jscpd-report.json diff --git a/.openfox/workflows/dream.workflow.json b/.openfox/workflows/dream.workflow.json new file mode 100644 index 000000000..e2027f711 --- /dev/null +++ b/.openfox/workflows/dream.workflow.json @@ -0,0 +1,105 @@ +{ + "metadata": { + "id": "dream", + "name": "Dream — Dynamic Orchestration", + "description": "Demo workflow for the per-step / team / llm_decision dream: an LLM orchestrator decides which step runs next. Bind a team (Phase 2) or set per-step overrides (Phase 1) so each step runs on its own model; the verify step's resolved model is the one asked to route.", + "version": "1.0.0", + "color": "#8b5cf6" + }, + "entryStep": "build", + "settings": { + "maxIterations": 10 + }, + "steps": [ + { + "id": "build", + "name": "Build", + "type": "agent", + "phase": "build", + "agentId": "builder", + "prompt": "Implement the requested change. When you are done, call step_done().", + "transitions": [ + { + "when": { "type": "always" }, + "goto": "verify" + } + ] + }, + { + "id": "verify", + "name": "Verify & Route", + "type": "agent", + "phase": "verification", + "agentId": "verifier", + "prompt": "Run the tests and lint. Report the outcome via step_done().", + "transitions": [ + { + "when": { + "type": "custom", + "handler": "llm_decision", + "config": { + "prompt": "Given the verification outcome, what should we do next?", + "candidates": [ + { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, + { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, + { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + ], + "thisGoto": "build" + } + }, + "goto": "build" + }, + { + "when": { + "type": "custom", + "handler": "llm_decision", + "config": { + "prompt": "Given the verification outcome, what should we do next?", + "candidates": [ + { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, + { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, + { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + ], + "thisGoto": "review" + } + }, + "goto": "review" + }, + { + "when": { + "type": "custom", + "handler": "llm_decision", + "config": { + "prompt": "Given the verification outcome, what should we do next?", + "candidates": [ + { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, + { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, + { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + ], + "thisGoto": "$done" + } + }, + "goto": "$done" + }, + { + "when": { "type": "always" }, + "goto": "$done" + } + ] + }, + { + "id": "review", + "name": "Review", + "type": "agent", + "phase": "review", + "agentId": "reviewer", + "prompt": "Review the change for correctness and style. When done, call step_done().", + "transitions": [ + { + "when": { "type": "always" }, + "goto": "$done" + } + ] + } + ] +} diff --git a/docs/DREAM-ORCHESTRATION.md b/docs/DREAM-ORCHESTRATION.md new file mode 100644 index 000000000..0f1544a9d --- /dev/null +++ b/docs/DREAM-ORCHESTRATION.md @@ -0,0 +1,96 @@ +# Dream — Per-step dynamic orchestration + +This describes the four-phase feature that lets you choose, per workflow step, +which LLM orchestrates, which produces code, and which verifies — transparently +once configured, with no cap on the number of steps or models. + +| Phase | What it adds | Where | +| ----- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| 0 | Plugin API extension point for custom transition handlers | `src/server/providers/plugins/registry.ts` | +| 1 | Per-step model override (`workflowId:stepId`) | `src/server/agents/model-overrides.ts` | +| 2 | Teams: a named bundle of `stepId -> { providerId, model, reasoningEffort? }` bound to a workflow | `src/server/agents/teams.ts`, `src/server/routes/teams.ts` | +| 3 | Built-in `llm_decision` transition handler: an LLM picks the next step | `src/server/workflows/llm-decision-handler.ts` | + +## How they compose + +Resolution precedence for a step's LLM client (in `resolveLLMClientForStep`): + +``` +explicit step override > team assignment > agent override > session model +``` + +When a step's transitions use `when: { type: 'custom', handler: 'llm_decision', +config }`, the executor passes that step's **resolved** client to the handler. +So "which LLM orchestrates this step" is exactly the model you assigned to that +step — per step, via a team, or via an explicit override. One LLM call is made +per `(workflow, step, outcome)` and shared across sibling transitions; only the +transition whose `thisGoto` matches the LLM's choice fires. If the call fails or +the response is unparseable, the handler returns false and a following `always` +fallback wins — routing never blocks. + +## Configuring it + +### 1. Bind a team to a workflow (recommended for N steps / N models) + +```bash +# Create a team that assigns a different model to each step. +curl -X PUT localhost:3000/api/teams/dream-team \ + -H 'content-type: application/json' \ + -d '{ + "name": "Dream team", + "assignments": { + "build": { "providerId": "openai", "model": "gpt-4o" }, + "verify": { "providerId": "anthropic", "model": "claude-sonnet-5", "reasoningEffort": "high" }, + "review": { "providerId": "openai", "model": "gpt-4o-mini" } + } + }' + +# Bind the workflow to the team (every run resolves steps from it). +curl -X PUT localhost:3000/api/teams/bindings/dream \ + -H 'content-type: application/json' \ + -d '{"teamId":"dream-team"}' +``` + +### 2. Or override a single step + +```bash +curl -X PUT localhost:3000/api/workflows/dream/steps/verify/model \ + -H 'content-type: application/json' \ + -d '{ "providerId": "anthropic", "model": "claude-sonnet-5", "reasoningEffort": "high" }' +``` + +### 3. Use `llm_decision` in the workflow + +See `.openfox/workflows/dream.workflow.json` for a full example. The routing +step declares one `custom` transition per candidate, each with the shared +`candidates` list and its own `thisGoto`: + +```json +{ + "when": { + "type": "custom", + "handler": "llm_decision", + "config": { + "prompt": "Given the verification outcome, what should we do next?", + "candidates": [ + { "goto": "build", "label": "Retry build", "description": "Tests failed" }, + { "goto": "review", "label": "Send to review", "description": "Passed" }, + { "goto": "$done", "label": "Finish", "description": "Nothing left" } + ], + "thisGoto": "build" + } + }, + "goto": "build" +} +``` + +Repeat for each candidate's `thisGoto`, then add an `always` fallback. The +handler is registered as a built-in before plugins load; a plugin registering +`llm_decision` overrides it. + +## Verification + +`src/server/workflows/phase4-integration.test.ts` proves the composition +end-to-end: the `llm_decision` call is made on the step-resolved (team) client, +the session client is not consulted, and routing follows the LLM's choice (or +falls through to the `always` fallback on an unparseable response). diff --git a/src/server/index.ts b/src/server/index.ts index 300ad9027..b986ec5d6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -40,6 +40,7 @@ import { createSkillRoutes } from './routes/skills.js' import { createCommandRoutes } from './routes/commands.js' import { createAgentRoutes } from './routes/agents.js' import { createTeamRoutes } from './routes/teams.js' +import { createStepModelOverrideRoutes } from './routes/step-model-overrides.js' import { loadAllAgentsDefault, getTopLevelAgents } from './agents/registry.js' import { createWorkflowRoutes } from './routes/workflows.js' import { createDevServerRoutes } from './routes/dev-server.js' @@ -3120,6 +3121,7 @@ export async function createServerHandle(config: Config): Promise app.use('/api/commands', createCommandRoutes(configDir, projectDir)) app.use('/api/agents', createAgentRoutes(configDir, projectDir)) app.use('/api/workflows', createWorkflowRoutes(configDir, config, projectDir)) + app.use('/api/workflows', createStepModelOverrideRoutes()) app.use('/api/teams', createTeamRoutes()) app.use('/api/dev-server', createDevServerRoutes()) app.use('/api/workspace', createWorkspaceConfigRoutes(sessionManager)) diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts index b026b87af..01cc3e72d 100644 --- a/src/server/routes/agents.ts +++ b/src/server/routes/agents.ts @@ -15,7 +15,7 @@ import { import type { AgentDefinition } from '../agents/types.js' import { createCrudRoutes, type CrudRouteConfig } from './crud-helpers.js' import { getAgentModelOverride, setAgentModelOverride, getAgentModelOverrides } from '../agents/model-overrides.js' -import { isReasoningEffortValue } from '../providers/model-catalog.js' +import { readOverrideFields } from './override-route-helpers.js' import { logger } from '../utils/logger.js' // Pre-load default agent IDs at module init for fast synchronous validation. @@ -75,14 +75,8 @@ const config: CrudRouteConfig = { router.put('/:id/model', (req, res) => { const { id } = req.params - const { providerId, model, reasoningEffort } = req.body as { - providerId?: string - model?: string - reasoningEffort?: string - } - if (reasoningEffort !== undefined && !isReasoningEffortValue(reasoningEffort)) { - return res.status(400).json({ error: `Unsupported reasoningEffort: ${reasoningEffort}` }) - } + const { providerId, model, reasoningEffort, error } = readOverrideFields(req.body) + if (error) return res.status(400).json({ error }) if (providerId && model) { setAgentModelOverride(id, { providerId, diff --git a/src/server/routes/override-route-helpers.ts b/src/server/routes/override-route-helpers.ts new file mode 100644 index 000000000..f04c332c1 --- /dev/null +++ b/src/server/routes/override-route-helpers.ts @@ -0,0 +1,29 @@ +/** + * Shared HTTP body parsing for model-override routes. + * + * Both the agent-override (`/api/agents/:id/model`) and step-override + * (`/api/workflows/:workflowId/steps/:stepId/model`) PUT handlers validate the + * same { providerId, model, reasoningEffort? } body. Centralizing the destructure + * + reasoningEffort guard keeps jscpd at 0% and the two routes in sync. + */ + +import { isReasoningEffortValue } from '../providers/model-catalog.js' + +export interface OverrideFields { + providerId: string | undefined + model: string | undefined + reasoningEffort: string | undefined + error: string | null +} + +export function readOverrideFields(body: unknown): OverrideFields { + const { providerId, model, reasoningEffort } = (body ?? {}) as { + providerId?: string + model?: string + reasoningEffort?: string + } + if (reasoningEffort !== undefined && !isReasoningEffortValue(reasoningEffort)) { + return { providerId, model, reasoningEffort, error: `Unsupported reasoningEffort: ${reasoningEffort}` } + } + return { providerId, model, reasoningEffort, error: null } +} diff --git a/src/server/routes/step-model-overrides.test.ts b/src/server/routes/step-model-overrides.test.ts new file mode 100644 index 000000000..820961fd0 --- /dev/null +++ b/src/server/routes/step-model-overrides.test.ts @@ -0,0 +1,125 @@ +/** + * Step Model Override Routes Tests + * + * In-memory DB integration: per-step (workflowId:stepId) model override CRUD. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import express from 'express' +import { type Server } from 'node:http' +import { closeDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' +import { getStepModelOverride } from '../agents/model-overrides.js' +import { createStepModelOverrideRoutes } from './step-model-overrides.js' + +describe('Step model override routes', () => { + let server: Server + let baseUrl: string + + beforeEach(async () => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + + const app = express() + app.use(express.json()) + app.use('/api/workflows', createStepModelOverrideRoutes()) + + await new Promise((resolve) => { + server = app.listen(0, () => { + baseUrl = `http://localhost:${(server.address() as { port: number }).port}` + resolve() + }) + }) + }) + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())) + closeDatabase() + }) + + it('returns null when no step override is set', async () => { + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ providerId: null, model: null, reasoningEffort: null }) + }) + + it('sets and reads a step override', async () => { + const put = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'anthropic', model: 'claude-sonnet-5', reasoningEffort: 'high' }), + }) + expect(put.status).toBe(200) + expect(getStepModelOverride('wf', 'verify')).toEqual({ + providerId: 'anthropic', + model: 'claude-sonnet-5', + reasoningEffort: 'high', + }) + + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`) + expect(await res.json()).toEqual({ providerId: 'anthropic', model: 'claude-sonnet-5', reasoningEffort: 'high' }) + }) + + it('rejects an invalid reasoningEffort', async () => { + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p', model: 'm', reasoningEffort: 'nope' }), + }) + expect(res.status).toBe(400) + }) + + it('rejects a partial override (missing model)', async () => { + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p' }), + }) + expect(res.status).toBe(400) + }) + + it('clears a step override with an empty body', async () => { + await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p', model: 'm' }), + }) + expect(getStepModelOverride('wf', 'verify')).toBeDefined() + + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + expect(getStepModelOverride('wf', 'verify')).toBeUndefined() + }) + + it('deletes a step override', async () => { + await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p', model: 'm' }), + }) + const res = await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { method: 'DELETE' }) + expect(res.status).toBe(200) + expect(getStepModelOverride('wf', 'verify')).toBeUndefined() + }) + + it('keeps overrides for different steps independent', async () => { + await fetch(`${baseUrl}/api/workflows/wf/steps/build/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p1', model: 'm1' }), + }) + await fetch(`${baseUrl}/api/workflows/wf/steps/verify/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'p2', model: 'm2' }), + }) + expect(getStepModelOverride('wf', 'build')?.model).toBe('m1') + expect(getStepModelOverride('wf', 'verify')?.model).toBe('m2') + }) +}) diff --git a/src/server/routes/step-model-overrides.ts b/src/server/routes/step-model-overrides.ts new file mode 100644 index 000000000..de6fc1816 --- /dev/null +++ b/src/server/routes/step-model-overrides.ts @@ -0,0 +1,48 @@ +/** + * Step Model Override Routes + * + * Per-step model overrides keyed by `workflowId:stepId`, stored in DB settings. + * Resolution precedence: step override > team assignment > agent override > + * session. See `resolveLLMClientForStep` in `agents/model-overrides.ts`. + * + * Mounted under `/api/workflows`, exposing + * GET/PUT/DELETE /:workflowId/steps/:stepId/model + */ + +import { Router } from 'express' +import { getStepModelOverride, setStepModelOverride } from '../agents/model-overrides.js' +import { readOverrideFields } from './override-route-helpers.js' + +export function createStepModelOverrideRoutes(): Router { + const router = Router() + + router.get('/:workflowId/steps/:stepId/model', (req, res) => { + const override = getStepModelOverride(req.params.workflowId, req.params.stepId) + res.json(override ?? { providerId: null, model: null, reasoningEffort: null }) + }) + + router.put('/:workflowId/steps/:stepId/model', (req, res) => { + const { providerId, model, reasoningEffort, error } = readOverrideFields(req.body) + if (error) return res.status(400).json({ error }) + // An empty body (no providerId/model) clears the override. + if (providerId && model) { + setStepModelOverride(req.params.workflowId, req.params.stepId, { + providerId, + model, + ...(reasoningEffort ? { reasoningEffort } : {}), + }) + } else if (!providerId && !model) { + setStepModelOverride(req.params.workflowId, req.params.stepId, null) + } else { + return res.status(400).json({ error: 'Both providerId and model are required' }) + } + res.json({ success: true }) + }) + + router.delete('/:workflowId/steps/:stepId/model', (req, res) => { + setStepModelOverride(req.params.workflowId, req.params.stepId, null) + res.json({ success: true }) + }) + + return router +} diff --git a/src/server/workflows/phase4-integration.test.ts b/src/server/workflows/phase4-integration.test.ts new file mode 100644 index 000000000..5f879271a --- /dev/null +++ b/src/server/workflows/phase4-integration.test.ts @@ -0,0 +1,276 @@ +/** + * Phase 4 — end-to-end composition of the "dream" workflow. + * + * Proves the three phases compose in one executeWorkflow run: + * - Phase 1/2: the step's resolved LLM client (per-step/team override) is + * what the orchestrator uses to route. Here sessionManager.createClientForAgent + * stands in for the team/step resolver and returns a distinct "team" client. + * - Phase 3: the `llm_decision` transition handler asks THAT client which step + * is next and fires only the matching transition. + * + * The session LLM client is a different mock; asserting it is NEVER called for + * the routing decision proves the orchestrator is the step-resolved model, not + * the session model — "which LLM orchestrates" is configurable per step. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { WorkflowDefinition } from './types.js' +import type { OrchestratorOptions } from '../runner/types.js' +import type { LLMCompletionResponse } from '../llm/types.js' +import { TransitionHandlerRegistry } from './transition-handlers.js' +import { createLlmDecisionHandler, __resetLlmDecisionCache } from './llm-decision-handler.js' + +// ============================================================================ +// Module mocks (same shape as executor-mode.test.ts; jscpd ignores test files) +// ============================================================================ + +vi.mock('../events/index.js', () => ({ + getEventStore: () => ({ + append: vi.fn(), + getLatestSeq: vi.fn(() => 0), + getEvents: vi.fn(() => []), + deleteEventsAfterSeq: vi.fn(), + }), + getCurrentContextWindowId: vi.fn(() => undefined), +})) + +const { runAgentTurnMock } = vi.hoisted(() => ({ + runAgentTurnMock: vi.fn( + async ( + _opts: unknown, + _metrics: unknown, + _agentId: string, + _append: unknown, + extra: + | { + onToolExecuted?: ( + tc: { name: string; arguments: unknown }, + tr: { success: boolean; output: string }, + ) => void + } + | undefined, + ) => { + // Every agent step calls step_done so the step completes and transitions run. + extra?.onToolExecuted?.({ name: 'step_done', arguments: {} }, { success: true, output: '' }) + return { returnValueResult: 'completed', returnValueContent: '' } + }, + ), +})) + +vi.mock('../chat/orchestrator.js', () => ({ + runAgentTurn: runAgentTurnMock, + createMessageStartEvent: vi.fn(() => ({ type: 'message.start', data: {} })), + TurnMetrics: class { + start = vi.fn() + end = vi.fn() + getMetrics = vi.fn(() => ({ durationMs: 0, tokenCount: 0 })) + }, +})) + +vi.mock('../sub-agents/manager.js', () => ({ + executeSubAgent: vi.fn(async () => ({ content: '', result: 'success' })), +})) + +vi.mock('../agents/registry.js', () => ({ + loadAllAgentsDefault: vi.fn(async () => []), + findAgentById: vi.fn(() => undefined), + resolveDefaultAgentId: vi.fn(() => 'planner'), +})) + +vi.mock('../tools/index.js', () => ({ + getToolRegistryForAgent: vi.fn(() => ({ tools: [], definitions: [], execute: vi.fn() })), +})) + +vi.mock('./shell.js', () => ({ + executeShellCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), +})) + +vi.mock('../utils/logger.js', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('../../shared/stats.js', () => ({ + computeSessionStats: vi.fn(() => ({ generationTokens: 0, avgGenerationSpeed: 0, responseCount: 0, llmCallCount: 0 })), +})) + +vi.mock('../git/diff.js', () => ({ + formatGitDiffFiles: vi.fn(async () => '(none)'), +})) + +import { executeWorkflow } from './executor.js' + +// ============================================================================ +// Fixtures +// ============================================================================ + +const CANDIDATES = [ + { goto: 'build', label: 'Retry build', description: 'Tests failed, rebuild' }, + { goto: 'review', label: 'Send to review', description: 'Build passed, review it' }, + { goto: '$done', label: 'Finish', description: 'Nothing left' }, +] + +function workflow(): WorkflowDefinition { + return { + metadata: { id: 'dream', name: 'Dream', description: '', version: '1' }, + entryStep: 'verify', + settings: { maxIterations: 10 }, + steps: [ + { + id: 'verify', + name: 'Verifier', + type: 'agent', + phase: 'verification', + agentId: 'verifier', + prompt: 'Verify the build.', + transitions: [ + { + when: { type: 'custom', handler: 'llm_decision', config: { candidates: CANDIDATES, thisGoto: 'review' } }, + goto: 'review', + }, + { + when: { type: 'custom', handler: 'llm_decision', config: { candidates: CANDIDATES, thisGoto: 'build' } }, + goto: 'build', + }, + { when: { type: 'always' }, goto: '$done' }, + ], + }, + { + id: 'review', + name: 'Reviewer', + type: 'agent', + phase: 'review', + agentId: 'reviewer', + prompt: 'Review the code.', + transitions: [{ when: { type: 'always' }, goto: '$done' }], + }, + { + id: 'build', + name: 'Builder', + type: 'agent', + phase: 'build', + agentId: 'builder', + prompt: 'Rebuild.', + transitions: [{ when: { type: 'always' }, goto: '$done' }], + }, + ], + } +} + +describe('Phase 4 — dream workflow composition', () => { + beforeEach(() => { + vi.clearAllMocks() + __resetLlmDecisionCache() + }) + + it('routes via the step-resolved (team) LLM client, not the session client', async () => { + // The "team" client the per-step/team resolver returns. Its choice routes + // to 'review' (label 'Send to review'). + const teamClient = { + complete: vi.fn().mockResolvedValue({ + id: 'r', + content: 'Send to review', + finishReason: 'stop', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + } as LLMCompletionResponse), + } + // The session client — must NOT be consulted for the routing decision. + const sessionClient = { complete: vi.fn(), getModel: () => 'session-model' } + + const createClientForAgent = vi.fn( + (_sid: string, _aid: string, _fallback: unknown, _stepContext: unknown) => teamClient, + ) + + const options: OrchestratorOptions = { + scope: 'auto', + sessionId: 's1', + llmClient: sessionClient as any, + sessionManager: { + requireSession: vi.fn(() => ({ workdir: '/tmp/t', messages: [], metadataEntries: {} })), + setMode: vi.fn(), + setPhase: vi.fn(), + createClientForAgent, + getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/t'), + getProjectWorkdir: vi.fn().mockReturnValue('/tmp/t'), + addMessage: vi.fn(), + startWorkflow: vi.fn(), + updateWorkflowStep: vi.fn(), + completeWorkflow: vi.fn(), + blockWorkflow: vi.fn(), + waitAtStep: vi.fn(), + resumeWorkflow: vi.fn(), + getActiveWorkflowExecution: vi.fn(() => null), + cancelWorkflow: vi.fn(), + } as any, + transitionHandlers: new TransitionHandlerRegistry(), + } + options.transitionHandlers!.register('llm_decision', createLlmDecisionHandler()) + + const result = await executeWorkflow(workflow(), options) + + // 1. The llm_decision handler called the step-resolved (team) client once. + expect(teamClient.complete).toHaveBeenCalledTimes(1) + // 2. The session client was never used for routing. + expect(sessionClient.complete).not.toHaveBeenCalled() + // 3. createClientForAgent was called with the verify step's context + // (workflowId + stepId), proving the per-step resolver path ran. + const verifyCall = createClientForAgent.mock.calls.find( + (c: unknown[]) => (c[3] as { workflowId: string; stepId: string }).stepId === 'verify', + ) + expect(verifyCall).toBeTruthy() + expect((verifyCall![3] as { workflowId: string }).workflowId).toBe('dream') + // 4. Routing followed the LLM's choice: verify -> review (agentId 'reviewer'), + // NOT build ('builder'). + const agentIds = runAgentTurnMock.mock.calls.map((c: unknown[]) => c[2] as string) + expect(agentIds).toContain('verifier') + expect(agentIds).toContain('reviewer') + expect(agentIds).not.toContain('builder') + // 5. Workflow reached the terminal $done state. + expect(result.finalAction.type).toBe('DONE') + }) + + it('falls through to the always-fallback when the LLM response is unparseable', async () => { + const teamClient = { + complete: vi.fn().mockResolvedValue({ + id: 'r', + content: 'absolutely no idea', + finishReason: 'stop', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + } as LLMCompletionResponse), + } + const sessionClient = { complete: vi.fn(), getModel: () => 'session-model' } + + const options: OrchestratorOptions = { + scope: 'auto', + sessionId: 's1', + llmClient: sessionClient as any, + sessionManager: { + requireSession: vi.fn(() => ({ workdir: '/tmp/t', messages: [], metadataEntries: {} })), + setMode: vi.fn(), + setPhase: vi.fn(), + createClientForAgent: vi.fn((_s: string, _a: string, _fb: unknown) => teamClient), + getEffectiveWorkdir: vi.fn().mockReturnValue('/tmp/t'), + getProjectWorkdir: vi.fn().mockReturnValue('/tmp/t'), + addMessage: vi.fn(), + startWorkflow: vi.fn(), + updateWorkflowStep: vi.fn(), + completeWorkflow: vi.fn(), + blockWorkflow: vi.fn(), + waitAtStep: vi.fn(), + resumeWorkflow: vi.fn(), + getActiveWorkflowExecution: vi.fn(() => null), + cancelWorkflow: vi.fn(), + } as any, + transitionHandlers: new TransitionHandlerRegistry(), + } + options.transitionHandlers!.register('llm_decision', createLlmDecisionHandler()) + + const result = await executeWorkflow(workflow(), options) + + // Unparseable -> neither llm_decision transition fires -> always -> $done. + const agentIds = runAgentTurnMock.mock.calls.map((c: unknown[]) => c[2] as string) + expect(agentIds).toEqual(['verifier']) + expect(agentIds).not.toContain('reviewer') + expect(agentIds).not.toContain('builder') + expect(result.finalAction.type).toBe('DONE') + }) +}) From 0e497b078cbdeaf4e093c6f1fa72b0d6379f93ed Mon Sep 17 00:00:00 2001 From: Gerald Date: Thu, 20 Aug 2026 08:43:48 +0200 Subject: [PATCH 6/7] fix(workflows): forward transitionHandlers to executor on WS runner.launch The WebSocket runner.launch path (ws/server.ts) did not pass transitionHandlers to launchWorkflowRun, so the executor received an empty TransitionHandlerRegistry. Custom transition conditions (e.g. the built-in llm_decision handler) were never evaluated: the registry was empty, evaluateConditionAsync logged 'handler not registered' and returned false, so routing always fell through to the fallback 'always' transition. The dynamic orchestration advertised by the dream workflow was an illusion: the LLM was never asked to pick the next step. The REST/task launch path (index.ts) already forwarded the registry; only the WS path was missing. Root cause: getTransitionHandlers was a parameter of createWebSocketServer but handleClientMessage is a top-level function (not a closure), so the handler referenced an undefined name instead of the injected registry. Fix: - server.ts: thread getTransitionHandlers through handleClientMessage (signature + call site) and pass it as transitionHandlers in the launchWorkflowRun deps (conditional spread for exactOptionalPropertyTypes). - index.ts: pass () => providerAdapters.getTransitionHandlers() as the 8th createWebSocketServer argument. - server.test.ts: regression test asserting the registry is forwarded to runOrchestrator on runner.launch. Demo agents: - dream-verifier / dream-reviewer: top-level (subagent: false) agents for the dream workflow's verify/review steps so step_done() is available (sub-agents have it filtered out). - dream.workflow.json: point verify/review steps at the new agents. Verified live: the llm_decision handler now fires on the verify step, the orchestrator LLM picks the next step (chose 'Send to review'), and sibling transitions share the single LLM call via the decision cache. --- .openfox/agents/dream-reviewer.agent.md | 19 +++++++ .openfox/agents/dream-verifier.agent.md | 22 ++++++++ .openfox/workflows/dream.workflow.json | 70 ++++++++++++++++++++----- src/server/index.ts | 1 + src/server/ws/server.test.ts | 63 ++++++++++++++++++++++ src/server/ws/server.ts | 5 ++ 6 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 .openfox/agents/dream-reviewer.agent.md create mode 100644 .openfox/agents/dream-verifier.agent.md diff --git a/.openfox/agents/dream-reviewer.agent.md b/.openfox/agents/dream-reviewer.agent.md new file mode 100644 index 000000000..97a320d9d --- /dev/null +++ b/.openfox/agents/dream-reviewer.agent.md @@ -0,0 +1,19 @@ +--- +id: dream-reviewer +name: Dream Reviewer +description: Top-level workflow agent for the dream demo's review step. Reviews the change for correctness and style, then calls step_done(). +subagent: false +color: '#a855f7' +allowedTools: + - read_file + - run_command +--- + +You are a code reviewer running as a top-level workflow step. + +Your job: + +1. Review the completed change for correctness and style. +2. Approve it or note requested changes, concisely. + +When you are finished, you MUST call `step_done()` to signal completion. Do not loop: one review pass + `step_done()` is enough. diff --git a/.openfox/agents/dream-verifier.agent.md b/.openfox/agents/dream-verifier.agent.md new file mode 100644 index 000000000..634b75451 --- /dev/null +++ b/.openfox/agents/dream-verifier.agent.md @@ -0,0 +1,22 @@ +--- +id: dream-verifier +name: Dream Verifier +description: Top-level workflow agent for the dream demo's verify step. Runs tests and lint, then calls step_done() so the llm_decision transition can route. +subagent: false +color: '#22c55e' +allowedTools: + - read_file + - run_command + - session_metadata + - web_fetch +--- + +You are a verifier running as a top-level workflow step. + +Your job: + +1. Run the project's tests and lint (if any exist). +2. Read the changed files to confirm the work meets the request. +3. Report the outcome concisely. + +When you are finished, you MUST call `step_done()` to signal completion. Do not loop: a single verification pass + `step_done()` is enough. diff --git a/.openfox/workflows/dream.workflow.json b/.openfox/workflows/dream.workflow.json index e2027f711..9423b9d20 100644 --- a/.openfox/workflows/dream.workflow.json +++ b/.openfox/workflows/dream.workflow.json @@ -20,7 +20,9 @@ "prompt": "Implement the requested change. When you are done, call step_done().", "transitions": [ { - "when": { "type": "always" }, + "when": { + "type": "always" + }, "goto": "verify" } ] @@ -30,7 +32,7 @@ "name": "Verify & Route", "type": "agent", "phase": "verification", - "agentId": "verifier", + "agentId": "dream-verifier", "prompt": "Run the tests and lint. Report the outcome via step_done().", "transitions": [ { @@ -40,9 +42,21 @@ "config": { "prompt": "Given the verification outcome, what should we do next?", "candidates": [ - { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, - { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, - { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + { + "goto": "build", + "label": "Retry build", + "description": "Tests or lint failed; rebuild after fixes" + }, + { + "goto": "review", + "label": "Send to review", + "description": "Everything passed; have it reviewed" + }, + { + "goto": "$done", + "label": "Finish", + "description": "Nothing left to do" + } ], "thisGoto": "build" } @@ -56,9 +70,21 @@ "config": { "prompt": "Given the verification outcome, what should we do next?", "candidates": [ - { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, - { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, - { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + { + "goto": "build", + "label": "Retry build", + "description": "Tests or lint failed; rebuild after fixes" + }, + { + "goto": "review", + "label": "Send to review", + "description": "Everything passed; have it reviewed" + }, + { + "goto": "$done", + "label": "Finish", + "description": "Nothing left to do" + } ], "thisGoto": "review" } @@ -72,9 +98,21 @@ "config": { "prompt": "Given the verification outcome, what should we do next?", "candidates": [ - { "goto": "build", "label": "Retry build", "description": "Tests or lint failed; rebuild after fixes" }, - { "goto": "review", "label": "Send to review", "description": "Everything passed; have it reviewed" }, - { "goto": "$done", "label": "Finish", "description": "Nothing left to do" } + { + "goto": "build", + "label": "Retry build", + "description": "Tests or lint failed; rebuild after fixes" + }, + { + "goto": "review", + "label": "Send to review", + "description": "Everything passed; have it reviewed" + }, + { + "goto": "$done", + "label": "Finish", + "description": "Nothing left to do" + } ], "thisGoto": "$done" } @@ -82,7 +120,9 @@ "goto": "$done" }, { - "when": { "type": "always" }, + "when": { + "type": "always" + }, "goto": "$done" } ] @@ -92,11 +132,13 @@ "name": "Review", "type": "agent", "phase": "review", - "agentId": "reviewer", + "agentId": "dream-reviewer", "prompt": "Review the change for correctness and style. When done, call step_done().", "transitions": [ { - "when": { "type": "always" }, + "when": { + "type": "always" + }, "goto": "$done" } ] diff --git a/src/server/index.ts b/src/server/index.ts index b986ec5d6..b6f2eb586 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3352,6 +3352,7 @@ export async function createServerHandle(config: Config): Promise sessionManager, providerManager, () => mcpManager.getAllServers(), + () => providerAdapters.getTransitionHandlers(), ) const wss = wssExports.wss diff --git a/src/server/ws/server.test.ts b/src/server/ws/server.test.ts index b6cc8bcde..3bb1080e7 100644 --- a/src/server/ws/server.test.ts +++ b/src/server/ws/server.test.ts @@ -440,6 +440,7 @@ async function createHarness( sessionManager?: ReturnType eventStore?: ReturnType providerManager?: unknown + getTransitionHandlers?: () => unknown } = {}, ) { const httpServer = createServer() @@ -460,6 +461,8 @@ async function createHarness( undefined, sessionManager as never, options.providerManager as never, + undefined, + options.getTransitionHandlers as never, ) await new Promise((resolve) => httpServer.listen(0, resolve)) @@ -1316,6 +1319,66 @@ describe('createWebSocketServer', () => { expect(callArgs.params).toEqual({ pr_number: '157', pr_title: 'Fix bug' }) }) + it('forwards transitionHandlers from getTransitionHandlers to runOrchestrator on runner.launch', async () => { + const { TransitionHandlerRegistry } = await import('../workflows/transition-handlers.js') + const registry = new TransitionHandlerRegistry() + registry.register('llm_decision', async () => false) + + const sessionState: any = { + id: 'session-1', + projectId: 'project-1', + workdir: '/tmp/project', + mode: 'planner', + phase: 'blocked', + isRunning: false, + metadata: { title: null }, + criteria: [{ id: 'tests-pass', description: 'Tests pass', status: { type: 'pending' }, attempts: [] }], + } + const sessionManager = createSessionManager({ + createSession: vi.fn(() => sessionState), + getSession: vi.fn(() => sessionState), + requireSession: vi.fn(() => sessionState), + setMode: vi.fn((_id, mode) => ({ ...sessionState, mode })), + setPhase: vi.fn((_id, phase) => ({ ...sessionState, phase })), + setRunning: vi.fn((_id, isRunning) => { + sessionState.isRunning = isRunning + }), + setCurrentContextSize: vi.fn(), + getContextState: vi.fn(() => ({ + currentTokens: 10, + maxTokens: 200000, + compactionCount: 0, + dangerZone: false, + canCompact: false, + dynamicContextChanged: false, + })), + getCurrentModelSettings: vi.fn(() => ({})), + getDynamicContextChanged: vi.fn(() => false), + setDynamicContextChanged: vi.fn(), + getCachedPrompt: vi.fn(() => undefined), + setCachedPrompt: vi.fn(), + getLspManager: vi.fn(), + drainAsapMessages: vi.fn(() => []), + getCurrentWindowMessages: vi.fn(() => []), + updateMessage: vi.fn(), + }) + + runOrchestratorMock.mockResolvedValue({ success: true }) + + const harness = await createHarness({ sessionManager, getTransitionHandlers: () => registry }) + + harness.send({ id: 'sl-ok', type: 'session.load', payload: { sessionId: 'session-1' } }) + await harness.nextMessage((message) => message.id === 'sl-ok') + + harness.send({ id: 'runner-th', type: 'runner.launch', payload: { workflowId: 'dream' } }) + await harness.nextMessage((message) => message.id === 'runner-th') + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(runOrchestratorMock).toHaveBeenCalled() + const callArgs = runOrchestratorMock.mock.calls[0]![0] + expect(callArgs.transitionHandlers).toBe(registry) + }) + it('routes runner.launch to the session named in the payload, not the active one', async () => { const sessionState: any = { id: 'session-1', diff --git a/src/server/ws/server.ts b/src/server/ws/server.ts index 11a75ddb4..114b98d67 100644 --- a/src/server/ws/server.ts +++ b/src/server/ws/server.ts @@ -19,6 +19,7 @@ import { runChatTurn } from '../chat/orchestrator.js' import { interruptLLMRetryWait, hasRecentLLMFailure } from '../chat/stream-pure.js' import { launchWorkflowRun } from '../runner/launch.js' +import type { TransitionHandlerRegistry } from '../workflows/transition-handlers.js' import { appendCompactionPrompt } from '../context/compactor.js' import { computeSessionHash, applyDynamicContext, computeUnifiedDiff } from '../chat/dynamic-context.js' import { provideAnswer } from '../tools/index.js' @@ -353,6 +354,7 @@ export function createWebSocketServer( sessionManager: SessionManager, providerManager?: ProviderManager, getMcpServers?: () => import('../mcp/types.js').McpServerState[], + getTransitionHandlers?: () => TransitionHandlerRegistry, ): WebSocketServerExports { const wss = new WebSocketServer({ server: httpServer }) const clients = new Map() @@ -817,6 +819,7 @@ export function createWebSocketServer( cleanupAfterTurn, enqueueSend, getMcpServers, + getTransitionHandlers, ) } catch (error) { logger.error('Error handling client message', { error, type: message.type }) @@ -907,6 +910,7 @@ async function handleClientMessage( ) => void, enqueueSendFn: (client: ClientConnection, data: string, seq: number) => void, getMcpServers?: () => import('../mcp/types.js').McpServerState[], + getTransitionHandlers?: () => TransitionHandlerRegistry, ): Promise { const send = (msg: ServerMessage) => { if (ws.readyState === WebSocket.OPEN) { @@ -1401,6 +1405,7 @@ async function handleClientMessage( statsIdentity: statsForSession(sessionId), broadcastForSession: (sid, msg) => _broadcastForSession(sid, msg), onFinished: () => cleanupAfterTurn(sessionId, controller, sendForSession, true), + ...(getTransitionHandlers ? { transitionHandlers: getTransitionHandlers() } : {}), }, { ...(launchPayload?.workflowId ? { workflowId: launchPayload.workflowId } : {}), From ff6b4d9851835edfaba85dc79d4622a3e5eb69ee Mon Sep 17 00:00:00 2001 From: Gerald Date: Thu, 20 Aug 2026 16:17:47 +0200 Subject: [PATCH 7/7] feat(workflows): add olgenius workflow with 5 agents + TDD tests Transposes the Olgenius multi-agent system into an openfox workflow: cadrage, planification, contradiction (2 rounds max), user validation, TDD execution phase by phase, independent QA (3 rounds max). - olgenius.workflow.json: 11 steps S0->S6, deterministic routing via metadata_all_match (bornes placed before verdicts, first-match-wins), validate is a user step (Valider/Amender/Rejeter), maxIterations 80. - 5 top-level agents (orchestrator, planificateur, avocat-du-diable, dev-tdd, qa); only dev-tdd may edit_file (separation of concerns). Agents write .olgenius/ artifacts. - TDD tests (32, green): routing logic, workflow structure, agent frontmatter/allowedTools. No regression (271/271 on workflows+agents). - Fixes vacuous-truth trap: cadrage/open_phase prompts initialise plan_rounds/dev_qa_rounds/qa_verdict so bornes don't fire falsely. --- .../__fixtures__/avocat-du-diable.agent.md | 44 +++++ .../agents/__fixtures__/dev-tdd.agent.md | 38 ++++ .../agents/__fixtures__/orchestrator.agent.md | 37 ++++ .../__fixtures__/planificateur.agent.md | 42 +++++ src/server/agents/__fixtures__/qa.agent.md | 38 ++++ src/server/agents/olgenius-agents.test.ts | 95 ++++++++++ .../__fixtures__/olgenius.workflow.json | 167 ++++++++++++++++++ .../workflows/olgenius-transitions.test.ts | 126 +++++++++++++ .../workflows/olgenius.workflow.test.ts | 94 ++++++++++ 9 files changed, 681 insertions(+) create mode 100644 src/server/agents/__fixtures__/avocat-du-diable.agent.md create mode 100644 src/server/agents/__fixtures__/dev-tdd.agent.md create mode 100644 src/server/agents/__fixtures__/orchestrator.agent.md create mode 100644 src/server/agents/__fixtures__/planificateur.agent.md create mode 100644 src/server/agents/__fixtures__/qa.agent.md create mode 100644 src/server/agents/olgenius-agents.test.ts create mode 100644 src/server/workflows/__fixtures__/olgenius.workflow.json create mode 100644 src/server/workflows/olgenius-transitions.test.ts create mode 100644 src/server/workflows/olgenius.workflow.test.ts diff --git a/src/server/agents/__fixtures__/avocat-du-diable.agent.md b/src/server/agents/__fixtures__/avocat-du-diable.agent.md new file mode 100644 index 000000000..34191ad5e --- /dev/null +++ b/src/server/agents/__fixtures__/avocat-du-diable.agent.md @@ -0,0 +1,44 @@ +--- +id: avocat-du-diable +name: Avocat du diable +description: "Olgenius : cherche activement ce qui va casser. Couvre 10 axes, écrit OBJECTIONS.md. N'approuve jamais. Ne code pas." +subagent: false +color: '#ef4444' +allowedTools: + - read_file + - write_file + - run_command + - session_metadata +--- + +Tu es **l'Avocat du diable** du système Olgenius. Ton mandat : **chercher activement ce qui va casser.** Tu n'es pas là pour approuver. Si tu ne trouves rien, tu l'écris explicitement et tu justifies pourquoi chaque axe est couvert : un accord silencieux est un échec de ta part. Tu ne codes pas. + +## Grille à couvrir, dans l'ordre + +1. Hypothèses non vérifiées du planificateur +2. Architecture : couplage, choix irréversibles, sur-ingénierie autant que sous-ingénierie +3. Modèle de données et migrations +4. Sécurité : authentification, autorisation, secrets, injections, données personnelles +5. Cas limites et modes de défaillance +6. Testabilité : ce que le découpage rend difficile à tester +7. Dépendances externes : maturité, licence, verrouillage +8. Exploitation : configuration, journalisation, reprise sur erreur, rollback +9. Périmètre : ce qui a été ajouté sans être demandé, ce qui a été oublié +10. Ordonnancement : phases qui vont devoir être refaites à cause de l'ordre choisi + +## Format de chaque objection + +``` +### OBJ-001 · [BLOQUANT|MAJEUR|MINEUR] · phase 03 +Constat : ... +Conséquence : ce qui casse concrètement, et quand +Proposition : la correction précise +``` + +- **BLOQUANT** : le plan produira un système faux, non sécurisé, ou irréparable sans reprise lourde. +- **MAJEUR** : coût significatif si on ne corrige pas maintenant. +- **MINEUR** : préférence, style, optimisation. + +## Interdits + +Approuver par politesse. Inventer un risque non documenté pour remplir la grille. Recopier le contenu du PRD/PLAN dans tes messages : tu donnes des chemins et des références de ligne. Modifier le code. diff --git a/src/server/agents/__fixtures__/dev-tdd.agent.md b/src/server/agents/__fixtures__/dev-tdd.agent.md new file mode 100644 index 000000000..3ea1a1377 --- /dev/null +++ b/src/server/agents/__fixtures__/dev-tdd.agent.md @@ -0,0 +1,38 @@ +--- +id: dev-tdd +name: Developpeur TDD +description: 'Olgenius : seul agent autorisé à modifier le code source. TDD strict red/green/refactor, commits atomiques. Ne merge pas.' +subagent: false +color: '#22c55e' +allowedTools: + - read_file + - write_file + - edit_file + - run_command + - session_metadata +--- + +Tu es le **Développeur TDD** du système Olgenius. Tu es le **seul** agent autorisé à modifier le code source. + +## Cycle strict red → green → refactor + +Aucune ligne de code de production n'est écrite avant un test qui échoue et qui prouve son absence. + +## Règles imposées + +- **Interdiction de modifier un test pour le faire passer.** Si un test est faux, tu le signales à l'orchestrateur au lieu de le corriger silencieusement. +- **Interdiction de sortir du périmètre de la phase.** Un besoin découvert en cours de route remonte à l'orchestrateur ; il ne s'implémente pas au passage. +- **Commits atomiques** au fil des cycles, message au format `phase(NN): `. +- Tu ne merges pas, tu ne changes pas de branche, tu ne touches pas à `main`. Seul l'orchestrateur exécute Git. + +## Ce qui doit être testé + +Logique métier, cas limites, chemins d'erreur, contrats d'interface entre modules, comportement fonctionnel de bout en bout de la phase, et un test de non-régression pour chaque bug corrigé. + +## Ce qui ne doit pas l'être + +Accesseurs triviaux, code généré, bibliothèques tierces, câblage sans logique. Un test qui ne peut pas échouer pour une vraie raison est du bruit : il coûte du temps, des jetons, et donne une fausse assurance. + +## Interdits + +Supprimer ou désactiver un test qui gêne. Écrire du code hors périmètre de phase, même « tant qu'on y est ». Inventer une API, une option ou une bibliothèque. Modifier PLAN.md. Toucher à Git. diff --git a/src/server/agents/__fixtures__/orchestrator.agent.md b/src/server/agents/__fixtures__/orchestrator.agent.md new file mode 100644 index 000000000..753272a34 --- /dev/null +++ b/src/server/agents/__fixtures__/orchestrator.agent.md @@ -0,0 +1,37 @@ +--- +id: orchestrator +name: Orchestrateur +description: 'Olgenius : dirige, arbitre, tranche, rend compte. Point de passage unique entre tous les rôles. Ne code pas.' +subagent: false +color: '#f59e0b' +allowedTools: + - read_file + - write_file + - run_command + - session_metadata + - ask_user +--- + +Tu es **l'Orchestrateur** du système Olgenius. Tu ne codes pas, tu ne testes pas, tu ne planifies pas toi-même : tu diriges, tu arbitres, tu tranches, tu rends compte. + +## Principes non négociables + +1. **Tu es le point de passage unique.** Aucun agent ne s'adresse directement à un autre. Tout remonte à toi, tout repart de toi. +2. **Tu es au courant de tout.** Chaque échange, chaque décision, chaque écart est consigné par toi dans `.olgenius/JOURNAL.md` (append-only) avant que tu passes à la suite. C'est ta mémoire. +3. **Tu transmets des chemins, pas des pavés.** Tu donnes le rôle attendu, les fichiers à lire (chemins), la consigne précise, le format de sortie et le fichier où écrire. Tu ne recopies jamais le contenu d'un artefact dans un message. +4. **Tu ne recopies pas les rapports vers l'utilisateur** : tu les synthétises. +5. **Seul le développeur TDD modifie le code source.** Toi non plus. +6. **Seul toi touches à Git.** +7. **Tu ne tranches jamais une décision produit à la place de l'utilisateur.** Décision technique interne, oui. Arbitrage fonctionnel, périmètre, coût, sécurité, données : tu demandes. +8. **Aucune étape n'est déclarée terminée sur une opinion.** Elle l'est parce qu'une commande a été exécutée et a produit le résultat attendu. +9. **Interdiction d'inventer.** Aucune bibliothèque, API, commande ou option n'est utilisée sans avoir été vérifiée dans le projet ou sa documentation. + +## Escalade + +Tu t'arrêtes et tu demandes à l'utilisateur quand : 3 échecs QA sur une même phase ; objection BLOQUANTE non résolue en 2 tours ; décision de périmètre, coût, sécurité ou données ; accès/credential/dépendance manquant ; critère d'acceptation invérifiable ; le dev demande à sortir du périmètre ; le plan s'avère faux en cours d'exécution ; une phase dépasse largement son effort estimé. + +En cas de doute, tu demandes. Un projet arrêté sur une question est récupérable ; un projet parti dans la mauvaise direction pendant six phases ne l'est pas. + +## Interdits + +Déclarer un critère satisfait sans avoir exécuté la commande. Résumer un artefact au lieu de le lire quand la décision en dépend. Approuver par politesse. Écrire du code hors périmètre. Supprimer ou désactiver un test qui gêne. Modifier PLAN.md sans passer par le planificateur. Inventer une API, une option ou une bibliothèque. diff --git a/src/server/agents/__fixtures__/planificateur.agent.md b/src/server/agents/__fixtures__/planificateur.agent.md new file mode 100644 index 000000000..c08c1199a --- /dev/null +++ b/src/server/agents/__fixtures__/planificateur.agent.md @@ -0,0 +1,42 @@ +--- +id: planificateur +name: Planificateur +description: 'Olgenius : établit et corrige le plan en jalons, phases et étapes. Produit PLAN.md avec critères exécutables. Ne code pas.' +subagent: false +color: '#3b82f6' +allowedTools: + - read_file + - write_file + - run_command + - session_metadata +--- + +Tu es le **Planificateur** du système Olgenius. Tu produis et corriges `.olgenius/PLAN.md`. Tu ne codes pas. Tu ne parles pas à l'utilisateur. + +## Format imposé de PLAN.md + +- **Jalons** (livrables ayant du sens pour l'utilisateur) + - **Phases** (unité de travail = une branche = un passage QA) + - **Étapes** (unité de travail du dev TDD) + +Chaque **phase** contient obligatoirement : + +- `ID` : `NN` sur deux chiffres +- `Objectif` : une phrase +- `Périmètre` / `Hors-périmètre` +- `Dépendances` : phases prérequises +- `Fichiers attendus` : créés / modifiés +- `Décisions techniques` : ce qui est figé, ce qui reste ouvert +- `Critères d'acceptation` : **une liste de commandes exécutables avec leur résultat attendu.** Pas de formulation subjective. « le code est propre » est interdit ; `npm run lint` sortie 0 est valide. +- `Risques` et leur mitigation +- `Effort estimé` : S / M / L + +Une phase dont les critères d'acceptation ne sont pas vérifiables par commande est un défaut de plan. + +## Contrôle à réception + +Couverture intégrale du PRD, absence de dépendance circulaire, ordonnancement réaliste (socle et schéma de données avant les fonctionnalités), granularité homogène. Une phase trop grosse est un risque : tu la découpes. + +## Interdits + +Modifier PLAN.md sans y être invité par l'orchestrateur. Inventer une bibliothèque ou une commande. Inclure un critère non vérifiable par commande. Sortir du périmètre du PRD. diff --git a/src/server/agents/__fixtures__/qa.agent.md b/src/server/agents/__fixtures__/qa.agent.md new file mode 100644 index 000000000..4a6e7ba74 --- /dev/null +++ b/src/server/agents/__fixtures__/qa.agent.md @@ -0,0 +1,38 @@ +--- +id: qa +name: Ingenieur QA +description: "Olgenius : exécute les critères d'acceptation et la suite complète. Verdict CONFORME/NON CONFORME. Ne corrige jamais." +subagent: false +color: '#a855f7' +allowedTools: + - read_file + - run_command + - session_metadata + - write_file +--- + +Tu es l'**Ingénieur QA** du système Olgenius. Tu vérifies une phase contre ses critères d'acceptation et tu produis un rapport. **Tu ne corriges jamais.** Tu constates. Tu ne codes pas. + +## Ce que tu fais + +1. Tu **exécutes** les critères d'acceptation de la phase. Tu ne les interprètes pas. +2. Tu exécutes aussi **la suite complète**, pas seulement les tests de la phase : une phase qui casse une phase antérieure est non conforme. +3. Tu vérifies la qualité des tests eux-mêmes : est-ce qu'ils échoueraient si le code était faux ? Des tests tautologiques rendent la phase non conforme. +4. Tu cherches les écarts entre ce qui était prévu et ce qui a été fait. +5. Tu écris `.olgenius/qa/phase-NN.md`. + +## Verdict : `CONFORME` ou `NON CONFORME` + +En cas de non-conformité, chaque écart est reproductible : + +``` +### ECART-01 · [BLOQUANT|MAJEUR|MINEUR] +Commande : ... +Attendu : ... +Obtenu : ... +Fichier : chemin:ligne +``` + +## Interdits + +Corriger le code. Approuver par politesse. Déclarer un critère satisfait sans avoir exécuté la commande correspondante. Résumer un artefact au lieu de le lire quand la décision en dépend. Inventer une commande ou un résultat. diff --git a/src/server/agents/olgenius-agents.test.ts b/src/server/agents/olgenius-agents.test.ts new file mode 100644 index 000000000..1272d01ed --- /dev/null +++ b/src/server/agents/olgenius-agents.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { loadUserAgents } from './registry.js' + +/** Read a fixture shipped as raw markdown source. */ +const fixture = (name: string): string => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), 'utf8') + +let dir: string +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'olgenius-agents-')) + await mkdir(join(dir, 'agents'), { recursive: true }) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const AGENT_FILES = [ + 'orchestrator.agent.md', + 'planificateur.agent.md', + 'avocat-du-diable.agent.md', + 'dev-tdd.agent.md', + 'qa.agent.md', +] + +describe('olgenius agents — fixtures load as top-level agents', () => { + it('loads exactly the 5 olgenius agents with expected ids', async () => { + for (const name of AGENT_FILES) { + await writeFile(join(dir, 'agents', name), fixture(name)) + } + const agents = await loadUserAgents(dir) + const ids = agents.map((a) => a.metadata.id).sort() + expect(ids).toEqual(['avocat-du-diable', 'dev-tdd', 'orchestrator', 'planificateur', 'qa']) + }) + + it('all 5 are top-level (subagent === false)', async () => { + for (const name of AGENT_FILES) { + await writeFile(join(dir, 'agents', name), fixture(name)) + } + const agents = await loadUserAgents(dir) + for (const a of agents) { + expect(a.metadata.subagent, `${a.metadata.id} should be top-level`).toBe(false) + } + }) + + it('each agent declares its role-specific allowedTools', async () => { + for (const name of AGENT_FILES) { + await writeFile(join(dir, 'agents', name), fixture(name)) + } + const agents = await loadUserAgents(dir) + const tools = (id: string): string[] => agents.find((a) => a.metadata.id === id)!.metadata.allowedTools + + // orchestrator: talks to user + routes via metadata, writes artifacts + expect(tools('orchestrator')).toContain('ask_user') + expect(tools('orchestrator')).toContain('session_metadata') + expect(tools('orchestrator')).toContain('write_file') + + // planificateur: writes PLAN, no user interaction + expect(tools('planificateur')).toContain('write_file') + expect(tools('planificateur')).not.toContain('ask_user') + + // avocat-du-diable: writes OBJECTIONS, no code edit + expect(tools('avocat-du-diable')).toContain('write_file') + expect(tools('avocat-du-diable')).not.toContain('edit_file') + + // dev-tdd: the ONLY one editing source code + expect(tools('dev-tdd')).toContain('edit_file') + expect(tools('dev-tdd')).toContain('write_file') + + // qa: never corrects — no edit_file + expect(tools('qa')).not.toContain('edit_file') + expect(tools('qa')).toContain('run_command') + }) + + it('only dev-tdd may edit_file (separation of concerns)', async () => { + for (const name of AGENT_FILES) { + await writeFile(join(dir, 'agents', name), fixture(name)) + } + const agents = await loadUserAgents(dir) + const editors = agents.filter((a) => a.metadata.allowedTools.includes('edit_file')).map((a) => a.metadata.id) + expect(editors).toEqual(['dev-tdd']) + }) + + it('each agent has a non-empty body prompt (the role instructions)', async () => { + for (const name of AGENT_FILES) { + await writeFile(join(dir, 'agents', name), fixture(name)) + } + const agents = await loadUserAgents(dir) + for (const a of agents) { + expect(a.prompt.length, `${a.metadata.id} prompt empty`).toBeGreaterThan(100) + } + }) +}) diff --git a/src/server/workflows/__fixtures__/olgenius.workflow.json b/src/server/workflows/__fixtures__/olgenius.workflow.json new file mode 100644 index 000000000..ab1923155 --- /dev/null +++ b/src/server/workflows/__fixtures__/olgenius.workflow.json @@ -0,0 +1,167 @@ +{ + "metadata": { + "id": "olgenius", + "name": "Olgenius", + "description": "Pilotage multi-agents d'un projet de code : cadrage, plan contradictoire (2 tours max), validation humaine, exécution TDD phase par phase, QA indépendante (3 rounds max). Transposition du système Olgenius. Les agents écrivent les artefacts dans .olgenius/.", + "version": "1.0.0", + "color": "#f59e0b", + "parameters": [ + { "name": "besoin", "description": "Énoncé du besoin, ou chemin vers un PRD.md existant", "required": false } + ] + }, + "entryStep": "cadrage", + "settings": { + "maxIterations": 80 + }, + "steps": [ + { + "id": "cadrage", + "name": "S0 Cadrage", + "type": "agent", + "agentId": "orchestrator", + "phase": "build", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S0 CADRAGE.\n\nBesoin recu : {{besoin}}\n\nSi le besoin est un chemin vers un fichier, lis-le avec read_file. Si vide, demandes à l'utilisateur (ask_user) ce qu'il veut construire puis arrete-toi (step_done) SANS créer d'artefacts.\n\nActions obligatoires :\n1. Vérifie l'environnement : depot Git ? branche courante ? arbre propre ? Langage, gestionnaire de paquets, commandes test/lint/build (run_command).\n2. Crée l'arborescence .olgenius/ (write_file) : PRD.md, PLAN.md, OBJECTIONS.md, DECISIONS.md, ETAT.md, JOURNAL.md, BACKLOG.md, qa/ (dossier).\n3. Constitue .olgenius/PRD.md : objectif, utilisateurs, périmètre, hors-périmètre explicite, contraintes techniques, sécurité/données, définition du succès.\n4. Initialise les compteurs de routing dans session_metadata (CRITIQUE, sinon les bornes firent faussement) :\n - clé \"plan_rounds\" : action=add, id=\"count\", description=\"Tours planificateur/avocat\", status=\"0\"\n - clé \"current_phase\" : action=add, id=\"count\", description=\"Phase en cours (NN)\", status=\"0\"\n5. Écris ETAT.md (état=S0) et une ligne dans JOURNAL.md (append).\n6. Si le besoin est trop flou, pose tes questions maintenant (ask_user, max 7, options + recommandation), puis step_done.\n7. Termine avec step_done().\n\nTu NE CODE PAS. Tu écris des chemins, pas des pavés. Ne recopie pas le contenu des artefacts dans tes messages.", + "nudgePrompt": "N'oublie pas d'initialiser plan_rounds=0 et current_phase=0 dans session_metadata, puis appelle step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "plan" }] + }, + { + "id": "plan", + "name": "S1 Planification", + "type": "agent", + "agentId": "planificateur", + "phase": "build", + "prompt": "Tu es le PLANIFICATEUR Olgenius. Étape S1 PLANIFICATION.\n\nLis .olgenius/PRD.md (read_file). Produis .olgenius/PLAN.md (write_file).\n\nFormat imposé de PLAN.md :\n- Jalons (livrables ayant du sens pour l'utilisateur)\n - Phases (unité = une branche = un passage QA)\n - Étapes (unité de travail du dev TDD)\n\nChaque phase contient obligatoirement : ID (NN sur 2 chiffres), Objectif (une phrase), Périmètre/Hors-périmètre, Dépendances (phases prérequises), Fichiers attendus (créés/modifiés), Décisions techniques (figé vs ouvert), Critères d'acceptation (liste de COMMANDES exécutables avec résultat attendu, pas de formulation subjective), Risques + mitigation, Effort (S/M/L).\n\nUne phase dont les critères d'acceptation ne sont pas vérifiables par commande est un défaut de plan.\n\nActions obligatoires :\n1. Écris .olgenius/PLAN.md.\n2. Écris le nombre total de phases dans session_metadata : clé \"total_phases\", action=add (ou update si existe), id=\"count\", description=\"Nombre total de phases\", status=\"\".\n3. Termine avec step_done().\n\nTu NE CODE PAS. Pas d'interaction utilisateur. Tu écris des chemins, pas des pavés.", + "nudgePrompt": "Écris PLAN.md puis le total_phases dans session_metadata, puis step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "contradict" }] + }, + { + "id": "contradict", + "name": "S2 Contradiction", + "type": "agent", + "agentId": "avocat-du-diable", + "phase": "review", + "prompt": "Tu es l'AVOCAT DU DIABLE Olgenius. Étape S2 CONTRADICTION.\n\nLis .olgenius/PRD.md et .olgenius/PLAN.md (read_file). Écris .olgenius/OBJECTIONS.md (write_file).\n\nTon mandat : chercher activement ce qui va casser. Tu n'es pas là pour approuver. Si tu ne trouves rien, écris-le explicitement et justifie pourquoi chaque axe est couvert (un accord silencieux est un échec de ta part).\n\nGrille à couvrir dans l'ordre :\n1. Hypothèses non vérifiées du planificateur\n2. Architecture : couplage, choix irréversibles, sur/sous-ingénierie\n3. Modèle de données et migrations\n4. Sécurité : auth, autorisation, secrets, injections, données personnelles\n5. Cas limites et modes de défaillance\n6. Testabilité\n7. Dépendances externes : maturité, licence, verrouillage\n8. Exploitation : config, logs, reprise sur erreur, rollback\n9. Périmètre : ajouté sans demande, oublié\n10. Ordonnancement : phases à refaire à cause de l'ordre\n\nFormat de chaque objection :\n### OBJ-001 · [BLOQUANT|MAJEUR|MINEUR] · phase 03\nConstat : ...\nConséquence : ...\nProposition : ...\n\nTermine avec step_done(). Tu n'approuves jamais. Tu n'écris pas de code.", + "nudgePrompt": "Couvre les 10 axes. Écris OBJECTIONS.md puis step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "arbitrate" }] + }, + { + "id": "arbitrate", + "name": "S3 Arbitrage", + "type": "agent", + "agentId": "orchestrator", + "phase": "review", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S3 ARBITRAGE. C'est ton travail le plus important, ne le délègue pas.\n\nLis .olgenius/OBJECTIONS.md (read_file). Statue objection par objection, en écrivant ta décision dans .olgenius/OBJECTIONS.md (write_file, append sous chaque objection) :\n- ACCEPTÉE → part en correction au planificateur\n- REJETÉE → avec un motif écrit (« non pertinent » n'est pas un motif)\n- REPORTÉE → va dans .olgenius/BACKLOG.md\n- À TRANCHER → remonte à l'utilisateur en S4\n\nActions obligatoires pour le routing :\n1. Lis le compteur de tours dans session_metadata : clé \"plan_rounds\", action=get. La valeur est dans le champ status.\n2. Incrémente ce compteur : action=update, id=\"count\", status=\"\".\n3. Décide :\n - S'il reste des objections ACCEPTÉES ET que la borne de 2 tours n'est pas atteinte (compteur < 2) : écris le routing dans session_metadata clé \"arbitration\", action=update (ou add), id=\"verdict\", status=\"REPLAN\".\n - Sinon (plus d'objection acceptée, OU borne 2 tours atteinte) : écris \"arbitration\" id=\"verdict\" status=\"VALIDATE\". Les objections non traitées partent au BACKLOG.\n4. Écris les décisions dans .olgenius/DECISIONS.md (contexte, options, choix, motif). Mets ETAT.md à jour. Append JOURNAL.md.\n5. Termine avec step_done().\n\nRÈGLE : une objection déjà rejetée avec motif ne peut pas être représentée à l'identique. Au 2e tour, seules les objections BLOQUANT sont recevables ; tout le reste part au backlog.\n\nLe workflow lira plan_rounds et arbitration dans session_metadata pour router. Tu NE CODE PAS.", + "nudgePrompt": "Lis plan_rounds (get), incrémente-le (update), puis écris arbitration=REPLAN ou VALIDATE selon les objections restantes et la borne. step_done().", + "transitions": [ + { + "when": { "type": "metadata_all_match", "key": "plan_rounds", "field": "status", "value": "2" }, + "goto": "validate" + }, + { + "when": { "type": "metadata_all_match", "key": "arbitration", "field": "status", "value": "VALIDATE" }, + "goto": "validate" + }, + { + "when": { "type": "metadata_all_match", "key": "arbitration", "field": "status", "value": "REPLAN" }, + "goto": "plan" + }, + { "when": { "type": "always" }, "goto": "validate" } + ] + }, + { + "id": "validate", + "name": "S4 Validation utilisateur", + "type": "user", + "phase": "waiting", + "transitions": [ + { "when": { "type": "step_result", "result": "Valider" }, "goto": "open_phase" }, + { "when": { "type": "step_result", "result": "Amender" }, "goto": "plan" }, + { "when": { "type": "step_result", "result": "Rejeter" }, "goto": "close" } + ] + }, + { + "id": "open_phase", + "name": "S5.1 Ouverture de phase", + "type": "agent", + "agentId": "orchestrator", + "phase": "build", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S5.1 OUVERTURE de phase.\n\nLis .olgenius/PLAN.md et session_metadata (current_phase) pour connaître la phase NN à ouvrir (read_file, session_metadata get). Si current_phase est \"0\", passe à \"01\".\n\nActions obligatoires :\n1. Depuis main à jour : `git checkout -b phase/NN-slug` (run_command). Crée le slug pertinent depuis l'objectif de la phase.\n2. Réinitialise les compteurs de boucle QA dans session_metadata (CRITIQUE) :\n - clé \"dev_qa_rounds\" : action=add (ou update), id=\"count\", description=\"Allers-retours dev/qa\", status=\"0\"\n - clé \"qa_verdict\" : action=add (ou update), id=\"verdict\", description=\"Verdict QA\", status=\"PENDING\"\n3. Incrémente current_phase si besoin (update id=\"count\" status=\"NN\").\n4. Append l'ouverture dans .olgenius/JOURNAL.md. Mets ETAT.md à jour (état=S5, phase NN).\n5. Termine avec step_done().\n\nTu NE CODE PAS. Seul toi touches à Git.", + "nudgePrompt": "git checkout -b phase/NN-slug, reset dev_qa_rounds=0 et qa_verdict=PENDING dans session_metadata, puis step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "develop" }] + }, + { + "id": "develop", + "name": "S5.2 Développement TDD", + "type": "agent", + "agentId": "dev-tdd", + "phase": "build", + "prompt": "Tu es le DÉVELOPPEUR TDD Olgenius. Étape S5.2 DÉVELOPPEMENT. Tu es le SEUL agent autorisé à modifier le code source.\n\nLis .olgenius/PLAN.md (phase NN uniquement, via current_phase dans session_metadata) et le code existant (read_file).\n\nRègles imposées :\n- Cycle strict red → green → refactor. Aucune ligne de code de production avant un test qui échoue et prouve son absence.\n- Interdiction de modifier un test pour le faire passer. Si un test est faux, signale-le (tu ne le corriges pas silencieusement).\n- Interdiction de sortir du périmètre de la phase. Un besoin découvert remonte à l'orchestrateur.\n- Commits atomiques au fil des cycles, message au format `phase(NN): ` (run_command git).\n- Tu ne merges pas, tu ne changes pas de branche, tu ne touches pas à main.\n\nÀ tester : logique métier, cas limites, chemins d'erreur, contrats d'interface, comportement E2E de la phase, test de non-régression par bug corrigé.\nÀ NE PAS tester : accesseurs triviaux, code généré, bibliothèques tierces, câblage sans logique.\n\nTermine avec step_done().", + "nudgePrompt": "TDD strict : test qui échoue d'abord, puis code, puis refactor. Commits phase(NN):. step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "verify" }] + }, + { + "id": "verify", + "name": "S5.3 Vérification QA", + "type": "agent", + "agentId": "qa", + "phase": "verification", + "prompt": "Tu es l'INGÉNIEUR QA Olgenius. Étape S5.3 VÉRIFICATION.\n\nLis .olgenius/PLAN.md (phase NN) et le code produit (read_file). Écris .olgenius/qa/phase-NN.md (write_file).\n\nActions obligatoires :\n1. Exécute les critères d'acceptation de la phase (run_command). Tu ne les interprètes pas, tu les exécutes.\n2. Exécute aussi la SUITE COMPLÈTE, pas seulement les tests de la phase : une phase qui casse une phase antérieure est non conforme.\n3. Vérifie la qualité des tests : échoueraient-ils si le code était faux ? Des tests tautologiques rendent la phase non conforme.\n4. Cherche les écarts entre prévu et fait.\n5. Tu NE CORRIGES RIEN. Tu constates.\n\nVerdict : CONFORME ou NON CONFORME. En cas de non-conformité, chaque écart est reproductible :\n### ECART-01 · [BLOQUANT|MAJEUR|MINEUR]\nCommande : ...\nAttendu : ...\nObtenu : ...\nFichier : chemin:ligne\n\nActions obligatoires pour le routing (CRITIQUE) :\n- Si CONFORME : session_metadata clé \"qa_verdict\", action=update, id=\"verdict\", status=\"CONFORME\".\n- Si NON CONFORME : session_metadata clé \"qa_verdict\" action=update id=\"verdict\" status=\"NON CONFORME\", ET incrémente clé \"dev_qa_rounds\" : action=get puis action=update id=\"count\" status=\"\".\n\nTermine avec step_done(). Le workflow lira qa_verdict et dev_qa_rounds pour router.", + "nudgePrompt": "Exécute les critères + la suite complète. Écris le verdict dans qa_verdict (update). Si NON CONFORME, incrémente dev_qa_rounds. step_done().", + "transitions": [ + { + "when": { "type": "metadata_all_match", "key": "qa_verdict", "field": "status", "value": "CONFORME" }, + "goto": "close_phase" + }, + { + "when": { "type": "metadata_all_match", "key": "dev_qa_rounds", "field": "status", "value": "3" }, + "goto": "$blocked" + }, + { + "when": { "type": "metadata_all_match", "key": "qa_verdict", "field": "status", "value": "NON CONFORME" }, + "goto": "develop" + }, + { "when": { "type": "always" }, "goto": "develop" } + ] + }, + { + "id": "close_phase", + "name": "S5.5 Clôture de phase", + "type": "agent", + "agentId": "orchestrator", + "phase": "verification", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S5.5 CLÔTURE de phase (uniquement si verdict CONFORME).\n\nActions obligatoires :\n1. `git checkout main && git merge --no-ff phase/NN-slug` (run_command). Message référençant la phase et le rapport QA (.olgenius/qa/phase-NN.md).\n2. Mets à jour .olgenius/ETAT.md, .olgenius/JOURNAL.md (append), .olgenius/BACKLOG.md.\n3. Termine avec step_done().\n\nRÈGLES Git (tu es le seul à les exécuter) : jamais de commit direct sur main, jamais de --force, jamais de merge si verdict QA ≠ CONFORME, merge --no-ff systématique. Conflit de fusion : tu ne résous pas le code métier toi-même, tu redonnes la résolution au dev-tdd.\n\nTu NE CODE PAS.", + "nudgePrompt": "git checkout main && git merge --no-ff phase/NN-slug. Mets ETAT/JOURNAL à jour. step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "next_phase" }] + }, + { + "id": "next_phase", + "name": "S5.6 Phase suivante", + "type": "agent", + "agentId": "orchestrator", + "phase": "summarize", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S5.6 PHASE SUIVANTE.\n\nLis .olgenius/PLAN.md et session_metadata (current_phase, total_phases) (read_file, session_metadata get).\n\nDécide :\n- Si current_phase < total_phases : il reste des phases. Incrémente current_phase (action=update id=\"count\" status=\"\") et écris le routing dans session_metadata clé \"phase_loop\" : action=update (ou add), id=\"loop\", status=\"NEXT\".\n- Sinon (toutes les phases faites) : écris \"phase_loop\" id=\"loop\" status=\"DONE\".\n\nAppend dans .olgenius/JOURNAL.md. Mets ETAT.md à jour.\n\nTermine avec step_done(). Le workflow lira phase_loop pour router. Tu NE CODE PAS.", + "nudgePrompt": "Compare current_phase à total_phases. Écris phase_loop=NEXT ou DONE dans session_metadata. step_done().", + "transitions": [ + { + "when": { "type": "metadata_all_match", "key": "phase_loop", "field": "status", "value": "DONE" }, + "goto": "close" + }, + { + "when": { "type": "metadata_all_match", "key": "phase_loop", "field": "status", "value": "NEXT" }, + "goto": "open_phase" + }, + { "when": { "type": "always" }, "goto": "close" } + ] + }, + { + "id": "close", + "name": "S6 Clôture", + "type": "agent", + "agentId": "orchestrator", + "phase": "done", + "prompt": "Tu es l'ORCHESTRATEUR Olgenius. Étape S6 CLÔTURE.\n\nActions obligatoires :\n1. Vérifie que main est vert : build, lint, suite complète (run_command).\n2. Écris le rapport final dans .olgenius/JOURNAL.md (append) : ce qui est livré par jalon, ce qui a été écarté et pourquoi, backlog restant, dette assumée, prochaines étapes recommandées.\n3. Annonce explicitement à l'utilisateur (ask_user) que le travail est terminé, en résumé.\n4. Mets ETAT.md à jour (état=S6, terminé).\n5. Termine avec step_done().\n\nTu NE CODE PAS.", + "nudgePrompt": "Vérifie main vert, écris le rapport final, annonce la fin. step_done().", + "transitions": [{ "when": { "type": "always" }, "goto": "$done" }] + } + ] +} diff --git a/src/server/workflows/olgenius-transitions.test.ts b/src/server/workflows/olgenius-transitions.test.ts new file mode 100644 index 000000000..9098f253b --- /dev/null +++ b/src/server/workflows/olgenius-transitions.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { findMatchingTransition, evaluateCondition } from './executor.js' +import type { WorkflowDefinition, WorkflowStep } from './types.js' +import type { StepOutcome } from './executor.js' +import type { MetadataEntry } from '../../shared/types.js' + +const wf = JSON.parse( + readFileSync(new URL('./__fixtures__/olgenius.workflow.json', import.meta.url), 'utf8'), +) as WorkflowDefinition +const stepById = (id: string): WorkflowStep => { + const s = wf.steps.find((s) => s.id === id) + if (!s) throw new Error(`step ${id} not found`) + return s +} +const completed: StepOutcome = { result: 'completed', output: {} } + +/** Build a metadataEntries map from a compact { key: [statuses] } shape. */ +const md = (entries: Record): Record => + Object.fromEntries( + Object.entries(entries).map(([key, statuses]) => [ + key, + statuses.map((status, i) => ({ id: String(i), description: '', status })), + ]), + ) + +describe('olgenius — arbitrate routing', () => { + const transitions = stepById('arbitrate').transitions + + it('borne 2 tours plan/avocat → validate (avant tout, even if REPLAN)', () => { + const m = md({ plan_rounds: ['2'], arbitration: ['REPLAN'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('validate') + }) + + it('arbitration=VALIDATE → validate', () => { + const m = md({ plan_rounds: ['1'], arbitration: ['VALIDATE'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('validate') + }) + + it('arbitration=REPLAN, under borne → plan', () => { + const m = md({ plan_rounds: ['1'], arbitration: ['REPLAN'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('plan') + }) + + it('fallback (no metadata) → validate', () => { + expect(findMatchingTransition(transitions, completed, md({}))?.goto).toBe('validate') + }) +}) + +describe('olgenius — verify routing (QA)', () => { + const transitions = stepById('verify').transitions + + it('qa_verdict=CONFORME → close_phase', () => { + const m = md({ qa_verdict: ['CONFORME'], dev_qa_rounds: ['0'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('close_phase') + }) + + it('dev_qa_rounds=3 → $blocked (borne avant NON CONFORME)', () => { + const m = md({ qa_verdict: ['NON CONFORME'], dev_qa_rounds: ['3'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('$blocked') + }) + + it('qa_verdict=NON CONFORME, under borne → develop', () => { + const m = md({ qa_verdict: ['NON CONFORME'], dev_qa_rounds: ['1'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('develop') + }) + + it('qa_verdict=PENDING init → develop (fallback always)', () => { + const m = md({ qa_verdict: ['PENDING'], dev_qa_rounds: ['0'] }) + expect(findMatchingTransition(transitions, completed, m)?.goto).toBe('develop') + }) +}) + +describe('olgenius — next_phase routing', () => { + const transitions = stepById('next_phase').transitions + + it('phase_loop=DONE → close', () => { + expect(findMatchingTransition(transitions, completed, md({ phase_loop: ['DONE'] }))?.goto).toBe('close') + }) + + it('phase_loop=NEXT → open_phase', () => { + expect(findMatchingTransition(transitions, completed, md({ phase_loop: ['NEXT'] }))?.goto).toBe('open_phase') + }) + + it('fallback (no metadata) → close', () => { + expect(findMatchingTransition(transitions, completed, md({}))?.goto).toBe('close') + }) +}) + +describe('olgenius — validate (user step)', () => { + const transitions = stepById('validate').transitions + + it('step_result "Valider" → open_phase', () => { + const outcome: StepOutcome = { result: 'Valider', output: {} } + expect(findMatchingTransition(transitions, outcome, md({}))?.goto).toBe('open_phase') + }) + + it('step_result "Amender" → plan', () => { + const outcome: StepOutcome = { result: 'Amender', output: {} } + expect(findMatchingTransition(transitions, outcome, md({}))?.goto).toBe('plan') + }) + + it('step_result "Rejeter" → close', () => { + const outcome: StepOutcome = { result: 'Rejeter', output: {} } + expect(findMatchingTransition(transitions, outcome, md({}))?.goto).toBe('close') + }) + + it('evaluateCondition step_result matches exact result only', () => { + const when = transitions[0]!.when + expect(evaluateCondition(when, { result: 'Valider', output: {} }, md({}))).toBe(true) + expect(evaluateCondition(when, { result: 'Amender', output: {} }, md({}))).toBe(false) + }) +}) + +describe('olgenius — invariant: agent steps never use step_result', () => { + it('no agent step has a step_result transition (impossible to fire after agent)', () => { + const offenders: string[] = [] + for (const step of wf.steps) { + if (step.type !== 'agent') continue + for (const t of step.transitions) { + if (t.when.type === 'step_result') offenders.push(`${step.id}.${t.goto}`) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/src/server/workflows/olgenius.workflow.test.ts b/src/server/workflows/olgenius.workflow.test.ts new file mode 100644 index 000000000..4c0a4838b --- /dev/null +++ b/src/server/workflows/olgenius.workflow.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { userStepChoices } from './executor.js' +import type { WorkflowDefinition, WorkflowStep, UserStep } from './types.js' + +const wf = JSON.parse( + readFileSync(new URL('./__fixtures__/olgenius.workflow.json', import.meta.url), 'utf8'), +) as WorkflowDefinition +const stepById = (id: string): WorkflowStep => { + const s = wf.steps.find((s) => s.id === id) + if (!s) throw new Error(`step ${id} not found`) + return s +} +const isAgent = (s: WorkflowStep): boolean => (s as { type: string }).type === 'agent' + +describe('olgenius workflow — structure', () => { + it('metadata id/name + entryStep + maxIterations', () => { + expect(wf.metadata.id).toBe('olgenius') + expect(wf.metadata.name).toBeTruthy() + expect(wf.entryStep).toBe('cadrage') + expect(wf.settings.maxIterations).toBe(80) + }) + + it('has exactly 11 steps', () => { + expect(wf.steps.length).toBe(11) + }) + + it('all step ids are unique', () => { + const ids = wf.steps.map((s) => s.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('every step has at least one transition ending at a real target or terminal', () => { + const ids = new Set(wf.steps.map((s) => s.id)) + const terminals = new Set(['$done', '$blocked']) + for (const step of wf.steps) { + expect(step.transitions.length).toBeGreaterThan(0) + for (const t of step.transitions) { + expect(terminals.has(t.goto) || ids.has(t.goto)).toBe(true) + } + } + }) + + it('validate is a user step with exactly 3 ordered choices', () => { + const v = stepById('validate') + expect((v as { type: string }).type).toBe('user') + const choices = userStepChoices(v as UserStep).map((c) => c.id) + expect(choices).toEqual(['Valider', 'Amender', 'Rejeter']) + }) + + it('all agent steps reference one of the 5 olgenius agents', () => { + const validAgents = new Set(['orchestrator', 'planificateur', 'avocat-du-diable', 'dev-tdd', 'qa']) + for (const step of wf.steps) { + if (!isAgent(step)) continue + const agentId = (step as { agentId?: string }).agentId + expect(agentId, `step ${step.id} missing agentId`).toBeTruthy() + expect(validAgents.has(agentId!)).toBe(true) + } + }) + + it('every agent step has a non-empty prompt', () => { + for (const step of wf.steps) { + if (!isAgent(step)) continue + expect((step as { prompt?: string }).prompt, `step ${step.id} missing prompt`).toBeTruthy() + } + }) + + it('close step terminates at $done', () => { + const close = stepById('close') + expect(close.transitions.some((t) => t.goto === '$done')).toBe(true) + }) + + it('verify step can reach $blocked (QA borne)', () => { + const verify = stepById('verify') + expect(verify.transitions.some((t) => t.goto === '$blocked')).toBe(true) + }) + + it('entry chain is reachable: cadrage → plan → contradict → arbitrate → validate', () => { + const goto = (id: string): string[] => stepById(id).transitions.map((t) => t.goto) + expect(goto('cadrage')).toContain('plan') + expect(goto('plan')).toContain('contradict') + expect(goto('contradict')).toContain('arbitrate') + expect(goto('arbitrate')).toContain('validate') + }) + + it('execution loop: validate → open_phase → develop → verify → close_phase → next_phase', () => { + const goto = (id: string): string[] => stepById(id).transitions.map((t) => t.goto) + expect(goto('validate')).toContain('open_phase') + expect(goto('open_phase')).toContain('develop') + expect(goto('develop')).toContain('verify') + expect(goto('verify')).toContain('close_phase') + expect(goto('close_phase')).toContain('next_phase') + }) +})