From 1a47556a0d8fccae0f1b654ea3fb3b6b29955a9d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 24 Aug 2026 08:46:30 +0100 Subject: [PATCH 1/5] An activity names its outcomes; the workflow names their destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An activity declares exits — named outcomes in its own vocabulary, each with the inline predicate that selects it — and the workflow's graph binds every exit to a destination. The two halves are checked against each other at load: an unbound exit, a binding naming an exit or destination that does not exist, and an activity with several exits and no single default all fail the load. A checkpoint option selects an exit rather than an activity, so present_checkpoint can state each option's consequence from the binding before the user chooses. An exit declared immediate ends the step sequence where it is selected, and the step-manifest check reads the recorded exit to account for the steps it skipped. Transitions, decision branches and the route-around field are retired into the exit vocabulary, along with the session records that only they wrote. --- schemas/activity.schema.json | 89 ++++------------- schemas/session-file.schema.json | 60 ++++++++--- schemas/state.schema.json | 44 +------- schemas/workflow.schema.json | 101 ++++++------------- scripts/check-decision-order.ts | 6 +- scripts/check-review-mode-gating.ts | 52 ++++++---- scripts/generate-session-token.ts | 2 - scripts/generate-site-data.ts | 14 +-- scripts/smoke/smoke-orchestrator.ts | 27 +++-- src/loaders/workflow-loader.ts | 143 ++++++++++++++++---------- src/schema/activity.schema.ts | 64 ++++++------ src/schema/session.schema.ts | 10 +- src/schema/state.schema.ts | 16 +-- src/schema/workflow.schema.ts | 16 ++- src/tools/workflow-tools.ts | 99 ++++++++++-------- src/utils/activity-variables.ts | 25 ++--- src/utils/session/migration.ts | 15 +-- src/utils/session/store.ts | 3 +- src/utils/validation.ts | 97 +++++++++++------- tests/e2e/walker.ts | 150 ++++++++++++++++------------ 20 files changed, 515 insertions(+), 518 deletions(-) diff --git a/schemas/activity.schema.json b/schemas/activity.schema.json index 5b9afd37a..56a54436f 100644 --- a/schemas/activity.schema.json +++ b/schemas/activity.schema.json @@ -456,16 +456,9 @@ "additionalProperties": {}, "description": "Variable assignments the server applies to the session variable bag when the option is selected — the one engine-applied checkpoint effect. Values are validated against the declared variable type, warn-only: mismatches are stored as written and surfaced in _meta.validation; `{name}` template passthroughs are exempt." }, - "transitionTo": { + "exit": { "type": "string", - "description": "Activity ID the orchestrator transitions to next via next_activity. Recorded and returned, not engine-applied: selecting the option does not itself move the session." - }, - "skipActivities": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Activity IDs the orchestrator routes around. Recorded in session bookkeeping (`skippedActivities`) and returned, not engine-applied." + "description": "Exit of the owning activity this option selects — a name from the activity's `exits[]`, never an activity id. The destination is the workflow's to state: `present_checkpoint` reads it from the workflow graph so the option's consequence is stated before the user chooses. An adhoc checkpoint has no declared exits, so its options carry setVariable only." } }, "additionalProperties": false @@ -580,84 +573,40 @@ }, "description": "Ordered, kind-tagged execution steps for this activity" }, - "decisions": { + "exits": { "type": "array", "items": { "type": "object", "properties": { "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" + "type": "string", + "description": "Outcome name, unique within the activity. Kebab-case, in the activity's vocabulary — never an activity id." }, - "branches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "condition": { - "$ref": "#/definitions/activity/properties/steps/items/anyOf/0/properties/actions/items/properties/condition" - }, - "transitionTo": { - "type": "string", - "description": "Activity ID to transition to. Omit for terminal branches (workflow ends)" - }, - "isDefault": { - "type": "boolean", - "default": false - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - "minItems": 2 - } - }, - "required": [ - "id", - "name", - "branches" - ], - "additionalProperties": false - }, - "description": "Conditional branching points; branch conditions are evaluated by the orchestrator, not the server." - }, - "transitions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "to": { + "label": { "type": "string", - "description": "Activity ID to transition to" + "description": "Human-readable statement of the outcome." }, - "condition": { - "$ref": "#/definitions/activity/properties/steps/items/anyOf/0/properties/actions/items/properties/condition" + "when": { + "type": "string", + "description": "Inline boolean expression selecting this exit, evaluated agent-side against the variable bag in the `when` dialect the step gates use. Omitted on an exit only a checkpoint option selects, and on the default exit." }, "isDefault": { "type": "boolean", - "default": false + "const": true, + "description": "The outcome when no `when` matched and no checkpoint option selected an exit — including a checkpoint dismissed because its condition was not met. Declared exactly once on an activity with two or more exits; `isDefault: false` is redundant and rejected." + }, + "immediate": { + "type": "boolean", + "const": true, + "description": "Selecting this exit at a checkpoint ends the step sequence there: the remaining steps do not run and the step-manifest check accounts for them. Declared for the aborts, where the tail would otherwise run against the user's decision. Without it an exit is recorded when chosen and taken when the sequence ends. `immediate: false` is redundant and rejected." } }, "required": [ - "to" + "id" ], "additionalProperties": false }, - "description": "Navigation to other activities. Legality is validated warn-only at next_activity — an out-of-graph transition warns in _meta.validation but is not blocked." + "description": "Named outcomes of this activity, one of which it takes when its steps end. Each is bound to a destination in the workflow's `graph`; an unbound exit fails the workflow load. Omitted on an activity that is terminal by omission." }, "triggers": { "type": "array", diff --git a/schemas/session-file.schema.json b/schemas/session-file.schema.json index b14107752..4459d5ac6 100644 --- a/schemas/session-file.schema.json +++ b/schemas/session-file.schema.json @@ -47,7 +47,7 @@ "type": "string", "default": "" }, - "condition": { + "exit": { "type": "string", "default": "" }, @@ -65,6 +65,45 @@ "yieldedAt": { "type": "string", "format": "date-time" + }, + "adhoc": { + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + "minItems": 2 + } + }, + "required": [ + "message", + "options" + ], + "additionalProperties": false } }, "required": [ @@ -86,13 +125,6 @@ }, "default": [] }, - "skippedActivities": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, "checkpointResponses": { "type": "object", "additionalProperties": { @@ -112,14 +144,8 @@ "type": "object", "additionalProperties": {} }, - "transitionedTo": { + "exit": { "type": "string" - }, - "activitiesSkipped": { - "type": "array", - "items": { - "type": "string" - } } }, "additionalProperties": false @@ -174,7 +200,9 @@ "activity_usage", "activity_dispatched", "activity_redelivered", - "batch_refused" + "batch_refused", + "activity_outcome", + "progress_published" ] }, "activity": { diff --git a/schemas/state.schema.json b/schemas/state.schema.json index c31e34a00..a1a2b89f0 100644 --- a/schemas/state.schema.json +++ b/schemas/state.schema.json @@ -44,13 +44,6 @@ }, "default": [] }, - "skippedActivities": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, "completedSteps": { "type": "object", "additionalProperties": { @@ -81,14 +74,8 @@ "type": "object", "additionalProperties": {} }, - "transitionedTo": { + "exit": { "type": "string" - }, - "activitiesSkipped": { - "type": "array", - "items": { - "type": "string" - } } }, "additionalProperties": false @@ -102,31 +89,6 @@ }, "default": {} }, - "decisionOutcomes": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "branchId": { - "type": "string" - }, - "decidedAt": { - "type": "string", - "format": "date-time" - }, - "transitionedTo": { - "type": "string" - } - }, - "required": [ - "branchId", - "decidedAt", - "transitionedTo" - ], - "additionalProperties": false - }, - "default": {} - }, "activeLoops": { "type": "array", "items": { @@ -208,7 +170,9 @@ "activity_usage", "activity_dispatched", "activity_redelivered", - "batch_refused" + "batch_refused", + "activity_outcome", + "progress_published" ] }, "activity": { diff --git a/schemas/workflow.schema.json b/schemas/workflow.schema.json index 7d052033a..1f0e81512 100644 --- a/schemas/workflow.schema.json +++ b/schemas/workflow.schema.json @@ -134,16 +134,9 @@ "additionalProperties": {}, "description": "Variable assignments the server applies to the session variable bag when the option is selected — the one engine-applied checkpoint effect. Values are validated against the declared variable type, warn-only: mismatches are stored as written and surfaced in _meta.validation; `{name}` template passthroughs are exempt." }, - "transitionTo": { + "exit": { "type": "string", - "description": "Activity ID the orchestrator transitions to next via next_activity. Recorded and returned, not engine-applied: selecting the option does not itself move the session." - }, - "skipActivities": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Activity IDs the orchestrator routes around. Recorded in session bookkeeping (`skippedActivities`) and returned, not engine-applied." + "description": "Exit of the owning activity this option selects — a name from the activity's `exits[]`, never an activity id. The destination is the workflow's to state: `present_checkpoint` reads it from the workflow graph so the option's consequence is stated before the user chooses. An adhoc checkpoint has no declared exits, so its options carry setVariable only." } }, "additionalProperties": false @@ -419,6 +412,16 @@ "type": "string", "description": "ID of the first activity to execute. Required for sequential workflows, optional when all activities are independent entry points." }, + "graph": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": "The workflow's shape: for each activity, where each of its exits leads. This is the single home for the routing — an activity names outcomes, the workflow names destinations, so a borrowed activity sits in this graph without its lending workflow having a say. Omitted only by a workflow whose activities declare no exits." + }, "activities": { "type": "array", "items": { @@ -760,84 +763,40 @@ }, "description": "Ordered, kind-tagged execution steps for this activity" }, - "decisions": { + "exits": { "type": "array", "items": { "type": "object", "properties": { "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" + "type": "string", + "description": "Outcome name, unique within the activity. Kebab-case, in the activity's vocabulary — never an activity id." }, - "branches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "condition": { - "$ref": "#/definitions/workflow/properties/fragments/properties/checkpoints/additionalProperties/properties/condition" - }, - "transitionTo": { - "type": "string", - "description": "Activity ID to transition to. Omit for terminal branches (workflow ends)" - }, - "isDefault": { - "type": "boolean", - "default": false - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - "minItems": 2 - } - }, - "required": [ - "id", - "name", - "branches" - ], - "additionalProperties": false - }, - "description": "Conditional branching points; branch conditions are evaluated by the orchestrator, not the server." - }, - "transitions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "to": { + "label": { "type": "string", - "description": "Activity ID to transition to" + "description": "Human-readable statement of the outcome." }, - "condition": { - "$ref": "#/definitions/workflow/properties/fragments/properties/checkpoints/additionalProperties/properties/condition" + "when": { + "type": "string", + "description": "Inline boolean expression selecting this exit, evaluated agent-side against the variable bag in the `when` dialect the step gates use. Omitted on an exit only a checkpoint option selects, and on the default exit." }, "isDefault": { "type": "boolean", - "default": false + "const": true, + "description": "The outcome when no `when` matched and no checkpoint option selected an exit — including a checkpoint dismissed because its condition was not met. Declared exactly once on an activity with two or more exits; `isDefault: false` is redundant and rejected." + }, + "immediate": { + "type": "boolean", + "const": true, + "description": "Selecting this exit at a checkpoint ends the step sequence there: the remaining steps do not run and the step-manifest check accounts for them. Declared for the aborts, where the tail would otherwise run against the user's decision. Without it an exit is recorded when chosen and taken when the sequence ends. `immediate: false` is redundant and rejected." } }, "required": [ - "to" + "id" ], "additionalProperties": false }, - "description": "Navigation to other activities. Legality is validated warn-only at next_activity — an out-of-graph transition warns in _meta.validation but is not blocked." + "description": "Named outcomes of this activity, one of which it takes when its steps end. Each is bound to a destination in the workflow's `graph`; an unbound exit fails the workflow load. Omitted on an activity that is terminal by omission." }, "triggers": { "type": "array", @@ -899,7 +858,7 @@ "additionalProperties": false }, "minItems": 1, - "description": "Activities that comprise this workflow. Activities with transitions form sequences; activities without transitions are independent entry points. Omitted in definition files where activities are separate files." + "description": "Activities that comprise this workflow. An activity whose exits the `graph` binds sits in a sequence; one declaring no exits is terminal. Omitted in definition files where activities are separate files." } }, "required": [ diff --git a/scripts/check-decision-order.ts b/scripts/check-decision-order.ts index 878771d4f..63824796b 100644 --- a/scripts/check-decision-order.ts +++ b/scripts/check-decision-order.ts @@ -28,7 +28,7 @@ interface Step { when?: string; condition?: unknown; actions?: { action?: string }[]; - options?: { effect?: { setVariable?: Record; transitionTo?: string } }[]; + options?: { effect?: { setVariable?: Record; exit?: string } }[]; } /** A value a gate requires of one variable. Only conjuncts a run must satisfy to reach the step. */ @@ -143,14 +143,14 @@ function doesWork(step: Step): boolean { return actions.some((a) => a.action !== 'message' && a.action !== 'log'); } -/** Variables a checkpoint's options bind, minus those bound by an option that re-enters. */ +/** Variables a checkpoint's options bind, minus those bound by an option that leaves the activity. */ function decidedVariables(step: Step): Set { const decided = new Set(); const reentrant = new Set(); for (const option of step.options ?? []) { const set = option.effect?.setVariable; if (set === undefined) continue; - const target = typeof option.effect?.transitionTo === 'string' ? reentrant : decided; + const target = typeof option.effect?.exit === 'string' ? reentrant : decided; for (const name of Object.keys(set)) target.add(name); } for (const name of reentrant) decided.delete(name); diff --git a/scripts/check-review-mode-gating.ts b/scripts/check-review-mode-gating.ts index c4595087b..fc2610fe8 100644 --- a/scripts/check-review-mode-gating.ts +++ b/scripts/check-review-mode-gating.ts @@ -6,15 +6,15 @@ * steps, and checkpoints branch on it. The failure this guards against is the class the review-mode * optimisation fixed: a checkpoint that is REACHABLE while `is_review_mode == true`, is NOT itself * mode-aware (its own gate never mentions `is_review_mode`), and auto-advances to a CONSEQUENTIAL - * default — a `defaultOption` whose option carries an `effect` (setVariable / transitionTo / - * skipActivities). In review mode that default is applied silently on the autoAdvance timer even + * default — a `defaultOption` whose option carries an `effect` (setVariable or exit). In review + * mode that default is applied silently on the autoAdvance timer even * though the mode may make it the wrong (create/mutating) action — exactly the spurious "skip this * create step" prompt the optimisation removed (pr-creation defaulting to "Create branch and PR", * review-outcome defaulting to "approved", etc.). * - * Reachability respects transition ORDER: a transition provably-true under `is_review_mode == true` - * (e.g. `is_review_mode == true`) fires first, so later default edges behind it (assumptions-review's - * default edge into `implement`) are correctly treated as unreachable in review mode. + * Reachability respects exit ORDER: an exit whose `when` is provably true under + * `is_review_mode == true` fires first, so later edges behind it (assumptions-review's default edge + * into `implement`) are correctly treated as unreachable in review mode. * * A workflow may declare headless auto-advance as its review-mode design (work-package's * `review-mode-headless-auto-advance` rule does). Under that design a default that merely RECORDS an @@ -68,7 +68,7 @@ export interface ReviewGatingViolation { interface CheckpointOption { id: string; - effect?: { setVariable?: Record; transitionTo?: string; skipActivities?: string[] }; + effect?: { setVariable?: Record; exit?: string }; } interface StepDef { kind?: string; @@ -81,7 +81,10 @@ interface StepDef { // loop body steps?: StepDef[]; } -interface ActivityDef { id: string; steps?: StepDef[]; transitions?: Array<{ to: string; condition?: Condition; isDefault?: boolean }>; } +interface ExitDef { id: string; when?: string; isDefault?: boolean } +interface ActivityDef { id: string; steps?: StepDef[]; exits?: ExitDef[]; } +/** activity id -> exit id -> destination, as the workflow binds them. */ +type Graph = Record>; /** A condition provably FALSE under is_review_mode == true, whatever the other variables are. */ function reviewExcluded(cond?: Condition): boolean { @@ -114,23 +117,36 @@ function whenExcludesReview(when?: string): boolean { return m[1] === '==' ? !truth : truth; // is_review_mode==false / !=true → excluded in review } +/** The same expression read the other way: true iff the gate is provably TRUE in review mode. */ +function whenIncludesReview(when?: string): boolean { + if (!when) return false; + const m = when.match(/\bis_review_mode\s*(==|!=)\s*(true|false)\b/); + if (!m) return false; + const truth = m[2] === 'true'; + return m[1] === '==' ? truth : !truth; +} + function mentionsReview(step: StepDef): boolean { if (step.when && /\bis_review_mode\b/.test(step.when)) return true; return step.condition ? JSON.stringify(step.condition).includes('is_review_mode') : false; } -/** Successor activities reachable in review mode, honouring first-provably-true-transition-wins order. */ -function reviewSuccessors(act: ActivityDef): string[] { +/** Successor activities reachable in review mode, honouring first-provably-true-exit-wins order. */ +function reviewSuccessors(act: ActivityDef, graph: Graph): string[] { + const bound = graph[act.id] ?? {}; const out: string[] = []; - for (const t of act.transitions ?? []) { - if (reviewExcluded(t.condition)) continue; // cannot be taken in review - out.push(t.to); - if (reviewProvablyTrue(t.condition)) break; // definitely taken → later edges unreachable in review + for (const exit of act.exits ?? []) { + const to = bound[exit.id]; + if (to === undefined) continue; // unbound exits fail the load; here they simply lead nowhere + if (whenExcludesReview(exit.when)) continue; // cannot be taken in review + out.push(to); + // The default exit fires whenever nothing before it did, so edges behind it are unreachable. + if (whenIncludesReview(exit.when) || (exit.when === undefined && exit.isDefault)) break; } return out; } -function reachableInReview(initial: string, activities: Map): Set { +function reachableInReview(initial: string, activities: Map, graph: Graph): Set { const seen = new Set(); const queue = [initial]; while (queue.length) { @@ -138,7 +154,7 @@ function reachableInReview(initial: string, activities: Map if (seen.has(id)) continue; seen.add(id); const act = activities.get(id); - if (act) for (const s of reviewSuccessors(act)) if (!seen.has(s)) queue.push(s); + if (act) for (const s of reviewSuccessors(act, graph)) if (!seen.has(s)) queue.push(s); } return seen; } @@ -162,7 +178,7 @@ function hasConsequentialDefault(cp: StepDef): boolean { if (!cp.defaultOption) return false; const opt = (cp.options ?? []).find(o => o.id === cp.defaultOption); const e = opt?.effect; - return Boolean(e && (e.setVariable || e.transitionTo || e.skipActivities)); + return Boolean(e && (e.setVariable || e.exit)); } export function collectReviewGatingViolations(root: string = DEFAULT_ROOT): ReviewGatingViolation[] { @@ -171,7 +187,7 @@ export function collectReviewGatingViolations(root: string = DEFAULT_ROOT): Revi for (const workflow of readdirSync(root).sort()) { const workflowYamlPath = join(root, workflow, 'workflow.yaml'); if (!existsSync(workflowYamlPath)) continue; - const wf = parse(readFileSync(workflowYamlPath, 'utf-8')) as { variables?: Array<{ name?: string }>; initialActivity?: string }; + const wf = parse(readFileSync(workflowYamlPath, 'utf-8')) as { variables?: Array<{ name?: string }>; initialActivity?: string; graph?: Graph }; const declaresReview = (wf.variables ?? []).some(v => v?.name === 'is_review_mode'); if (!declaresReview) continue; // guard applies only to workflows with a review mode @@ -186,7 +202,7 @@ export function collectReviewGatingViolations(root: string = DEFAULT_ROOT): Revi } const initial = wf.initialActivity ?? [...activities.keys()][0]; if (!initial) continue; - const reachable = reachableInReview(initial, activities); + const reachable = reachableInReview(initial, activities, wf.graph ?? {}); for (const actId of reachable) { const act = activities.get(actId); diff --git a/scripts/generate-session-token.ts b/scripts/generate-session-token.ts index 6e1240604..01708300c 100644 --- a/scripts/generate-session-token.ts +++ b/scripts/generate-session-token.ts @@ -172,10 +172,8 @@ async function main() { updatedAt: now, currentActivity: '', completedActivities: [] as string[], - skippedActivities: [] as string[], completedSteps: {}, checkpointResponses: {}, - decisionOutcomes: {}, activeLoops: [] as unknown[], variables: { planning_folder_path: path, diff --git a/scripts/generate-site-data.ts b/scripts/generate-site-data.ts index 2dfbb6796..a660ffa5e 100644 --- a/scripts/generate-site-data.ts +++ b/scripts/generate-site-data.ts @@ -370,9 +370,9 @@ const SITE_TOOL_GUIDES: Partial> = { ], next_activity: [ 'Moves the session to a new activity. This is the orchestrator\'s advance call — it updates state and records the trace but does not return the activity body.', - 'After `next_activity`, the worker should call `get_activity` to load steps, checkpoints, transitions, and technique references.', - 'For the first transition, use `initialActivity` from `get_workflow`. After that, use ids from the current activity\'s `transitions`.', - 'Optional `step_manifest` and `transition_condition` help the server validate what you completed. Manifest checks are advisory — mismatches produce warnings, not hard errors.', + 'After `next_activity`, the worker should call `get_activity` to load steps, checkpoints, exits, and technique references.', + 'For the first transition, use `initialActivity` from `get_workflow`. After that, take the exit the activity reports and read its destination from the `graph` in `get_workflow`.', + 'Optional `step_manifest` and `exit` help the server validate what you completed. Manifest checks are advisory — mismatches produce warnings, not hard errors.', ], get_activity: [ 'Loads the full definition for whatever activity the session is currently on. No `activity_id` parameter — the server reads it from session state.', @@ -427,15 +427,15 @@ const SITE_PARAM_HINTS: Record = { agent_id: 'Label for this agent in the session trace.', context_mode: '`persistent`: reuse earlier deliveries when one agent keeps full context. `fresh` (default): always return full content.', planning_slug: 'Slug for the promoted planning folder when dispatching from a meta bootstrap session. Ignored if the parent already has a persistent folder.', - activity_id: 'Activity to move to. First call: use `initialActivity` from `get_workflow`. Later: use an id from `transitions`.', - transition_condition: 'The condition name that led to this transition, from the previous activity.', + activity_id: 'Activity to move to. First call: use `initialActivity` from `get_workflow`. Later: the activity the `graph` binds to the exit just taken.', + exit: 'Name of the exit the previous activity took.', step_manifest: 'Steps completed in the previous activity, for example `[{ "step_id": "detect-review-mode", "output": "is_review_mode=false" }]`. Omit if no steps ran.', 'step_manifest[].step_id': 'Step id from the activity definition (field name is `step_id`, not `id`).', 'step_manifest[].output': 'Short summary of what the step produced. Use a JSON object when the step has multiple outputs.', - activity_manifest: 'History of completed activities with outcomes and transition conditions.', + activity_manifest: 'History of completed activities with their outcomes and the exit each took.', 'activity_manifest[].activity_id': 'Completed activity id.', 'activity_manifest[].outcome': 'Short outcome summary for that activity.', - 'activity_manifest[].transition_condition': 'Condition that led out of that activity, if any.', + 'activity_manifest[].exit': 'Exit that activity took, if any.', context_tokens: 'Your worker context window in tokens. Required so the server can size inline technique bundling.', bundle: '`reference`: return unchanged markers for content already delivered. `full`: always return complete text.', checkpoint_id: 'Id of the checkpoint step you are yielding.', diff --git a/scripts/smoke/smoke-orchestrator.ts b/scripts/smoke/smoke-orchestrator.ts index 53b46a1a7..b0b5dee68 100644 --- a/scripts/smoke/smoke-orchestrator.ts +++ b/scripts/smoke/smoke-orchestrator.ts @@ -26,9 +26,10 @@ import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHarness } from '../../tests/e2e/harness.js'; import { parseToolResponse, parseWorkflowResponse, parseBundle } from '../../tests/e2e/harness.js'; -import { pickNext, activityCheckpointSteps, type ActivityDef, type CheckpointDef } from '../../tests/e2e/walker.js'; +import { pickNext, activityCheckpointSteps, type ActivityDef, type CheckpointDef, type Graph } from '../../tests/e2e/walker.js'; import { defaultPolicy, makePolicy } from '../../tests/e2e/policies.js'; import { evaluateCondition } from '../../src/schema/condition.schema.js'; +import { parseWhen } from '../../src/schema/when-expression.js'; import { checkSession, relayGaps } from '../check-session-contract.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -236,6 +237,8 @@ async function main() { // Initial activity comes from the workflow definition, not a hardcoded id. const wfSummary = parseWorkflowResponse(await h.client.callTool({ name: 'get_workflow', arguments: { session_index: sessionIndex } })); const variables: Record = {}; + // Where each activity's exits lead, read from the workflow rather than assembled per activity. + const graph = (wfSummary.graph as Graph | undefined) ?? {}; /** * The completing activity's worker output, relayed on the next transition. Seeded with the * planning folder the orchestrator established: the first activity reads it before anything @@ -369,18 +372,22 @@ async function main() { activity: current, checkpoints: cpRecords, workerTurns: turn, workerReports, variablesChanged: produced, }); - let next = pickNext(act, variables); + let next = pickNext(act, graph, variables); // Forward-advance fallback (workflow-agnostic): if the graph stalls or loops back, advance to - // an unvisited activity, satisfying its simple gate — stands in for agent-set convergence vars. + // an unvisited activity, satisfying its gate — stands in for agent-set convergence vars. if (next === null || visited.has(next)) { - for (const t of act.transitions ?? []) { - if (visited.has(t.to)) continue; - const c = t.condition as { type?: string; variable?: string; operator?: string; value?: unknown } | undefined; - if (!c) { next = t.to; break; } - if (c.type === 'simple' && typeof c.variable === 'string') { - variables[c.variable] = c.operator === '!=' ? (typeof c.value === 'boolean' ? !c.value : `__ne_${String(c.value)}`) : c.value; - next = t.to; break; + const bound = graph[act.id] ?? {}; + for (const exit of act.exits ?? []) { + const to = bound[exit.id]; + if (to === undefined || visited.has(to)) continue; + if (exit.when === undefined) { next = to; break; } + const parsed = parseWhen(exit.when); + if (parsed.ok && parsed.ast.kind === 'cmp') { + const { path, op, value } = parsed.ast; + variables[path] = op === '!=' ? (typeof value === 'boolean' ? !value : `__ne_${String(value)}`) : value; + next = to; break; } + if (parsed.ok && parsed.ast.kind === 'truthy') { variables[parsed.ast.path] = true; next = to; break; } } } log(`next: ${next ?? '(terminal)'}`); diff --git a/src/loaders/workflow-loader.ts b/src/loaders/workflow-loader.ts index 9614b582e..d82bdca2e 100644 --- a/src/loaders/workflow-loader.ts +++ b/src/loaders/workflow-loader.ts @@ -357,7 +357,7 @@ export async function loadWorkflowWithDiagnostics(workflowDir: string, workflowI if (workflow.activities) workflow.activities = materialized; // Contribute each activity's write declarations to the workflow's variable set (#493). Being - // in the graph IS the registration: everything downstream — seeding, declared-type validation, + // in the workflow IS the registration: everything downstream — seeding, declared-type validation, // the get_workflow payload — reads one merged set, and a name two activities declare is one // variable. A pair that disagrees on type or default is a contradiction, and a session seeded // from either reading would be wrong, so the workflow does not load. @@ -370,6 +370,16 @@ export async function loadWorkflowWithDiagnostics(workflowDir: string, workflowI } if (merged.variables.length > 0) workflow.variables = merged.variables; + // The exits and the graph that binds them are authored in different files, so the load is where + // they have to agree. Checked after materialization: a ref-form checkpoint's options are the + // fragment's, and the exit each selects has to be one the activity running it declares. + const knownActivityIds = new Set([ + ...(workflow.activities ?? []).map(a => a.id), + ...activityLoadErrors.map(e => e.activity_id).filter((id): id is string => id !== undefined), + ]); + const bindingErrors = validateExitBindings(workflow, knownActivityIds); + if (bindingErrors.length > 0) return err(new WorkflowValidationError(workflowId, bindingErrors)); + logInfo('Workflow loaded', { workflowId, version: workflow.version, activityCount: workflow.activities?.length ?? 0 }); return ok({ workflow, activityLoadErrors, activitySourceWorkflow }); } catch (error) { @@ -478,76 +488,101 @@ export function getCheckpoint(workflow: Workflow, activityId: string, checkpoint return defs.find(c => checkpointBaseId(c.id) === base); } -/** Get all valid transitions from an activity */ -export function getValidTransitions(workflow: Workflow, fromActivityId: string): string[] { - const activity = getActivity(workflow, fromActivityId); - if (!activity) return []; - const transitions: string[] = []; - activity.transitions?.forEach(t => transitions.push(t.to)); - activity.decisions?.forEach(d => d.branches.forEach(b => { if (b.transitionTo) transitions.push(b.transitionTo); })); - activityCheckpoints(activity).forEach(c => c.options.forEach(o => o.effect?.transitionTo && transitions.push(o.effect.transitionTo))); - return [...new Set(transitions)]; -} - -export interface TransitionEntry { +export interface ExitBinding { + /** The exit's name in the activity's own vocabulary. */ + exit: string; + /** Where the workflow binds it — an activity id, or TERMINAL_SENTINEL. */ to: string; - condition?: string | undefined; + /** The inline expression selecting this exit, when it has one. */ + when?: string | undefined; + /** The outcome when nothing else selected an exit. */ isDefault?: boolean | undefined; + /** Selecting this exit at a checkpoint ends the step sequence there. */ + immediate?: boolean | undefined; } -/** Get the transition list for an activity with human-readable conditions */ -export function getTransitionList(workflow: Workflow, fromActivityId: string): TransitionEntry[] { +/** + * The activity's exits as the workflow binds them: each declared outcome paired with the + * destination the graph gives it. The one place the two halves of the routing meet — every reader + * that needs to know where an activity can go reads this, or `exitDestinations` for the bare list. + * An exit the graph does not bind is absent here; the load-time check is what stops that happening. + */ +export function getExitBindings(workflow: Workflow, fromActivityId: string): ExitBinding[] { const activity = getActivity(workflow, fromActivityId); if (!activity) return []; + const bound = workflow.graph?.[fromActivityId] ?? {}; + return (activity.exits ?? []) + .filter(e => bound[e.id] !== undefined) + .map(e => ({ exit: e.id, to: bound[e.id]!, when: e.when, isDefault: e.isDefault, immediate: e.immediate })); +} - const entries: TransitionEntry[] = []; - const seen = new Set(); +/** The activities an activity can reach, deduped. */ +export function exitDestinations(workflow: Workflow, fromActivityId: string): string[] { + return [...new Set(getExitBindings(workflow, fromActivityId).map(b => b.to))]; +} - for (const t of activity.transitions ?? []) { - entries.push({ - to: t.to, - condition: t.condition ? conditionToString(t.condition) : undefined, - isDefault: t.isDefault || undefined, - }); - seen.add(t.to); - } +/** + * Check the activities and the graph against each other. The two halves of the routing are + * authored in different files, so the load is where they have to agree: an exit no one bound is a + * dead end the reader cannot see, and a binding naming an exit or a destination that does not exist + * is a graph describing a workflow other than this one. Both fail the load rather than warning, + * because a session cannot be walked through a graph with a hole in it. + * + * `knownActivityIds` is the loaded activities plus the ones whose files failed to load — a binding + * to an activity excluded by its own load error is that error's business, not a second report. + */ +export function validateExitBindings(workflow: Workflow, knownActivityIds: ReadonlySet): string[] { + const errors: string[] = []; + const graph = workflow.graph ?? {}; + + for (const activity of workflow.activities ?? []) { + const exits = activity.exits ?? []; + const bound = graph[activity.id] ?? {}; + + const seen = new Set(); + for (const exit of exits) { + if (seen.has(exit.id)) errors.push(`Activity '${activity.id}' declares exit '${exit.id}' twice.`); + seen.add(exit.id); + if (bound[exit.id] === undefined) { + errors.push(`Activity '${activity.id}' exit '${exit.id}' is unbound: add '${activity.id}.${exit.id}' to the workflow's graph.`); + } + } + + const defaults = exits.filter(e => e.isDefault); + if (exits.length > 1 && defaults.length !== 1) { + errors.push( + `Activity '${activity.id}' declares ${exits.length} exits and ${defaults.length} defaults; exactly one must be isDefault, so a dismissed checkpoint and an unmatched predicate both resolve to a named exit.`, + ); + } - for (const d of activity.decisions ?? []) { - for (const b of d.branches) { - if (b.transitionTo && !seen.has(b.transitionTo)) { - entries.push({ to: b.transitionTo, condition: b.condition ? conditionToString(b.condition) : undefined, isDefault: b.isDefault || undefined }); - seen.add(b.transitionTo); + for (const checkpoint of activityCheckpoints(activity)) { + for (const option of checkpoint.options) { + const exit = option.effect?.exit; + if (exit !== undefined && !seen.has(exit)) { + errors.push(`Activity '${activity.id}' checkpoint '${checkpoint.id}' option '${option.id}' selects exit '${exit}', which the activity does not declare.`); + } } } } - for (const c of activityCheckpoints(activity)) { - for (const o of c.options) { - if (o.effect?.transitionTo && !seen.has(o.effect.transitionTo)) { - entries.push({ to: o.effect.transitionTo, condition: `checkpoint:${c.id}:${o.id}` }); - seen.add(o.effect.transitionTo); + for (const [activityId, bindings] of Object.entries(graph)) { + if (!knownActivityIds.has(activityId)) { + errors.push(`Workflow graph binds activity '${activityId}', which this workflow does not contain.`); + continue; + } + const activity = getActivity(workflow, activityId); + const declared = new Set((activity?.exits ?? []).map(e => e.id)); + for (const [exitId, destination] of Object.entries(bindings)) { + if (activity && !declared.has(exitId)) { + errors.push(`Workflow graph binds '${activityId}.${exitId}', which that activity does not declare as an exit.`); + } + if (destination !== TERMINAL_SENTINEL && !knownActivityIds.has(destination)) { + errors.push(`Workflow graph sends '${activityId}.${exitId}' to '${destination}', which this workflow does not contain.`); } } } - return entries; -} - -function conditionToString(condition: { type: string; variable?: string; operator?: string; value?: unknown; conditions?: unknown[]; condition?: unknown }): string { - switch (condition.type) { - case 'simple': - return `${condition.variable} ${condition.operator} ${JSON.stringify(condition.value)}`; - case 'and': - if (!Array.isArray(condition.conditions)) return String(condition); - return (condition.conditions as Array).map(c => conditionToString(c)).join(' AND '); - case 'or': - if (!Array.isArray(condition.conditions)) return String(condition); - return (condition.conditions as Array).map(c => conditionToString(c)).join(' OR '); - case 'not': - return `NOT (${conditionToString(condition.condition as typeof condition)})`; - default: - return String(condition); - } + return errors; } /** diff --git a/src/schema/activity.schema.ts b/src/schema/activity.schema.ts index 5f0bb71ef..a47b2c188 100644 --- a/src/schema/activity.schema.ts +++ b/src/schema/activity.schema.ts @@ -48,9 +48,8 @@ export const CheckpointOptionSchema = z.object({ description: z.string().optional(), effect: z.object({ setVariable: z.record(z.unknown()).optional().describe('Variable assignments the server applies to the session variable bag when the option is selected — the one engine-applied checkpoint effect. Values are validated against the declared variable type, warn-only: mismatches are stored as written and surfaced in _meta.validation; `{name}` template passthroughs are exempt.'), - transitionTo: z.string().optional().describe('Activity ID the orchestrator transitions to next via next_activity. Recorded and returned, not engine-applied: selecting the option does not itself move the session.'), - skipActivities: z.array(z.string()).optional().describe('Activity IDs the orchestrator routes around. Recorded in session bookkeeping (`skippedActivities`) and returned, not engine-applied.'), - }).optional(), + exit: z.string().optional().describe('Exit of the owning activity this option selects — a name from the activity\'s `exits[]`, never an activity id. The destination is the workflow\'s to state: `present_checkpoint` reads it from the workflow graph so the option\'s consequence is stated before the user chooses. An adhoc checkpoint has no declared exits, so its options carry setVariable only.'), + }).strict().optional(), }); export type CheckpointOption = z.infer; @@ -242,32 +241,21 @@ export interface Checkpoint { autoAdvanceMs?: number | undefined; } -// Decision branch schema -export const DecisionBranchSchema = z.object({ - id: z.string(), - label: z.string(), - condition: ConditionSchema.optional(), - transitionTo: z.string().optional().describe('Activity ID to transition to. Omit for terminal branches (workflow ends)'), - isDefault: z.boolean().default(false), -}); -export type DecisionBranch = z.infer; - -// Decision schema -export const DecisionSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - branches: z.array(DecisionBranchSchema).min(2), -}); -export type Decision = z.infer; - -// Transition schema -export const TransitionSchema = z.object({ - to: z.string().describe('Activity ID to transition to'), - condition: ConditionSchema.optional(), - isDefault: z.boolean().default(false), -}); -export type Transition = z.infer; +/** + * A named outcome of the activity, in the activity's own vocabulary (`converged`, + * `revision-needed`, `aborted`; `done` for an activity that simply finishes). An exit says what + * happened, never what runs next: the destination is bound per exit in the workflow's `graph`, so + * two workflows can run one borrowed activity in different orders without editing its file. + * An activity with no exits is terminal by omission. + */ +export const ExitSchema = z.object({ + id: z.string().describe('Outcome name, unique within the activity. Kebab-case, in the activity\'s vocabulary — never an activity id.'), + label: z.string().optional().describe('Human-readable statement of the outcome.'), + when: z.string().optional().describe('Inline boolean expression selecting this exit, evaluated agent-side against the variable bag in the `when` dialect the step gates use. Omitted on an exit only a checkpoint option selects, and on the default exit.'), + isDefault: z.literal(true).optional().describe('The outcome when no `when` matched and no checkpoint option selected an exit — including a checkpoint dismissed because its condition was not met. Declared exactly once on an activity with two or more exits; `isDefault: false` is redundant and rejected.'), + immediate: z.literal(true).optional().describe('Selecting this exit at a checkpoint ends the step sequence there: the remaining steps do not run and the step-manifest check accounts for them. Declared for the aborts, where the tail would otherwise run against the user\'s decision. Without it an exit is recorded when chosen and taken when the sequence ends. `immediate: false` is redundant and rejected.'), +}).strict(); +export type Exit = z.infer; // Unified Activity schema. Closed object: a field outside the declared set is a schema error. // The activity's artifact contract is not a schema field at all — `get_activity` synthesizes it @@ -296,9 +284,9 @@ export const ActivitySchema = z.object({ // separate checkpoints[]/loops[] arrays in the unified model. steps: z.array(StepSchema).optional().describe('Ordered, kind-tagged execution steps for this activity'), - // Activity-level routing (read by the orchestrator at the activity boundary, not part of the worker step sequence). - decisions: z.array(DecisionSchema).optional().describe('Conditional branching points; branch conditions are evaluated by the orchestrator, not the server.'), - transitions: z.array(TransitionSchema).optional().describe('Navigation to other activities. Legality is validated warn-only at next_activity — an out-of-graph transition warns in _meta.validation but is not blocked.'), + // The activity's named outcomes (read at the activity boundary, not part of the worker step + // sequence). Where each leads is the workflow's `graph` to bind — the activity names no other activity. + exits: z.array(ExitSchema).optional().describe('Named outcomes of this activity, one of which it takes when its steps end. Each is bound to a destination in the workflow\'s `graph`; an unbound exit fails the workflow load. Omitted on an activity that is terminal by omission.'), triggers: z.array(WorkflowTriggerSchema).optional().describe('Workflows the orchestrator dispatches from this activity (via dispatch_child with an explicit workflow_id); the server does not act on trigger declarations.'), // Metadata (optional) @@ -329,6 +317,18 @@ export function flattenActivitySteps(activity: Activity): Step[] { return out; } +/** + * The index in the activity's top-level `steps` of the step with this id, or of the top-level step + * whose loop body contains it. A nested step belongs to its top-level ancestor because that is the + * unit the sequence advances through: an immediate exit selected inside a loop body ends the whole + * sequence, not the iteration. Returns -1 when no step carries the id. + */ +export function topLevelStepIndex(activity: Activity, stepId: string): number { + const contains = (steps: Step[] | undefined): boolean => + (steps ?? []).some(s => s.id === stepId || (s.kind === 'loop' && contains(s.steps as Step[]))); + return (activity.steps ?? []).findIndex(s => s.id === stepId || (s.kind === 'loop' && contains(s.steps as Step[]))); +} + /** * The activity's checkpoint definitions: the inline kind:checkpoint steps, in document order. A * kind:checkpoint step carries its message/options/effects inline, so it maps directly to a diff --git a/src/schema/session.schema.ts b/src/schema/session.schema.ts index 58af37ef9..2c9fe2052 100644 --- a/src/schema/session.schema.ts +++ b/src/schema/session.schema.ts @@ -98,7 +98,8 @@ const SessionFileBaseSchema = z.object({ /** Current execution position. */ currentActivity: z.string().default(''), currentTechnique: z.string().default(''), - condition: z.string().default(''), + /** Exit the last completed activity took, as its orchestrator reported it. */ + exit: z.string().default(''), /** Outstanding checkpoint, if one is active. */ activeCheckpoint: ActiveCheckpointSchema.optional(), @@ -108,7 +109,6 @@ const SessionFileBaseSchema = z.object({ /** Activity bookkeeping. */ completedActivities: z.array(z.string()).default([]), - skippedActivities: z.array(z.string()).default([]), /** * Map of "activityId-checkpointId" → resolution record. Mirrors the @@ -207,11 +207,10 @@ export interface SessionFile { startedAt: string; currentActivity: string; currentTechnique: string; - condition: string; + exit: string; activeCheckpoint?: ActiveCheckpoint; variables: Record; completedActivities: string[]; - skippedActivities: string[]; checkpointResponses: Record; history: HistoryEntry[]; status: 'running' | 'completed' | 'aborted'; @@ -318,10 +317,9 @@ export function createInitialSessionFile(args: { startedAt: now.toISOString(), currentActivity: '', currentTechnique: '', - condition: '', + exit: '', variables: seeded, completedActivities: [], - skippedActivities: [], checkpointResponses: {}, // Defaults seeded from the workflow's variable declarations (#166 B7) are // recorded as ONE variables_seeded event carrying the whole map — they are diff --git a/src/schema/state.schema.ts b/src/schema/state.schema.ts index b28b8ceb2..088d2ba22 100644 --- a/src/schema/state.schema.ts +++ b/src/schema/state.schema.ts @@ -97,20 +97,12 @@ export const CheckpointResponseSchema = z.object({ respondedAt: z.string().datetime(), effects: z.object({ variablesSet: z.record(z.unknown()).optional(), - transitionedTo: z.string().optional(), - activitiesSkipped: z.array(z.string()).optional(), + /** The activity exit the selected option named. The destination is the workflow graph's to say. */ + exit: z.string().optional(), }).optional(), }); export type CheckpointResponse = z.infer; -// Key format: "activityId-decisionId" -export const DecisionOutcomeSchema = z.object({ - branchId: z.string(), - decidedAt: z.string().datetime(), - transitionedTo: z.string(), -}); -export type DecisionOutcome = z.infer; - export const LoopStateSchema = z.object({ activityId: z.string(), loopId: z.string(), @@ -171,10 +163,8 @@ export const WorkflowStateBaseSchema = z.object({ currentActivity: z.string().optional(), currentStep: StepIndex.optional(), completedActivities: z.array(z.string()).default([]), - skippedActivities: z.array(z.string()).default([]), completedSteps: z.record(z.array(StepIndex)).default({}), checkpointResponses: z.record(CheckpointResponseSchema).default({}), - decisionOutcomes: z.record(DecisionOutcomeSchema).default({}), activeLoops: z.array(LoopStateSchema).default([]), variables: z.record(z.unknown()).default({}), history: z.array(HistoryEntrySchema).default([]), @@ -224,7 +214,7 @@ export function createInitialState(workflowId: string, workflowVersion: string, const now = new Date().toISOString(); return { workflowId, workflowVersion, stateVersion: 1, startedAt: now, updatedAt: now, currentActivity: initialActivity, - completedActivities: [], skippedActivities: [], completedSteps: {}, checkpointResponses: {}, decisionOutcomes: {}, + completedActivities: [], completedSteps: {}, checkpointResponses: {}, activeLoops: [], variables: initialVariables ?? {}, triggeredWorkflows: [], history: [{ timestamp: now, type: 'workflow_started', activity: initialActivity, data: { initialVariables } }], status: 'running', diff --git a/src/schema/workflow.schema.ts b/src/schema/workflow.schema.ts index 7f6330596..876d3c2e2 100644 --- a/src/schema/workflow.schema.ts +++ b/src/schema/workflow.schema.ts @@ -53,6 +53,15 @@ export const WorkflowFragmentsSchema = z.object({ }).strict(); export type WorkflowFragments = z.infer; +/** + * Exit bindings: activity id → exit id → destination activity id. A destination of + * `__terminal__` (TERMINAL_SENTINEL) ends the run without landing on an activity. Every exit every + * activity in the workflow declares is bound here; an unbound exit, an unknown exit and an unknown + * destination each fail the load, so the graph and the activities cannot drift apart. + */ +export const GraphSchema = z.record(z.record(z.string())); +export type Graph = z.infer; + export const WorkflowSchema = z.object({ $schema: z.string().optional(), id: z.string().describe('Unique workflow identifier'), @@ -66,12 +75,13 @@ export const WorkflowSchema = z.object({ variables: z.array(VariableDefinitionSchema).optional().describe('The variables this workflow file owns: facts about the session and policy spanning activities. A variable an activity writes is declared by that activity, under its own `variables.writes`, and contributed here when the activity joins this workflow\'s graph — get_workflow renders the whole set, and two declarations of one name that disagree on type or default fail the load. The session variable bag is seeded from each declaration\'s defaultValue at session creation; thereafter the server writes it through checkpoint setVariable effects and through the worker outputs an orchestrator relays as next_activity\'s variables_changed.'), techniques: WorkflowTechniquesSchema.optional().describe('Workflow techniques partitioned by audience: `workflow` (orchestrator, bundled into get_workflow) and `activity` (inherited by every activity, injected into get_activity).'), initialActivity: z.string().optional().describe('ID of the first activity to execute. Required for sequential workflows, optional when all activities are independent entry points.'), + graph: GraphSchema.optional().describe('The workflow\'s shape: for each activity, where each of its exits leads. This is the single home for the routing — an activity names outcomes, the workflow names destinations, so a borrowed activity sits in this graph without its lending workflow having a say. Omitted only by a workflow whose activities declare no exits.'), // JSON Schema validates individual definition files where activities are separate files. // Zod validates the full assembled runtime workflow object, so activities are included here. // The shorthand string references are resolved into fully typed Activity objects during load, // but we allow strings in the intermediate raw schema before transformation. // However, the final Workflow type expects Activity[] to avoid type errors across the codebase. - activities: z.array(ActivitySchema).min(1).optional().describe('Activities that comprise this workflow. Activities with transitions form sequences; activities without transitions are independent entry points. Omitted in definition files where activities are separate files.'), + activities: z.array(ActivitySchema).min(1).optional().describe('Activities that comprise this workflow. An activity whose exits the `graph` binds sits in a sequence; one declaring no exits is terminal. Omitted in definition files where activities are separate files.'), }); export type Workflow = z.infer; @@ -84,9 +94,7 @@ export { type Step, type Checkpoint, type CheckpointOption, - type Decision, - type DecisionBranch, - type Transition, + type Exit, type Action, type TechniquesReference, } from './activity.schema.js'; diff --git a/src/tools/workflow-tools.ts b/src/tools/workflow-tools.ts index 05bd8e491..96428dd0f 100644 --- a/src/tools/workflow-tools.ts +++ b/src/tools/workflow-tools.ts @@ -8,7 +8,7 @@ import { DEFAULT_BATCH_MAX_ACTIVITIES, presentPathToAgent, } from '../config.js'; -import { listWorkflows, listWorkflowsWithDiagnostics, loadWorkflow, loadWorkflowWithDiagnostics, getActivity, getCheckpoint, readActivityRaw, buildFragmentsLookup, TERMINAL_SENTINEL } from '../loaders/workflow-loader.js'; +import { listWorkflows, listWorkflowsWithDiagnostics, loadWorkflow, loadWorkflowWithDiagnostics, getActivity, getCheckpoint, getExitBindings, readActivityRaw, buildFragmentsLookup, TERMINAL_SENTINEL } from '../loaders/workflow-loader.js'; import { injectCheckpointFragmentBodies, resolveCheckpointFragment, scanCheckpointRefLines } from '../loaders/fragment-resolver.js'; import { resolveTechniques, formatTechniqueBundle, composeActivityTechnique, projectTechnique, projectTechniqueToYaml } from '../loaders/technique-loader.js'; import { CORE_ORCHESTRATOR_TECHNIQUES, CORE_WORKER_TECHNIQUES } from '../loaders/core-ops.js'; @@ -46,7 +46,7 @@ import { listSessionSearchRoots, } from '../utils/session/index.js'; import type { SessionFile } from '../schema/session.schema.js'; -import { buildValidation, validateWorkflowVersion, validateActivityTransition, validateStepManifest, validateTechniqueFetches, validateTransitionCondition, validateActivityManifest } from '../utils/validation.js'; +import { buildValidation, validateWorkflowVersion, validateActivityTransition, validateStepManifest, validateTechniqueFetches, validateReportedExit, validateActivityManifest } from '../utils/validation.js'; import type { StepManifestEntry, ActivityManifestEntry } from '../utils/validation.js'; import { createTraceToken, decodeTraceToken } from '../trace.js'; import type { TraceEvent, TraceTokenPayload } from '../trace.js'; @@ -59,8 +59,8 @@ const stepManifestSchema = z.array(z.object({ const activityManifestSchema = z.array(z.object({ activity_id: z.string(), outcome: z.string(), - transition_condition: z.string().optional(), -})).optional().describe('Orchestrator activity-completion manifest: [{activity_id, outcome, transition_condition?}].'); + exit: z.string().optional(), +})).optional().describe('Orchestrator activity-completion manifest: [{activity_id, outcome, exit?}].'); const usageSchema = z.record(z.unknown()).describe( 'Harness-reported token usage for ONE activity, on the basis the sibling `basis` parameter states. \n' @@ -233,7 +233,7 @@ export function projectActivities(s: SessionFile): Record { .map(e => ({ activity: e.activity!, outcome: e.data?.['outcome'], - ...(e.data?.['transitionCondition'] !== undefined ? { transitionCondition: e.data['transitionCondition'] } : {}), + ...(e.data?.['exit'] !== undefined ? { exit: e.data['exit'] } : {}), })); // Activities entered whose in-progress mark the dispatch did not publish, and those it // said nothing about. The mark exists for someone watching a long activity in flight, @@ -249,7 +249,6 @@ export function projectActivities(s: SessionFile): Record { const progress_mark_unreported = [...new Set(entered.map(e => e.activity!))].filter(a => !reported.has(a)); return { completed: s.completedActivities ?? [], - skipped: s.skippedActivities ?? [], current: s.currentActivity, outcomes, progress_mark_unpublished, @@ -651,6 +650,9 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): })(), variables: wf.variables, initialActivity: wf.initialActivity, + // The workflow's shape, in one place: for each activity, where each of its exits leads. + // Report the exit on next_activity and the target is this map's answer, not a guess. + graph: wf.graph, activities: wf.activities?.map((a: { id: string; name?: string; required?: boolean; artifactPrefix?: string | undefined }) => ({ id: a.id, name: a.name, required: a.required, artifactPrefix: a.artifactPrefix })) ?? [], // Activity files that failed to load and are missing from `activities` — surfaced here // instead of silently skipped, so a broken definition is visible at workflow load rather @@ -685,8 +687,8 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): server.tool('next_activity', 'Orchestrator tool: transition to `activity_id` (does not return the activity body — the worker calls `get_activity`). First call: `initialActivity` from get_workflow; later: an id from the current activity\'s transitions. Optional manifests enable advisory validation.', { ...sessionIndexParam, - activity_id: z.string().describe('Target activity id. First call: initialActivity from get_workflow; later: from current activity transitions.'), - transition_condition: z.string().optional().describe('Optional. Transition condition text from the previous activity (advisory validation).'), + activity_id: z.string().describe('Target activity id. First call: initialActivity from get_workflow; later: the activity the workflow graph binds to the exit the previous activity took.'), + exit: z.string().optional().describe('Optional. Name of the exit the previous activity took. Checked against the workflow graph: an exit bound to an activity other than `activity_id` warns.'), step_manifest: stepManifestSchema, activity_manifest: activityManifestSchema, variables_changed: variablesChangedSchema, @@ -701,7 +703,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): 'Whether the in-progress Progress mark for this activity is committed and pushed before the worker spawns. Recorded as a `progress_published` event, so an activity opened without one is answerable from the session rather than only from whoever was watching the working tree at the time. Omit only where the session has no planning folder to mark.', ), }, - withAuditLog('next_activity', withSessionStoreErrors(async ({ session_index, activity_id, transition_condition, step_manifest, activity_manifest, variables_changed, artifacts_produced, agent_id, context_tokens, progress_published }) => { + withAuditLog('next_activity', withSessionStoreErrors(async ({ session_index, activity_id, exit, step_manifest, activity_manifest, variables_changed, artifacts_produced, agent_id, context_tokens, progress_published }) => { const loadOpts = await sessionLoadOpts(); const loaded = await loadSessionForTool(planningRootDir, session_index, loadOpts); const { state } = loaded; @@ -723,7 +725,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): const view = sessionView(state); const manifestWarnings: (string | null)[] = []; if (step_manifest && state.currentActivity) { - const mw = validateStepManifest(step_manifest as StepManifestEntry[], result.value, state.currentActivity); + const mw = validateStepManifest(step_manifest as StepManifestEntry[], result.value, state.currentActivity, state.checkpointResponses); manifestWarnings.push(...mw); // Fidelity observability (#166 B8): advisory cross-check of the // manifest against the technique_fetched events get_technique @@ -733,8 +735,8 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): manifestWarnings.push(`No step_manifest provided for previous activity '${state.currentActivity}'. Include a manifest to enable step completion validation.`); } - const condWarning = (transition_condition !== undefined && state.currentActivity) - ? validateTransitionCondition(view, result.value, activity_id, transition_condition) + const exitWarning = (exit !== undefined && state.currentActivity) + ? validateReportedExit(view, result.value, activity_id, exit) : null; const activityManifestWarnings: string[] = []; @@ -781,7 +783,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): activity: entry.activity_id, data: { outcome: entry.outcome, - ...(entry.transition_condition !== undefined ? { transitionCondition: entry.transition_condition } : {}), + ...(entry.exit !== undefined ? { exit: entry.exit } : {}), }, }); } @@ -829,7 +831,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): draft.declaredArtifacts = [...byId.values()]; } draft.currentActivity = activity_id; - draft.condition = transition_condition ?? ''; + draft.exit = exit ?? ''; delete draft.activeCheckpoint; draft.history.push({ timestamp: now, type: 'activity_entered', activity: activity_id }); // Whether the dispatch published this activity's in-progress mark. The mark lives @@ -906,7 +908,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): const validation = buildValidation( validateActivityTransition(view, result.value, activity_id), validateWorkflowVersion(view, result.value), - condWarning, + exitWarning, ...manifestWarnings, ...activityManifestWarnings, ...variableWarnings, @@ -1671,8 +1673,7 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): const effects = priorResponse.effects; const effect: Record = {}; if (effects?.variablesSet) effect['setVariable'] = effects.variablesSet; - if (effects?.transitionedTo) effect['transitionTo'] = effects.transitionedTo; - if (effects?.activitiesSkipped) effect['skipActivities'] = effects.activitiesSkipped; + if (effects?.exit) effect['exit'] = effects.exit; const replayedAt = new Date().toISOString(); const next = advanceSession(state, (draft) => { @@ -1859,8 +1860,26 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): validateWorkflowVersion(view, result.value), ); + // An option names an outcome; the workflow graph says where that outcome leads. Resolving it + // here is what lets the orchestrator state each option's consequence before the user chooses, + // instead of the user learning it from where the run went afterwards. + // An adhoc checkpoint decides something the activity does not declare, so it has no exit to + // name and its options carry no consequence beyond themselves. + const bindings = active.adhoc ? [] : getExitBindings(result.value, active.activityId); + const options = checkpoint.options.map((option) => { + const exitId = 'effect' in option ? option.effect?.exit : undefined; + if (exitId === undefined) return option; + const binding = bindings.find(b => b.exit === exitId); + return { + ...option, + consequence: binding + ? { exit: exitId, next_activity: binding.to, ...(binding.immediate ? { ends_activity: true } : {}) } + : { exit: exitId }, + }; + }); + return { - content: [{ type: 'text' as const, text: stringifyForResponse({ ...checkpoint, session_index }) }], + content: [{ type: 'text' as const, text: stringifyForResponse({ ...checkpoint, options, session_index }) }], _meta: { session_index, validation }, }; }), traceOpts)); @@ -1970,22 +1989,19 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): // `condition_not_met` dismissals we still record the resolution with // a sentinel option id so the on-disk schema stays valid. const recordedOptionId = resolvedOptionId ?? (condition_not_met ? '__condition_not_met__' : '__unknown__'); - // Unwrap the response effect into the schema-flat shape: - // The encoded effect gives { setVariable: {...}, transitionTo: '...', skipActivities: [...] }; - // the schema stores variablesSet / transitionedTo / activitiesSkipped. - const effectObj = effect as undefined | { setVariable?: Record; transitionTo?: string; skipActivities?: string[] }; + // Unwrap the response effect into the schema-flat shape: the encoded effect gives + // { setVariable: {...}, exit: '...' } and the schema stores variablesSet / exit. + const effectObj = effect as undefined | { setVariable?: Record; exit?: string }; const variablesSet = effectObj?.setVariable; - const transitionedTo = effectObj?.transitionTo; - const activitiesSkipped = effectObj?.skipActivities; - const record: { optionId: string; respondedAt: string; effects?: { variablesSet?: Record; transitionedTo?: string; activitiesSkipped?: string[] } } = { + const selectedExit = effectObj?.exit; + const record: { optionId: string; respondedAt: string; effects?: { variablesSet?: Record; exit?: string } } = { optionId: recordedOptionId, respondedAt, }; - if (variablesSet || transitionedTo || activitiesSkipped) { + if (variablesSet || selectedExit) { record.effects = {}; if (variablesSet) record.effects.variablesSet = variablesSet; - if (transitionedTo) record.effects.transitionedTo = transitionedTo; - if (activitiesSkipped) record.effects.activitiesSkipped = activitiesSkipped; + if (selectedExit) record.effects.exit = selectedExit; } draft.checkpointResponses = { ...(draft.checkpointResponses ?? {}), [recordKey]: record }; draft.history.push({ @@ -2006,19 +2022,6 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): source: 'setVariable', })); } - // Apply explicitly-skipped activities to the bookkeeping array. - if (activitiesSkipped) { - for (const id of activitiesSkipped) { - if (!draft.skippedActivities.includes(id)) { - draft.skippedActivities.push(id); - draft.history.push({ - timestamp: respondedAt, - type: 'activity_skipped', - activity: id, - }); - } - } - } }); await saveSessionForTool(loaded, next); @@ -2040,6 +2043,20 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig): if (resolvedOptionId !== undefined) responseData['resolved_option'] = resolvedOptionId; if (effect !== undefined) responseData['effect'] = effect; if (condition_not_met) responseData['dismissed'] = true; + // The option named an outcome; the workflow graph says what follows it. An immediate exit + // ends the sequence here, so the worker is told to stop rather than run the remaining steps. + const chosenExit = (effect as { exit?: string } | undefined)?.exit; + if (chosenExit !== undefined) { + const binding = getExitBindings(result.value, active.activityId).find(b => b.exit === chosenExit); + responseData['exit'] = { + id: chosenExit, + ...(binding ? { next_activity: binding.to } : {}), + ...(binding?.immediate ? { ends_activity: true } : {}), + }; + if (binding?.immediate) { + responseData['message'] = `Exit '${chosenExit}' ends this activity here: do not run the remaining steps. Report the steps you did run in next_activity's step_manifest and hand back to the orchestrator, whose next target is '${binding.to}'.`; + } + } return { content: [{ type: 'text' as const, text: JSON.stringify(responseData, null, 2) }], diff --git a/src/utils/activity-variables.ts b/src/utils/activity-variables.ts index ecd22919c..4c67db608 100644 --- a/src/utils/activity-variables.ts +++ b/src/utils/activity-variables.ts @@ -409,9 +409,8 @@ export async function deriveActivityContract(args: { routingReads.add(name); read(name); }; - for (const transition of activity.transitions ?? []) conditionReads(transition.condition).forEach(routingRead); - for (const decision of activity.decisions ?? []) { - for (const branch of decision.branches) conditionReads(branch.condition).forEach(routingRead); + for (const exit of activity.exits ?? []) { + if (exit.when) whenReads(exit.when).forEach(routingRead); } for (const rule of activity.rules ?? []) tokenReads(rule).forEach(read); for (const outcome of activity.outcome ?? []) tokenReads(outcome).forEach(read); @@ -470,25 +469,15 @@ const orchestratorInputsCache = new Map>(); export type ActivityGraph = Map; /** - * The workflow's activity graph: every transition, decision branch and checkpoint-option route, - * keyed by the activity it leaves. Targets outside the graph (the terminal sentinel, a typo) are - * kept — the reachability walk needs to know a path leaves. + * The workflow's activity graph as the reachability walk needs it: the destinations bound to each + * activity's exits, keyed by the activity they leave. One source — the workflow's own `graph` — so + * the walk sees the whole shape without assembling it from the activities. Destinations that are + * not activities (the terminal sentinel) are kept: the walk needs to know a path leaves. */ export function activityGraph(workflow: Workflow): ActivityGraph { const graph: ActivityGraph = new Map(); for (const activity of workflow.activities ?? []) { - const targets = new Set(); - for (const transition of activity.transitions ?? []) targets.add(transition.to); - for (const decision of activity.decisions ?? []) { - for (const branch of decision.branches) if (branch.transitionTo) targets.add(branch.transitionTo); - } - for (const step of flattenActivitySteps(activity)) { - if (step.kind !== 'checkpoint') continue; - for (const option of step.options ?? []) { - if (option.effect?.transitionTo) targets.add(option.effect.transitionTo); - } - } - graph.set(activity.id, [...targets]); + graph.set(activity.id, [...new Set(Object.values(workflow.graph?.[activity.id] ?? {}))]); } return graph; } diff --git a/src/utils/session/migration.ts b/src/utils/session/migration.ts index 7e76e828f..d1eff5613 100644 --- a/src/utils/session/migration.ts +++ b/src/utils/session/migration.ts @@ -184,7 +184,7 @@ function buildSessionFromLegacy(args: { const currentTechnique = asString(payload['technique']) ?? asString(payload['skill']) ?? asString(state['currentTechnique']) ?? asString(state['currentSkill']) ?? ''; - const condition = asString(payload['cond']) ?? ''; + const exit = asString(payload['exit']) ?? ''; // Build minimal valid SessionFile with the resolved sessionIndex. const base = createInitialSessionFile({ @@ -200,9 +200,8 @@ function buildSessionFromLegacy(args: { asString(state['startedAt']) ?? base.startedAt; - // Carry over variables / completedActivities / skippedActivities / - // checkpointResponses verbatim when they exist in the envelope. History is - // intentionally dropped (format mismatch). + // Carry over variables / completedActivities / checkpointResponses verbatim when they exist in + // the envelope. History is intentionally dropped (format mismatch). const variables = state['variables'] && typeof state['variables'] === 'object' ? (state['variables'] as Record) @@ -212,11 +211,6 @@ function buildSessionFromLegacy(args: { (v): v is string => typeof v === 'string', ) : []; - const skippedActivities = Array.isArray(state['skippedActivities']) - ? (state['skippedActivities'] as unknown[]).filter( - (v): v is string => typeof v === 'string', - ) - : []; const checkpointResponses = state['checkpointResponses'] && typeof state['checkpointResponses'] === 'object' ? (state['checkpointResponses'] as Record) @@ -248,10 +242,9 @@ function buildSessionFromLegacy(args: { startedAt, currentActivity, currentTechnique, - condition, + exit, variables, completedActivities, - skippedActivities, checkpointResponses: normalisedResponses as SessionFile['checkpointResponses'], }; return result; diff --git a/src/utils/session/store.ts b/src/utils/session/store.ts index f15c4704f..2dfac123a 100644 --- a/src/utils/session/store.ts +++ b/src/utils/session/store.ts @@ -112,13 +112,12 @@ const TOP_LEVEL_KEY_PRIORITY = [ 'planningFolderPath', 'currentActivity', 'currentTechnique', - 'condition', + 'exit', 'activeCheckpoint', 'seq', 'ts', 'startedAt', 'completedActivities', - 'skippedActivities', 'variables', 'checkpointResponses', 'history', diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 48b31a1b0..f9728257a 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import type { Workflow } from '../schema/workflow.schema.js'; import type { HistoryEntry } from '../schema/state.schema.js'; -import { flattenActivitySteps, techniqueName } from '../schema/activity.schema.js'; -import { getValidTransitions, getActivity, getTransitionList, TERMINAL_SENTINEL } from '../loaders/workflow-loader.js'; +import { flattenActivitySteps, techniqueName, topLevelStepIndex } from '../schema/activity.schema.js'; +import type { CheckpointResponse } from '../schema/state.schema.js'; +import { checkpointBaseId, exitDestinations, getActivity, getExitBindings, TERMINAL_SENTINEL } from '../loaders/workflow-loader.js'; /** * Minimal view of session state required by the validation helpers. The @@ -41,11 +42,11 @@ export function validateActivityTransition(view: SessionView, workflow: Workflow // be reached via an abort/checkpoint effect rather than a declared transition). if (activityId === TERMINAL_SENTINEL) return null; - const valid = getValidTransitions(workflow, view.act); + const valid = exitDestinations(workflow, view.act); if (valid.length === 0) return null; if (!valid.includes(activityId)) { - return `Activity '${activityId}' is not a direct transition from '${view.act}'. Valid transitions: [${valid.join(', ')}]`; + return `Activity '${activityId}' is not bound to any exit of '${view.act}'. The workflow graph sends its exits to: [${valid.join(', ')}]`; } return null; } @@ -62,10 +63,40 @@ export interface StepManifestEntry { output: string; } +/** + * Where an immediate exit ended the step sequence, as the index in the activity's top-level steps + * of the checkpoint that selected it — or -1 when the sequence ran to its end. A checkpoint + * response records the exit the user's option named, so the fact is already on the session and + * needs no second record. A revisit needs no visit-scoping either: `yield_checkpoint` replays a + * recorded response, so an activity re-entered takes the same immediate exit again, and a loop-body + * checkpoint yielded as `#` records one response per iteration. + */ +export function immediateExitCut( + workflow: Workflow, + activityId: string, + checkpointResponses: Record | undefined, +): number { + const activity = getActivity(workflow, activityId); + if (!activity || !checkpointResponses) return -1; + const immediate = new Set((activity.exits ?? []).filter(e => e.immediate).map(e => e.id)); + if (immediate.size === 0) return -1; + + let cut = -1; + const prefix = `${activityId}-`; + for (const [key, response] of Object.entries(checkpointResponses)) { + if (!key.startsWith(prefix)) continue; + if (!response.effects?.exit || !immediate.has(response.effects.exit)) continue; + const index = topLevelStepIndex(activity, checkpointBaseId(key.slice(prefix.length))); + if (index >= 0 && (cut === -1 || index < cut)) cut = index; + } + return cut; +} + export function validateStepManifest( manifest: StepManifestEntry[], workflow: Workflow, activityId: string, + checkpointResponses?: Record, ): string[] { const activity = getActivity(workflow, activityId); if (!activity) return [`Cannot validate manifest: activity '${activityId}' not found`]; @@ -74,9 +105,13 @@ export function validateStepManifest( if (!steps || steps.length === 0) return []; const topLevelIds = steps.map(s => s.id).filter((id): id is string => id !== undefined); + // An immediate exit legitimately ends the sequence where it was selected, so the steps after that + // checkpoint are accounted for rather than reported missing. + const cut = immediateExitCut(workflow, activityId, checkpointResponses); + const inSequence = cut === -1 ? steps : steps.slice(0, cut + 1); // `when`/`condition` gates are evaluated agent-side; a gated step may be // legitimately skipped, so only ungated top-level steps are required. - const requiredIds = steps + const requiredIds = inSequence .filter(s => s.when === undefined && s.condition === undefined) .map(s => s.id) .filter((id): id is string => id !== undefined); @@ -193,39 +228,33 @@ export function validateTechniqueFetches( ]; } -export function validateTransitionCondition(view: SessionView, workflow: Workflow, activityId: string, claimedCondition: string | undefined): string | null { - if (!view.act) return null; +/** + * The exit an orchestrator reports for the activity it is leaving names an outcome the workflow + * binds; the requested target is that binding, or the report and the move disagree. Naming the + * outcome is checkable in a way naming a condition never was — the binding is a fact in the + * workflow file rather than a string to be matched against rendered prose. + */ +export function validateReportedExit(view: SessionView, workflow: Workflow, activityId: string, reportedExit: string | undefined): string | null { + if (!view.act || reportedExit === undefined || reportedExit === '') return null; if (view.act === activityId) return null; - const transitions = getTransitionList(workflow, view.act); - if (transitions.length === 0) return null; - - const matchingTransition = transitions.find(t => t.to === activityId); - if (!matchingTransition) return null; - - const claimIsEmpty = claimedCondition === undefined || claimedCondition === ''; - const claimIsDefault = claimedCondition === 'default'; + const bindings = getExitBindings(workflow, view.act); + if (bindings.length === 0) return null; - if (claimIsEmpty || claimIsDefault) { - if (matchingTransition.isDefault || !matchingTransition.condition) return null; - return `Transition to '${activityId}' requires condition '${matchingTransition.condition}' but agent claimed ${claimIsDefault ? "'default'" : 'no condition'}.`; + const binding = bindings.find(b => b.exit === reportedExit); + if (!binding) { + return `Activity '${view.act}' has no exit '${reportedExit}'. Its exits are: [${bindings.map(b => b.exit).join(', ')}]`; } - - if (matchingTransition.isDefault && !matchingTransition.condition) { - return `Transition to '${activityId}' is the default (no condition) but agent claimed condition '${claimedCondition}'.`; - } - - if (matchingTransition.condition && matchingTransition.condition !== claimedCondition) { - return `Condition mismatch for transition to '${activityId}': workflow defines '${matchingTransition.condition}' but agent claimed '${claimedCondition}'.`; + if (binding.to !== activityId) { + return `Exit '${reportedExit}' of '${view.act}' is bound to '${binding.to}' but '${activityId}' was requested.`; } - return null; } export interface ActivityManifestEntry { activity_id: string; outcome: string; - transition_condition?: string; + exit?: string; } export function validateActivityManifest( @@ -242,16 +271,10 @@ export function validateActivityManifest( if (!entry.outcome || (typeof entry.outcome === 'string' && entry.outcome.trim().length === 0)) { warnings.push(`Activity '${entry.activity_id}' has empty outcome`); } - if (entry.transition_condition !== undefined && entry.transition_condition !== '') { - const activity = getActivity(workflow, entry.activity_id); - if (activity) { - const transitions = getTransitionList(workflow, entry.activity_id); - const hasMatchingCondition = transitions.some( - t => t.condition === entry.transition_condition || (t.isDefault && entry.transition_condition === 'default') - ); - if (!hasMatchingCondition && transitions.length > 0) { - warnings.push(`Activity '${entry.activity_id}' claims transition condition '${entry.transition_condition}' not found in workflow transitions`); - } + if (entry.exit !== undefined && entry.exit !== '') { + const bindings = getExitBindings(workflow, entry.activity_id); + if (bindings.length > 0 && !bindings.some(b => b.exit === entry.exit)) { + warnings.push(`Activity '${entry.activity_id}' claims exit '${entry.exit}', which it does not declare`); } } } diff --git a/tests/e2e/walker.ts b/tests/e2e/walker.ts index 33f19f912..2ad230647 100644 --- a/tests/e2e/walker.ts +++ b/tests/e2e/walker.ts @@ -16,7 +16,7 @@ import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { evaluateCondition, type Condition } from '../../src/schema/condition.schema.js'; -import { evaluateWhenExpression } from '../../src/schema/when-expression.js'; +import { evaluateWhenExpression, parseWhen } from '../../src/schema/when-expression.js'; import { unboundPositiveReads, type GateUnansweredCounts } from '../../src/utils/gate-liveness.js'; import { TERMINAL_SENTINEL } from '../../src/loaders/workflow-loader.js'; import { parseToolResponse, parseWorkflowResponse, parseBundle, rawText, isError, type Harness } from './harness.js'; @@ -27,8 +27,7 @@ export interface CheckpointOption { description?: string; effect?: { setVariable?: Record; - transitionTo?: string; - skipActivities?: string[]; + exit?: string; }; } @@ -41,10 +40,21 @@ export interface CheckpointDef { defaultOption?: string; } -export interface TransitionDef { - to: string; - condition?: Condition; +export interface ExitDef { + id: string; + label?: string; + when?: string; isDefault?: boolean; + immediate?: boolean; +} + +/** Exit bindings as the workflow declares them: activity id → exit id → destination. */ +export type Graph = Record>; + +/** An exit the activity took, with the destination the workflow binds it to. */ +export interface ExitChoice { + exit: string; + to: string; } export interface StepAction { @@ -76,7 +86,7 @@ export interface StepDef { export interface ActivityDef { id: string; steps?: StepDef[]; - transitions?: TransitionDef[]; + exits?: ExitDef[]; operations?: string[]; techniques?: { primary?: string; supporting?: string[] }; artifactPrefix?: string; @@ -110,8 +120,7 @@ export interface CheckpointRecord { checkpointId: string; optionId: string; setVariable?: Record; - transitionTo?: string; - skipActivities?: string[]; + exit?: string; } export interface WalkStep { @@ -236,50 +245,63 @@ function defaultVariables(wf: Record): Record } /** - * Select the next activity id from an activity's transitions, or null when the - * activity is terminal. A checkpoint `transitionTo` effect overrides the graph. + * The exit the activity takes, paired with the destination the workflow binds it to. An exit a + * checkpoint option selected wins; otherwise the first exit whose `when` holds; otherwise the + * default. Null when the activity is terminal or its exit leads nowhere the graph names. */ -export function pickNext(act: ActivityDef, variables: Record, override?: string): string | null { - if (override) return override; - const transitions = act.transitions ?? []; - let fallback: string | null = null; - for (const t of transitions) { - if (t.condition) { - if (evaluateCondition(t.condition, variables)) return t.to; - } else if (!t.isDefault) { - return t.to; // unconditional, non-default → take immediately - } - if (t.isDefault) fallback = t.to; +export function pickExit(act: ActivityDef, graph: Graph, variables: Record, selected?: string): ExitChoice | null { + const exits = act.exits ?? []; + const bound = graph[act.id] ?? {}; + const at = (id: string | undefined): ExitChoice | null => { + if (id === undefined) return null; + const to = bound[id]; + return to === undefined ? null : { exit: id, to }; + }; + if (selected !== undefined) return at(selected); + for (const e of exits) { + if (e.when === undefined) continue; + if (evaluateWhenExpression(e.when, variables)) return at(e.id); } - return fallback; + return at(exits.find((e) => e.isDefault)?.id); +} + +/** Where the activity goes, or null when it is terminal. */ +export function pickNext(act: ActivityDef, graph: Graph, variables: Record, selected?: string): string | null { + return pickExit(act, graph, variables, selected)?.to ?? null; } /** - * Workflow-agnostic forward advance: pick a transition to an as-yet-unvisited activity, - * optimistically satisfying its (simple) gate condition by mutating `variables`. This stands in - * for the agent-set convergence variables a no-LLM walker cannot infer, so any workflow drives - * forward to coverage without per-workflow simulation. Returns the chosen activity id, or null - * when no unvisited target can be reached (compound gates that cannot be satisfied are skipped). + * Workflow-agnostic forward advance: pick an exit leading to an as-yet-unvisited activity, + * optimistically satisfying its `when` by mutating `variables`. This stands in for the agent-set + * convergence variables a no-LLM walker cannot infer, so any workflow drives forward to coverage + * without per-workflow simulation. Returns the chosen activity id, or null when no unvisited target + * can be reached (compound gates that cannot be satisfied are skipped). */ -function advanceToUnvisited(act: ActivityDef, variables: Record, visits: Map): string | null { - for (const t of act.transitions ?? []) { - if ((visits.get(t.to) ?? 0) > 0) continue; - if (!t.condition) return t.to; +function advanceToUnvisited(act: ActivityDef, graph: Graph, variables: Record, visits: Map): string | null { + const bound = graph[act.id] ?? {}; + for (const e of act.exits ?? []) { + const to = bound[e.id]; + if (to === undefined || (visits.get(to) ?? 0) > 0) continue; + if (e.when === undefined) return to; const snapshot = { ...variables }; - satisfyCondition(t.condition, variables); - if (evaluateCondition(t.condition, variables)) return t.to; + satisfyWhen(e.when, variables); + if (evaluateWhenExpression(e.when, variables)) return to; for (const k of Object.keys(variables)) delete variables[k]; Object.assign(variables, snapshot); } return null; } -/** Best-effort: set the bag so a SIMPLE condition evaluates true (compound conditions are left alone). */ -function satisfyCondition(cond: unknown, variables: Record): void { - const c = cond as { type?: string; variable?: string; operator?: string; value?: unknown }; - if (!c || c.type !== 'simple' || typeof c.variable !== 'string') return; - if (c.operator === '!=') variables[c.variable] = typeof c.value === 'boolean' ? !c.value : `__ne_${String(c.value)}`; - else variables[c.variable] = c.value; // ==, >=, <=, etc.: set to the compared value +/** Best-effort: set the bag so a single-comparison `when` holds (compound expressions are left alone). */ +function satisfyWhen(when: string, variables: Record): void { + const parsed = parseWhen(when); + if (!parsed.ok) return; + const ast = parsed.ast; + if (ast.kind === 'truthy') { variables[ast.path] = true; return; } + if (ast.kind !== 'cmp') return; + variables[ast.path] = ast.op === '!=' + ? (typeof ast.value === 'boolean' ? !ast.value : `__ne_${String(ast.value)}`) + : ast.value; // ==, >=, <=, etc.: set to the compared value } /** Render an activity's declared artifact filenames (best-effort token interpolation). */ @@ -372,7 +394,7 @@ interface StepExecution { stepsExecuted: string[]; /** `:` for each gate read with nothing in the bag to read. */ gatesReadUnbound: string[]; - transitionOverride?: string; + selectedExit?: string; } /** @@ -396,16 +418,16 @@ async function executeActivitySteps( const stepsExecuted: string[] = []; const gatesReadUnbound: string[] = []; const decidedLater = activityDecidedVariables(act); - let transitionOverride: string | undefined; + let selectedExit: string | undefined; const fireCheckpoint = async (cp: CheckpointDef): Promise => { const optionId = policy.choose({ activityId, checkpoint: cp, variables }); const effect = await resolveCheckpoint(client, sessionIndex, cp.id, optionId); if (effect.setVariable) Object.assign(variables, effect.setVariable); - if (effect.transitionTo) transitionOverride = effect.transitionTo; + if (effect.exit) selectedExit = effect.exit; cpRecords.push({ activityId, checkpointId: cp.id, optionId, - setVariable: effect.setVariable, transitionTo: effect.transitionTo, skipActivities: effect.skipActivities, + setVariable: effect.setVariable, exit: effect.exit, }); // A resumed worker re-requests its activity under the identity its dispatch bound, carrying // `bundle: "reference"` so content it still holds arrives as markers. Recording the delivery @@ -469,7 +491,7 @@ async function executeActivitySteps( } }; await walk(act.steps); - return { cpRecords, manifest, stepsExecuted, gatesReadUnbound, transitionOverride }; + return { cpRecords, manifest, stepsExecuted, gatesReadUnbound, selectedExit }; } /** The activity's checkpoint definitions in document order: the inline kind:checkpoint steps, @@ -531,7 +553,7 @@ async function resolveCheckpoint( sessionIndex: string, checkpointId: string, optionId: string, -): Promise<{ setVariable?: Record; transitionTo?: string; skipActivities?: string[] }> { +): Promise<{ setVariable?: Record; exit?: string }> { const y = await client.callTool({ name: 'yield_checkpoint', arguments: { session_index: sessionIndex, checkpoint_id: checkpointId } }); if (isError(y)) throw new Error(`yield_checkpoint(${checkpointId}) failed`); const yieldBody = parseToolResponse(y); @@ -544,8 +566,7 @@ async function resolveCheckpoint( const effect = (yieldBody.effect ?? {}) as Record; return { setVariable: (effect.setVariable ?? effect.variablesSet) as Record | undefined, - transitionTo: (effect.transitionTo ?? effect.transitionedTo) as string | undefined, - skipActivities: (effect.skipActivities ?? effect.activitiesSkipped) as string[] | undefined, + exit: effect.exit as string | undefined, }; } @@ -559,8 +580,7 @@ async function resolveCheckpoint( const effect = (resp.effect ?? {}) as Record; return { setVariable: (effect.setVariable ?? effect.variablesSet) as Record | undefined, - transitionTo: (effect.transitionTo ?? effect.transitionedTo) as string | undefined, - skipActivities: (effect.skipActivities ?? effect.activitiesSkipped) as string[] | undefined, + exit: effect.exit as string | undefined, }; } @@ -589,6 +609,9 @@ export async function walk( const orchestratorUnresolved = (parseBundle(wfRes).unresolved as string[] | undefined) ?? []; const wfActivities = (wf.activities as Array<{ id: string; artifactPrefix?: string }> | undefined) ?? []; const declaredActivities = wfActivities.map(a => a.id); + // The workflow's own graph: for each activity, where each of its exits leads. The walk reads the + // shape from here and the outcome from the activity, which is the split the definitions make. + const graph = (wf.graph as Graph | undefined) ?? {}; const activityPrefixes = new Map(wfActivities.map(a => [a.id, a.artifactPrefix] as const)); const variables: Record = { ...defaultVariables(wf), ...(policy.initialVariables ?? {}) }; @@ -639,7 +662,7 @@ export async function walk( } let cpRecords: CheckpointRecord[]; - let transitionOverride: string | undefined; + let selectedExit: string | undefined; let stepsExecuted: string[] = []; let gatesReadUnbound: string[] = []; let artifactsWritten: string[] = []; @@ -647,7 +670,7 @@ export async function walk( if (mode === 'robot') { const exec = await executeActivitySteps(client, sessionIndex, current, act, variables, policy, bundledSteps, worker); cpRecords = exec.cpRecords; - transitionOverride = exec.transitionOverride; + selectedExit = exec.selectedExit; stepsExecuted = exec.stepsExecuted; gatesReadUnbound = exec.gatesReadUnbound; pendingManifest = exec.manifest; @@ -663,10 +686,10 @@ export async function walk( ? (cp.options.find((o) => o.id === optionId)?.effect ?? {}) : await resolveCheckpoint(client, sessionIndex, cp.id, optionId); if (effect.setVariable) Object.assign(variables, effect.setVariable); - if (effect.transitionTo) transitionOverride = effect.transitionTo; + if (effect.exit) selectedExit = effect.exit; cpRecords.push({ activityId: current, checkpointId: cp.id, optionId, - setVariable: effect.setVariable, transitionTo: effect.transitionTo, skipActivities: effect.skipActivities, + setVariable: effect.setVariable, exit: effect.exit, }); } } @@ -676,20 +699,21 @@ export async function walk( const simulated = policy.simulate?.({ activityId: current, variables }); if (simulated) Object.assign(variables, simulated); - let next = pickNext(act, variables, transitionOverride); - if (!transitionOverride) { - const targets = [...new Set((act.transitions ?? []).map((t) => t.to))]; + let next = pickNext(act, graph, variables, selectedExit); + if (selectedExit === undefined) { + const bound = graph[act.id] ?? {}; + const targets = [...new Set((act.exits ?? []).map((e) => bound[e.id]).filter((t): t is string => t !== undefined))]; if (targets.length && opts.decide) { - // The natural (happy) target — pickNext's choice, or the forward-advance target, or the - // first declared transition — is the base-path suggestion; the enumerator forks the rest. + // The natural (happy) target — pickExit's choice, or the forward-advance target, or the + // first bound exit — is the base-path suggestion; the enumerator forks the rest. let suggested = next; - if (suggested === null || (visits.get(suggested) ?? 0) > 0) suggested = advanceToUnvisited(act, { ...variables }, visits) ?? next; + if (suggested === null || (visits.get(suggested) ?? 0) > 0) suggested = advanceToUnvisited(act, graph, { ...variables }, visits) ?? next; const chosen = opts.decide({ kind: 'transition', activityId: current, id: 'next', options: targets, suggested: suggested ?? targets[0]! }) ?? suggested ?? targets[0]!; - const t = (act.transitions ?? []).find((tr) => tr.to === chosen); - if (t?.condition) satisfyCondition(t.condition, variables); + const exit = (act.exits ?? []).find((e) => bound[e.id] === chosen); + if (exit?.when) satisfyWhen(exit.when, variables); next = chosen; } else if (opts.autoAdvance && (next === null || (visits.get(next) ?? 0) > 0)) { - const fwd = advanceToUnvisited(act, variables, visits); + const fwd = advanceToUnvisited(act, graph, variables, visits); if (fwd) next = fwd; } } From 7ab26960317a410030c782761b3b16a5a21a2c0e Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 24 Aug 2026 09:08:38 +0100 Subject: [PATCH 2/5] Test the graph the workflow states and the exit that ends an activity Loader coverage for the binding check: an unbound exit, a binding naming an exit or destination that does not exist, a missing or duplicated default, and an option selecting an exit its activity never declared. One case runs a single activity in two workflows that bind its exits in opposite orders, neither editing the other. Manifest coverage for the immediate exit: the cut lands on the checkpoint that selected it, on the enclosing top-level step when the checkpoint sits in a loop body, and on the base id when the checkpoint was yielded per iteration. The same manifest is clean with the exit recorded and reports its tail missing without it. Through the server: present_checkpoint states each option's destination from the binding, and aborting a submission ends the activity there. The walk snapshots are unchanged, so the corpus walks the same paths it did when its activities carried the routing. --- site/api/schemas.html | 52 ++---- site/api/tools.html | 16 +- tests/activity-variables.test.ts | 17 +- tests/batch-loop-walk.test.ts | 7 +- tests/context-window-smoke.test.ts | 8 +- tests/decision-order-guard.test.ts | 4 +- tests/e2e/__snapshots__/corpus-sha.json | 2 +- tests/enforcement-notes.test.ts | 10 +- .../activities/00-alpha-activity.yaml | 1 - .../activities/00-beta-activity.yaml | 1 - .../activities/00-gamma-activity.yaml | 1 - .../inspect-session/inspect_session.py | 7 +- .../activities/00-checkpoint-activity.yaml | 4 +- .../variable-model/seed-fixture/workflow.yaml | 3 + tests/hybrid-bundling.test.ts | 8 +- tests/mcp-server.test.ts | 132 +++++++++++--- tests/schema-validation.test.ts | 52 ++---- tests/session-schema.test.ts | 1 - tests/validation.test.ts | 115 ++++++++++++- tests/workflow-loader.test.ts | 162 +++++++++++------- workflows | 2 +- 21 files changed, 404 insertions(+), 201 deletions(-) diff --git a/site/api/schemas.html b/site/api/schemas.html index cea3a94f0..01b9fdcae 100644 --- a/site/api/schemas.html +++ b/site/api/schemas.html @@ -89,20 +89,12 @@

activity.schema.json

techniquesstring[]noActivity-wide technique references (:: paths); bundled into get_activity. bundleTechniquesobjectnoOpt-in hybrid bundling: get_activity inlines each step technique whose composed wire form is at most maxChars and whose gate answers true at activity open; larger ones, and those whose gate has no answer yet, remain lazy-fetched via get_technique. Bundled deliveries are recorded as technique_bundled history events and satisfy the manifest fidelity check. steps(technique | action | checkpoint | loop)[]noOrdered, kind-tagged execution steps for this activity - decisionsobject[]noConditional branching points; branch conditions are evaluated by the orchestrator, not the server. - decisions[].idstringyes- - decisions[].namestringyes- - decisions[].descriptionstringno- - decisions[].branchesobject[]yes- - decisions[].branches[].idstringyes- - decisions[].branches[].labelstringyes- - decisions[].branches[].conditionconditionno- - decisions[].branches[].transitionTostringnoActivity ID to transition to. Omit for terminal branches (workflow ends) - decisions[].branches[].isDefaultbooleannoDefault: false - transitionsobject[]noNavigation to other activities. Legality is validated warn-only at next_activity — an out-of-graph transition warns in _meta.validation but is not blocked. - transitions[].tostringyesActivity ID to transition to - transitions[].conditionconditionno- - transitions[].isDefaultbooleannoDefault: false + exitsobject[]noNamed outcomes of this activity, one of which it takes when its steps end. Each is bound to a destination in the workflow's graph; an unbound exit fails the workflow load. Omitted on an activity that is terminal by omission. + exits[].idstringyesOutcome name, unique within the activity. Kebab-case, in the activity's vocabulary — never an activity id. + exits[].labelstringnoHuman-readable statement of the outcome. + exits[].whenstringnoInline boolean expression selecting this exit, evaluated agent-side against the variable bag in the when dialect the step gates use. Omitted on an exit only a checkpoint option selects, and on the default exit. + exits[].isDefaulttruenoThe outcome when no when matched and no checkpoint option selected an exit — including a checkpoint dismissed because its condition was not met. Declared exactly once on an activity with two or more exits; isDefault: false is redundant and rejected. + exits[].immediatetruenoSelecting this exit at a checkpoint ends the step sequence there: the remaining steps do not run and the step-manifest check accounts for them. Declared for the aborts, where the tail would otherwise run against the user's decision. Without it an exit is recorded when chosen and taken when the sequence ends. immediate: false is redundant and rejected. triggersobject[]noWorkflows the orchestrator dispatches from this activity (via dispatch_child with an explicit workflow_id); the server does not act on trigger declarations. triggers[].workflowstringyesID of the workflow to trigger triggers[].descriptionstringnoDescription of when/why this workflow is triggered @@ -140,15 +132,14 @@

session-file.schema.json

startedAtstringyes- currentActivitystringnoDefault: "" currentTechniquestringnoDefault: "" - conditionstringnoDefault: "" + exitstringnoName of the exit the previous activity took. Default: "" activeCheckpointobjectno- variablesobjectnoDefault: {} completedActivitiesstring[]noDefault: [] - skippedActivitiesstring[]noDefault: [] checkpointResponsesobjectnoDefault: {} historyobject[]noDefault: [] history[].timestampstringyes- - history[].type"workflow_started" | "workflow_completed" | "workflow_aborted" | "workflow_triggered" | "workflow_returned" | "workflow_suspended" | "activity_entered" | "activity_exited" | "activity_skipped" | "step_started" | "step_completed" | "checkpoint_reached" | "checkpoint_response" | "checkpoint_replayed" | "decision_reached" | "decision_branch_taken" | "loop_started" | "loop_iteration" | "loop_completed" | "loop_break" | "variable_set" | "error" | "technique_fetched" | "resource_fetched" | "technique_bundled" | "variables_seeded" | "activity_usage" | "activity_dispatched" | "activity_redelivered" | "batch_refused"yes- + history[].type"workflow_started" | "workflow_completed" | "workflow_aborted" | "workflow_triggered" | "workflow_returned" | "workflow_suspended" | "activity_entered" | "activity_exited" | "activity_skipped" | "step_started" | "step_completed" | "checkpoint_reached" | "checkpoint_response" | "checkpoint_replayed" | "decision_reached" | "decision_branch_taken" | "loop_started" | "loop_iteration" | "loop_completed" | "loop_break" | "variable_set" | "error" | "technique_fetched" | "resource_fetched" | "technique_bundled" | "variables_seeded" | "activity_usage" | "activity_dispatched" | "activity_redelivered" | "batch_refused" | "activity_outcome" | "progress_published"yes- history[].activitystringno- history[].stepintegerno- history[].checkpointstringno- @@ -197,10 +188,8 @@

