Merge planner and workflow agents
@hanna-paasivirta is already working on this.
Since Aug 6, 2026.
- Dominant language
- Jupyter Notebook
- Stars
- 5
- Forks
- 10
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 17
Description
Plan: Planner as workflow agent (& workflow-edit tools) WIP
This plan builds on issue #530 (workflow_chat: replace structured outputs with tool use), adapted so the tools live on the PlannerAgent instead of inside workflow_chat. This can be implemented after the initial migration to the global assistant is done.
Summary
Remove the workflow agent as a subagent. The planner edits workflow structure directly through a set of small, deterministic tools (add_jobs, update_jobs, remove_jobs, add_edges, update_edges, remove_edges, set_trigger, create_workflow, update_workflow). Each tool is plain Python that mutates the planner's current_yaml with validation, no LLM call. The server owns IDs and job bodies; the model can never see or emit them.
The job code agent is unchanged: it stays a subagent because it earns the boundary (parallel fan-out, bulk code kept out of the planner's context).
This is not a huge amount of code in the first pass, but it changes the output a lot. The incremental-edit approach needed substantial fixing when we did it for job_chat, so testing is a first-class part of this plan, not a follow-up.
Why not a subagent anymore
A subagent boundary is justified by parallelism, context isolation, or specialised reasoning. The workflow agent has none of these:
- It can never run in parallel. It owns the single shared YAML, so every call is a serial bottleneck (unlike job code agents, which fan out).
- It isolates nothing. The planner already holds the redacted workflow structure in context every turn. The subagent re-reads what the planner just read and re-emits the whole document to express a two-line change. That round trip is the 30-40s cost on even trivial edits like adding a step.
- Structural edits need no reasoning. "Add step X with adaptor Y after Z" is a deterministic operation. One-shot whole-YAML regeneration is the slow and unreliable part: every re-emitted element (IDs, adaptors, edge conditions, webhook sub-keys) is an opportunity for silent corruption.
The subagent's real value was its knowledge (YAML semantics, adaptor rules) and its safety machinery (ID/body placeholders, parse-retry). Both move: the knowledge into the planner's system prompt, the safety into tool code, where it becomes stronger. A tool whose schema has no body or id parameter cannot corrupt job code or IDs by construction.
Why not a skill: skills are per-turn injected instructions, invoked explicitly. Workflow editing is the planner's core competence, always on, and a skill would change only what the planner knows, not what it can do: it would still emit whole YAML itself, which is the failure mode being removed. Skills remain the layer for user-invoked procedures (/diagnose, /qa) that sequence these tools.
Target architecture
User → global_chat → Router (Haiku)
├─ job_chat (direct route, unchanged)
└─ PlannerAgent (Opus) ── owns current_yaml
├─ workflow-edit tools (new, deterministic, in-process)
│ create_workflow, update_workflow, add_jobs, update_jobs,
│ remove_jobs, add_edges, update_edges, remove_edges, set_trigger
├─ call_job_code_agent (subagent, parallelisable, unchanged)
├─ inspect_job_code (unchanged)
└─ search_documentation (unchanged)
The planner IS the workflow agent. There is exactly one agent that reasons about workflow structure, and it manipulates it the way it already manipulates job code stitching: through deterministic in-process operations on self.current_yaml, streamed to the client per mutation via the existing _send_yaml / spinner / settled-status machinery.
Repo layout after the change
services/global_chat/
global_chat.py # unchanged
router.py # workflow route now lands on the planner
planner.py # dispatches the new tools; loop/streaming unchanged
subagent_caller.py # call_workflow_agent removed; call_job_agent stays
prompts.yaml # planner prompt gains the workflow-editing section
tools/
tool_definitions.py # new tool schemas; CALL_WORKFLOW_AGENT_TOOL removed
workflow_edit/
handlers.py # one function per tool: validate → mutate → dump
validation.py # adaptor names, key collisions, edge refs, trigger rules
tests/ # deterministic unit tests, no LLM
services/workflow_chat/ # deleted once Lightning's direct calls are sunset
Tool design (adapted from #530)
Carried over from #530 unchanged:
- All edit tools accept arrays for batch edits (one call adds five steps).
- Fail fast, atomically. All validation runs before any write; one failure means nothing is persisted and the error string is returned as the tool result. The planner's existing loop retries with the error in context, so correction happens in-turn (bounded by the existing
max_tool_calls). - Tools cannot touch what they don't name. No fuzzy matching; unknown key is an error with a did-you-mean hint, never a guess.
set_triggerpreserves webhook sub-keys (webhook_reply,webhook_response_config) unless explicitly passed. The "preserve EXACTLY" prompt rule becomes code.- Unknown fields pass through untouched. A mutation writes only the fields it owns, so anything else Lightning puts in the YAML (canvas positions, webhook response config, future fields) survives by default. Under whole-YAML regeneration, only fields the prompt explicitly said to preserve survived.
- Read-only mode: mutating tools are simply not mounted for that request.
Simplified relative to #530:
- No
save_workflow, no strict key accounting. The renames/deletes maps andunaccounted_keysreconciliation existed to make whole-structure re-emission safe. Granular tools make re-emission unnecessary: a restructure isremove_jobs+add_jobs+add_edges, each validated independently.create_workflowcovers the from-scratch case (takes the full initial structure of steps/edges/trigger in one call so new workflows appear in one round; errors if any jobs already exist). - "Start over" is deletion plus creation, not replacement.
remove_jobswith every key (one batch call), thencreate_workflow/add_jobs.remove_jobscascades: edges attached to removed jobs are removed too, and the tool result reports what was cascaded and how many removed jobs contained code. Deletion is always explicit, the model names every key it destroys, so nothing is lost silently; that was the real purpose of #530's key accounting, achieved without it. Prompt rule: confirm with the user before wholesale deletion of jobs that contain code. - No
rename_jobs.update_jobstakes an optionalnew_key; the server cascades the change through edge keys and references. - No
get_workflow. The planner receives the redacted structure with the user turn and again after every mutation (existing behaviour). - No new agent loop, streaming protocol, or retry cap. The planner's existing loop, spinner/settled events and per-mutation
_send_yamlalready do all of this. search_knowledgestays deferred, as in #530. The adaptor list remains in the prompt for selection; validation of it lives in code.
Structure coverage (checked against the workflow_chat prompt and handlers)
Details of the project.yaml structure the tools must cover explicitly:
- Workflow
name(top-level): set bycreate_workflow; renaming an existing workflow gets a smallupdate_workflowtool (top-level fields only, currently justname). - Triggers: keyed by name,
type: cron(withcron_expression) ortype: webhook; defaultenabled: false.set_triggeralso coverscron_cursor_job_id, which references a job ID: the tool takescron_cursor_jobby job key and the server resolves it to the ID. Job rename/removal cascades update or clear it. Note: todayrestore_componentsonly restoresidfields on jobs/edges, so a model-emittedcron_cursor_job_id: __ID_JOB_x__placeholder appears to leak unrestored into output — a latent bug that server-owned IDs remove by design (verify when migrating). - Trigger edge: edges from the trigger use
source_trigger(notsource_job) and the trigger can connect to exactly ONE job (validated). Replacing or renaming the trigger viaset_triggercascades its edge (key andsource_triggerfield). - Edge fields:
condition_typeis an enum (always,on_job_success,on_job_failure,js_expression);condition_expressionis required iffjs_expression(validated);enableddefaults to true. Edge keys aresource->targetand rename cascades rewrite them while preserving edge IDs — stronger than today, where ID survival across a rename depends on the model copying placeholders verbatim and silently regenerates UUIDs when it fails. - Job constraints (validated): unique keys, unique display names, names under 100 characters, sanitised characters. Adaptor default is
@latest; a version already set is never changed unless asked (prompt rule).
Every capability and instruction in workflow_chat moves to a named home. Nothing is preserved by accident.
| workflow_chat today | New home |
|---|---|
YAML structure prompt (yaml_structure_*) |
Mostly obsolete: the model never emits YAML. Concepts (jobs, triggers, edges, condition types, one job from trigger) go in the planner prompt; the shape lives in the tool schemas. |
ID preservation (extract_and_preserve_components, restore_components, __ID_*__ placeholders) |
Deleted. Server owns IDs: tools have no id parameter; new components get UUIDs in the handler. Preservation is by construction. |
Job body preservation (__CODE_BLOCK_*__) |
Deleted. Tools have no body parameter; new jobs get the standard placeholder body in the handler. |
Adaptor validation (validate_adaptors, currently log-only) |
Hard validation in validation.py: unknown adaptor rejects the call with the valid-names hint, model corrects in-turn. |
Job name sanitisation (sanitize_job_names) |
Runs in add_jobs / update_jobs handlers. |
| Parse-retry loop (regenerate on bad YAML) | Obsolete: no YAML is generated. Replaced by tool-error retry in the existing planner loop. |
Streaming YAML changes events |
Existing planner _send_yaml, now per mutation, so each edit renders in the editor the moment it lands. |
read_only mode |
Mutating tools not mounted for the request. Payload gap: only the legacy direct endpoint has a read_only field today; it must be added to the global_chat payload as part of the Lightning migration or the mode is silently lost. |
errors payload (error mode) |
Appended to the planner's user content, same as page context today. Same payload gap as read_only: the field must be added to global_chat. |
general_knowledge prompt section, adaptor list |
Merged into the planner system prompt (cached prefix). Includes edge-semantics knowledge (multiple edges into one job = one run per edge, not a merge/wait) and webhook response-config rules (never add proactively; warn before switching away from after_completion/custom codes). |
Mode intros and answering instructions (normal_mode_*, error_mode_*) |
Behavioural rules port to the planner prompt: ask for clarification rather than build when intent is unclear, don't edit unnecessarily, be brief, describe the workflow as a chart (users never see YAML). JSON output-format sections are obsolete. |
| Handover / subagent mode | Obsolete: there is no subagent to hand over from. (The job_chat direct route's handover is untouched.) |
| History YAML redaction ("previously generated YAML has been redacted from history") | Non-issue by construction: the planner's returned history holds only user text and final assistant text; YAML travels in attachments and tool state, never in history. |
Prompt migration is a real task, not a copy-paste: go through gen_project_prompts.yaml section by section and either port each rule into the planner prompt, encode it in a handler, or record it as deliberately dropped (e.g. all placeholder-handling rules).
Implementation steps
- Handlers and validation (
workflow_edit/): pure functions over the parsed YAML dict, full unit-test coverage. No planner changes yet. This is where correctness is won, deterministically and cheaply. - Tool schemas + planner dispatch: add the tools to
tool_definitions.py, dispatch in_execute_tool, add spinner/settled messages per tool ("Adding steps...", "Added "Fetch Patients""). RemoveCALL_WORKFLOW_AGENT_TOOLandcall_workflow_agent. Tighten the planner's job-code path to the same strict-keys contract:find_job_in_yamlcurrently fuzzy-matches the planner'sjob_key(case, hyphens vs underscores, display name) before stitching code; make it exact key or error-with-hint, since the planner always has the exact keys in context. URL-derived lookups (page names, the router'sjob_keyfor the job_chat direct route) keep the normalisation — they parse user URLs, not model output. Reviewmax_tool_calls(currently 10 in config.yaml): granular edits use more rounds than onecall_workflow_agentdid, though each round is far cheaper. - Prompt migration: planner system prompt gains the workflow-editing section per the table above. Beyond adding content, two existing rules invert or relax:
- "Never name an adaptor yourself — the workflow agent is the expert" inverts: adaptor choice is now the planner's job (
add_jobstakes an adaptor). The adaptor list (fromlatest_adaptors, as workflow_chat injects it today) moves into the planner system prompt. It's dynamic content in a cached prefix; it changes only when the adaptor cache refreshes, so the occasional cache bust is acceptable. - "Never call call_job_code_agent and call_workflow_agent in the same step" can relax:
_execute_tool_blocksalready runs non-job tools before job-code tools, and with deterministic structural tools that ordering guard is fully reliable. "Structure before code" stays as the ordering rule. - The "Trusting Subagent Responses" section drops its workflow-agent half; it now applies to the job code agent only.
- "Never name an adaptor yourself — the workflow agent is the expert" inverts: adaptor choice is now the planner's job (
- Router: remove the
workflow_agentdestination and its handover path, and rewrite the routing rules — the whole class of workflow-vs-planner distinctions ("add an empty step" goes direct, the[Steps contain job code]check,workflow_has_job_code) disappears. Two destinations remain: job_chat direct route and planner. Note the cost shift: simple workflow questions ("what time does the trigger run?") previously answered by Sonnet via the direct route are now answered by the Opus planner — usually in one round with no tool call, since the redacted structure is already in its context, but pricier per question. Acceptable at current volume; check Langfuse after cutover. - Payload: add
read_onlyanderrorsto the global_chat payload spec (see table above) and coordinate with Lightning so both modes survive the sunset of the direct workflow_chat endpoint. - Testing (below), then sunset: delete
services/workflow_chat/once Lightning's direct calls are gone.
Testing
This change affects output heavily and incremental edits bit us in job_chat, so the gate to merge is the test suite, not the implementation.
- Deterministic unit tests for every handler and validator: key collisions, unknown keys, rename cascades through edge keys and
cron_cursor_job_id, trigger replacement cascading its edge and preserving webhook sub-keys,condition_expressionrequired iffjs_expression, atomicity (one bad item in a batch writes nothing), name sanitisation. These are free to run and catch the bug class that hurt in job_chat. - Preservation invariants, now assertable exactly because the server owns the YAML: after any edit sequence, untouched job bodies and all IDs are byte-identical. Property-style tests over random edit sequences are cheap here and worth it.
- Acceptance suite (
services/testing/harness + gold workflows): a scenario matrix of real edit requests: add/remove/rename steps, retarget edges, change triggers, multi-step builds, "start over", ambiguous requests that should ask rather than edit. Judge both the final YAML and the number of tool rounds taken. - Comparison run: run the same scenario set against the old call_workflow_agent path and the new tools (Langfuse traces on both) and review diffs in output quality and latency before cutover.
Cutover is a straight switch once the acceptance results and timings look good; no live A/B and no dual code paths kept around.
What this buys us
Structural edits go from a 30-40s serial subagent round-trip to in-process mutations, and small edits drop from full-YAML-out to a ~100-token tool call. The time and complexity freed up is what lets us add the subagents and skills we actually need next, e.g. a proofreading/QA phase that reviews a finished workflow for coherence and quality, which slots into the same planner tool loop without touching this design.
Out of scope (later)
search_knowledgeover adaptors/docs/examples (replaces the prompt-embedded adaptor list; #530 section 6).- Proofreading/QA subagent or skill.
- User-defined skills (separate skills v0/v1 plan).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.