Skip to content

Latest commit

 

History

History
366 lines (289 loc) · 23.9 KB

File metadata and controls

366 lines (289 loc) · 23.9 KB

Workflow node capabilities

What a Graph node can declare beyond a prompt, and how the daemon enforces it. Everything here is opt-in: a node that declares none of it behaves exactly as it did before these fields existed. See the Orchestration records in Otto Knowledge (orchestration-domain-model-and-engine-invariants, orchestration-phase-run-engine, orchestration-graph-engine-execution-model, orchestration-agent-binding-and-provider-coverage, and the "Decided, not built" design records) for the architecture — reconciled to code; where this page and those disagree, code wins, then this page.

Engine: packages/server/src/server/workflow/graph-engine.ts, with one module per concern beside it. Schemas: packages/protocol/src/workflow.ts.

Node results are three-valued

A node settles done, failed, or skipped. A skip is control flow routing around a node - an ordinary outcome, never an error - and it always carries a reason:

RunPhase.skipReason Meaning
condition An incoming edge's condition chose another branch.
upstream-skipped Everything feeding this node was itself skipped.
upstream-failed An upstream node failed.
canceled The run was canceled before this node dispatched.

notes carries the same fact as a sentence, which is what the Workflows page shows. The wrap-up message names every skipped node and tells the orchestrator to say so - a run must never report success while silently omitting part of the graph.

A phase written by an older daemon has no skipReason; read that as upstream-failed, the only skip that existed then.

Output fields - the value plane