state.schema.json

currentActivitystringno- currentStepintegerno- completedActivitiesstring[]noDefault: [] - skippedActivitiesstring[]noDefault: [] completedStepsobjectnoDefault: {} checkpointResponsesobjectnoDefault: {} - decisionOutcomesobjectnoDefault: {} activeLoopsobject[]noDefault: [] activeLoops[].activityIdstringyes- activeLoops[].loopIdstringyes- @@ -211,7 +200,7 @@

state.schema.json

variablesobjectnoDefault: {} historyobject[]noDefault: [] history[].timestampstringyes- - history[].type"workflow_started" | "workflow_completed" | "workflow_aborted" | "workflow_triggered" | "workflow_returned" | "workflow_suspended" | "activity_entered" | "activity_exited" | "activity_skipped" | "step_started" | "step_completed" | "checkpoint_reached" | "checkpoint_response" | "checkpoint_replayed" | "decision_reached" | "decision_branch_taken" | "loop_started" | "loop_iteration" | "loop_completed" | "loop_break" | "variable_set" | "error" | "technique_fetched" | "resource_fetched" | "technique_bundled" | "variables_seeded" | "activity_usage" | "activity_dispatched" | "activity_redelivered" | "batch_refused"yes- + history[].type"workflow_started" | "workflow_completed" | "workflow_aborted" | "workflow_triggered" | "workflow_returned" | "workflow_suspended" | "activity_entered" | "activity_exited" | "activity_skipped" | "step_started" | "step_completed" | "checkpoint_reached" | "checkpoint_response" | "checkpoint_replayed" | "decision_reached" | "decision_branch_taken" | "loop_started" | "loop_iteration" | "loop_completed" | "loop_break" | "variable_set" | "error" | "technique_fetched" | "resource_fetched" | "technique_bundled" | "variables_seeded" | "activity_usage" | "activity_dispatched" | "activity_redelivered" | "batch_refused" | "activity_outcome" | "progress_published"yes- history[].activitystringno- history[].stepintegerno- history[].checkpointstringno- @@ -284,7 +273,8 @@

workflow.schema.json

variables[].requiredbooleannoAuthoring metadata; the server does not check that the variable is ever set. Default: false techniquesobjectnoWorkflow techniques partitioned by audience: workflow (orchestrator, bundled into get_workflow) and activity (inherited by every activity, injected into get_activity). initialActivitystringnoID of the first activity to execute. Required for sequential workflows, optional when all activities are independent entry points. - activitiesobject[]noActivities that comprise this workflow. Activities with transitions form sequences; activities without transitions are independent entry points. Omitted in definition files where activities are separate files. + graphobjectnoThe workflow's shape: for each activity, where each of its exits leads. This is the single home for the routing — an activity names outcomes, the workflow names destinations, so a borrowed activity sits in this graph without its lending workflow having a say. Omitted only by a workflow whose activities declare no exits. + activitiesobject[]noActivities that comprise this workflow. An activity whose exits the graph binds sits in a sequence; one declaring no exits is terminal. Omitted in definition files where activities are separate files. activities[].idstringyesUnique identifier for the activity activities[].versionstringyesSemantic version of the activity activities[].namestringyesHuman-readable activity name @@ -293,20 +283,12 @@

workflow.schema.json

