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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
27 changes: 27 additions & 0 deletions server/lib/cosValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
154 changes: 154 additions & 0 deletions server/lib/orchestrationProfile.js
Original file line number Diff line number Diff line change
@@ -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: <rung>` 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 };
}

85 changes: 85 additions & 0 deletions server/lib/orchestrationProfile.test.js
Original file line number Diff line number Diff line change
@@ -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']);
});
});
5 changes: 5 additions & 0 deletions server/routes/cosTaskRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading