diff --git a/server/lib/README.md b/server/lib/README.md index 6536a8c8ab..f5fd005817 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -477,6 +477,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `objects.js` | Object utilities — `deepMerge` (recursive merge w/ array replacement), `isPlainObject` (non-null, non-array `object` guard for JSON / LLM payloads), `POLLUTING_KEYS` (shared `__proto__`/`constructor`/`prototype` denylist for sanitizers), `canonicalStringify` (recursive sorted-key JSON serialization for cross-machine content hashing), `isEmptyScalar` (true for null/undefined/whitespace-string/empty-array — merge gap-fill gate). | | `openapiSpec.js` | Builds two OpenAPI 3.0.3 documents: the Settings-controlled exposed public surface and the complete internal HTTP inventory. Detailed operations reuse canonical Zod contracts; generated operations are visibly labeled rather than claiming unmodeled schemas. | | `openapiDowngrade.js` | `OPENAPI_VERSION` plus `toOpenApi30Schema` / `toOpenApi30Operation`, which rewrite Zod's draft-2020-12 output (null unions, numeric exclusive bounds, `const`, tuples, `propertyNames`) into the OpenAPI 3.0.3 dialect. Apply ONLY at the OpenAPI document boundary — the AsyncAPI payloads, CoS provider tool definitions, and tool resource read the same schemas as plain JSON Schema, where these rewrites silently widen bounds and drop null branches. | +| `orchestrationProfile.js` | Orchestrated CoS execution (#5992) — `ORCHESTRATION_MODES` / `ORCHESTRATION_ROLES` (architect, implementer, reviewer), `normalizeOrchestrationProfile` / `normalizeOrchestrationMode`, `isOrchestratedTask` / `roleAssignment` (both inert on a `direct` task, so a stored profile stays opt-in), the six-part `SPEC_PARTS` contract a delegated context-free lane needs, plus `parseReasoningDirective` (an unsupported rung is an error, never a silent downgrade). | | `apiToolResource.js` | Builds the minimized semantic tool resource served at `/api/api-docs/tools.min.json` — only `x-portos-tool`-annotated operations, flattened to provider-neutral tool records with an HTTP binding and a shared error vocabulary. | | `asyncApiSpec.js` | Builds the AsyncAPI 3 Socket.IO document from the generated event catalog with direction-aware operations and explicit modeled/generated payload status. | | `mergeGateContract.js` | `mergeGateOwed({taskOpenPR, ownsPrWorkflow, leaveOpen})` decides whether a completing run actually owed its own PR merge; `resolveMergeGateVerdict({prProbe, summary, alreadyReprompted})` classifies a run that did (`merged` / `unreadable` / `leave-open-stated` / `needs-reprompt` / `reprompt-exhausted`) from the PR's live state and the agent's own sentinel text; `summaryStatesLeaveOpen` and `buildMergeGateReprompt` are its text-matching and corrective-prompt helpers. Pure — the PR lookup lives in `../services/prProbe.js`, the re-prompt delivery in `agentTuiSpawning.js`. | diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js index 1b2afcebb0..606a56de34 100644 --- a/server/lib/cosValidation.js +++ b/server/lib/cosValidation.js @@ -15,6 +15,7 @@ import { EFFORT_LEVELS } from './providerModels.js'; import { isValidSlashdoCommand } from './slashdoInvocation.js'; import { PR_COMPLETION_VALUES } from './prDisposition.js'; import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js'; +import { ORCHESTRATION_MODES, ORCHESTRATION_ROLES } from './orchestrationProfile.js'; import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js'; import { recurrenceRuleSchema } from './recurrenceValidation.js'; import { TASK_DATA_INPUT_DEFINITIONS, TASK_DATA_INPUT_IDS } from './taskDataInputCatalog.js'; @@ -122,6 +123,28 @@ const claimOverrideContextSchema = z.preprocess( z.string().max(CLAIM_OVERRIDE_CONTEXT_MAX_CHARS).optional() ); +// Orchestrated execution (#5992). `direct` — the default — is today's behavior: +// one provider, one model, one effort for the whole run. `orchestrated` splits it +// into architect / implementer / reviewer roles, each with its own provider+model, +// and hands the architect the six-part spec contract a context-free delegated lane +// needs. '' from a form's "Default" option → undefined so no mode is persisted; +// on update ''/null survives as null so the store can clear the pin +// (absent-vs-cleared, AGENTS.md). +const orchestrationRoleSchema = z.object({ + provider: z.string().trim().min(1).max(120).optional(), + model: z.string().trim().min(1).max(300).optional(), + // Per-role reasoning effort. The architect's own rung for its planning pass; + // for the implementer it is the DEFAULT a spec's `REASONING:` line overrides. + effort: z.enum(EFFORT_LEVELS).optional(), +}).strict(); + +export const orchestrationProfileSchema = z.object( + Object.fromEntries(ORCHESTRATION_ROLES.map(role => [role, orchestrationRoleSchema.optional()])) +).strict(); + +const orchestrationModeInputSchema = z.preprocess(emptyToUndefined, z.enum(ORCHESTRATION_MODES).optional()); +const orchestrationModeUpdateSchema = z.preprocess(emptyToNull, z.enum(ORCHESTRATION_MODES).nullable().optional()); + export const createCosTaskSchema = z.object({ description: z.string().min(1), diagnostics: cosTaskDiagnosticsSchema.optional(), @@ -134,6 +157,8 @@ export const createCosTaskSchema = z.object({ model: z.string().optional(), provider: z.string().optional(), effort: effortInputSchema, + orchestrationMode: orchestrationModeInputSchema, + orchestrationProfile: orchestrationProfileSchema.optional(), temperature: taskTemperatureInputSchema, thinking: z.boolean().optional(), app: z.string().optional(), @@ -259,6 +284,8 @@ export const updateCosTaskSchema = z.object({ model: z.string().optional(), provider: z.string().optional(), effort: effortUpdateSchema, + orchestrationMode: orchestrationModeUpdateSchema, + orchestrationProfile: orchestrationProfileSchema.nullable().optional(), temperature: taskTemperatureUpdateSchema, thinking: z.boolean().nullable().optional(), app: z.string().optional(), diff --git a/server/lib/index.js b/server/lib/index.js index f44be2b575..caf7813bc8 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -455,6 +455,7 @@ export * from './mirrorParity.js'; export * from './objects.js'; export * from './openapiSpec.js'; export * from './openapiDowngrade.js'; +export * from './orchestrationProfile.js'; export * from './apiToolResource.js'; export * from './mergeGateContract.js'; export * from './prDisposition.js'; diff --git a/server/lib/orchestrationProfile.js b/server/lib/orchestrationProfile.js new file mode 100644 index 0000000000..ea43b8dec3 --- /dev/null +++ b/server/lib/orchestrationProfile.js @@ -0,0 +1,154 @@ +/** + * Orchestration profiles for CoS agent runs (issue #5992). + * + * A CoS task normally resolves ONE provider and ONE model for its whole run, so + * a user who wants strong reasoning for the planning also pays that model's rate + * for the mechanical editing. An orchestration profile splits the run into three + * ROLES — architect (plans and writes specs), implementer (executes one spec at + * a time), reviewer (checks the result) — each with its own provider/model, plus + * a per-step reasoning rung the architect names in the spec it hands down. + * + * This module is the pure vocabulary + normalizer + spec-directive grammar. The + * Zod surface lives in `cosValidation.js`, model selection in + * `services/agentModelSelection.js`, and the prompt doctrine in + * `services/promptSections/orchestrationDoctrine.js` — all of them read the + * definitions HERE so the four cannot drift. + * + * Opt-in by construction: `direct` is the default mode and every accessor + * returns null for a task that carries no profile, so an install that never + * configures one behaves exactly as it did before. + */ + +import { EFFORT_LEVELS } from './providerModels.js'; +import { isPlainObject } from './objects.js'; + +/** Execution modes a CoS task can run under. `direct` is today's behavior. */ +export const ORCHESTRATION_MODES = Object.freeze(['direct', 'orchestrated']); +export const DEFAULT_ORCHESTRATION_MODE = 'direct'; + +/** + * The roles a run is split into. Ordered plan → build → check, which is also the + * order the doctrine section renders them in. + */ +export const ORCHESTRATION_ROLES = Object.freeze(['architect', 'implementer', 'reviewer']); + +/** + * The role a `direct`-mode run — and the top-level agent of an orchestrated run + * — is dispatched as. Naming it keeps `selectModelForTask`'s delegation honest: + * with no profile the architect assignment is empty and selection is unchanged. + */ +export const PRIMARY_ORCHESTRATION_ROLE = 'architect'; + +const MAX_PROVIDER_ID_LENGTH = 120; +const MAX_MODEL_ID_LENGTH = 300; + +/** + * The six parts every delegated unit of work must carry. A delegated lane shares + * NONE of the architect's context — it sees only the spec — so a spec missing + * any of these is a lane that has to guess. Rendered into the doctrine section + * so the architect prompt and any later spec check read one definition. + */ +export const SPEC_PARTS = Object.freeze([ + Object.freeze({ key: 'objective', label: 'OBJECTIVE', description: 'the single outcome this unit delivers, stated so it can be judged done or not' }), + Object.freeze({ key: 'files', label: 'FILES', description: 'every path the unit may read or write, absolute or repo-relative' }), + Object.freeze({ key: 'interfaces', label: 'INTERFACES', description: 'the exact signatures, schemas, and event names it must produce or consume' }), + Object.freeze({ key: 'constraints', label: 'CONSTRAINTS', description: 'what it must not touch, plus the conventions it inherits' }), + Object.freeze({ key: 'verification', label: 'VERIFICATION', description: 'one runnable command that proves the unit landed' }), + Object.freeze({ key: 'reasoning', label: 'REASONING', description: `the effort rung for this step — one of ${EFFORT_LEVELS.join(', ')}` }), +]); + +/** Directive line the architect writes to pin one step's reasoning effort. */ +export const REASONING_DIRECTIVE_LABEL = 'REASONING'; + +const trimmedString = (value, maxLength) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > maxLength) return null; + return trimmed; +}; + +/** + * Normalize ONE role's assignment. Every field is optional — a role that pins + * only a model inherits the run's provider, and a role that pins nothing at all + * normalizes away entirely (null) rather than persisting an empty object that + * would read as a configured-but-blank override. + */ +function normalizeRoleAssignment(raw) { + if (!isPlainObject(raw)) return null; + const provider = trimmedString(raw.provider, MAX_PROVIDER_ID_LENGTH); + const model = trimmedString(raw.model, MAX_MODEL_ID_LENGTH); + const effort = EFFORT_LEVELS.includes(raw.effort) ? raw.effort : null; + if (!provider && !model && !effort) return null; + return Object.freeze({ + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }); +} + +/** + * Normalize a whole profile to the persisted shape, or null when nothing usable + * survives. Unknown roles are dropped rather than carried: the role vocabulary + * is what the prompt doctrine and model selection can actually act on, so a typo + * must not persist as a silently-inert assignment. + */ +export function normalizeOrchestrationProfile(raw) { + if (!isPlainObject(raw)) return null; + const profile = {}; + for (const role of ORCHESTRATION_ROLES) { + const assignment = normalizeRoleAssignment(raw[role]); + if (assignment) profile[role] = assignment; + } + return Object.keys(profile).length > 0 ? Object.freeze(profile) : null; +} + +/** Normalize a mode string, falling back to `direct` for anything unrecognized. */ +export function normalizeOrchestrationMode(raw) { + return ORCHESTRATION_MODES.includes(raw) ? raw : DEFAULT_ORCHESTRATION_MODE; +} + +/** + * Is this task running under the orchestrated contract? Requires BOTH the mode + * flag and a usable profile — an orchestrated task with no role assignments has + * nothing to delegate differently, and telling its agent to emit specs for lanes + * that all resolve to the same model buys context loss for nothing. + */ +export function isOrchestratedTask(task) { + return normalizeOrchestrationMode(task?.metadata?.orchestrationMode) === 'orchestrated' + && normalizeOrchestrationProfile(task?.metadata?.orchestrationProfile) !== null; +} + +/** + * The assignment for one role on a task, or null. Returns null for every role + * on a `direct`-mode task, so callers do not need to check the mode themselves + * — this is the single gate that keeps a stored-but-disabled profile inert. + */ +export function roleAssignment(task, role) { + if (!ORCHESTRATION_ROLES.includes(role)) return null; + if (!isOrchestratedTask(task)) return null; + return normalizeOrchestrationProfile(task?.metadata?.orchestrationProfile)?.[role] ?? null; +} + +/** + * Parse a `REASONING: ` directive out of spec text. + * + * NEVER rounds. An unsupported rung is an error, not a downgrade: the whole + * point of a per-step rung is that the architect chose it deliberately, and + * silently substituting the nearest supported level would run the step at an + * effort nobody asked for while reporting success. Callers decide whether to + * reject the spec or surface the error; this only refuses to guess. + * + * @param {string} text - spec text (a full spec or a single directive line) + * @returns {{ rung: string }|{ error: string }|null} null when no directive is present + */ +export function parseReasoningDirective(text) { + if (typeof text !== 'string') return null; + const match = text.match(new RegExp(`^\\s*${REASONING_DIRECTIVE_LABEL}\\s*:\\s*(\\S+)\\s*$`, 'mi')); + if (!match) return null; + const rung = match[1]; + if (!EFFORT_LEVELS.includes(rung)) { + return { error: `Unsupported reasoning rung "${rung}" — expected one of ${EFFORT_LEVELS.join(', ')}` }; + } + return { rung }; +} + diff --git a/server/lib/orchestrationProfile.test.js b/server/lib/orchestrationProfile.test.js new file mode 100644 index 0000000000..feb610a8e5 --- /dev/null +++ b/server/lib/orchestrationProfile.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_ORCHESTRATION_MODE, + ORCHESTRATION_ROLES, + isOrchestratedTask, + normalizeOrchestrationMode, + normalizeOrchestrationProfile, + parseReasoningDirective, + roleAssignment, +} from './orchestrationProfile.js'; + +const orchestrated = (profile) => ({ + metadata: { orchestrationMode: 'orchestrated', orchestrationProfile: profile }, +}); + +describe('normalizeOrchestrationProfile', () => { + it('keeps only known roles and only fields that carry a value', () => { + expect(normalizeOrchestrationProfile({ + architect: { provider: ' claude-code ', model: 'opus', effort: 'high' }, + implementer: { model: 'haiku' }, + reviewer: {}, + saboteur: { model: 'evil' }, + })).toEqual({ + architect: { provider: 'claude-code', model: 'opus', effort: 'high' }, + implementer: { model: 'haiku' }, + }); + }); + + it('returns null when nothing usable survives, so an empty profile is never persisted', () => { + expect(normalizeOrchestrationProfile({ architect: {}, reviewer: { effort: 'nonsense' } })).toBeNull(); + expect(normalizeOrchestrationProfile(null)).toBeNull(); + expect(normalizeOrchestrationProfile('architect')).toBeNull(); + }); +}); + +describe('mode + role gating', () => { + it('defaults to direct and treats an unknown mode as direct', () => { + expect(normalizeOrchestrationMode(undefined)).toBe(DEFAULT_ORCHESTRATION_MODE); + expect(normalizeOrchestrationMode('turbo')).toBe('direct'); + }); + + it('is inert without BOTH the orchestrated mode and a usable profile', () => { + const profile = { architect: { model: 'opus' } }; + expect(isOrchestratedTask({ metadata: { orchestrationProfile: profile } })).toBe(false); + expect(isOrchestratedTask({ metadata: { orchestrationMode: 'orchestrated' } })).toBe(false); + expect(isOrchestratedTask(orchestrated(profile))).toBe(true); + }); + + it('returns no assignment for a stored profile the task did not enable', () => { + const task = { metadata: { orchestrationMode: 'direct', orchestrationProfile: { architect: { model: 'opus' } } } }; + expect(roleAssignment(task, 'architect')).toBeNull(); + }); + + it('resolves each configured role and nothing else', () => { + const task = orchestrated({ architect: { model: 'opus' }, implementer: { model: 'haiku', effort: 'low' } }); + expect(roleAssignment(task, 'architect')).toEqual({ model: 'opus' }); + expect(roleAssignment(task, 'implementer')).toEqual({ model: 'haiku', effort: 'low' }); + expect(roleAssignment(task, 'reviewer')).toBeNull(); + expect(roleAssignment(task, 'saboteur')).toBeNull(); + }); +}); + +describe('reasoning directives', () => { + it('reads the rung the architect wrote into a spec', () => { + const spec = 'OBJECTIVE: ship it\nREASONING: xhigh'; + expect(parseReasoningDirective(spec)).toEqual({ rung: 'xhigh' }); + }); + + it('errors on an unsupported rung instead of rounding it to a supported one', () => { + const result = parseReasoningDirective('REASONING: galaxy-brain'); + expect(result.error).toContain('galaxy-brain'); + expect(result.rung).toBeUndefined(); + }); + + it('reports absence as null, distinct from an invalid rung', () => { + expect(parseReasoningDirective('OBJECTIVE: ship it')).toBeNull(); + expect(parseReasoningDirective(undefined)).toBeNull(); + }); +}); + +describe('role vocabulary', () => { + it('is the plan → build → check triple the doctrine renders', () => { + expect([...ORCHESTRATION_ROLES]).toEqual(['architect', 'implementer', 'reviewer']); + }); +}); diff --git a/server/routes/cosTaskRoutes.js b/server/routes/cosTaskRoutes.js index 3fda3696a5..0b03e64d64 100644 --- a/server/routes/cosTaskRoutes.js +++ b/server/routes/cosTaskRoutes.js @@ -44,6 +44,7 @@ const enhanceTaskSchema = z.object({ // operator action that happened. const TASK_CREATE_PAYLOAD_FIELDS = [ 'prompt', 'description', 'context', 'provider', 'model', 'effort', 'app', + 'orchestrationMode', 'orchestrationProfile', 'useWorktree', 'openPR', 'prCompletion', 'planOnly', 'reviewLoop', 'reviewers', 'approvalRequired', // The one create field with an open shape (`cosTaskDiagnosticsSchema` is a @@ -434,6 +435,10 @@ router.put('/tasks/:id', asyncHandler(async (req, res) => { if (fields.model !== undefined) updates.model = fields.model; if (fields.provider !== undefined) updates.provider = fields.provider; if (fields.effort !== undefined) updates.effort = fields.effort; + // Orchestrated execution (#5992). `null` is the explicit clear the schema + // preserves — the store re-normalizes and drops the key (absent-vs-cleared). + if (fields.orchestrationMode !== undefined) updates.orchestrationMode = fields.orchestrationMode; + if (fields.orchestrationProfile !== undefined) updates.orchestrationProfile = fields.orchestrationProfile; if (fields.temperature !== undefined) updates.temperature = fields.temperature; if (fields.thinking !== undefined) updates.thinking = fields.thinking; if (fields.app !== undefined) updates.app = fields.app; diff --git a/server/routes/cosTaskRoutes.test.js b/server/routes/cosTaskRoutes.test.js index e7915ad4b4..7721ef8339 100644 --- a/server/routes/cosTaskRoutes.test.js +++ b/server/routes/cosTaskRoutes.test.js @@ -130,6 +130,55 @@ describe('POST /api/cos/tasks — targetInstanceId (#4520)', () => { }); }); +describe('CoS task routes — orchestration profiles (#5992)', () => { + const PROFILE = { architect: { provider: 'claude-code', model: 'opus', effort: 'xhigh' }, implementer: { model: 'haiku' } }; + + it('passes the mode + profile through to addTask', async () => { + const res = await request(buildApp()) + .post('/api/cos/tasks') + .send({ description: 'refactor the resolver', orchestrationMode: 'orchestrated', orchestrationProfile: PROFILE }); + expect(res.status).toBe(200); + expect(cos.addTask).toHaveBeenCalledWith( + expect.objectContaining({ orchestrationMode: 'orchestrated', orchestrationProfile: PROFILE }), + 'user' + ); + }); + + it('rejects an unknown role rather than persisting an assignment nothing reads', async () => { + const res = await request(buildApp()) + .post('/api/cos/tasks') + .send({ description: 'x', orchestrationProfile: { saboteur: { model: 'evil' } } }); + expect(res.status).toBe(400); + expect(cos.addTask).not.toHaveBeenCalled(); + }); + + it('rejects an effort rung outside the supported ladder', async () => { + const res = await request(buildApp()) + .post('/api/cos/tasks') + .send({ description: 'x', orchestrationProfile: { architect: { effort: 'galaxy-brain' } } }); + expect(res.status).toBe(400); + }); + + it('re-pins and clears the mode on update', async () => { + const app = buildApp(); + const flip = await request(app).put('/api/cos/tasks/task-1').send({ orchestrationMode: 'orchestrated' }); + expect(flip.status).toBe(200); + expect(cos.updateTask.mock.calls[0][1]).toHaveProperty('orchestrationMode', 'orchestrated'); + + cos.updateTask.mockClear(); + const clear = await request(app).put('/api/cos/tasks/task-1').send({ orchestrationMode: '' }); + expect(clear.status).toBe(200); + expect(cos.updateTask.mock.calls[0][1]).toHaveProperty('orchestrationMode', null); + }); + + it('leaves the pins untouched when the patch omits them', async () => { + const res = await request(buildApp()).put('/api/cos/tasks/task-1').send({ description: 'new title' }); + expect(res.status).toBe(200); + expect(cos.updateTask.mock.calls[0][1]).not.toHaveProperty('orchestrationMode'); + expect(cos.updateTask.mock.calls[0][1]).not.toHaveProperty('orchestrationProfile'); + }); +}); + describe('POST /api/cos/tasks — plan-only tracker gate', () => { it('rejects issue-only planning for a non-issue tracker', async () => { getAppWorkTracker.mockResolvedValue({ resolved: 'jira' }); diff --git a/server/services/agentModelSelection.js b/server/services/agentModelSelection.js index d5eeccbfbc..95be01bae9 100644 --- a/server/services/agentModelSelection.js +++ b/server/services/agentModelSelection.js @@ -11,6 +11,7 @@ import { suggestModelTier } from './taskLearning.js'; // mirrors the exact same classification the completion records under. import { classifyUntypedTask } from './taskLearning/store.js'; import { taskContextBlock } from '../lib/cosTaskPrompt.js'; +import { ORCHESTRATION_ROLES, roleAssignment } from '../lib/orchestrationProfile.js'; /** * Extract task type key for learning lookup. @@ -47,6 +48,42 @@ export function extractTaskTypeKey(task) { * - Learning-based model suggestions from historical success rates * - Automatic upgrades when task type has <60% success rate */ +/** + * Select the model for ONE ROLE of an orchestrated run (#5992). + * + * An orchestration profile pins architect / implementer / reviewer separately so + * the planning pass can run on a strong model while the mechanical editing runs + * on a cheap one. A role that pins a model wins outright — it is a user choice, + * exactly like `metadata.model`, and the complexity heuristics and learning + * store below have no role dimension to reason about it with. + * + * Everything else falls through to `selectModelForTask`, so a `direct` task, an + * unpinned role, or an unknown role all resolve exactly as they did before this + * existed. + * + * @param {object} task + * @param {string} role - one of ORCHESTRATION_ROLES + * @param {object} provider - resolved provider config + * @param {object} [agent] + * @returns {Promise} the same selection shape `selectModelForTask` returns + */ +export async function selectModelForRole(task, role, provider, agent = {}) { + const assignment = ORCHESTRATION_ROLES.includes(role) ? roleAssignment(task, role) : null; + if (assignment?.model) { + console.log(`🎼 Orchestrated ${role} model: ${assignment.model}`); + return { + model: assignment.model, + tier: 'user-specified', + reason: `orchestration-role-${role}`, + orchestrationRole: role, + userProvider: assignment.provider || task.metadata?.provider || null, + ...(assignment.effort ? { orchestrationEffort: assignment.effort } : {}), + }; + } + const selection = await selectModelForTask(task, provider, agent); + return assignment ? { ...selection, orchestrationRole: role, ...(assignment.effort ? { orchestrationEffort: assignment.effort } : {}) } : selection; +} + export async function selectModelForTask(task, provider, agent = {}) { const desc = (task.description || '').toLowerCase(); // Prompt payload + human note (#4153) — complexity scales with everything the diff --git a/server/services/agentModelSelection.test.js b/server/services/agentModelSelection.test.js index ef5e54e098..4d82fac3bb 100644 --- a/server/services/agentModelSelection.test.js +++ b/server/services/agentModelSelection.test.js @@ -6,7 +6,7 @@ vi.mock('./taskLearning.js', () => ({ suggestModelTier: vi.fn() })); -import { selectModelForTask, extractTaskTypeKey } from './agentModelSelection.js'; +import { selectModelForRole, selectModelForTask, extractTaskTypeKey } from './agentModelSelection.js'; import { suggestModelTier } from './taskLearning.js'; import { EXTERNAL_UNTYPED_TASK_TYPE } from './taskLearning/store.js'; @@ -79,3 +79,69 @@ describe('extractTaskTypeKey — spawn-time key mirror (issue #2333)', () => { expect(extractTaskTypeKey({})).not.toBe('unknown'); }); }); + +describe('selectModelForRole — orchestration profiles (#5992)', () => { + beforeEach(() => vi.clearAllMocks()); + + const orchestratedTask = (profile) => ({ + ...benignTask, + metadata: { orchestrationMode: 'orchestrated', orchestrationProfile: profile }, + }); + + it('honors the role model pin over the complexity heuristics', async () => { + suggestModelTier.mockResolvedValue(null); + const result = await selectModelForRole( + orchestratedTask({ implementer: { model: 'cheap-model', provider: 'codex' } }), + 'implementer', + PROVIDER + ); + expect(result.model).toBe('cheap-model'); + expect(result.tier).toBe('user-specified'); + expect(result.reason).toBe('orchestration-role-implementer'); + expect(result.userProvider).toBe('codex'); + expect(suggestModelTier).not.toHaveBeenCalled(); + }); + + it('falls through to selectModelForTask for a role the profile does not pin', async () => { + suggestModelTier.mockResolvedValue(null); + const task = orchestratedTask({ architect: { model: 'opus' } }); + const direct = await selectModelForTask(task, PROVIDER); + const role = await selectModelForRole(task, 'reviewer', PROVIDER); + expect(role.model).toBe(direct.model); + expect(role.reason).toBe(direct.reason); + expect(role.orchestrationRole).toBeUndefined(); + }); + + it('carries a role effort default forward even when only the model falls through', async () => { + suggestModelTier.mockResolvedValue(null); + const result = await selectModelForRole( + orchestratedTask({ reviewer: { effort: 'low' } }), + 'reviewer', + PROVIDER + ); + expect(result.model).toBe(PROVIDER.defaultModel); + expect(result.orchestrationRole).toBe('reviewer'); + expect(result.orchestrationEffort).toBe('low'); + }); + + it('is byte-identical to selectModelForTask on a direct-mode task, profile or not', async () => { + suggestModelTier.mockResolvedValue(null); + const task = { + ...benignTask, + metadata: { orchestrationProfile: { architect: { model: 'opus' } } }, + }; + expect(await selectModelForRole(task, 'architect', PROVIDER)) + .toEqual(await selectModelForTask(task, PROVIDER)); + }); + + it('ignores an unknown role rather than treating it as unpinned config', async () => { + suggestModelTier.mockResolvedValue(null); + const result = await selectModelForRole( + orchestratedTask({ architect: { model: 'opus' } }), + 'saboteur', + PROVIDER + ); + expect(result.model).toBe(PROVIDER.defaultModel); + expect(result.orchestrationRole).toBeUndefined(); + }); +}); diff --git a/server/services/agentPromptBuilder.js b/server/services/agentPromptBuilder.js index 43a2833105..c3d7fb6e80 100644 --- a/server/services/agentPromptBuilder.js +++ b/server/services/agentPromptBuilder.js @@ -27,6 +27,7 @@ import { detectSkillTemplates, getAgentInstructionsContext, loadSkillTemplates } import { buildCompactionSection, buildTaskBlock, reconcileSplitContext } from './promptSections/taskContext.js'; import { applySlashdoInvocation } from './promptSections/slashdo.js'; import { manualForgeCli, resolveManualForgeCli } from './promptSections/forge.js'; +import { buildOrchestrationDoctrineSection } from './promptSections/orchestrationDoctrine.js'; import { buildPlannerAttributionSection } from './promptSections/plannerAttribution.js'; import { isPublicReviewNoToolProfile, isPublicReviewRestrictedProfile } from '../lib/agentExecutionProfiles.js'; import { @@ -58,6 +59,7 @@ export { loadSkillTemplate, loadSkillTemplates, } from './promptSections/instructions.js'; +export { buildOrchestrationDoctrineSection } from './promptSections/orchestrationDoctrine.js'; export { buildPlannerAttributionSection } from './promptSections/plannerAttribution.js'; export { buildCompactionSection, buildTaskBlock, reconcileSplitContext } from './promptSections/taskContext.js'; export { @@ -377,6 +379,9 @@ export async function buildAgentPrompt(task, config, workspaceDir, worktreeInfo const plannerAttributionSection = skipDevContext ? '' : buildPlannerAttributionSection({ providerId, model: providerModel }); + // Architect doctrine for an orchestrated run (#5992). '' for every direct-mode + // task, which is the default, so this is inert unless a profile is configured. + const orchestrationSection = buildOrchestrationDoctrineSection(task); // Fetch independent context sections in parallel const [memorySection, agentInstructionsSection, digitalTwinSection] = await Promise.all([ skipDevContext @@ -695,7 +700,7 @@ ${task.metadata.jiraBranch ? 'Commit your changes to this branch. Do NOT switch }).catch(() => null); if (promptData?.prompt) { - return `${promptData.prompt}${plannerAttributionSection ? `\n\n${plannerAttributionSection}` : ''}\n\n${UNATTENDED_RUN_RULE}${uiAuditRuntimeSection ? `\n\n${uiAuditRuntimeSection}` : ''}\n\n${PM2_SAFETY_RULE}`; + return `${promptData.prompt}${orchestrationSection ? `\n\n${orchestrationSection}` : ''}${plannerAttributionSection ? `\n\n${plannerAttributionSection}` : ''}\n\n${UNATTENDED_RUN_RULE}${uiAuditRuntimeSection ? `\n\n${uiAuditRuntimeSection}` : ''}\n\n${PM2_SAFETY_RULE}`; } const taskBlock = buildTaskBlock(task, { screenshotsAsList: false }); @@ -713,7 +718,7 @@ ${taskBlock.attachments} ${worktreeSection} ${pipelineSection} ${jiraSection} -${plannerAttributionSection ? `${plannerAttributionSection}\n` : ''}${simplifySection} +${orchestrationSection ? `${orchestrationSection}\n` : ''}${plannerAttributionSection ? `${plannerAttributionSection}\n` : ''}${simplifySection} ${tuiCompletionSection} ${reviewLoopSection} ${reviewLoopFollowUpSection} @@ -982,6 +987,13 @@ function buildLightContextSections(task, workspaceDir, worktreeInfo, isTruthyMet const lightPlannerSection = buildPlannerAttributionSection({ providerId, model: providerModel, forgeCli: resolvedForgeCli }); if (lightPlannerSection) contractSections.push(lightPlannerSection); + // --- Orchestrated execution --------------------------------------------- + // Sits directly after planner attribution and before the worktree/completion + // contract: it reframes the whole run (specs, not code), so it has to land + // before the sections that tell the agent how to finish one. '' when direct. + const lightOrchestrationSection = buildOrchestrationDoctrineSection(task); + if (lightOrchestrationSection) contractSections.push(lightOrchestrationSection); + // --- Worktree ---------------------------------------------------------- if (worktreeInfo) { contractSections.push([ diff --git a/server/services/agentProviderResolution.js b/server/services/agentProviderResolution.js index eb0e4966a8..53472e8965 100644 --- a/server/services/agentProviderResolution.js +++ b/server/services/agentProviderResolution.js @@ -16,7 +16,8 @@ import { emitLog } from './cosEvents.js'; import { getActiveProvider, getAllProviders, getProviderById } from './providers.js'; import { isProviderAvailable, getFallbackProvider, getProviderStatus } from './providerStatus.js'; -import { selectModelForTask } from './agentModelSelection.js'; +import { selectModelForRole, selectModelForTask } from './agentModelSelection.js'; +import { PRIMARY_ORCHESTRATION_ROLE, roleAssignment } from '../lib/orchestrationProfile.js'; import { publicReviewPostureForTask, resolvePublicReviewProvider } from './publicReviewProviderSelection.js'; /** @@ -86,7 +87,13 @@ async function resolveOrdinaryProviderAndModel(task) { // whole point of pinning. The pinned provider then runs through the same // availability/fallback logic below as any other resolved provider. let provider = null; - const userProviderId = task.metadata?.provider; + // An orchestration profile (#5992) pins the top-level agent's provider under + // the architect role — that agent is the one that plans and delegates. It is + // read as an ordinary provider pin so it inherits the whole chain below + // (availability, fallback swap, the model-pin invalidation a swap triggers) + // rather than growing a second, subtly-different resolution path. Null on a + // `direct` task, so the metadata pin stays authoritative as before. + const userProviderId = roleAssignment(task, PRIMARY_ORCHESTRATION_ROLE)?.provider || task.metadata?.provider; let userProviderMissing = false; if (userProviderId) { const userProvider = await getProviderById(userProviderId); @@ -188,8 +195,10 @@ async function resolveOrdinaryProviderAndModel(task) { }; } - // Select optimal model for this task (async to allow learning-based suggestions) - const modelSelection = await selectModelForTask(task, provider); + // Select optimal model for this task (async to allow learning-based suggestions). + // Routed through the ARCHITECT role so an orchestrated task's planning model + // wins here; with no profile this is `selectModelForTask` unchanged. + const modelSelection = await selectModelForRole(task, PRIMARY_ORCHESTRATION_ROLE, provider); let selectedModel = modelSelection.model; // A configured "Fallback Model" pin (from the provider- or task-level diff --git a/server/services/agentProviderResolution.test.js b/server/services/agentProviderResolution.test.js index 780b1a1067..6f02e9797e 100644 --- a/server/services/agentProviderResolution.test.js +++ b/server/services/agentProviderResolution.test.js @@ -22,12 +22,12 @@ vi.mock('./providerStatus.js', () => ({ getFallbackProvider: vi.fn(), getProviderStatus: vi.fn(), })); -vi.mock('./agentModelSelection.js', () => ({ selectModelForTask: vi.fn() })); +vi.mock('./agentModelSelection.js', () => ({ selectModelForTask: vi.fn(), selectModelForRole: vi.fn() })); import { resolveAgentProviderAndModel } from './agentProviderResolution.js'; import { getActiveProvider, getAllProviders, getProviderById } from './providers.js'; import { isProviderAvailable, getFallbackProvider, getProviderStatus } from './providerStatus.js'; -import { selectModelForTask } from './agentModelSelection.js'; +import { selectModelForRole, selectModelForTask } from './agentModelSelection.js'; const TASK = { id: 'task-1', metadata: {} }; @@ -36,6 +36,10 @@ beforeEach(() => { // Sensible defaults: provider present + available, plain model selection. isProviderAvailable.mockReturnValue(true); selectModelForTask.mockResolvedValue({ model: 'm-default', tier: 'medium', reason: 'default' }); + // The ordinary path resolves through the ARCHITECT role (#5992); with no + // profile that is `selectModelForTask` verbatim, which is what the real + // module does and what every selection assertion below is written against. + selectModelForRole.mockImplementation((task, _role, provider, agent) => selectModelForTask(task, provider, agent)); }); describe('resolveAgentProviderAndModel', () => { @@ -403,3 +407,41 @@ describe('resolveAgentProviderAndModel — public-review stages', () => { .resolves.toMatchObject({ ok: false, permanent: true }); }); }); + +describe('orchestration profiles (#5992)', () => { + it('resolves the architect provider pin instead of the active provider', async () => { + const architectProvider = { id: 'p-architect', type: 'cli', defaultModel: 'm-architect', models: ['m-architect'] }; + getProviderById.mockResolvedValue(architectProvider); + getActiveProvider.mockResolvedValue({ id: 'p-active', type: 'cli', defaultModel: 'm-active' }); + selectModelForRole.mockResolvedValue({ model: 'm-architect', tier: 'user-specified', reason: 'orchestration-role-architect' }); + + const result = await resolveAgentProviderAndModel({ + id: 'task-orchestrated', + metadata: { + orchestrationMode: 'orchestrated', + orchestrationProfile: { architect: { provider: 'p-architect', model: 'm-architect' } }, + }, + }); + + expect(getProviderById).toHaveBeenCalledWith('p-architect'); + expect(result.ok).toBe(true); + expect(result.provider.id).toBe('p-architect'); + expect(result.selectedModel).toBe('m-architect'); + }); + + it('leaves a direct-mode task on its own metadata provider pin', async () => { + getProviderById.mockResolvedValue({ id: 'p-pinned', type: 'cli', defaultModel: 'm-pinned' }); + getActiveProvider.mockResolvedValue({ id: 'p-active', type: 'cli', defaultModel: 'm-active' }); + + const result = await resolveAgentProviderAndModel({ + id: 'task-direct', + metadata: { + provider: 'p-pinned', + orchestrationProfile: { architect: { provider: 'p-architect' } }, + }, + }); + + expect(getProviderById).toHaveBeenCalledWith('p-pinned'); + expect(result.provider.id).toBe('p-pinned'); + }); +}); diff --git a/server/services/cosTaskStore.js b/server/services/cosTaskStore.js index de5cd0bccb..666492d61b 100644 --- a/server/services/cosTaskStore.js +++ b/server/services/cosTaskStore.js @@ -26,6 +26,7 @@ import { REQUEUED_AT_KEY } from '../lib/taskRequeue.js'; import { isInvestigationTask } from '../lib/investigationTasks.js'; import { PAUSED_BLOCKED_CATEGORIES, USER_DECISION_BLOCKED_CATEGORIES } from '../lib/taskBlockCategories.js'; import { splitTaskPromptFields } from '../lib/cosTaskPrompt.js'; +import { normalizeOrchestrationMode, normalizeOrchestrationProfile } from '../lib/orchestrationProfile.js'; import { loadState, withStateLock, ROOT_DIR } from './cosState.js'; import { cosEvents } from './cosEvents.js'; import { CLAIM_METADATA_KEYS, TARGET_INSTANCE_KEY, getTargetInstance } from './cosTaskClaim.js'; @@ -67,7 +68,7 @@ const isTerminalTaskStatus = (status) => status === 'completed' || status === 'b // with the #4153 split so the task editor can edit the agent-facing payload the // same way it edits the human note — deliberately WITHOUT re-classification, so // a multi-line note edit can't overwrite the payload (see `splitTaskPromptFields`). -const LEGACY_DIRECT_FIELDS = ['context', 'prompt', 'model', 'provider', 'effort', 'temperature', 'thinking', 'app']; +const LEGACY_DIRECT_FIELDS = ['context', 'prompt', 'model', 'provider', 'effort', 'temperature', 'thinking', 'app', 'orchestrationMode', 'orchestrationProfile']; // Equality for metadata values across a fresh markdown re-parse: primitives by // ===, arrays/objects (reviewers[], screenshots[], …) by JSON since the two @@ -338,6 +339,16 @@ export async function addTask(taskData, taskType = 'user', { raw = false, ignore if (taskData.model) metadata.model = taskData.model; if (taskData.provider) metadata.provider = taskData.provider; if (taskData.effort) metadata.effort = taskData.effort; + // Orchestrated execution (#5992). Both keys are persisted only when they + // survive normalization, so a mode with no usable profile — or a profile of + // empty role objects — leaves the task in today's `direct` posture rather + // than stamping an inert override onto it. The default mode is never written: + // absent already means `direct`, and writing it would touch every task. + const orchestrationProfile = normalizeOrchestrationProfile(taskData.orchestrationProfile); + if (orchestrationProfile) metadata.orchestrationProfile = orchestrationProfile; + if (normalizeOrchestrationMode(taskData.orchestrationMode) === 'orchestrated') { + metadata.orchestrationMode = 'orchestrated'; + } if (taskData.temperature !== undefined) metadata.temperature = taskData.temperature; if (taskData.thinking !== undefined) metadata.thinking = taskData.thinking; if (taskData.app) metadata.app = taskData.app; @@ -717,6 +728,17 @@ async function writeTaskUpdate(taskId, updates, taskType, { now, suppressDequeue for (const f of LEGACY_DIRECT_FIELDS) { if (updates[f] !== undefined) updatedMetadata[f] = updates[f] ?? undefined; } + // Re-normalize the orchestration pins the loop above just copied in verbatim + // (#5992), so an update lands the same persisted shape `addTask` writes: a + // profile of empty role objects, or the default `direct` mode, is stored as + // absent rather than as an inert override. A null from the route still reaches + // here as `undefined` and is deleted by the cleanup pass — an explicit clear. + if (updatedMetadata.orchestrationProfile !== undefined) { + updatedMetadata.orchestrationProfile = normalizeOrchestrationProfile(updatedMetadata.orchestrationProfile) ?? undefined; + } + if (updatedMetadata.orchestrationMode !== undefined) { + updatedMetadata.orchestrationMode = normalizeOrchestrationMode(updatedMetadata.orchestrationMode) === 'orchestrated' ? 'orchestrated' : undefined; + } // Clear blocked/failure metadata when transitioning out of blocked status. // diff --git a/server/services/promptSections/README.md b/server/services/promptSections/README.md index 2c391e3973..4c1e6b6c98 100644 --- a/server/services/promptSections/README.md +++ b/server/services/promptSections/README.md @@ -9,6 +9,7 @@ Leaf modules used by `agentPromptBuilder.js`. The builder remains the public fac | `constants.js` | Constants shared across full and light prompt paths. | | `forge.js` | Forge CLI selection for generated workflow text. | | `instructions.js` | Skill-template routing and bounded instruction-file discovery. | +| `orchestrationDoctrine.js` | The architect doctrine an orchestrated run (#5992) gets — role/provider/model table, delegate-exploration and emit-specs rules, and the six-part spec contract incl. the pass-through `REASONING:` rung. Empty for a `direct` task. | | `plannerAttribution.js` | The `planner:` label a filing agent stamps, resolved from the run's own provider/model. | | `reviewLifecycle.js` | Reviewer, CI-gate, and merge sections. | | `slashdo.js` | Slashdo invocation and procedure expansion. | diff --git a/server/services/promptSections/index.js b/server/services/promptSections/index.js index 3a34f6f01a..74e645193b 100644 --- a/server/services/promptSections/index.js +++ b/server/services/promptSections/index.js @@ -3,6 +3,7 @@ export * from './completion.js'; export * from './constants.js'; export * from './forge.js'; export * from './instructions.js'; +export * from './orchestrationDoctrine.js'; export * from './plannerAttribution.js'; export * from './reviewLifecycle.js'; export * from './slashdo.js'; diff --git a/server/services/promptSections/orchestrationDoctrine.js b/server/services/promptSections/orchestrationDoctrine.js new file mode 100644 index 0000000000..ecb4b163ca --- /dev/null +++ b/server/services/promptSections/orchestrationDoctrine.js @@ -0,0 +1,63 @@ +/** + * Architect-doctrine prompt section for orchestrated CoS runs (#5992). + * + * An orchestrated task's top-level agent is the ARCHITECT: it plans, writes a + * spec per unit of work, and delegates execution to lanes running on cheaper + * models. The delegated lane shares NONE of the architect's context — it sees + * only the spec text — so a spec that omits any of the six parts is a lane that + * has to guess, and guessing is what makes cheap-model delegation fail. + * + * The section is the whole delivery mechanism for the per-step reasoning rung: + * PortOS cannot intercept an agent's own sub-agent dispatch, so the contract is + * stated here and the architect writes `REASONING: ` into each spec, which + * `thinkingLevels.resolveStepEffort` then reads back without rounding. + * + * Renders '' for every `direct`-mode task, which is every task by default. + */ + +import { ORCHESTRATION_ROLES, SPEC_PARTS, isOrchestratedTask, roleAssignment } from '../../lib/orchestrationProfile.js'; + +const ROLE_DUTIES = { + architect: 'plan, write one spec per unit of work, delegate, then integrate and judge what comes back', + implementer: 'execute exactly one spec at a time, with no context beyond that spec', + reviewer: 'check a completed unit against its spec and report, without rewriting it', +}; + +function roleLine(task, role) { + const assignment = roleAssignment(task, role); + const pins = [ + assignment?.provider ? `provider \`${assignment.provider}\`` : null, + assignment?.model ? `model \`${assignment.model}\`` : null, + assignment?.effort ? `default reasoning \`${assignment.effort}\`` : null, + ].filter(Boolean); + const target = pins.length ? pins.join(', ') : 'this run’s own provider and model'; + return `- **${role}** — ${ROLE_DUTIES[role]}. Runs on ${target}.`; +} + +/** + * The `## Orchestrated Execution` section for one task, or '' when the task is + * not orchestrated. + * + * @param {object} task + * @returns {string} + */ +export function buildOrchestrationDoctrineSection(task) { + if (!isOrchestratedTask(task)) return ''; + return [ + '## Orchestrated Execution', + '', + 'You are the **architect** for this run. Your output is specs and integration, not code you wrote yourself.', + '', + ...ORCHESTRATION_ROLES.map(role => roleLine(task, role)), + '', + '**Delegate exploration.** Do not spend your own context reading the repository to find things. Send a sub-agent to locate the files, signatures, and conventions, and have it report back the findings — not the file contents.', + '', + '**Emit specs, not code.** Break the work into units that one lane can finish alone, and hand each lane a spec carrying ALL SIX parts below. The lane sees only what you write; anything you leave implicit, it will invent.', + '', + ...SPEC_PARTS.map(part => `- \`${part.label}:\` — ${part.description}`), + '', + `Write each part on its own line, labeled exactly as above. The \`${SPEC_PARTS[SPEC_PARTS.length - 1].label}\` rung is passed through to the lane unchanged — it is never rounded to a level the lane happens to support, so name a rung from the list or the step is rejected rather than quietly downgraded.`, + '', + '**Integrate deliberately.** A returned unit is done when its own `VERIFICATION` command passes. Run it yourself before you build on the unit; do not take the lane’s word for it.', + ].join('\n'); +} diff --git a/server/services/promptSections/orchestrationDoctrine.test.js b/server/services/promptSections/orchestrationDoctrine.test.js new file mode 100644 index 0000000000..5fdbab2462 --- /dev/null +++ b/server/services/promptSections/orchestrationDoctrine.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { buildOrchestrationDoctrineSection } from './orchestrationDoctrine.js'; +import { SPEC_PARTS } from '../../lib/orchestrationProfile.js'; + +const task = (metadata) => ({ id: 'task-1', metadata }); + +describe('buildOrchestrationDoctrineSection', () => { + it('renders nothing for a direct-mode task, which is every task by default', () => { + expect(buildOrchestrationDoctrineSection(task({}))).toBe(''); + expect(buildOrchestrationDoctrineSection(task({ + orchestrationMode: 'direct', + orchestrationProfile: { architect: { model: 'opus' } }, + }))).toBe(''); + expect(buildOrchestrationDoctrineSection({})).toBe(''); + }); + + it('carries all six spec parts, since a delegated lane sees only the spec', () => { + const section = buildOrchestrationDoctrineSection(task({ + orchestrationMode: 'orchestrated', + orchestrationProfile: { architect: { model: 'opus' } }, + })); + for (const part of SPEC_PARTS) expect(section).toContain(`\`${part.label}:\``); + }); + + it('names each role its configured provider/model/effort, and the run default otherwise', () => { + const section = buildOrchestrationDoctrineSection(task({ + orchestrationMode: 'orchestrated', + orchestrationProfile: { + architect: { provider: 'claude-code', model: 'opus', effort: 'xhigh' }, + implementer: { model: 'haiku' }, + }, + })); + expect(section).toContain('**architect**'); + expect(section).toContain('provider `claude-code`, model `opus`, default reasoning `xhigh`'); + expect(section).toContain('model `haiku`'); + // reviewer is unpinned — it must still be listed, running on the run's own model + expect(section).toContain('**reviewer**'); + expect(section).toContain('this run’s own provider and model'); + }); + + it('states that the reasoning rung is passed through rather than rounded', () => { + const section = buildOrchestrationDoctrineSection(task({ + orchestrationMode: 'orchestrated', + orchestrationProfile: { implementer: { model: 'haiku' } }, + })); + expect(section).toMatch(/never rounded/); + }); +}); diff --git a/server/services/thinkingLevels.js b/server/services/thinkingLevels.js index 100fac5da5..b647aa24e5 100644 --- a/server/services/thinkingLevels.js +++ b/server/services/thinkingLevels.js @@ -6,6 +6,7 @@ */ import { cosEvents } from './cosEvents.js' +import { PRIMARY_ORCHESTRATION_ROLE, parseReasoningDirective, roleAssignment } from '../lib/orchestrationProfile.js' // Thinking level definitions const THINKING_LEVELS = { @@ -279,6 +280,38 @@ function downgradeLevel(currentLevel) { return levels[currentIndex - 1] } + +/** + * Resolve the reasoning effort for ONE DELEGATED STEP of an orchestrated run + * (#5992), rather than one effort for the whole run. + * + * Precedence: the `REASONING: ` directive the architect wrote into this + * step's spec → the role's configured default from the orchestration profile → + * the run-level effort already resolved for the task. + * + * NEVER rounds. An unsupported rung returns `{ error }` so the caller can refuse + * the spec: silently substituting the nearest supported level would run the step + * at an effort nobody chose while still reporting success — and the architect + * naming a rung deliberately is the entire point of the per-step contract. + * + * @param {object} options + * @param {string} [options.spec] - the delegated step's spec text + * @param {object} [options.task] - the task carrying the orchestration profile + * @param {string} [options.role] - the role executing this step + * @param {string|null} [options.runEffort] - the run-level effort already resolved + * @returns {{ effort: string|null, source: string }|{ error: string }} + */ +function resolveStepEffort({ spec = '', task = null, role = PRIMARY_ORCHESTRATION_ROLE, runEffort = null } = {}) { + const directive = parseReasoningDirective(spec) + if (directive?.error) return { error: directive.error } + if (directive?.rung) return { effort: directive.rung, source: 'spec' } + + const roleDefault = roleAssignment(task, role)?.effort + if (roleDefault) return { effort: roleDefault, source: 'role' } + + return { effort: runEffort || null, source: runEffort ? 'run' : 'default' } +} + /** * Get thinking level statistics * @returns {Object} - Usage statistics @@ -331,6 +364,7 @@ export { AUTO_THRESHOLDS, TASK_TYPE_LEVELS, resolveThinkingLevel, + resolveStepEffort, suggestLevel, suggestLevelFromContext, getModelForLevel, diff --git a/server/services/thinkingLevels.test.js b/server/services/thinkingLevels.test.js index 3b6d3eed02..6c0f6bd970 100644 --- a/server/services/thinkingLevels.test.js +++ b/server/services/thinkingLevels.test.js @@ -4,6 +4,7 @@ import { AUTO_THRESHOLDS, TASK_TYPE_LEVELS, resolveThinkingLevel, + resolveStepEffort, suggestLevel, suggestLevelFromContext, getModelForLevel, @@ -343,3 +344,47 @@ describe('Thinking Levels Service', () => { }); }); }); + +describe('resolveStepEffort — per-delegated-step reasoning (#5992)', () => { + const orchestrated = (profile) => ({ + metadata: { orchestrationMode: 'orchestrated', orchestrationProfile: profile }, + }); + + it('prefers the rung the architect wrote into the spec over the role default', () => { + expect(resolveStepEffort({ + spec: 'OBJECTIVE: ship it\nREASONING: xhigh', + task: orchestrated({ implementer: { effort: 'low' } }), + role: 'implementer', + runEffort: 'medium', + })).toEqual({ effort: 'xhigh', source: 'spec' }); + }); + + it('falls back to the role default, then the run effort, then nothing', () => { + const task = orchestrated({ implementer: { effort: 'low' } }); + expect(resolveStepEffort({ task, role: 'implementer', runEffort: 'medium' })) + .toEqual({ effort: 'low', source: 'role' }); + expect(resolveStepEffort({ task, role: 'reviewer', runEffort: 'medium' })) + .toEqual({ effort: 'medium', source: 'run' }); + expect(resolveStepEffort({ task, role: 'reviewer' })) + .toEqual({ effort: null, source: 'default' }); + }); + + it('errors on an unsupported rung rather than downgrading it to a supported one', () => { + const result = resolveStepEffort({ + spec: 'REASONING: galaxy-brain', + task: orchestrated({ implementer: { effort: 'low' } }), + role: 'implementer', + runEffort: 'medium', + }); + expect(result.error).toContain('galaxy-brain'); + expect(result.effort).toBeUndefined(); + }); + + it('ignores a profile the task has not switched into orchestrated mode', () => { + expect(resolveStepEffort({ + task: { metadata: { orchestrationProfile: { implementer: { effort: 'low' } } } }, + role: 'implementer', + runEffort: 'medium', + })).toEqual({ effort: 'medium', source: 'run' }); + }); +});