activities[].techniquesstring[]noActivity-wide technique references (:: paths); bundled into get_activity. activities[].bundleTechniquesobjectnoOpt-in hybrid bundling: get_activity inlines each step technique whose composed wire form is at most maxChars and whose gate answers true at activity open; larger ones, and those whose gate has no answer yet, remain lazy-fetched via get_technique. Bundled deliveries are recorded as technique_bundled history events and satisfy the manifest fidelity check. activities[].steps(technique | action | checkpoint | loop)[]noOrdered, kind-tagged execution steps for this activity - activities[].decisionsobject[]noConditional branching points; branch conditions are evaluated by the orchestrator, not the server. - activities[].decisions[].idstringyes- - activities[].decisions[].namestringyes- - activities[].decisions[].descriptionstringno- - activities[].decisions[].branchesobject[]yes- - activities[].decisions[].branches[].idstringyes- - activities[].decisions[].branches[].labelstringyes- - activities[].decisions[].branches[].conditionconditionno- - activities[].decisions[].branches[].transitionTostringnoActivity ID to transition to. Omit for terminal branches (workflow ends) - activities[].decisions[].branches[].isDefaultbooleannoDefault: false - activities[].transitionsobject[]noNavigation to other activities. Legality is validated warn-only at next_activity — an out-of-graph transition warns in _meta.validation but is not blocked. - activities[].transitions[].tostringyesActivity ID to transition to - activities[].transitions[].conditionconditionno- - activities[].transitions[].isDefaultbooleannoDefault: false + activities[].exitsobject[]noNamed outcomes of this activity, one of which it takes when its steps end. Each is bound to a destination in the workflow's graph; an unbound exit fails the workflow load. Omitted on an activity that is terminal by omission. + activities[].exits[].idstringyesOutcome name, unique within the activity. Kebab-case, in the activity's vocabulary — never an activity id. + activities[].exits[].labelstringnoHuman-readable statement of the outcome. + activities[].exits[].whenstringnoInline boolean expression selecting this exit, evaluated agent-side against the variable bag in the when dialect the step gates use. Omitted on an exit only a checkpoint option selects, and on the default exit. + activities[].exits[].isDefaulttruenoThe outcome when no when matched and no checkpoint option selected an exit — including a checkpoint dismissed because its condition was not met. Declared exactly once on an activity with two or more exits; isDefault: false is redundant and rejected. + activities[].exits[].immediatetruenoSelecting this exit at a checkpoint ends the step sequence there: the remaining steps do not run and the step-manifest check accounts for them. Declared for the aborts, where the tail would otherwise run against the user's decision. Without it an exit is recorded when chosen and taken when the sequence ends. immediate: false is redundant and rejected. activities[].triggersobject[]noWorkflows the orchestrator dispatches from this activity (via dispatch_child with an explicit workflow_id); the server does not act on trigger declarations. activities[].triggers[].workflowstringyesID of the workflow to trigger activities[].triggers[].descriptionstringnoDescription of when/why this workflow is triggered diff --git a/site/api/tools.html b/site/api/tools.html index 95dbf6e47..e0ccb009d 100644 --- a/site/api/tools.html +++ b/site/api/tools.html @@ -236,19 +236,19 @@

next_activity

Full description

Moves the session to a new activity. This is the orchestrator's advance call — it updates state and records the trace but does not return the activity body.

-

After next_activity, the worker should call get_activity to load steps, checkpoints, transitions, and technique references.

-

For the first transition, use initialActivity from get_workflow. After that, use ids from the current activity's transitions.

-

Optional step_manifest and transition_condition help the server validate what you completed. Manifest checks are advisory — mismatches produce warnings, not hard errors.

+

After next_activity, the worker should call get_activity to load steps, checkpoints, exits, and technique references.

+

For the first transition, use initialActivity from get_workflow. After that, take the exit the activity reports and read its destination from the graph in get_workflow.

+

Optional step_manifest and exit help the server validate what you completed. Manifest checks are advisory — mismatches produce warnings, not hard errors.

- - + + - + @@ -268,7 +268,7 @@

next_activity

- + @@ -412,7 +412,7 @@

get_technique