A node declares the fields it will produce (GraphNode.output.fields). Each descriptor is { key, type, description?, required? } - plain JSON, never a serialized Zod schema, because the same descriptor has to be three things: wire-safe, renderable as a form, and compilable to both Zod (validation) and JSON Schema (the tool's input).

type is an open vocabulary - string, number, boolean, array today. An unknown type validates as "anything" rather than failing, so an old daemon meeting a new type degrades to accepting the value instead of refusing a graph it could otherwise run. required absent means required.

When a node declares fields:

  1. Its task gains a short instruction listing them (buildOutputInstruction).
  2. Its agent gets a submit_output tool (below).
  3. Its result is validated and persisted to RunPhaseCandidate.outputFields.
  4. Downstream nodes receive the values as a labelled JSON block alongside the prose - the prose still carries reasoning the fields deliberately don't.

Rule: references, not contents. Fields carry values and paths; anything large is a file the next node reads with its own tools.

submit_output - enforcement with in-session self-correction

packages/server/src/server/workflow/node-output.ts

The node's declared fields ride to the spawned agent as a label (otto.orchestration-output-fields), and the per-agent Otto tool catalog registers submit_output for that one agent. This is why structured output is provider-neutral: MCP-capable seats reach the tool through the daemon's MCP server, openai-compat seats through the daemon-owned tool loop, and local models get the identical contract. No provider branch exists anywhere in the engine.

Validation failure returns isError: true with a precise message, so the model corrects within the same session - one extra turn instead of a re-dispatch.

Two details are load-bearing and easy to undo by accident:

  • The advertised input schema is permissive. The catalog parses a tool's input before the handler runs and throws on failure, so a strict shape would turn the most common mistake (a missing field) into a thrown parse error instead of a correctable tool error. Every field is advertised as optional; requiredness is enforced in the handler.
  • submit_output is registered past the group and policy gates. Those gates decide which Otto capabilities a node may use. This is not a capability - it is the node's own deliverable channel, and a deterministic node (the kind most likely to declare fields) would otherwise have it stripped with the agents group.

Harvest order on settle: the tool call, then a JSON object recovered from the final message, then failure. The prose fallback exists because small local models often write correct JSON instead of calling the tool, and discarding that work would be the wrong kind of strict. A node that declares fields and delivers neither fails, naming the contract.

Conditional edges

packages/server/src/server/workflow/edge-conditions.ts

GraphEdge.when.expression is a JSONata expression evaluated against the upstream node's output fields (plus output, its prose) once it settles. JSONata rather than a bespoke DSL because it is parsed and evaluated, never eval'd - a graph is user-authored data and must never become code the daemon executes.

GraphEdge.fields narrows what an edge carries. Selection only, never renaming.

The gating rule, and why it makes diamonds work:

A drawn edge is a requirement. A node runs when at least one edge delivers and none was ruled out by its own condition. An upstream that was skipped contributes nothing and vetoes nothing.

That distinction is the whole feature. A join below two conditional branches still runs off whichever branch executed, because the pruned side reaches it as upstream-skipped rather than as a veto. The starter-triage graph is the shipped example.

Otto's scheduler needs no fixed-point pass. Each node decides after awaiting all of its upstream promises, so nothing it read can still change - the memoised-promise model carries conditional edges unchanged.

A condition that throws fails the node; it is never treated as a quiet false. A typo would otherwise silently prune half the graph. Expression syntax is checked before the run starts (validateEdgeConditions, daemon-side - the shared validator stays parser-free for the client bundle); reviewOrchestrationGraph returns the advisory warning when a condition targets a node with no declared fields.

Node authority

Three narrowings, all applied at spawn and never requested in prose. Workspace access has its own section below; the other two:

Otto tool groups (GraphNode.tools) - an allowlist over the existing groups, read from a label and intersected with the daemon-wide allowlist. A node can hand itself less authority than the daemon allows, never more. An empty array is meaningful ("no Otto tools at all"). This is a cost lever as much as a safety one: the catalog is paid for in input tokens on every request, and a smaller catalog measurably helps smaller models stay on task.

Query tools (GraphNode.queryTools) - author-defined read-only lookups scoped to one node's session (node-query-tools.ts), namespaced query_* so they can never shadow a built-in. Three kinds, each read-only by construction rather than by validation:

Kind Safety property
command argv array spawned with shell: false. A parameter containing ;, &&, $() is one argument - there is no string to inject into. Pipelines belong in a script the tool points at.
http-get GET only, no author-supplied headers, http(s) only. A graph template cannot carry a credential outbound.
file-read Path resolved, then checked against the run's cwd. Escapes are refused, not clipped.

SECURITY (same trust boundary as EJS templates below): http-get has no host restrictions - a tool URL can target localhost services or link-local/metadata addresses. Acceptable today because graphs are authored locally by the machine's own user, but this is SSRF-shaped: the day graphs become shareable or importable, http-get needs a host policy behind the same trust gate as template rendering. Do not add an import path without both.

Workspace access

GraphNode.access - none, read or write (absent means write, today's behaviour). It rides to the agent on AgentSessionConfig.workspaceAccess, and each provider adapter narrows its own tool surface. The meaning of each level lives in one place (agent/workspace-access.ts); how it is imposed is per adapter.

It is a boundary, not an instruction. A level is enforced by withholding tools, never by prose. An agent that was never given a write tool cannot be argued into writing, and a prompt injection in a file it reads cannot reach for a tool that does not exist.

Provider How it is imposed
openai-compat (including local models) Total: the daemon owns the tool loop, so availableToolSpecs withholds the forbidden specs and the model is never told they exist.
Claude applyWorkspaceAccess adds the level's denied tools to disallowedTools and strips them from allowedTools, applied after the dontAsk allowlist so a deny always wins.
Codex read and write only. Maps onto its native sandbox tiers as a ceiling: resolveSandboxPolicyType narrows the seat's tier and can never widen it. Read-only is Codex's floor: its shell reads inside every tier and the app-server protocol has no tool-deny list, so none cannot be enforced and a none node on a Codex seat is refused at spawn.
Everything else Not supported. The node is refused at spawn (below).

Where the shell sits is the one judgement call. Bash is denied at none but allowed at read, because read exists for reviewer nodes that run tests, linters and git queries, and denying the shell would make the level useless for its main purpose. The trade is explicit: read bounds tools, and a shell can still write. A node that must not touch the workspace at all is none.

A seat that cannot enforce it refuses the run. Before spawning, the daemon checks the resolved provider's capabilities per level (capabilitiesEnforceAccess: supportsWorkspaceAccess for read, plus supportsWorkspaceAccessNone for none) and throws, naming the node, the level, the provider and the provider's floor when it has one. Silently running a node that asked for read with full access, or one that asked for none with reads live, is precisely the failure this feature exists to prevent, so never set either capability flag without the enforcement behind it.

Deterministic checks

A Graph check node evaluates one JSONata assertion against the material delivered by its incoming edges. It never starts an agent, calls a provider, or consumes the agent cap. Its expression has one explicit scope:

upstream.<node-id>.fields.<declared-field>
upstream.<node-id>.output

For example, upstream.verify.fields.ready = true passes only when the upstream worker submitted the declared ready field as true. A true assertion marks the Check done and releases downstream nodes. A false assertion fails the durable Run with the Check's optional failure message; evaluation or syntax errors also fail loudly. This is deterministic validation, not an instruction asking a model to review itself.

This first form has one pass continuation. Pass/fail output ports and recovery branches remain future control-flow work; a failed Check stops the current run and ordinary dependency handling marks its downstream nodes skipped.

Human approval gates

A Graph gate node is an attended human boundary, not an agent. It has a title and an optional review prompt, receives the upstream work as an ordering dependency, and pauses the durable run with no provider call and no agent-cap cost. The existing Runs visualizer shows the blocked gate and exposes Approve and Reject.

Approve marks the gate done and releases its downstream nodes. Reject marks the gate canceled (a user decision, never an error), cancels the run with the gate name and the reviewer's note as its reason, and leaves downstream nodes skipped by their ordinary dependency rule. The same canceled phase status is used for a worker interrupted by a run cancel; failed is reserved for real errors. A gate is therefore the right boundary before an irreversible, costly, or otherwise human-owned action; it is not a prompt asking a model to review itself.

This first form has one approved continuation. Separate approved/rejected output ports and branch-specific recovery are future control-flow work.

Retry and time limit

Retry (GraphNode.retry) is resilience, distinct from Loop, which is quality iteration: a loop re-runs work that succeeded but wasn't good enough; a retry re-runs work that never completed. Retry wraps the whole node including its loop.

Three invariants:

  • Only a node that cannot edit may retry. A failed attempt often dies mid-edit (an error, or a time limit that cancels it), and nothing reverts what it wrote. A retry re-runs the whole prompt with a fresh agent on those changed files. So a node whose workspace access is write, the default, is refused a retry above one attempt; set its access to read or none, or remove the retry. Loops stay allowed on any node, because each iteration is told what the previous one produced. See workflows.md.
  • One loop, one counter. Retry is never re-entered from the failure path. Prior art this design studied re-enters its executor from its own failure handler with a fresh allowance at every level, so a persistently failing step retries forever.
  • Every attempt is charged to the run. Retries spawn through the same capped path as any other agent, so they count against maxAgents and the concurrency semaphore. A retry is never a private allowance.

Backoff is backoffMs * multiplier^(attempt-1) (multiplier defaults to 2) and ends early if the run is canceled.

Time limit (GraphNode.timeoutMs) must cancel, not merely stop waiting - an abandoned agent keeps running and keeps spending. On expiry the engine calls the port's cancelAgent, marks RunPhase.timedOut, and fails the node; its retry policy may then catch it, and independent branches finish normally. Otto can do this because node agents are managed processes; an in-process engine cannot.

Prompt templates and snippets

prompt-template-store.ts (persistence, mirrors GraphStore) and prompt-render.ts (EJS). Stored at $OTTO_HOME/prompt-templates/. A template with snippet: true is meant to be included by others; include("id") resolves against the store, not the filesystem, because templates are host records rather than files.

A node binds one with GraphNode.promptTemplate - { templateId, variables }, where a variable is a literal, $inputs.<key>, or $output.<nodeId>.<field>. An unresolvable reference renders empty rather than leaking its own syntax into the prompt, where it would read as an instruction.

Failure degrades, never blocks. A deleted template, a syntax error, or a host without the store falls back to the node's inline prompt and logs. This is the one place a fallback is right: otherwise removing a shared snippet breaks every graph that referenced it.

The convention the starters demonstrate: the driver instruction stays in the node, and reusable behavioural rules live in a snippet (submit-rules). Repeating those rules in every node is duplication and tokens on every dispatch.

SECURITY: EJS compiles templates to JavaScript that runs in the daemon process. Acceptable today because templates are authored locally by the machine's own user - the same trust level as a workspace script. The day templates become shareable or importable, this is a code-execution vector and needs an explicit trust gate.

Headless Graph Workflow boundary

The CLI can operate a saved Graph without opening the designer:

otto workflow graph ls
otto workflow graph inspect <graph-id> --json
otto workflow graph run <graph-id> --input question="Should we ship?"

inspect --json emits the exact stored Graph document for review or source control. run binds the Workflow to an existing workspace: it finds one registered for --cwd, or accepts --workspace <id> when the caller needs to name it directly. It never creates a workspace as a side effect of execution. workflow graph validate <file> checks a local JSON document against the shared Graph schema, portable-document format/version, and structural validator, but does not save it or execute it. Its success means structurally valid, not execution-ready: JSONata syntax, seat availability, workspace authority, and template resolution remain daemon-side launch checks. Unversioned local Graphs remain valid with an export warning; portable documents use format: "otto.workflow.graph" and formatVersion: 1. File import and run --file are deliberately not present yet: Graph query tools and EJS templates make an imported document executable local authority. Every drafted and executed Graph Run now carries the exact Graph document it was built from, so later Graph edits cannot rewrite run history. Add the trust boundary before crossing either file-import line.

Authoring these in the designer

Every node property on this page is editable from the node card's Advanced disclosure - workspace access, output fields, retry, time limit, Otto tool groups, query tools and the prompt-template binding. Edge properties (condition, fields carried) live in an inspector panel that opens when you select a wire. The disclosure opens on its own whenever any of them is set, because a collapsed card that hides a real constraint reads as a plain node.

Three of them use one-per-line text forms rather than repeating row editors, because a 320px node card has no room for a nested table:

Property Form Notes
Output fields name : type : description Trailing ? = optional. A bare name is a required string. Splits on the first two colons only, so a description may contain colons.
Query tools name | kind | spec | description Pipe, not colon - a URL carries colons. spec is the argv line, URL or path.
Template variables name = value Splits on the first =, so a value may contain one.

Query-tool parameters are derived, not declared. Each {{name}} in the spec becomes a string parameter - the substitution syntax already names them, and a parameter nothing substitutes is one the tool cannot use. That is lossy against a hand-authored tool with typed or described parameters, so parseQueryTools takes the node's existing tools and hands back the original object for any line that still formats identically. Editing a line rewrites it in the simple form; leaving it alone keeps every detail.

Otto tools is tri-state, and the third state matters. "Whatever the policy allows" writes no tools property at all; "Only these groups" writes the array - including an empty array, which is the real declaration "no Otto tools". Never collapse the empty array to absent on save: they mean opposite things.

Round-trip safety. The canvas rebuilds every node and edge from its own state on export, so any property it cannot edit is explicitly carried across (carryUneditedNodeFields / carryUneditedEdgeFields in graph-doc.ts, keyed from→to for edges). Without that, opening a graph that uses a capability the designer has no control for and pressing Save would silently delete it. When you add a node or edge property, either add a control for it or confirm it survives the round-trip - graph-doc.test.ts is where that is proven. CANVAS_OWNED_NODE_KEYS must list exactly what buildGraphNode writes: a property with a control that is missing from the set is written twice (harmless), but a property in the set with no control is deleted on save, which is the failure this guard exists to prevent.

Gotcha: schema declaration order in the protocol

zod-aot loads packages/protocol/src/workflow.ts at codegen time, so a schema referenced before its const is initialised fails the build (ReferenceError: Cannot access 'X' before initialization) even though typecheck passes. Declare a schema above its first use.

Invariants

  1. A skip is never an error, and always carries a machine-readable reason.
  2. A run never reports done while silently omitting part of the graph.
  3. An unknown field type accepts; it never fails a run.
  4. submit_output reaches every provider through the per-agent Otto tool catalog - no provider branch in the engine.
  5. Validation failure is a correctable tool error, not a thrown parse error.
  6. A condition that cannot be evaluated fails its node; it is never a quiet false.
  7. A node's tool allowlist intersects the daemon's; it can only narrow.
  8. Workspace access is enforced by withholding tools, never by prompting.
  9. A provider advertises an access capability only if it actually withholds at that level (supportsWorkspaceAccess for read, supportsWorkspaceAccessNone for none); a node asking for a level its seat can't enforce is refused, never silently run with a weaker boundary.
  10. Query-tool safety is structural (no shell, no headers, resolved-path check), not validated.
  11. Retry is one bounded loop, never re-entered from the failure path, always charged to the run's caps.
  12. A time limit cancels the agent; it never just stops awaiting it.
  13. A template that cannot render degrades to the inline prompt.