- + diff --git a/tests/activity-variables.test.ts b/tests/activity-variables.test.ts index 24ab551d7..c217ad73b 100644 --- a/tests/activity-variables.test.ts +++ b/tests/activity-variables.test.ts @@ -164,23 +164,24 @@ describe('read reachability', () => { })).toEqual([]); }); - it('reads every route out of an activity into the graph', () => { + it('reads every destination the workflow binds an activity to', () => { const workflow = { id: 'wf', version: '1.0.0', title: 'WF', + graph: { thing: { done: 'next', escalate: 'escalation-target', go: 'checkpoint-target' } }, activities: [{ id: 'thing', version: '1.0.0', name: 'Thing', required: true, - transitions: [{ to: 'next', isDefault: true }], - decisions: [{ id: 'd', name: 'D', branches: [ - { id: 'a', label: 'A', transitionTo: 'branch-target', isDefault: false }, - { id: 'b', label: 'B', isDefault: true }, - ] }], + exits: [ + { id: 'escalate', when: 'escalation_needed == true' }, + { id: 'done', isDefault: true }, + { id: 'go' }, + ], steps: [{ kind: 'checkpoint' as const, id: 'ask', message: 'Which?', - options: [{ id: 'go', label: 'Go', effect: { transitionTo: 'checkpoint-target' } }], + options: [{ id: 'go', label: 'Go', effect: { exit: 'go' } }], }], }], } as unknown as Workflow; - expect(activityGraph(workflow).get('thing')).toEqual(['next', 'branch-target', 'checkpoint-target']); + expect(activityGraph(workflow).get('thing')).toEqual(['next', 'escalation-target', 'checkpoint-target']); }); }); diff --git a/tests/batch-loop-walk.test.ts b/tests/batch-loop-walk.test.ts index e6b209317..5e27dc3d4 100644 --- a/tests/batch-loop-walk.test.ts +++ b/tests/batch-loop-walk.test.ts @@ -279,11 +279,8 @@ describe('client activity loop walked (#407)', () => { expect(advanceWrite?.value).toBe('{worker_result.next_activity_id}'); // And the activity leaves for close-out on the same condition the loop exits by. - const transitions = (def as unknown as { transitions?: Array<{ to?: string; condition?: { variable?: string; operator?: string; value?: unknown } }> }).transitions ?? []; - const exit = transitions.find((tr) => tr.to === 'end-workflow'); - expect(exit?.condition?.variable).toBe('current_activity'); - expect(exit?.condition?.operator).toBe('=='); - expect(exit?.condition?.value ?? null).toBeNull(); + const exits = (def as unknown as { exits?: Array<{ id: string; when?: string }> }).exits ?? []; + expect(exits.map((e) => e.when)).toContain('current_activity == null'); }); it('advances the session pointer exactly once per activity, never twice in an iteration', () => { diff --git a/tests/context-window-smoke.test.ts b/tests/context-window-smoke.test.ts index d1073fed8..461ab5f29 100644 --- a/tests/context-window-smoke.test.ts +++ b/tests/context-window-smoke.test.ts @@ -76,6 +76,9 @@ describe('context-window sweep — graduated cumulative bundling (#189 C1c)', () 'version: 1.0.0', 'title: Context-window sweep fixture', 'initialActivity: sweep', + 'graph:', + ' sweep:', + ' done: done', 'variables:', ' - name: run_optional', ' type: boolean', @@ -106,8 +109,9 @@ describe('context-window sweep — graduated cumulative bundling (#189 C1c)', () ' id: gated', ' technique: gated', ' when: run_optional == true', - 'transitions:', - ' - to: done', + 'exits:', + ' - id: done', + ' isDefault: true', ].join('\n')); writeFileSync(join(wf, 'activities', '02-done.yaml'), [ diff --git a/tests/decision-order-guard.test.ts b/tests/decision-order-guard.test.ts index 7a055aeff..9d3004214 100644 --- a/tests/decision-order-guard.test.ts +++ b/tests/decision-order-guard.test.ts @@ -107,7 +107,7 @@ describe('decision-order guard', () => { expect(findingsFor(`${exclusiveReader}${exclusiveDecider}`)).toEqual([]); }); - it('exempts an option that re-enters, since the next pass reads what it wrote', () => { + it('exempts an option that leaves the activity, since the next pass reads what it wrote', () => { const reentrant = ` - kind: checkpoint id: pick-platform message: Which platform? @@ -117,7 +117,7 @@ describe('decision-order guard', () => { effect: setVariable: platform: jira - transitionTo: thing + exit: retry `; expect(findingsFor(`${READER}${reentrant}`)).toEqual([]); }); diff --git a/tests/e2e/__snapshots__/corpus-sha.json b/tests/e2e/__snapshots__/corpus-sha.json index 1faa1061d..358665083 100644 --- a/tests/e2e/__snapshots__/corpus-sha.json +++ b/tests/e2e/__snapshots__/corpus-sha.json @@ -1,4 +1,4 @@ { - "corpusSha": "393e244b28c717115fed6430724a438d1da2d373", + "corpusSha": "f9ee51c06b4d395c40154c27ab0eff055b52a857", "note": "Corpus commit the committed walk snapshots were generated against. Update it in the same commit that bumps the workflows submodule and re-baselines the walk (npm run baseline:stamp)." } diff --git a/tests/enforcement-notes.test.ts b/tests/enforcement-notes.test.ts index 279f4daf7..1906936f6 100644 --- a/tests/enforcement-notes.test.ts +++ b/tests/enforcement-notes.test.ts @@ -33,6 +33,9 @@ describe('payload-borne enforcement hints (#189 C7)', () => { 'version: 1.0.0', 'title: Enforcement fixture', 'initialActivity: acts', + 'graph:', + ' acts:', + ' done: plain', 'variables:', ' - name: proceed_confirmed', ' type: boolean', @@ -60,11 +63,12 @@ describe('payload-borne enforcement hints (#189 C7)', () => { ' - id: go', ' label: Go', ' effect:', - ' transitionTo: plain', + ' exit: done', ' defaultOption: go', ' autoAdvanceMs: 1000', - 'transitions:', - ' - to: plain', + 'exits:', + ' - id: done', + ' isDefault: true', ].join('\n')); // plain: a single technique step, no action verbs, no checkpoint → no enforcement_notes. diff --git a/tests/fixtures/fragments/alpha-fixture/activities/00-alpha-activity.yaml b/tests/fixtures/fragments/alpha-fixture/activities/00-alpha-activity.yaml index 6f34cddab..017c1058e 100644 --- a/tests/fixtures/fragments/alpha-fixture/activities/00-alpha-activity.yaml +++ b/tests/fixtures/fragments/alpha-fixture/activities/00-alpha-activity.yaml @@ -5,4 +5,3 @@ steps: - kind: checkpoint id: alpha-confirm ref: confirm-gate -transitions: [] diff --git a/tests/fixtures/fragments/beta-fixture/activities/00-beta-activity.yaml b/tests/fixtures/fragments/beta-fixture/activities/00-beta-activity.yaml index 3d632465a..ee85749ad 100644 --- a/tests/fixtures/fragments/beta-fixture/activities/00-beta-activity.yaml +++ b/tests/fixtures/fragments/beta-fixture/activities/00-beta-activity.yaml @@ -14,4 +14,3 @@ steps: id: beta-missing ref: alpha-fixture::missing-gate - ref: alpha-fixture::confirm-gate -transitions: [] diff --git a/tests/fixtures/fragments/gamma-fixture/activities/00-gamma-activity.yaml b/tests/fixtures/fragments/gamma-fixture/activities/00-gamma-activity.yaml index f633fd169..f483cc098 100644 --- a/tests/fixtures/fragments/gamma-fixture/activities/00-gamma-activity.yaml +++ b/tests/fixtures/fragments/gamma-fixture/activities/00-gamma-activity.yaml @@ -24,4 +24,3 @@ steps: - id: reject label: Reject description: Regenerate the artifacts -transitions: [] diff --git a/tests/fixtures/inspect-session/inspect_session.py b/tests/fixtures/inspect-session/inspect_session.py index 24be15212..7bbb2625f 100644 --- a/tests/fixtures/inspect-session/inspect_session.py +++ b/tests/fixtures/inspect-session/inspect_session.py @@ -75,7 +75,7 @@ def checkpoints(s): def activities(s): - """Completed / skipped / current, the outcome each completed activity reported, and + """Completed / current, the outcome and exit each completed activity reported, and the activities entered without a published in-progress Progress mark. `outcomes` is what close-out measures a run against where the client workflow seeded @@ -90,8 +90,8 @@ def activities(s): continue data = e.get("data") or {} row = {"activity": e.get("activity"), "outcome": data.get("outcome")} - if data.get("transitionCondition") is not None: - row["transitionCondition"] = data["transitionCondition"] + if data.get("exit") is not None: + row["exit"] = data["exit"] outcomes.append(row) entered = [] @@ -106,7 +106,6 @@ def activities(s): return { "completed": s.get("completedActivities") or [], - "skipped": s.get("skippedActivities") or [], "current": s.get("currentActivity"), "outcomes": outcomes, "progress_mark_unpublished": [a for a in entered if reported.get(a) is False], diff --git a/tests/fixtures/variable-model/seed-fixture/activities/00-checkpoint-activity.yaml b/tests/fixtures/variable-model/seed-fixture/activities/00-checkpoint-activity.yaml index 66bdbddff..35dbca37c 100644 --- a/tests/fixtures/variable-model/seed-fixture/activities/00-checkpoint-activity.yaml +++ b/tests/fixtures/variable-model/seed-fixture/activities/00-checkpoint-activity.yaml @@ -25,6 +25,6 @@ steps: effect: setVariable: mode_label: "{unset_marker}" -transitions: - - to: followup-activity +exits: + - id: done isDefault: true diff --git a/tests/fixtures/variable-model/seed-fixture/workflow.yaml b/tests/fixtures/variable-model/seed-fixture/workflow.yaml index 69241a8e6..f731cc567 100644 --- a/tests/fixtures/variable-model/seed-fixture/workflow.yaml +++ b/tests/fixtures/variable-model/seed-fixture/workflow.yaml @@ -3,6 +3,9 @@ version: 1.0.0 title: Seed Fixture description: Fixture workflow exercising defaultValue seeding and setVariable type validation (#166 B7). initialActivity: checkpoint-activity +graph: + checkpoint-activity: + done: followup-activity variables: - name: review_needed type: boolean diff --git a/tests/hybrid-bundling.test.ts b/tests/hybrid-bundling.test.ts index 643d11e95..df6d1f71c 100644 --- a/tests/hybrid-bundling.test.ts +++ b/tests/hybrid-bundling.test.ts @@ -38,6 +38,9 @@ describe('hybrid technique bundling (#189 C1c)', () => { 'version: 1.0.0', 'title: Bundling fixture', 'initialActivity: work', + 'graph:', + ' work:', + ' done: wrap', 'variables:', ' - name: run_optional', ' type: boolean', @@ -87,8 +90,9 @@ describe('hybrid technique bundling (#189 C1c)', () => { ' - kind: technique', ' id: gated-loop-op', ' technique: loop-op', - 'transitions:', - ' - to: wrap', + 'exits:', + ' - id: done', + ' isDefault: true', ].join('\n')); writeFileSync(join(wf, 'activities', '02-wrap.yaml'), [ diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 9dd5f1e40..d878a9df4 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -260,7 +260,7 @@ describe('mcp-server integration', () => { expect(Array.isArray(activity.steps)).toBe(true); // Unified model: checkpoints are inline kind:checkpoint steps (no separate checkpoints[] array). expect(activity.steps.some((s: { kind?: string }) => s.kind === 'checkpoint')).toBe(true); - expect(activity.transitions).toBeDefined(); + expect(activity.exits).toBeDefined(); expect(activity.session_index).toBeDefined(); }); @@ -414,6 +414,87 @@ describe('mcp-server integration', () => { expect(content.session_index).toBe(nextToken); }); + // submit-for-review's body-non-conformant gate offers a re-entry and an abort, with eleven + // steps after it. The ungated ones before it are what a worker that aborts there has run. + const RAN_BEFORE_ABORT = [ + { step_id: 'announce-start', output: 'announced' }, + { step_id: 'review-summary-approval', output: 'approved' }, + { step_id: 'dco-sign-off-confirmation', output: 'confirmed' }, + { step_id: 'private-remote-confirmation', output: 'confirmed' }, + { step_id: 'push-confirmation', output: 'confirmed' }, + { step_id: 'body-non-conformant', output: 'user aborted' }, + ]; + + it('states each option\'s consequence from the workflow graph before the user chooses', async () => { + const { nextToken } = await transitionToActivity(client, sessionToken, 'submit-for-review'); + await client.callTool({ + name: 'yield_checkpoint', + arguments: { session_index: nextToken, checkpoint_id: 'body-non-conformant' }, + }); + + const presented = await client.callTool({ + name: 'present_checkpoint', + arguments: { session_index: nextToken }, + }); + const checkpoint = parseToolResponse(presented); + const options = checkpoint.options as Array<{ id: string; consequence?: Record }>; + + expect(options.find(o => o.id === 'provide-input')?.consequence) + .toEqual({ exit: 'provide-input', next_activity: 'submit-for-review' }); + expect(options.find(o => o.id === 'abort')?.consequence) + .toEqual({ exit: 'abort', next_activity: 'complete', ends_activity: true }); + }); + + it('ends the activity where an immediate exit is selected, and accounts for the steps it skipped', async () => { + const { nextToken } = await transitionToActivity(client, sessionToken, 'submit-for-review'); + await client.callTool({ + name: 'yield_checkpoint', + arguments: { session_index: nextToken, checkpoint_id: 'body-non-conformant' }, + }); + + await new Promise(r => setTimeout(r, 3100)); + const responded = await client.callTool({ + name: 'respond_checkpoint', + arguments: { session_index: nextToken, option_id: 'abort' }, + }); + expect(responded.isError).toBeFalsy(); + const payload = parseToolResponse(responded); + expect(payload.exit).toEqual({ id: 'abort', next_activity: 'complete', ends_activity: true }); + expect(payload.message).toContain('do not run the remaining steps'); + + // The worker reports only what it ran. The steps after the gate are the exit's doing, so the + // manifest check accounts for them rather than reporting them missing. + const moved = await client.callTool({ + name: 'next_activity', + arguments: { + session_index: nextToken, + activity_id: 'complete', + exit: 'abort', + step_manifest: RAN_BEFORE_ABORT, + }, + }); + expect(moved.isError).toBeFalsy(); + const validation = (moved._meta as Record)['validation'] as { warnings: string[] }; + expect(validation.warnings.some(w => w.includes('Missing steps'))).toBe(false); + }); + + it('reports the tail missing when the same manifest arrives with no immediate exit taken', async () => { + const { nextToken } = await transitionToActivity(client, sessionToken, 'submit-for-review'); + + const moved = await client.callTool({ + name: 'next_activity', + arguments: { + session_index: nextToken, + activity_id: 'complete', + exit: 'review-approved', + step_manifest: RAN_BEFORE_ABORT, + }, + }); + expect(moved.isError).toBeFalsy(); + const validation = (moved._meta as Record)['validation'] as { warnings: string[] }; + expect(validation.warnings.some(w => w.includes('Missing steps') && w.includes('announce-completion'))).toBe(true); + }); + it('admits a gate the activity does not declare when it carries the decision (#477)', async () => { const { nextToken } = await transitionToActivity(client, sessionToken, 'start-work-package'); @@ -690,7 +771,7 @@ describe('mcp-server integration', () => { const meta = result._meta as Record; const validation = meta['validation'] as { status: string; warnings: string[] }; expect(validation.status).toBe('warning'); - expect(validation.warnings.some((w: string) => w.includes('not a direct transition'))).toBe(true); + expect(validation.warnings.some((w: string) => w.includes('is not bound to any exit of'))).toBe(true); }); it('should not warn on valid activity transition with manifest', async () => { @@ -725,8 +806,8 @@ describe('mcp-server integration', () => { // ============== Transition Condition Tracking ============== - describe('transition condition validation', () => { - it('should accept correct condition-activity pairing', async () => { + describe('reported exit validation', () => { + it('should accept an exit the graph binds to the requested activity', async () => { const { nextToken, actResponse } = await transitionToActivity(client, sessionToken, 'codebase-comprehension'); const tokenAtComprehension = await resolveCheckpoints(client, nextToken, actResponse); @@ -735,17 +816,16 @@ describe('mcp-server integration', () => { arguments: { session_index: tokenAtComprehension, activity_id: 'requirements-elicitation', - transition_condition: 'needs_elicitation == true', + exit: 'needs-elicitation', }, }); expect(result.isError).toBeFalsy(); const meta = result._meta as Record; const validation = meta['validation'] as { status: string; warnings: string[] }; - const condWarnings = validation.warnings.filter((w: string) => w.includes('Condition mismatch') || w.includes('condition')); - expect(condWarnings).toHaveLength(0); + expect(validation.warnings.filter((w: string) => w.includes('exit'))).toHaveLength(0); }); - it('should warn on mismatched condition for activity', async () => { + it('should warn when the reported exit is bound elsewhere', async () => { const { nextToken, actResponse } = await transitionToActivity(client, sessionToken, 'codebase-comprehension'); const tokenAtComprehension = await resolveCheckpoints(client, nextToken, actResponse); @@ -754,17 +834,35 @@ describe('mcp-server integration', () => { arguments: { session_index: tokenAtComprehension, activity_id: 'requirements-elicitation', - transition_condition: 'skip_optional_activities == true', + exit: 'skip-optional-activities', }, }); expect(result.isError).toBeFalsy(); const meta = result._meta as Record; const validation = meta['validation'] as { status: string; warnings: string[] }; expect(validation.status).toBe('warning'); - expect(validation.warnings.some((w: string) => w.includes('Condition mismatch'))).toBe(true); + expect(validation.warnings.some((w: string) => w.includes("is bound to 'plan-prepare'"))).toBe(true); + }); + + it('should warn when the activity declares no such exit', async () => { + const { nextToken, actResponse } = await transitionToActivity(client, sessionToken, 'codebase-comprehension'); + const tokenAtComprehension = await resolveCheckpoints(client, nextToken, actResponse); + + const result = await client.callTool({ + name: 'next_activity', + arguments: { + session_index: tokenAtComprehension, + activity_id: 'requirements-elicitation', + exit: 'no-such-exit', + }, + }); + expect(result.isError).toBeFalsy(); + const meta = result._meta as Record; + const validation = meta['validation'] as { status: string; warnings: string[] }; + expect(validation.warnings.some((w: string) => w.includes("has no exit 'no-such-exit'"))).toBe(true); }); - it('should accept default transition with empty condition', async () => { + it('should accept the transition with the exit omitted', async () => { const { nextToken, actResponse } = await transitionToActivity(client, sessionToken, 'start-work-package'); const tokenAtStart = await resolveCheckpoints(client, nextToken, actResponse); @@ -773,17 +871,15 @@ describe('mcp-server integration', () => { arguments: { session_index: tokenAtStart, activity_id: 'design-philosophy', - transition_condition: 'default', }, }); expect(result.isError).toBeFalsy(); const meta = result._meta as Record; const validation = meta['validation'] as { status: string; warnings: string[] }; - const condWarnings = validation.warnings.filter((w: string) => w.includes('condition') || w.includes('Condition')); - expect(condWarnings).toHaveLength(0); + expect(validation.warnings.filter((w: string) => w.includes('exit'))).toHaveLength(0); }); - it('condition should not block execution', async () => { + it('a mismatched exit should not block execution', async () => { const { nextToken, actResponse } = await transitionToActivity(client, sessionToken, 'codebase-comprehension'); const tokenAtComprehension = await resolveCheckpoints(client, nextToken, actResponse); @@ -792,7 +888,7 @@ describe('mcp-server integration', () => { arguments: { session_index: tokenAtComprehension, activity_id: 'requirements-elicitation', - transition_condition: 'wrong_condition == true', + exit: 'skip-optional-activities', }, }); expect(result.isError).toBeFalsy(); @@ -2523,7 +2619,6 @@ describe('mcp-server integration', () => { is_review_mode: false, }, completedActivities: ['start-work-package', 'research', 'wp-plan'], - skippedActivities: ['codebase-comprehension'], checkpointResponses: { 'wp-plan-plan-approved': { optionId: 'approved', @@ -2567,7 +2662,6 @@ describe('mcp-server integration', () => { condition: '', variables: { severity: 'high' }, completedActivities: ['intake'], - skippedActivities: [], checkpointResponses: {}, history: [{ timestamp: '2026-07-11T10:40:00.000Z', type: 'workflow_started' }], status: 'running' as const, @@ -2596,7 +2690,6 @@ describe('mcp-server integration', () => { condition: '', variables: {}, completedActivities: [], - skippedActivities: [], checkpointResponses: {}, history: [{ timestamp: '2026-07-11T10:45:00.000Z', type: 'workflow_started' }], status: 'running' as const, @@ -2652,7 +2745,6 @@ describe('mcp-server integration', () => { const activities = parseToolResponse(await callInspect({ view: 'activities' })); expect(activities).toEqual({ completed: ['start-work-package', 'research', 'wp-plan'], - skipped: ['codebase-comprehension'], current: 'implement', // This fixture reports neither outcomes nor progress marks, so every activity it // entered is unreported and none is known to have skipped the write. diff --git a/tests/schema-validation.test.ts b/tests/schema-validation.test.ts index 592072e86..5f52332e4 100644 --- a/tests/schema-validation.test.ts +++ b/tests/schema-validation.test.ts @@ -6,7 +6,7 @@ import { import { ActivitySchema, StepSchema, - DecisionSchema, + ExitSchema, } from '../src/schema/activity.schema.js'; import { ConditionSchema } from '../src/schema/condition.schema.js'; import { @@ -161,46 +161,24 @@ describe('schema-validation', () => { }); }); - describe('DecisionSchema', () => { - it('should validate decision with branches', () => { - const decision = { - id: 'decision-1', - name: 'Validation Check', - branches: [ - { id: 'pass', label: 'Pass', transitionTo: 'next-activity' }, - { id: 'fail', label: 'Fail', transitionTo: 'retry-activity', isDefault: true }, - ], - }; - const result = DecisionSchema.safeParse(decision); + describe('ExitSchema', () => { + it('should validate an outcome with a predicate', () => { + const result = ExitSchema.safeParse({ id: 'revision-needed', label: 'Revision needed', when: 'review_passed == false' }); expect(result.success).toBe(true); }); - it('should validate decision with conditions', () => { - const decision = { - id: 'decision-1', - name: 'Conditional', - branches: [ - { - id: 'branch-a', - label: 'Branch A', - transitionTo: 'activity-a', - condition: { type: 'simple', variable: 'flag', operator: '==', value: true }, - }, - { id: 'branch-b', label: 'Branch B', transitionTo: 'activity-b', isDefault: true }, - ], - }; - const result = DecisionSchema.safeParse(decision); - expect(result.success).toBe(true); + it('should validate a default outcome and an immediate one', () => { + expect(ExitSchema.safeParse({ id: 'converged', isDefault: true }).success).toBe(true); + expect(ExitSchema.safeParse({ id: 'aborted', immediate: true }).success).toBe(true); }); - it('should reject decision with fewer than 2 branches', () => { - const decision = { - id: 'decision-1', - name: 'Single Branch', - branches: [{ id: 'only', label: 'Only', transitionTo: 'next' }], - }; - const result = DecisionSchema.safeParse(decision); - expect(result.success).toBe(false); + it('should reject a redundant negative on isDefault or immediate', () => { + expect(ExitSchema.safeParse({ id: 'converged', isDefault: false }).success).toBe(false); + expect(ExitSchema.safeParse({ id: 'aborted', immediate: false }).success).toBe(false); + }); + + it('should reject an exit naming a destination, which is the workflow graph\'s to name', () => { + expect(ExitSchema.safeParse({ id: 'converged', to: 'next-activity' }).success).toBe(false); }); }); @@ -235,7 +213,7 @@ describe('schema-validation', () => { steps: [{ kind: 'technique', id: 'inner', technique: 'helper-technique::each' }], }, ], - transitions: [{ to: 'activity-2', isDefault: true }], + exits: [{ id: 'done', isDefault: true }], outcome: ['Something is done'], }; const result = ActivitySchema.safeParse(activity); diff --git a/tests/session-schema.test.ts b/tests/session-schema.test.ts index 266912002..13ea0b300 100644 --- a/tests/session-schema.test.ts +++ b/tests/session-schema.test.ts @@ -26,7 +26,6 @@ function minimalSession(overrides: Partial = {}): SessionFile { condition: '', variables: {}, completedActivities: [], - skippedActivities: [], checkpointResponses: {}, history: [], triggeredWorkflows: [], diff --git a/tests/validation.test.ts b/tests/validation.test.ts index c6c10184a..72dbf495d 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + immediateExitCut, validateActivityTransition, validateWorkflowVersion, validateStepManifest, @@ -26,18 +27,22 @@ function makeWorkflow(overrides: Partial = {}): Workflow { id: 'test-wf', version: '1.0.0', title: 'Test Workflow', + graph: { + planning: { done: 'implementation' }, + implementation: { done: 'review' }, + }, activities: [ { id: 'planning', title: 'Planning', steps: [{ id: 'plan-step', title: 'Plan', instructions: 'Plan it' }], - transitions: [{ to: 'implementation' }], + exits: [{ id: 'done', isDefault: true }], }, { id: 'implementation', title: 'Implementation', steps: [{ id: 'impl-step', title: 'Implement', instructions: 'Do it' }], - transitions: [{ to: 'review' }], + exits: [{ id: 'done', isDefault: true }], }, { id: 'review', @@ -73,10 +78,10 @@ describe('validation', () => { expect(result).toBeTypeOf('string'); expect(result).toContain('review'); expect(result).toContain('planning'); - expect(result).toContain('Valid transitions'); + expect(result).toContain('The workflow graph sends its exits to'); }); - it('returns null when current activity has no transitions (terminal activity)', () => { + it('returns null when the current activity declares no exits (terminal activity)', () => { const token = makeToken({ act: 'review' }); const workflow = makeWorkflow(); const result = validateActivityTransition(token, workflow, 'planning'); @@ -90,14 +95,15 @@ describe('validation', () => { expect(result).toBeNull(); }); - it('lists valid transitions in the warning message', () => { + it('lists the bound destinations in the warning message', () => { const workflow = makeWorkflow({ + graph: { hub: { 'a-chosen': 'branch-a', 'b-chosen': 'branch-b' } }, activities: [ { id: 'hub', title: 'Hub', steps: [{ id: 's1', title: 'Step', instructions: 'Do' }], - transitions: [{ to: 'branch-a' }, { to: 'branch-b' }], + exits: [{ id: 'a-chosen', when: 'wants_a == true' }, { id: 'b-chosen', isDefault: true }], }, { id: 'branch-a', title: 'A', steps: [{ id: 'a1', title: 'A', instructions: 'A' }] }, { id: 'branch-b', title: 'B', steps: [{ id: 'b1', title: 'B', instructions: 'B' }] }, @@ -220,6 +226,103 @@ describe('validation', () => { }); }); + describe('immediateExitCut: a sequence an immediate exit ended', () => { + // An abort offered mid-sequence, and a second checkpoint inside a loop body — the two shapes + // the cut has to read, since an immediate exit selected inside a loop ends the whole sequence. + function makeAbortWorkflow(): Workflow { + return { + id: 'test-wf', + version: '1.0.0', + title: 'Test Workflow', + graph: { work: { done: 'next', aborted: '__terminal__', 'give-up': '__terminal__' } }, + activities: [ + { + id: 'work', + version: '1.0.0', + name: 'Work', + exits: [ + { id: 'aborted', immediate: true }, + { id: 'give-up', immediate: true }, + { id: 'done', isDefault: true }, + ], + steps: [ + { kind: 'technique', id: 'first-step', technique: 'grp::first-step' }, + { + kind: 'checkpoint', + id: 'keep-going', + message: 'Continue?', + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'abort', label: 'Abort', effect: { exit: 'aborted' } }, + ], + }, + { + kind: 'loop', + id: 'item-loop', + loopType: 'forEach', + variable: 'current_item', + over: 'pending_items', + steps: [ + { kind: 'technique', id: 'process-item', technique: 'grp::process-item' }, + { kind: 'checkpoint', id: 'still-worth-it', message: 'Still worth it?', options: [ + { id: 'yes', label: 'Yes' }, + { id: 'stop', label: 'Stop', effect: { exit: 'give-up' } }, + ] }, + ], + }, + { kind: 'technique', id: 'announce', technique: 'grp::announce' }, + { kind: 'technique', id: 'last-step', technique: 'grp::last-step' }, + ], + }, + ], + } as unknown as Workflow; + } + + const responded = (checkpoint: string, optionId: string, exit: string) => ({ + [`work-${checkpoint}`]: { optionId, respondedAt: '2026-08-01T00:00:00.000Z', effects: { exit } }, + }); + + it('is -1 when no immediate exit was selected', () => { + expect(immediateExitCut(makeAbortWorkflow(), 'work', {})).toBe(-1); + expect(immediateExitCut(makeAbortWorkflow(), 'work', responded('keep-going', 'yes', ''))).toBe(-1); + }); + + it('cuts at the checkpoint that selected the exit', () => { + expect(immediateExitCut(makeAbortWorkflow(), 'work', responded('keep-going', 'abort', 'aborted'))).toBe(1); + }); + + it('cuts at the top-level step containing a loop-body checkpoint', () => { + expect(immediateExitCut(makeAbortWorkflow(), 'work', responded('still-worth-it', 'stop', 'give-up'))).toBe(2); + }); + + it('reads a per-iteration instance id back to its checkpoint', () => { + const responses = { 'work-still-worth-it#2': { optionId: 'stop', respondedAt: '2026-08-01T00:00:00.000Z', effects: { exit: 'give-up' } } }; + expect(immediateExitCut(makeAbortWorkflow(), 'work', responses)).toBe(2); + }); + + it('accounts for the steps the exit skipped instead of reporting them missing', () => { + const manifest = [ + { step_id: 'first-step', output: 'done' }, + { step_id: 'keep-going', output: 'user aborted' }, + ]; + const workflow = makeAbortWorkflow(); + + expect(validateStepManifest(manifest, workflow, 'work', responded('keep-going', 'abort', 'aborted'))).toEqual([]); + // Without the exit the same manifest is a worker that stopped early for no stated reason. + expect(validateStepManifest(manifest, workflow, 'work', {}).some(w => w.includes('Missing steps') && w.includes('announce'))).toBe(true); + }); + + it('still requires the steps before the cut', () => { + const warnings = validateStepManifest( + [{ step_id: 'keep-going', output: 'user aborted' }], + makeAbortWorkflow(), + 'work', + responded('keep-going', 'abort', 'aborted'), + ); + expect(warnings.some(w => w.includes('Missing steps') && w.includes('first-step'))).toBe(true); + }); + }); + describe('validateStepManifest: gated and loop-body steps', () => { function makeManifestWorkflow(): Workflow { return { diff --git a/tests/workflow-loader.test.ts b/tests/workflow-loader.test.ts index 91b61e970..288531337 100644 --- a/tests/workflow-loader.test.ts +++ b/tests/workflow-loader.test.ts @@ -6,8 +6,10 @@ import { listWorkflowsWithDiagnostics, getActivity, getCheckpoint, - getValidTransitions, - getTransitionList, + getExitBindings, + exitDestinations, + validateExitBindings, + TERMINAL_SENTINEL, checkpointBaseId, } from '../src/loaders/workflow-loader.js'; import type { Workflow } from '../src/schema/workflow.schema.js'; @@ -267,92 +269,130 @@ describe('workflow-loader', () => { }); }); - describe('getTransitionList (BF-12)', () => { - it('should return transitions from the transitions array', async () => { + describe('getExitBindings', () => { + it('pairs each declared exit with the destination the workflow binds it to', async () => { const workflow = await loadMetaWorkflow(); - const transitions = getTransitionList(workflow, 'discover-session'); + const bindings = getExitBindings(workflow, 'discover-session'); - const targets = transitions.map(t => t.to); - // discover-session has transition to initialize-session - expect(targets).toContain('initialize-session'); + expect(bindings.map(b => b.exit)).toContain('done'); + expect(bindings.find(b => b.exit === 'done')?.to).toBe('initialize-session'); }); - it('should include targets from decisions branches', async () => { - // work-package/post-impl-review has a decision (blocker-gate) that branches to 'implement' - const wpResult = await loadWorkflow(WORKFLOW_DIR, 'work-package'); - if (wpResult.success) { - const wpWorkflow = wpResult.value; - const transitions = getTransitionList(wpWorkflow, 'post-impl-review'); - - const targets = transitions.map(t => t.to); - // post-impl-review has a decision that branches to implement - expect(targets).toContain('implement'); - } + it('carries the predicate and the default flag from the activity', async () => { + const workflow = await loadMetaWorkflow(); + + const conditional = getExitBindings(workflow, 'dispatch-client-workflow') + .find(b => b.to === 'end-workflow'); + expect(conditional?.when).toBeDefined(); + + expect(getExitBindings(workflow, 'discover-session').find(b => b.isDefault)).toBeDefined(); }); - it('should deduplicate targets via the seen Set', async () => { + it('carries an exit a checkpoint option selects, and its immediate flag', async () => { const workflow = await loadMetaWorkflow(); - const transitions = getTransitionList(workflow, 'discover-session'); + const abort = getExitBindings(workflow, 'discover-session').find(b => b.exit === 'abort-binding'); - const targets = transitions.map(t => t.to); - const uniqueTargets = [...new Set(targets)]; - expect(targets.length).toBe(uniqueTargets.length); + expect(abort?.to).toBe('end-workflow'); + expect(abort?.immediate).toBe(true); + expect(abort?.when).toBeUndefined(); }); - it('should include condition strings for conditional transitions', async () => { + it('returns nothing for an activity the workflow does not contain', async () => { const workflow = await loadMetaWorkflow(); - const transitions = getTransitionList(workflow, 'dispatch-client-workflow'); - - const conditionalTransition = transitions.find(t => t.to === 'end-workflow'); - expect(conditionalTransition).toBeDefined(); - expect(conditionalTransition?.condition).toBeDefined(); + expect(getExitBindings(workflow, 'no-such-activity')).toEqual([]); }); + }); - it('should mark default transitions with isDefault', async () => { + describe('exitDestinations', () => { + it('lists the activities an activity can reach, deduped', async () => { const workflow = await loadMetaWorkflow(); - const transitions = getTransitionList(workflow, 'discover-session'); + const targets = exitDestinations(workflow, 'discover-session'); - const defaultTransition = transitions.find(t => t.isDefault); - expect(defaultTransition).toBeDefined(); + expect(targets).toContain('initialize-session'); + expect(targets.length).toBe([...new Set(targets)].length); + }); + + it('reaches an activity only a checkpoint option routes to', async () => { + const wpResult = await loadWorkflow(WORKFLOW_DIR, 'work-package'); + expect(wpResult.success).toBe(true); + if (!wpResult.success) return; + + expect(exitDestinations(wpResult.value, 'submit-for-review')).toContain('complete'); }); - it('should return empty array for non-existent activity', async () => { + it('returns empty for an activity the workflow does not contain', async () => { const workflow = await loadMetaWorkflow(); - const transitions = getTransitionList(workflow, 'no-such-activity'); - expect(transitions).toEqual([]); - }); - - it('should include checkpoint-sourced transitions with checkpoint: prefix', async () => { - // Need a workflow with checkpoint transitions. - const wpResult = await loadWorkflow(WORKFLOW_DIR, 'prism-audit'); - if (wpResult.success) { - const wpWorkflow = wpResult.value; - const transitions = getTransitionList(wpWorkflow, 'scope-definition'); - const checkpointEntry = transitions.find(t => t.condition?.startsWith('checkpoint:')); - expect(checkpointEntry).toBeDefined(); - } + expect(exitDestinations(workflow, 'no-such-activity')).toEqual([]); }); }); - describe('getValidTransitions (BF-12)', () => { - it('should include targets from transitions, decisions, and checkpoints', async () => { - const workflow = await loadMetaWorkflow(); - const valid = getValidTransitions(workflow, 'discover-session'); + describe('validateExitBindings', () => { + const activity = (exits: unknown) => ({ + id: 'thing', version: '1.0.0', name: 'Thing', required: true, exits, + }); + const wf = (graph: unknown, exits: unknown) => ({ + id: 'wf', version: '1.0.0', title: 'WF', graph, activities: [activity(exits)], + } as unknown as Workflow); + const known = new Set(['thing', 'next', 'other']); - expect(valid).toContain('initialize-session'); + it('accepts a graph that binds every exit to an activity it contains', () => { + expect(validateExitBindings(wf({ thing: { done: 'next' } }, [{ id: 'done' }]), known)).toEqual([]); }); - it('should deduplicate targets', async () => { - const workflow = await loadMetaWorkflow(); - const valid = getValidTransitions(workflow, 'discover-session'); + it('accepts the terminal sentinel as a destination', () => { + expect(validateExitBindings(wf({ thing: { done: TERMINAL_SENTINEL } }, [{ id: 'done' }]), known)).toEqual([]); + }); - const unique = [...new Set(valid)]; - expect(valid.length).toBe(unique.length); + it('reports an exit the graph leaves unbound', () => { + const errors = validateExitBindings(wf({ thing: { done: 'next' } }, [{ id: 'done' }, { id: 'escalate', isDefault: true }]), known); + expect(errors.join(' ')).toContain("exit 'escalate' is unbound"); }); - it('should return empty array for non-existent activity', async () => { - const workflow = await loadMetaWorkflow(); - expect(getValidTransitions(workflow, 'no-such-activity')).toEqual([]); + it('reports a binding naming an exit the activity does not declare', () => { + const errors = validateExitBindings(wf({ thing: { done: 'next', ghost: 'other' } }, [{ id: 'done' }]), known); + expect(errors.join(' ')).toContain("'thing.ghost', which that activity does not declare"); + }); + + it('reports a destination the workflow does not contain', () => { + const errors = validateExitBindings(wf({ thing: { done: 'elsewhere' } }, [{ id: 'done' }]), known); + expect(errors.join(' ')).toContain("to 'elsewhere', which this workflow does not contain"); + }); + + it('reports an activity the workflow does not contain', () => { + const errors = validateExitBindings(wf({ thing: { done: 'next' }, ghost: { done: 'next' } }, [{ id: 'done' }]), new Set(['thing', 'next'])); + expect(errors.join(' ')).toContain("binds activity 'ghost'"); + }); + + it('requires exactly one default once an activity has more than one exit', () => { + const graph = { thing: { done: 'next', escalate: 'other' } }; + expect(validateExitBindings(wf(graph, [{ id: 'done' }, { id: 'escalate' }]), known).join(' ')) + .toContain('exactly one must be isDefault'); + expect(validateExitBindings(wf(graph, [{ id: 'done', isDefault: true }, { id: 'escalate', isDefault: true }]), known).join(' ')) + .toContain('exactly one must be isDefault'); + expect(validateExitBindings(wf(graph, [{ id: 'done', isDefault: true }, { id: 'escalate' }]), known)).toEqual([]); + }); + + it('reports a checkpoint option selecting an exit the activity does not declare', () => { + const workflow = { + id: 'wf', version: '1.0.0', title: 'WF', + graph: { thing: { done: 'next' } }, + activities: [{ + ...activity([{ id: 'done' }]), + steps: [{ kind: 'checkpoint', id: 'ask', message: 'Which?', options: [{ id: 'go', label: 'Go', effect: { exit: 'ghost' } }] }], + }], + } as unknown as Workflow; + expect(validateExitBindings(workflow, known).join(' ')).toContain("selects exit 'ghost'"); + }); + + it('lets two workflows run one activity in different orders', () => { + const borrowed = activity([{ id: 'reviewed', isDefault: true }, { id: 'rejected' }]); + const first = { id: 'a', version: '1.0.0', title: 'A', graph: { thing: { reviewed: 'next', rejected: 'other' } }, activities: [borrowed] } as unknown as Workflow; + const second = { id: 'b', version: '1.0.0', title: 'B', graph: { thing: { reviewed: 'other', rejected: 'next' } }, activities: [borrowed] } as unknown as Workflow; + + expect(validateExitBindings(first, known)).toEqual([]); + expect(validateExitBindings(second, known)).toEqual([]); + expect(exitDestinations(first, 'thing')).toEqual(['next', 'other']); + expect(exitDestinations(second, 'thing')).toEqual(['other', 'next']); }); }); }); diff --git a/workflows b/workflows index 393e244b2..f9ee51c06 160000 --- a/workflows +++ b/workflows @@ -1 +1 @@ -Subproject commit 393e244b28c717115fed6430724a438d1da2d373 +Subproject commit f9ee51c06b4d395c40154c27ab0eff055b52a857 From a8619be93b24849af4389a189b11d9fc8b9a04f2 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 24 Aug 2026 09:17:35 +0100 Subject: [PATCH 3/5] Document the split between an activity's outcomes and a workflow's shape The schema guide, state-management model, checkpoint model and fidelity layers describe exits and the graph: an activity names what happened, the workflow file says what follows, the load fails where the two disagree, and an immediate exit ends the sequence where the user chose it. Layer 4 of the fidelity stack now checks a reported exit against its binding rather than matching a rendered condition string. --- README.md | 2 +- docs/architecture.md | 2 +- docs/checkpoint-model.md | 4 +- docs/development.md | 2 +- docs/orchestra-specification.md | 2 +- docs/state-management-model.md | 45 +++-- docs/technique-protocol-specification.md | 2 +- docs/workflow-fidelity.md | 47 +++--- schemas/README.md | 204 +++++++++-------------- 9 files changed, 142 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 61dac54b8..19cea78a7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Workflow Server guides AI agents through structured, multi-step workflows. A sin 1. **Discover** — The agent learns which workflows exist and how to begin 2. **Start** — A session is started for the matched workflow 3. **Navigate** — The agent moves through activities in order, loading each phase’s steps and guidance as needed -4. **Execute** — Work proceeds activity by activity, pausing at checkpoints for user decisions and following transitions between phases +4. **Execute** — Work proceeds activity by activity, pausing at checkpoints for user decisions and following the workflow's graph between phases ### Architecture diff --git a/docs/architecture.md b/docs/architecture.md index 0c2feab89..12a8bc344 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ Work is handed down [a chain of agents](dispatch-model.md) rather than done by o Because a worker runs in the background with no channel to the user, it cannot ask a question when it hits one. [Checkpoints](checkpoint-model.md) are how it stops and gets an answer anyway: the pause is recorded in the session, travels up the chain to the agent that can ask, and the answer travels back down. -What happens next is never left to the model's judgement. [Transitions are deterministic](state-management-model.md): the orchestrator evaluates structured conditions against a bag of declared variables, so the same definition and the same state always take the same path. That page also covers how variables get their initial values and the two paths by which they change. +What happens next is never left to the model's judgement. [Transitions are deterministic](state-management-model.md): an activity names the outcome it reached by evaluating declared predicates against a bag of declared variables, and the workflow file says where each outcome leads, so the same definition and the same state always take the same path. That page also covers how variables get their initial values and the two paths by which they change. Planning and code are kept strictly apart. [Workspace isolation](artifact-management-model.md) covers the boundary: session state, plans and artifacts live under an engineering root, feature worktrees live under the checkout, and the two are committed independently. It also covers how artifacts are named and how the planning folder is laid out. diff --git a/docs/checkpoint-model.md b/docs/checkpoint-model.md index a478a4cc8..35c21974c 100644 --- a/docs/checkpoint-model.md +++ b/docs/checkpoint-model.md @@ -43,7 +43,7 @@ The server reads `activeCheckpoint`, finds the matching definition in the workfl respond_checkpoint({ session_index, option_id: "proceed" }) ``` -The server clears `activeCheckpoint`, records the decision, and applies each effect on its own terms. A `setVariable` effect is written into the session variable bag. A `skipActivities` effect is recorded as bookkeeping. A `transitionTo` effect is handed back for the orchestrator to enact, because resolving a checkpoint does not itself move the session. +The server clears `activeCheckpoint`, records the decision, and applies each effect on its own terms. A `setVariable` effect is written into the session variable bag. An `exit` effect names one of the activity's declared outcomes; the server reads its destination from the workflow graph and hands both back for the orchestrator to enact, because resolving a checkpoint does not itself move the session. Where the named exit is `immediate`, the response says so, and the activity's remaining steps do not run. ### Three ways to resolve one @@ -131,7 +131,7 @@ or loses nothing by not firing: | The variable declares a `defaultValue` | Seeding puts it in the bag at session creation, so the earlier gate reads the default rather than nothing | | The earlier gate reads by `exists` / `notExists` | A presence test answers on a missing variable; absence is one of its two answers | | The earlier step only messages or logs | An announcement that does not fire costs nothing, and gating one on a not-yet-decided value is the ordinary way to stay quiet until it is known | -| The deciding option carries `transitionTo` | Re-entry sends the run back through the earlier step, which then reads what the option wrote | +| The deciding option carries an `exit` | Leaving the activity sends the run back through the earlier step on its next visit, which then reads what the option wrote | | The two gates demand incompatible values of one variable | No single run reaches both steps, so the earlier one was never waiting on this decision | The last two carve out the corpus's standard way of settling a value: a technique derives it, an diff --git a/docs/development.md b/docs/development.md index 3b7521c85..25e9f70d6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -210,7 +210,7 @@ npm run profile:run -- --transcript=~/.claude/projects//.jsonl `--session` resolves an id or id-prefix under `--projects-dir` (default `~/.claude/projects`); `--transcript` takes a path. Both are repeatable. `--window=startup` (the default) runs from the first record to the point the client workflow's opening activity is reported done. `--json` puts the whole profile on stdout in place of the text report. -Which activity that is comes off the session the transitions name, not a flag: a session index that never carries a meta activity belongs to the client workflow, and by the `next_activity` contract the first call against it names that workflow's `initialActivity`. Every client workflow in the corpus opens on a different id, so the profiler discovers the opener — and reports it — rather than being told it. The rule also holds on a run that abandons one meta session and starts another before dispatching. +Which activity that is comes off the session the graph leads to, not a flag: a session index that never carries a meta activity belongs to the client workflow, and by the `next_activity` contract the first call against it names that workflow's `initialActivity`. Every client workflow in the corpus opens on a different id, so the profiler discovers the opener — and reports it — rather than being told it. The rule also holds on a run that abandons one meta session and starts another before dispatching. The two token columns are scoped differently, on purpose. Main-context figures cover the orchestrator turns inside the window. A worker joins on its **dispatch** time, and its whole ledger comes with it — a dispatch made to do startup work costs what it costs, even when its last turn lands after the milestone. Worker turns are read from the `subagents/` directory beside the transcript; when a transcript instead carries them inline and has no such directory, the profile sets `workerTurnsUnread` and the report says the worker figures are unread rather than zero. diff --git a/docs/orchestra-specification.md b/docs/orchestra-specification.md index ce075428a..ea9256fd5 100644 --- a/docs/orchestra-specification.md +++ b/docs/orchestra-specification.md @@ -2,7 +2,7 @@ Orchestra is a design for writing an activity's control flow down explicitly — its steps, its branch points, its loops — so the path through an activity is legible in the file rather than reconstructed by an agent reading it. The server implements a different shape, so nothing on this page describes a file the loader accepts. -**Read this for the design, not to author a definition.** The language the server does load is covered by the [schema guide](../schemas/README.md), and the generated [schema reference](../site/api/schemas.html) gives each file shape field by field. An activity there is a list of steps, each tagged with the kind of work it does, followed by the transitions leading out of the activity. There are no flow declarations and no `skill:` key, and the grammar below assumes both. +**Read this for the design, not to author a definition.** The language the server does load is covered by the [schema guide](../schemas/README.md), and the generated [schema reference](../site/api/schemas.html) gives each file shape field by field. An activity there is a list of steps, each tagged with the kind of work it does, followed by the named outcomes it can reach; where each outcome leads is the workflow file's to state. There are no flow declarations and no `skill:` key, and the grammar below assumes both. **Who this is for:** anyone weighing the design — how an explicit control-flow language would work, and what it would take — rather than anyone writing workflow definitions today. If you are authoring, the [technique protocol specification](technique-protocol-specification.md) and the schema guide are the two documents you need. diff --git a/docs/state-management-model.md b/docs/state-management-model.md index 8b8e5e6b4..a9dc2176e 100644 --- a/docs/state-management-model.md +++ b/docs/state-management-model.md @@ -57,27 +57,48 @@ An action step is carried out by the worker rather than by the engine, so the wa ## Choosing the next activity -An activity that is complete hands the decision to its `transitions` list: +The choice is made in two halves, in two files. An activity that is complete names the outcome it +reached, from the outcomes it declares: ```yaml -transitions: - - to: "select-submodule" - condition: - type: simple - variable: is_monorepo - operator: == - value: true - - to: "analyze-codebase" +exits: + - id: monorepo + when: is_monorepo == true + - id: single-repo isDefault: true ``` -The orchestrator evaluates that list in order against the current state and takes the first condition that holds. It asks neither the user nor the model, which is what the structured form is for, and it then calls `next_activity` with the id it matched. +The workflow that runs the activity says where each outcome leads: -A condition takes one of three shapes: a simple comparison of a variable against a value, using `==`, `!=`, `>`, `<`, `>=`, `<=`, `exists` or `notExists`; an `and` or `or` over nested conditions; or a `not` negating a single one. Two other places carry a transition target the same way — a decision branch, and the effect on a checkpoint option. +```yaml +graph: + detect-repository: + monorepo: select-submodule + single-repo: analyze-codebase +``` + +The orchestrator evaluates the exits in order against the current state and takes the first whose +`when` holds, falling to the default when none does; a checkpoint option may name an exit instead, +and that selection wins. It then reads the destination from the graph and calls `next_activity` with +that id, reporting the exit it took as the `exit` parameter. It asks neither the user nor the model, +which is what the declared form is for. + +An exit's `when` is the same inline expression a step gate uses: comparisons with `==`, `!=`, `>`, +`<`, `>=` and `<=`, bare identifier truthiness, unary `!`, and `&&` / `||` with parentheses. + +Splitting the two halves is what lets one activity sit in two workflows. `remediate-vuln` runs +fourteen of `work-package`'s activities and binds their exits in its own file, so it can place them +in a different order without editing files it does not own. It is also what lets an outcome end the +run: a graph may send an exit to `__terminal__`, which completes the session without landing on an +activity. + +An exit may be declared `immediate`. Selecting one at a checkpoint ends the activity's step sequence +there, so a user who aborts does not then watch the remaining steps run; the step-manifest check +reads the recorded exit and accounts for the steps it skipped. ## Varying the path -A workflow varies its path through ordinary state rather than through a mechanism of its own. A boolean set early, by a detection step or by a checkpoint, marks the variant, and conditional transitions and step gates branch on it to skip or redirect activities. Because the variable lives in the single bag, the variant persists across activities without anything carrying it. Work-package's review mode, and workflow-design's update and review modes, are all built this way. +A workflow varies its path through ordinary state rather than through a mechanism of its own. A boolean set early, by a detection step or by a checkpoint, marks the variant, and exit predicates and step gates branch on it to skip or redirect activities. Because the variable lives in the single bag, the variant persists across activities without anything carrying it. Work-package's review mode, and workflow-design's update and review modes, are all built this way. ## Persistence diff --git a/docs/technique-protocol-specification.md b/docs/technique-protocol-specification.md index 641bf913d..1af1b7147 100644 --- a/docs/technique-protocol-specification.md +++ b/docs/technique-protocol-specification.md @@ -160,7 +160,7 @@ ancestor, and the producing technique additionally declares it as an output. - A **symbol** — an input, output, or protocol variable — becomes a **runtime variable**. The engine stores variables in a name-keyed bag and resolves references by **exact string match** (`getVariableValue`); the agent sets a variable under the name the prose dictates, and activity - conditions/transitions read it by that same name. So a symbol id must be the *same string* as the + gates and exit predicates read it by that same name. So a symbol id must be the *same string* as the variable it binds to. Activities, conditions, and session state are authored in `snake_case` (`target_path`, `is_review_mode`, `planning_folder_path`), so **symbol ids are `snake_case`** — and protocol variables follow suit (`{$resolved_content}`). Case carries no meaning beyond this; it does diff --git a/docs/workflow-fidelity.md b/docs/workflow-fidelity.md index 01ac6ba81..3711bb96d 100644 --- a/docs/workflow-fidelity.md +++ b/docs/workflow-fidelity.md @@ -15,7 +15,7 @@ The workflow server addresses these through seven layers of enforcement, each op ### The shape of a transition -Most enforcement happens where one activity hands over to the next, so that moment is worth seeing whole. The labels `L1` to `L7` in the diagram are the seven layers, which the sections below then take in turn: the seal over session state, the checkpoint gate, the cross-activity check, the transition condition, the step manifest, the activity manifest, and the trace. +Most enforcement happens where one activity hands over to the next, so that moment is worth seeing whole. The labels `L1` to `L7` in the diagram are the seven layers, which the sections below then take in turn: the seal over session state, the checkpoint gate, the cross-activity check, the reported exit, the step manifest, the activity manifest, and the trace. ```mermaid flowchart TD @@ -26,10 +26,10 @@ flowchart TD yieldCp --> gate{{"L2: activeCheckpoint set —\nnext_activity and resume_checkpoint refuse"}} gate --> respond["respond_checkpoint\nL2: option validated, timer enforced"] respond --> cleared["activeCheckpoint cleared"] - cleared --> nextB["next_activity(B, step_manifest,\nactivity_manifest, transition_condition)"] + cleared --> nextB["next_activity(B, step_manifest,\nactivity_manifest, exit)"] nextB --> hardGate{{"L2: activeCheckpoint empty?"}} - hardGate --> transCheck["L3: is A to B a declared transition?"] - transCheck -.-> condCheck["L4: does the claimed condition match?"] + hardGate --> transCheck["L3: does the graph bind an exit of A to B?"] + transCheck -.-> condCheck["L4: does the reported exit lead to B?"] condCheck -.-> stepCheck["L5: is the step manifest complete?"] stepCheck -.-> actCheck["L6: is the activity manifest valid?"] actCheck --> tracePackage["L7: trace token packaged for A"] @@ -91,22 +91,22 @@ When an agent makes a tool call, the server compares the position it recorded on | Check | What it detects | |-------|----------------| | Workflow consistency | Agent switched workflows mid-session without starting a new session | -| Activity transition | Agent jumped to an activity that isn't a valid transition from the previous one | +| Activity transition | Agent jumped to an activity the workflow graph binds no exit of the previous one to | | Technique association | Agent loaded a technique not declared by the current activity | | Version drift | Workflow definition changed on disk since the session started | **Design principle:** Warnings don't block execution — the tool still returns its result. This allows agents to self-correct rather than being hard-blocked, while making violations visible. All validation warnings are captured in the execution trace (Layer 7). -### Layer 4: transition condition tracking +### Layer 4: reported exit tracking -When calling `next_activity` to transition to a new activity, agents can include a `transition_condition` parameter — the condition string (from the `transitions` field of the current activity's definition) that caused the transition. +When calling `next_activity`, agents can include an `exit` parameter — the name of the outcome the activity being left reached, from that activity's `exits`. **What it enforces:** -- The claimed condition actually maps to the target activity in the transition table -- Default transitions are correctly reported (no false condition claims) -- The condition is recorded in the sealed session state and in the trace, so the agent cannot revise it afterwards +- The activity declares an exit by that name +- The workflow graph binds that exit to the requested target activity +- The exit is recorded in the sealed session state and in the trace, so the agent cannot revise it afterwards -**What it cannot verify in real-time:** Whether the condition is actually true in the agent's state. However, conditions are typically set by user choices at checkpoints, which are logged. Post-hoc review can cross-reference claimed conditions against checkpoint responses and trace data. +**What it cannot verify in real-time:** Whether the exit's predicate is actually true in the agent's state. Exits are often selected by user choices at checkpoints, which are logged, so post-hoc review can cross-reference reported exits against checkpoint responses and trace data. ### Layer 5: step completion manifest @@ -139,9 +139,9 @@ When transitioning between activities via `next_activity`, agents can include an ```json { "activity_manifest": [ - { "activity_id": "start-work-package", "outcome": "completed", "transition_condition": "default" }, - { "activity_id": "design-philosophy", "outcome": "completed", "transition_condition": "skip_optional_activities == true" }, - { "activity_id": "plan-prepare", "outcome": "revised", "transition_condition": "needs_research == true" } + { "activity_id": "start-work-package", "outcome": "completed", "exit": "done" }, + { "activity_id": "codebase-comprehension", "outcome": "completed", "exit": "skip-optional-activities" }, + { "activity_id": "plan-prepare", "outcome": "revised", "exit": "done" } ] } ``` @@ -149,7 +149,7 @@ When transitioning between activities via `next_activity`, agents can include an **What it enforces (advisory):** - Activity IDs reference activities that exist in the workflow definition - Outcomes are non-empty -- The claimed transition condition matches one defined in the workflow for that activity +- The claimed exit is one that activity declares **Design principle:** Activity manifest validation is advisory — it produces warnings, not rejections. This matches the design principle of Layer 3. The manifest provides a workflow-level audit trail that complements the step-level detail of Layer 5, particularly in orchestrator/worker patterns where the orchestrator tracks the workflow journey and the worker tracks step execution. @@ -200,20 +200,25 @@ Beyond enforcement, the server reduces the context burden on agents: `get_workflow` returns lightweight metadata (~2KB) rather than the full workflow definition (~13KB): the orchestrator gets rules, variables, `initialActivity`, and activity stubs without consuming its context window with step-level detail. Step detail and the worker-facing `rules.activity` / `techniques.activity` reach workers through `get_activity`. The response is preceded by the technique bundle (the workflow's `techniques.workflow` plus the core orchestrator techniques), so the orchestrator receives its execution surface in a single round-trip. -### Transitions in activity definitions +### Exits in activity definitions, destinations in the workflow -`get_activity` returns the complete activity definition including its `transitions` field with human-readable conditions. The agent matches conditions against its state variables to determine the next activity: +`get_activity` returns the complete activity definition including its `exits` — the outcomes it can +reach and the predicate selecting each. The agent matches those predicates against its state +variables to name the outcome: ```json { - "transitions": [ - { "to": "requirements-elicitation", "condition": "needs_elicitation == true" }, - { "to": "implementation-analysis", "isDefault": true } + "exits": [ + { "id": "needs-elicitation", "when": "needs_elicitation == true" }, + { "id": "comprehension-complete", "isDefault": true } ] } ``` -Transitions are also derived from `decisions` (branch `transitionTo` fields) and `checkpoints` (option `effect.transitionTo` fields), giving the orchestrator a complete view of all possible next activities. +Where each one leads is the workflow's, and `get_workflow` returns it as `graph`. A checkpoint +option may name an exit too, and `present_checkpoint` resolves it through the graph so the +orchestrator can state each option's consequence before the user chooses — which together give the +orchestrator a complete view of all possible next activities. ### Technique and resource loading diff --git a/schemas/README.md b/schemas/README.md index 513e0548b..1487ef44e 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -11,7 +11,7 @@ The workflow server uses six interconnected schemas: | Schema | Purpose | Use Case | |--------|---------|----------| | `workflow.schema.json` | Defines workflow structure | Creating new workflows with activities, steps, checkpoints | -| `condition.schema.json` | Defines conditional expressions | Controlling transitions and decisions | +| `condition.schema.json` | Defines conditional expressions | Gating steps and dismissing checkpoints | | `state.schema.json` | In-memory runtime execution state schema | Internal workflow-engine progress tracking | | `session-file.schema.json` | Persistent server-managed session file (`session.json`) | On-disk session state owned by the workflow server; loaded by `session_index` and sealed by `.session-token` | | `technique.schema.json` | Defines agent technique capabilities | Describing tool orchestration patterns and execution guidance | @@ -28,9 +28,9 @@ The server enforces structure at load time plus a small runtime core; most schem | Construct | Engine-enforced | Advisory (incl. warn-only checks) | Agent-interpreted | |---|---|---|---| | Workflow | `id` (file resolution); `techniques.workflow` / `techniques.activity` (bundle composition); `activities` / `activitiesDir` (assembly); `variables[].defaultValue` (seeded into the session variable bag at session creation, recorded as a `variables_seeded` history event) | `version` (mid-session drift warns); `title`, `description`, `tags`; `rules.*`; `variables[]` declarations (the file's own, plus every `variables.writes` declaration the activities in its graph contribute; rendered in `get_workflow`); `initialActivity` (wrong first activity warns); `variables[].type` (checkpoint `setVariable` values validated warn-only — mismatches stored as written) | `author`; `variables[].required` (never checked — authoring metadata) | -| Activity | `variables.writes[]` (contributed to the including workflow's variable set at load; two declarations of one name that disagree on `type` or `defaultValue` fail the load); `id` (navigation key); `artifactPrefix` (server-computed from the filename; also orders activities); the composed artifact contract (synthesized from bound techniques' outputs); `techniques[]` (bundle); `bundleTechniques` (hybrid step-technique bundling in `get_activity`) | `variables.reads[]` (the names the activity needs the workflow to supply; `check:activity-variables` holds the graph to them); `name`, `description`, `required`, `rules[]`; `transitions[]` (legality warns only — `next_activity` moves anywhere); `decisions[]` (stringified for warn-only transition matching) | `triggers[]` / `passContext` (`dispatch_child` takes an explicit `workflow_id`; a child session's bag starts from the child workflow's own declared defaults); `outcome[]` (never reconciled against manifests) | +| Activity | `variables.writes[]` (contributed to the including workflow's variable set at load; two declarations of one name that disagree on `type` or `defaultValue` fail the load); `id` (navigation key); `artifactPrefix` (server-computed from the filename; also orders activities); the composed artifact contract (synthesized from bound techniques' outputs); `techniques[]` (bundle); `bundleTechniques` (hybrid step-technique bundling in `get_activity`) | `variables.reads[]` (the names the activity needs the workflow to supply; `check:activity-variables` holds the graph to them); `name`, `description`, `required`, `rules[]`; `exits[]` (every one bound in the workflow's `graph` or the load fails; the destination reached warns only — `next_activity` moves anywhere) | `triggers[]` / `passContext` (`dispatch_child` takes an explicit `workflow_id`; a child session's bag starts from the child workflow's own declared defaults); `outcome[]` (never reconciled against manifests) | | Step (common) | `kind` (selects the per-kind closed contract); `id` (duplicate ids are a load error; the key for manifests and step-bound `get_technique`) | absence of a gated step from a `step_manifest` is accepted; ungated omissions warn | `when` / `condition` gates (the server never evaluates a condition; on a checkpoint step only `condition` enables `condition_not_met` dismissal); `required` (worker hint); `actions[]` (no verb has a server interpreter — `set` does not write the variable bag and is slated for removal at the next schema major, #166 B7/B12) | -| Checkpoint step | `options[]` (`option_id` hard-validated); `effect.setVariable` (applied to the session variable bag — the one engine-applied effect); `defaultOption` + `autoAdvanceMs` (the server enforces the full timer before `auto_advance`) | `effect.transitionTo` (recorded and returned; the orchestrator enacts it via `next_activity`); `effect.skipActivities` (recorded in `skippedActivities` bookkeeping) | `blocking` (orchestrator directive; the server's auto-advance gate does not consult it) | +| Checkpoint step | `options[]` (`option_id` hard-validated); `effect.setVariable` (applied to the session variable bag — the one engine-applied effect); `defaultOption` + `autoAdvanceMs` (the server enforces the full timer before `auto_advance`) | `effect.exit` (checked at load against the activity's `exits`; the destination is read from the workflow graph, recorded and returned, and the orchestrator enacts it via `next_activity`) | `blocking` (orchestrator directive; the server's auto-advance gate does not consult it) | | Loop step | body `steps[]` structure (id uniqueness per scope, flattened for lookups and artifact composition) | loop-body step ids are accepted in `step_manifest` but never required | `loopType` semantics, `variable` / `over`, `breakCondition`, `maxIterations` — iteration is executed and bounded entirely by the agent | | Technique | `id` (resolution); rule addressing (`tech::rule`, group-prefix expansion); `inputs[].id` / `outputs[].id` (composition merge keys); `outputs[].artifact.name` (drives the composed artifact contract); `Initial` / `Final` protocol titles (composition wrapping) | `version`, `capability`; `inputs[].required` / `default` (rendered; the server neither verifies a required input was supplied nor applies a default); protocol content | input-binding resolution and output remaps (the name-match convention is an agent convention; step-bound `get_technique` annotates resolution statically) | | Condition | — | condition text is rendered for warn-only `transition_condition` matching (exact string equality) | all evaluation — `simple` / `and` / `or` / `not`, `exists` / null semantics | @@ -43,7 +43,7 @@ The schemas work together to define workflows (design-time) and track their exec ### Workflow Structure -A workflow consists of activities connected by transitions. Each activity contains a single ordered `steps[]` where every step carries a `kind`: a technique step (binds an operation), an action step (control-only), a checkpoint step (an inline user decision point at its concrete position), or a loop step (a compound step whose body is a nested `steps[]`). Activity-level `decisions` (automated branching) and `transitions` route between activities, and an activity can optionally trigger other workflows. The `initialActivity` property determines where sequential workflows begin; workflows with all independent activities (no transitions) don't require `initialActivity`. +A workflow consists of activities and the `graph` binding their exits to one another. Each activity contains a single ordered `steps[]` where every step carries a `kind`: a technique step (binds an operation), an action step (control-only), a checkpoint step (an inline user decision point at its concrete position), or a loop step (a compound step whose body is a nested `steps[]`). An activity declares `exits` — its named outcomes — and the workflow's `graph` says where each leads, so an activity borrowed by two workflows sits in each one's shape without either editing the other's files. An activity can optionally trigger other workflows. The `initialActivity` property determines where sequential workflows begin; workflows whose activities are all independent entry points don't require `initialActivity`. ```mermaid stateDiagram-v2 @@ -51,8 +51,8 @@ stateDiagram-v2 state "Workflow Definition" as WD { [*] --> Activity1: initialActivity - Activity1 --> Activity2: transition - Activity2 --> Activity3: transition (with condition) + Activity1 --> Activity2: graph binds an exit + Activity2 --> Activity3: graph binds an exit (predicate selects it) Activity3 --> [*]: complete state Activity1 { @@ -61,7 +61,7 @@ stateDiagram-v2 } state Activity2 { Steps - Decisions + Exits } state Activity3 { LoopStep @@ -80,7 +80,7 @@ stateDiagram-v2 The second diagram shows how the schema files depend on each other: - **workflow.schema.json** defines the overall structure and references `activity.schema.json` for activities -- **activity.schema.json** defines unified activities with a single ordered `steps[]` (each step a kind: technique, action, checkpoint, or loop), plus activity-level decisions, transitions, and triggers +- **activity.schema.json** defines unified activities with a single ordered `steps[]` (each step a kind: technique, action, checkpoint, or loop), plus the activity's exits and triggers - **technique.schema.json** defines agent capabilities, tool orchestration patterns, and execution protocols - **condition.schema.json** provides reusable condition expressions (simple comparisons, AND/OR/NOT combinators) - **state.schema.json** describes the in-memory runtime execution state used internally by the workflow engine @@ -93,14 +93,14 @@ flowchart TB subgraph Workflow["workflow.schema.json"] W[Workflow] --> A[Activities] W --> SK1["techniques{workflow,activity}"] + W --> G["graph (exit -> destination)"] end subgraph Activity["activity.schema.json"] A --> S["steps[] (kind: technique|action|checkpoint|loop)"] S --> C["checkpoint step (inline)"] S --> L["loop step (compound, nested steps[])"] - A --> D[Decisions] - A --> T[Transitions] + A --> T["exits[]"] A --> TR[Triggers] A --> SK2["techniques[]"] end @@ -152,9 +152,10 @@ erDiagram Workflow ||--o{ Variable : defines Activity ||--o{ Step : "contains (ordered, kind-tagged)" - Activity ||--o{ Decision : contains - Activity ||--o{ Transition : contains + Activity ||--o{ Exit : declares Activity ||--o{ WorkflowTrigger : triggers + Workflow ||--o{ ExitBinding : binds + ExitBinding |o--|| Exit : "gives a destination to" Step ||--o{ Action : "performs (technique/action kind)" Step |o--o| Condition : "gated by (when/condition)" @@ -162,10 +163,6 @@ erDiagram Step ||--o{ Step : "iterates (loop kind, nested body)" CheckpointOption ||--o| Effect : triggers - Decision ||--|{ DecisionBranch : has - DecisionBranch |o--o| Condition : "evaluated by" - - Transition |o--o| Condition : "guarded by" Workflow { string id PK @@ -207,22 +204,18 @@ erDiagram string description } - Decision { - string id PK - string name - string description - } - - DecisionBranch { + Exit { string id PK string label - string transitionTo FK + string when boolean isDefault + boolean immediate } - - Transition { - string to FK - boolean isDefault + + ExitBinding { + string activity FK + string exit FK + string destination FK } LoopStep { @@ -264,8 +257,7 @@ erDiagram Effect { object setVariable - string transitionTo - array skipActivities + string exit FK } ``` @@ -273,7 +265,7 @@ erDiagram #### Workflow (Root Entity) -A workflow is the top-level container representing a complete process definition. Its activities are connected by `transitions` and entered at `initialActivity`. +A workflow is the top-level container representing a complete process definition. Its activities are connected by its `graph` and entered at `initialActivity`. | Field | Type | Purpose | | ----------------- | ---------- | ---------------------------------------------------------- | @@ -288,12 +280,13 @@ A workflow is the top-level container representing a complete process definition | `techniques` | { workflow?, activity?: string[] } | Workflow techniques partitioned by audience: `workflow` (orchestrator-only, bundled into `get_workflow`) and `activity` (inherited by every activity, injected into every `get_activity` technique bundle) | | `variables` | Variable[] | State variables | | `initialActivity` | string | Starting activity ID (required for sequential workflows) | +| `graph` | object | Exit bindings: activity id → exit id → destination activity id (or `__terminal__`). Every exit of every activity is bound here; an unbound exit, an unknown exit and an unknown destination each fail the load | | `activitiesDir` | string | Directory containing external activity files (server-resolved) | | `activities` | Activity[] | Inline activity definitions (or loaded from activitiesDir) | #### Activity -A unified activity defines workflow execution as a single ordered `steps[]` (each step kind-tagged), plus activity-level decisions and transitions. Activities can also trigger other workflows. +A unified activity defines workflow execution as a single ordered `steps[]` (each step kind-tagged), plus the outcomes it can reach. Activities can also trigger other workflows. | Field | Type | Purpose | | ----------------- | ----------------- | ------------------------------------------ | @@ -304,8 +297,7 @@ A unified activity defines workflow execution as a single ordered `steps[]` (eac | `techniques` | TechniquesReference | Activity-wide technique references (`::` paths) | | `bundleTechniques` | BundleTechniques | Opt-in hybrid bundling: `get_activity` inlines each ungated step technique whose composed wire form is at most `maxChars`; larger and gated ones stay lazy via `get_technique` | | `steps` | Step[] | Ordered, kind-tagged execution list (technique / action / checkpoint / loop) | -| `decisions` | Decision[] | Automated branching points (activity-level)| -| `transitions` | Transition[] | Activity navigation rules | +| `exits` | Exit[] | Named outcomes of the activity; the workflow's `graph` binds each to a destination | | `triggers` | WorkflowTrigger[] | Workflows to trigger from this activity | | `outcome` | string[] | Expected outcomes on completion (advisory; never reconciled against manifests) | | `required` | boolean | Whether activity must be completed | @@ -353,26 +345,17 @@ A checkpoint step is authored in exactly one of two forms: | `defaultOption` | string | Option ID to auto-select when `autoAdvanceMs` elapses. | | `autoAdvanceMs` | integer | Milliseconds to wait before auto-selecting `defaultOption`; the server enforces the full timer on `respond_checkpoint { auto_advance }`. | -#### Decision - -A decision is an automated branching point based on variable conditions. The orchestrator evaluates the branch conditions; the server stringifies them only for warn-only transition matching. +#### Exit -| Field | Type | Purpose | -| ------------- | ---------------- | --------------------------------- | -| `id` | string | Unique identifier within activity | -| `name` | string | Decision name | -| `description` | string | What is being decided | -| `branches` | DecisionBranch[] | Conditional paths (min 2) | +An exit is a named outcome of the activity, in the activity's own vocabulary. It says what happened, never what runs next: the destination is bound per exit in the workflow's `graph`. An activity declaring no exits is terminal. Where the destination the orchestrator moves to disagrees with the exit it reports, `next_activity` warns in `_meta.validation` but is not blocked. -#### Transition - -A transition defines navigation from one activity to another. Transition legality is validated warn-only at `next_activity` — an out-of-graph transition warns in `_meta.validation` but is not blocked. - -| Field | Type | Purpose | -| ----------- | --------- | ------------------------------- | -| `to` | string | Target activity ID | -| `condition` | Condition | When this transition applies | -| `isDefault` | boolean | Fallback if no conditions match | +| Field | Type | Purpose | +| ----------- | ------- | ----------------------------------------------------------------------- | +| `id` | string | Outcome name, unique within the activity | +| `label` | string | Human-readable statement of the outcome | +| `when` | string | Inline expression selecting this exit, in the dialect step gates use | +| `isDefault` | true | The outcome when no `when` held and no checkpoint option named one; declared exactly once on an activity with two or more exits | +| `immediate` | true | Selecting this exit at a checkpoint ends the step sequence there | #### WorkflowTrigger @@ -546,7 +529,7 @@ Variables store state that persists across activities. Define them at the workfl ### Activities -Activities are the execution units of a workflow. Each activity contains an ordered, kind-tagged `steps[]` and activity-level `transitions`, and is reached via `transitions` from the `initialActivity`. +Activities are the execution units of a workflow. Each activity contains an ordered, kind-tagged `steps[]` and the `exits` it can reach, and is reached through the workflow's `graph` from the `initialActivity`. ```json { @@ -557,7 +540,7 @@ Activities are the execution units of a workflow. Each activity contains an orde "name": "Initial Activity", "description": "The first activity of the workflow", "steps": [], - "transitions": [] + "exits": [] } ] } @@ -573,8 +556,7 @@ Activities are the execution units of a workflow. Each activity contains an orde | `description` | string | Activity description | | `required` | boolean | Whether activity is required (default: true) | | `steps` | array | Ordered, kind-tagged execution list (technique / action / checkpoint / loop) | -| `decisions` | array | Automated branching points (activity-level) | -| `transitions` | array | Activity transition rules | +| `exits` | array | Named outcomes; the workflow's `graph` binds each to a destination | | `triggers` | array | Workflows to trigger from this activity | | `outcome` | string[] | Expected outcomes on completion | | `rules` | array | Activity-level execution rules and constraints | @@ -626,7 +608,7 @@ A `kind: checkpoint` step pauses execution and requires user input. It sits inli "id": "cancel", "label": "No, cancel", "effect": { - "transitionTo": "cancelled" + "exit": "cancelled" } } ] @@ -635,49 +617,15 @@ A `kind: checkpoint` step pauses execution and requires user input. It sits inli } ``` -The `when` / `condition` gate uses the same formal condition schema shared by every step kind, transitions, and decisions (`condition.schema.json`). If omitted, the checkpoint is always presented. The two gate spellings differ at the dismissal seam: only a structured `condition` makes the checkpoint dismissible via `respond_checkpoint { condition_not_met }` — a `when`-gated checkpoint cannot be dismissed that way. +The `when` / `condition` gate uses the same formal condition schema shared by every step kind (`condition.schema.json`). If omitted, the checkpoint is always presented. The two gate spellings differ at the dismissal seam: only a structured `condition` makes the checkpoint dismissible via `respond_checkpoint { condition_not_met }` — a `when`-gated checkpoint cannot be dismissed that way. **Replay and instance-qualified ids.** `yield_checkpoint` stores responses under `-`. A later yield of the same key returns `status: "replayed"` (no new `activeCheckpoint`). Inside a loop, pass `#` when each iteration needs its own decision; the loader resolves the definition by base id (portion before `#`). Use a bare id when one answer should cover every iteration. **Checkpoint Option Effects** (per-effect enforcement): - `setVariable` — the server applies the assignments to the session variable bag; the one engine-applied effect -- `transitionTo` — returned to the orchestrator, which enacts the transition via `next_activity`; selecting the option does not itself move the session -- `skipActivities` — recorded in the session's `skippedActivities` bookkeeping and returned; the orchestrator routes around the listed activities - -### Decisions +- `exit` — one of the owning activity's declared exits, checked at load. `present_checkpoint` resolves it through the workflow graph and returns the destination with the option, so the orchestrator states the consequence before the user chooses; `respond_checkpoint` records it and returns it for the orchestrator to enact via `next_activity`. Where the exit is `immediate`, the response also says the activity ends there. -Decisions are automated branching points based on conditions: - -```json -{ - "decisions": [ - { - "id": "decision-1", - "name": "Path Selection", - "description": "Choose path based on variable", - "branches": [ - { - "id": "branch-a", - "label": "Path A", - "condition": { - "type": "simple", - "variable": "option", - "operator": "==", - "value": "a" - }, - "transitionTo": "activity-a" - }, - { - "id": "branch-default", - "label": "Default Path", - "transitionTo": "activity-default", - "isDefault": true - } - ] - } - ] -} -``` +An adhoc checkpoint — one the activity does not declare, supplied at `yield_checkpoint` — has no declared exits to name, so its options carry `setVariable` only. ### Loop Steps @@ -708,30 +656,38 @@ A `kind: loop` step is a compound step that iterates over collections or while c **Loop Types (`loopType`):** `forEach`, `while`, `doWhile` -### Transitions +### Exits and the graph -Transitions define how to move between activities: +An activity names the outcomes it can reach; the workflow says where each one leads. The activity: ```json { - "transitions": [ - { - "to": "next-activity", - "condition": { - "type": "simple", - "variable": "user_confirmed", - "operator": "==", - "value": true - } - }, - { - "to": "fallback-activity", - "isDefault": true - } + "exits": [ + { "id": "confirmed", "when": "user_confirmed == true" }, + { "id": "declined", "isDefault": true }, + { "id": "aborted", "immediate": true } ] } ``` +And the workflow that runs it: + +```json +{ + "graph": { + "confirm-scope": { + "confirmed": "next-activity", + "declined": "fallback-activity", + "aborted": "__terminal__" + } + } +} +``` + +The orchestrator takes the first exit whose `when` holds, falls to the default when none does, and +lets an exit a checkpoint option named win over both. A destination of `__terminal__` ends the run +without landing on an activity. + ### Triggers Triggers allow an activity to invoke another workflow: @@ -752,7 +708,7 @@ Triggers allow an activity to invoke another workflow: ## Condition Schema -The condition schema (`condition.schema.json`) defines expressions for controlling transitions, decisions, loops, and checkpoints. Conditions are evaluated by the executing agents against the session's variable state — the server never evaluates a condition at runtime; it renders condition text only for the warn-only `transition_condition` match on `next_activity`. +The condition schema (`condition.schema.json`) defines expressions for gating steps and loops and for dismissing checkpoints. Conditions are evaluated by the executing agents against the session's variable state — the server never evaluates a condition at runtime. An exit's predicate is the inline `when` expression instead. ### Simple Conditions @@ -892,10 +848,8 @@ The state schema (`state.schema.json`) tracks runtime execution of a workflow us | `currentActivity` | string | Current activity ID | | `currentStep` | integer | Current step index within activity (1-based) | | `completedActivities` | string[] | Completed activity IDs | -| `skippedActivities` | string[] | Skipped activity IDs | | `completedSteps` | Record | Steps completed per activity | | `checkpointResponses` | Record | Checkpoint answers (key: `-`, including any `#instance` suffix) | -| `decisionOutcomes` | Record | Decision results (key: "activity-decision") | | `activeLoops` | LoopState[] | Currently executing loops | | `variables` | Record | Runtime variable values | | `history` | HistoryEntry[] | Execution event log | @@ -922,7 +876,6 @@ The state schema (`state.schema.json`) tracks runtime execution of a workflow us "first-activity": [1, 2] }, "checkpointResponses": {}, - "decisionOutcomes": {}, "activeLoops": [], "variables": { "user_confirmed": true @@ -1081,15 +1034,10 @@ Here's a minimal valid workflow that demonstrates all key concepts: ] } ], - "transitions": [ + "exits": [ { - "to": "process", - "condition": { - "type": "simple", - "variable": "approved", - "operator": "==", - "value": true - } + "id": "approved", + "when": "approved == true" }, { "to": "rejected", @@ -1160,15 +1108,16 @@ if (result.success) { |-------|-------|-----| | Missing required property | `id`, `version`, `title`, or `activities` not provided | Add the required property | | Invalid version format | Version doesn't match `X.Y.Z` pattern | Use semantic versioning | -| Invalid activity reference | `initialActivity` or transition `to` references non-existent activity | Check activity IDs match | +| Invalid activity reference | `initialActivity` or a `graph` destination references a non-existent activity | Check activity IDs match | | Checkpoint missing options | Checkpoint defined without any options | Add at least one option | -| Decision needs branches | Decision defined with fewer than 2 branches | Add at least 2 branches | +| Unbound exit | An activity declares an exit the workflow's `graph` does not bind | Bind it, or remove the exit | +| Ambiguous default | An activity declares several exits and no single `isDefault` | Mark exactly one as the default | --- ## Activity Schema -The activity schema (`activity.schema.json`) defines unified activities that combine workflow execution: a single ordered, kind-tagged `steps[]` (technique / action / checkpoint / loop) plus activity-level decisions, transitions, and triggers. Activities are reached via `transitions` from the workflow's `initialActivity`. This schema is **generated** by [`scripts/generate-schemas.ts`](../scripts/generate-schemas.ts) from the Zod source of truth (it was previously hand-maintained) — do not hand-edit `activity.schema.json`. +The activity schema (`activity.schema.json`) defines unified activities that combine workflow execution: a single ordered, kind-tagged `steps[]` (technique / action / checkpoint / loop) plus the activity's exits and triggers. Activities are reached through the workflow's `graph` from its `initialActivity`. This schema is **generated** by [`scripts/generate-schemas.ts`](../scripts/generate-schemas.ts) from the Zod source of truth (it was previously hand-maintained) — do not hand-edit `activity.schema.json`. ### Top-Level Structure @@ -1200,8 +1149,7 @@ The activity schema (`activity.schema.json`) defines unified activities that com | `description` | string | Detailed description | | `bundleTechniques` | BundleTechniques | Opt-in hybrid bundling (`{ maxChars }`): `get_activity` inlines each ungated step technique whose composed wire form is at most `maxChars` | | `steps` | Step[] | Ordered, kind-tagged execution list (technique / action / checkpoint / loop) | -| `decisions` | Decision[] | Automated branching points (activity-level) | -| `transitions` | Transition[] | Navigation to other activities | +| `exits` | Exit[] | Named outcomes of the activity | | `triggers` | WorkflowTrigger[] | Workflows to trigger from this activity | | `outcome` | string[] | Expected outcomes when activity completes | | `required` | boolean | Whether activity is required (default: true) | @@ -1210,7 +1158,7 @@ The activity schema (`activity.schema.json`) defines unified activities that com ### Activity Flow -Activities have `transitions` connecting them, form a workflow flow, and require `initialActivity` on the parent workflow. *Workflow* selection — which workflow handles a request — happens at the catalog level via `list_workflows` and `start_session`, scored on title, description, and `tags`. +Activities are connected by the parent workflow's `graph`, which binds each activity's exits to the activity that follows, and require `initialActivity` on that workflow. *Workflow* selection — which workflow handles a request — happens at the catalog level via `list_workflows` and `start_session`, scored on title, description, and `tags`. ### Complete Example From 8c4b93fd3fcd1ae8b7cbced9eeac2650132f1b28 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 24 Aug 2026 09:22:58 +0100 Subject: [PATCH 4/5] Adopt the corpus whose workflows state their own shape The corpus at this pointer carries the exits and graphs the schema here defines, and the walk baseline is stamped against it. The engineering pointer carries the survey the exit naming and the immediate-exit choices were measured from. --- .engineering | 2 +- tests/e2e/__snapshots__/corpus-sha.json | 2 +- workflows | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.engineering b/.engineering index 9363bf5cc..b40e97d83 160000 --- a/.engineering +++ b/.engineering @@ -1 +1 @@ -Subproject commit 9363bf5cc8a6dc54964d5135e2e050c29edbb3b3 +Subproject commit b40e97d8362d4449f4d9dbabab3e6c59348dd1c6 diff --git a/tests/e2e/__snapshots__/corpus-sha.json b/tests/e2e/__snapshots__/corpus-sha.json index 358665083..6cd5e2b2b 100644 --- a/tests/e2e/__snapshots__/corpus-sha.json +++ b/tests/e2e/__snapshots__/corpus-sha.json @@ -1,4 +1,4 @@ { - "corpusSha": "f9ee51c06b4d395c40154c27ab0eff055b52a857", + "corpusSha": "b5e034e5bc1cbeb35d578084227d6fdc17525097", "note": "Corpus commit the committed walk snapshots were generated against. Update it in the same commit that bumps the workflows submodule and re-baselines the walk (npm run baseline:stamp)." } diff --git a/workflows b/workflows index f9ee51c06..b5e034e5b 160000 --- a/workflows +++ b/workflows @@ -1 +1 @@ -Subproject commit f9ee51c06b4d395c40154c27ab0eff055b52a857 +Subproject commit b5e034e5bc1cbeb35d578084227d6fdc17525097 From d56fee1929a603875326ec77387811341385232d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 24 Aug 2026 13:27:24 +0100 Subject: [PATCH 5/5] Walk the exits a predicate can take, and budget for the ones a decision held An exit carrying neither a `when` nor `isDefault` is selectable only by a checkpoint option naming it, so the walker's forward advance and the enumerator's fork over targets read the predicate exits alone. Offering them every exit let a walk arrive at an activity without passing the gate that decides to go there, and the options behind that gate then went untaken. An activity's routing is its exits, and those include the branches a decision used to hold. The walker never read decisions, so those branches were not edges and the enumerator never forked on them; now they are, the fork tree is wider and the dry-streak that ends a workflow's enumeration needs room for it. Fifty covers 154 of 275 options across the corpus, where thirty ends before three of workflow-design's batch-review-attested options are reached. --- tests/e2e/option-coverage.test.ts | 17 ++++++++++++----- tests/e2e/walker.ts | 30 +++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/tests/e2e/option-coverage.test.ts b/tests/e2e/option-coverage.test.ts index 3ab69d153..78fa51c55 100644 --- a/tests/e2e/option-coverage.test.ts +++ b/tests/e2e/option-coverage.test.ts @@ -42,12 +42,19 @@ interface Expected { * Consecutive walks exercising no new option before the enumerator calls a workflow done. * * The tail is not wasted work, which is worth recording because it looks like it should be. Measured - * over the whole set: 8 covers 151 options in 303 seconds, 16 covers 152, and 30 covers 155 in 794. - * So more than half the wall clock buys the last four options — and coverage is the point of the - * exercise, so it is bought. Overridable from the environment to re-check that trade, but the - * expectation file is recorded against the committed value. + * over the whole set: 50 covers 154 of 275 declared options across 14 workflows in 1144 seconds. The + * last few options are most of that wall clock — and coverage is the point of the exercise, so it is + * bought. Overridable from the environment to re-check the trade, but the expectation file is + * recorded against the committed value. + * + * The plateau this detects is a property of the graph, so it has to be re-measured whenever the graph + * grows. An activity's routing is its exits, which include the branches a decision used to hold; those + * branches are walkable edges, and each one widens the enumerator's fork tree. Measured on + * workflow-design, whose `quality-review` carries such an edge: at 30 the streak ends before the forks + * behind it are dequeued and three `batch-review-attested` options go unreached, 36 is the lowest value + * that clears, and this sits above that rather than on it — a bound one walk from the edge is a flake. */ -const DRY_WALKS = Number(process.env.WF_DRY_WALKS ?? '30'); +const DRY_WALKS = Number(process.env.WF_DRY_WALKS ?? '50'); /** * The workflows this run walks, and the options it may therefore judge. diff --git a/tests/e2e/walker.ts b/tests/e2e/walker.ts index 2ad230647..aa14a5600 100644 --- a/tests/e2e/walker.ts +++ b/tests/e2e/walker.ts @@ -4,13 +4,14 @@ * Drives a workflow from its initial activity to a terminal activity through * the real MCP server, deterministically. At each activity it resolves the * applicable checkpoints (yield → respond → resume) by asking a Policy which - * option to pick, accumulates the resulting variable effects, and selects the - * next activity by evaluating the activity's transitions against that variable - * bag with the server's own `evaluateCondition`. + * option to pick, accumulates the resulting variable effects, and names the exit + * the activity took — the one a checkpoint option selected, else the first whose + * `when` holds against that variable bag, else the default — then reads the + * destination from the workflow's graph. * - * The walker tracks variables locally only to CHOOSE a transition; the server - * remains the source of truth and validates each transition. A divergence - * surfaces as a thrown error — itself a useful consistency signal. + * The walker tracks variables locally only to CHOOSE an exit; the server remains + * the source of truth and validates each transition. A divergence surfaces as a + * thrown error — itself a useful consistency signal. */ import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -270,6 +271,17 @@ export function pickNext(act: ActivityDef, graph: Graph, variables: Record e.when !== undefined || e.isDefault); +} + /** * Workflow-agnostic forward advance: pick an exit leading to an as-yet-unvisited activity, * optimistically satisfying its `when` by mutating `variables`. This stands in for the agent-set @@ -279,7 +291,7 @@ export function pickNext(act: ActivityDef, graph: Graph, variables: Record, visits: Map): string | null { const bound = graph[act.id] ?? {}; - for (const e of act.exits ?? []) { + for (const e of predicateExits(act)) { const to = bound[e.id]; if (to === undefined || (visits.get(to) ?? 0) > 0) continue; if (e.when === undefined) return to; @@ -702,14 +714,14 @@ export async function walk( let next = pickNext(act, graph, variables, selectedExit); if (selectedExit === undefined) { const bound = graph[act.id] ?? {}; - const targets = [...new Set((act.exits ?? []).map((e) => bound[e.id]).filter((t): t is string => t !== undefined))]; + const targets = [...new Set(predicateExits(act).map((e) => bound[e.id]).filter((t): t is string => t !== undefined))]; if (targets.length && opts.decide) { // The natural (happy) target — pickExit's choice, or the forward-advance target, or the // first bound exit — is the base-path suggestion; the enumerator forks the rest. let suggested = next; if (suggested === null || (visits.get(suggested) ?? 0) > 0) suggested = advanceToUnvisited(act, graph, { ...variables }, visits) ?? next; const chosen = opts.decide({ kind: 'transition', activityId: current, id: 'next', options: targets, suggested: suggested ?? targets[0]! }) ?? suggested ?? targets[0]!; - const exit = (act.exits ?? []).find((e) => bound[e.id] === chosen); + const exit = predicateExits(act).find((e) => bound[e.id] === chosen); if (exit?.when) satisfyWhen(exit.when, variables); next = chosen; } else if (opts.autoAdvance && (next === null || (visits.get(next) ?? 0) > 0)) {
ParameterTypeRequiredDescription
session_indexstringyesSix-character token from start_session. Use the same value for every call in this session.
activity_idstringyesActivity to move to. First call: use initialActivity from get_workflow. Later: use an id from transitions.
transition_conditionstringnoThe condition name that led to this transition, from the previous activity.
activity_idstringyesActivity to move to. First call: use initialActivity from get_workflow. Later: the activity the graph binds to the exit just taken.
exitstringnoName of the exit the previous activity took.
step_manifestobject[]noSteps completed in the previous activity, for example [{ "step_id": "detect-review-mode", "output": "is_review_mode=false" }]. Omit if no steps ran.
activity_manifestobject[]noHistory of completed activities with outcomes and transition conditions.
activity_manifestobject[]noHistory of completed activities with their outcomes and the exit each took.
variables_changedobjectnoVariable assignments the completing activity produced — relay the worker's activity_complete variables_changed map verbatim. The server writes them into the session variable bag and records one variable_set history event per name, so the bag a later get_workflow_status / inspect_session returns reflects worker outputs and survives a lost agent context. Declared types are validated warn-only: a mismatch is stored as written and surfaced in _meta.validation. Omit when the activity changed nothing.
artifacts_producedobject[]noArtifacts the completing activity produced: [{id, name, path?}]. Merged by id into the session declared-artifact accumulation; planning-folder reconciliation joins on id (warn-only).
agent_idstringnoLabel for this agent in the session trace.
outputstringyesShort summary of what the step produced. Use a JSON object when the step has multiple outputs.
activity_manifest[]activity_idstringyesCompleted activity id.
outcomestringyesShort outcome summary for that activity.
transition_conditionstringnoCondition that led out of that activity, if any.
exitstringnoExit that activity took, if any.
artifacts_produced[]idstringyes-
namestringyes-
pathstringno-
session_indexstringyesSix-character token from start_session. Use the same value for every call in this session.
agent_idstringnoLabel for this agent in the session trace.
step_idstringnoStep within the current activity. Omit to get the first technique for the activity or workflow.
activity_idstringnoActivity to move to. First call: use initialActivity from get_workflow. Later: use an id from transitions.
activity_idstringnoActivity to move to. First call: use initialActivity from get_workflow. Later: the activity the graph binds to the exit just taken.
bundle"reference" | "full"noreference: return unchanged markers for content already delivered. full: always return complete text.
fullbooleannoForce full content even when persistent mode would return an unchanged marker (get_technique or get_resource).