Skip to content

Subtask Authoring — Two Paths, One Predicate

AI subtask authoring runs on two agent shapes, and which one serves an ask is decided by a single question: does the user have a task's subtask builder open?

Owner: MauricioFeature gate: ai-task-builderRuntime: copilot

📐 Scope — how a subtask (task step) gets authored by AI: which agent owns the turn, how the two paths differ in what they can offer, and how the runtime decides. The transport layer — sessions, SSE frames, the greenfield playbook generation trace — is Playbook Builder Agent Flow. What a FORM step's document is belongs to Form Builder Architecture. The router's hub-and-spoke shape in general is Views Agent Architecture.

TL;DR

Mode follows the screen

Two operations, never one

From a module/task list with nothing open, a subtask ask is a sub-agent call inside the playbook orchestrator's turn. Inside a specific task builder, it is a top-level handoff to the task-builder domain. The content on screen is what decides.

One predicate, three deciders

open_task_id(ui_context)

The frontend already publishes it. Three independent decisions read it: which UI bucket the turn is in, whether a task-builder pick survives, and which store an emitted step event lands in.

The affordances follow

A sub-agent cannot ask a question

The interrupt protocol keys off stop_reason on the top-level dispatched agent. The sub-agent runs inside a tool on a background worker thread, so it can never own a delete-confirmation card. That is why the two tiers exist, and why the list path is additive-only instead.

The two paths

User isOperationAgent shapeAddress
On a module/task list, nothing openBulk fan-out over N tasks; single-task creation with nothing openSub-agent inside playbook-editor's turnCanvas coordinates (module_index, task_index)
In a specific task builder — embedded or standaloneFocused authoring, refinement, deletionTop-level handoff to task-builderTask id, bound at target resolution

Nothing is "switching to the task builder" in the first row, so there is nothing for a top-level agent to be top-level about: no single task to plan rows for, no visible canvas to confirm a delete against. Handing off there would also cost the fan-out, because a handoff transfers control to exactly one agent for the turn.

Both paths are permanent. They emit byte-identical event names and payloads, so one frontend handler serves both — the only difference is the address key.

A
Sub-agent
canvas-addressed
B
Handoff
task-addressed

A · Sub-agent, canvas-addressed

The playbook orchestrator's build_task_steps tool (agents/playbooks/step_builder.py) runs a one-shot Strands agent per target task, under a 300-second timeout. It is registered only when task_builder_enabled (shared/feature_codes.py) sees ai-task-builder on the vendor — and the same flag gates the prompt block that says so, because registration and prompt must never disagree.

Target. The canvas target, agents/playbooks/tools/step_tree.py. Its assignment kwarg has no "unscoped" value, which is what makes canvas-wide write reach unreachable rather than merely discouraged. _canvas_emitter re-asserts module_index / task_index after the payload spread, so a write cannot re-point itself.

Refusals, in resolution order: not editable → unassigned → invalid index → linked module (_refuse_if_linked). Then per-write, the additive-only gate _refuse_if_persisted. Reads deliberately keep canvas-wide reach — _read_canvas_task does index validation only, so cross-task lookups still work.

Fan-out. Parallel under ephemeral.step_worker_semaphore, capped at 2 (agents/playbooks/state.py) — a separate semaphore from the module-worker pool so module and step passes cannot starve each other.

Progress. Orchestrator-minted rows, id t{module_index}_{task_index}. The sub-agent's own narrate calls are intercepted by narrating_event_sink (shared/steps/narration.py): a narration event flips the row's detail and is not forwarded to the wire, every other event flips the row and forwards. The frontend canvas stores have no handler for the narration event — it is internal.

Result. _on_success drains the per-assignment refusal ledger and reports "{built}, {n} left unchanged", so a refused write is visible to the model and the user rather than silently dropped.

The discriminator

The predicate already existed and was already on the wire. taskBuilderStore.agentViewContext (app/ui-organization/stores/taskBuilder.store.js) is non-null only when the builder is mounted and a task is loaded, and it is spread into ui_context unconditionally by Private.vue's getPageContext() and by the /tasks/:id page contribution.

Two details carry more weight than they look like they do:

🔍 The mount gate, not populated state

isTaskBuilderMounted() counts mounts in a module-scoped ref (stores/agentStepBridge.js). It has to be the mount, because TaskEditor hydrates the store for every tab and Private.vue spreads the getter on every route — so "the store has a task" is true in places where no builder is on screen.

👁 Rendering is what makes the signal exist

current_task_id was published by both builder surfaces while nothing rendered it into the router's context block, so the router could not tell a builder was open and never chose that domain. _FOCUS_FIELDS in shared/utils/ui_context.py renders it as subtask builder open for task …. An unrendered ui_context field is invisible to the router.

Server-side, open_task_id (shared/utils/ui_context.py) is the single collapse point: it stringifies, strips, and folds 0, False, "" and " " to None. That matters because a guard reading one of those as open would suppress the redirect while lifecycle refused the same turn as NO_TARGET — the turn would dead-end in a refusal instead of being served by anyone.

mermaid
flowchart TD
    FE["taskBuilderStore.agentViewContext<br/>mounted AND task loaded"] --> UC["ui_context.current_task_id"]
    UC --> OPEN["open_task_id in shared/utils/ui_context.py<br/>falsy values collapse to None"]

    OPEN --> D1["Decider 1 · _compute_ui_bucket<br/>adds the +builder suffix"]
    OPEN --> D2["Decider 2 · _redirect_task_builder_off_the_playbook_list<br/>redirect only while NO builder is open"]
    OPEN --> D3["Decider 3 · resolveStepEventTarget<br/>which store applies the step event"]

    D1 --> UNPIN["bucket changed, so active_domain unpins<br/>router classifies fresh at hop 0"]
    D2 --> DOM{"Which agent owns the turn"}
    UNPIN --> DOM
    DOM -->|"builder open, subtask ask"| TB["task-builder"]
    DOM -->|"no builder, or a chrome ask"| PE["playbook-editor"]

    D3 --> ADDR{"Address key on the event"}
    ADDR -->|"task_id"| TBS["taskBuilder.store.js"]
    ADDR -->|"module_index and task_index"| PBS["playbook.store.js"]

The three deciders

1
UI bucket
runtime
2
Redirect guard
runtime
3
Event target
frontend

1 · _compute_ui_bucket — the +builder suffix

runtimes/copilot/main.py. Every location bucket gains a +builder suffix while a subtask builder is on screen. Without it, the highest-traffic surface pays a tax nobody would have costed: the playbook editor's embedded builder opens and closes with no route change, no playbook_id change and no is_draft_active change, so page identity alone cannot see it happen. The bucket would be unchanged, active_domain would stay pinned, and every alternation between a chrome ask and a subtask ask in that one page would burn a full same-turn handoff — visible stall, wasted tokens.

With it, the bucket changes, the pin drops, and the router classifies fresh at hop 0 — the same cost as navigating between pages, which is to say none.

It keys on presence, not the task id, so moving between two builders does not spend a re-classification.

Affordances, and why they differ

AffordanceSub-agent (list)Handoff (builder open)
Delete confirmationNone — structurally impossibleapproval.py interrupt card
Progress rowsOrchestrator-minted t{m}_{t}Agent-owned StepProgressRows
Own conversationNo — inside the orchestrator's turnYes
emit_suggest_replies chipsOn the orchestrator, not the sub-agentYes
narrateYes, intercepted into row detailYes, on the build_subtask sub-agent — same sink
WritesAdditive only (_refuse_if_persisted)Full CRUD
Delete toolremove_step (singular)remove_steps (list)
Linked-module refusal_refuse_if_linked, module-globalresolve_task_for_write, only from inside a playbook
Cross-task read by idYes (tools/task_lookup.py)No — bound to the open task

Why the missing delete card is not a gap. The canvas path's _refuse_if_persisted refuses every write to an already-saved subtask, at all four write sites. So the set that path can still remove is exactly the set the task-addressed path removes without asking anyway — steps minted in the same turn, which have nothing to lose. The absent card is unreachable by construction, not merely unlikely.

add_step deliberately has no such gate: nesting under a saved branchable step appends a branch bucket rather than rewriting anything, and "add a follow-up under the No branch" is an additive ask like any other.

Two UX tiers, deliberately

The same verb has two tiers, permanently. From a list: no delete card, no separate conversation, orchestrator-minted rows. With a builder open: all three.

This is an accepted engineering call, not an oversight and not a product escalation. The delete card is meaningless without a canvas to confirm against, the list path's behaviour is unchanged from before either tier existed, and the additive constraint above is what makes the asymmetry safe rather than merely tolerated. Recorded here so it is findable: a reader who meets the inconsistency should find it named, with its reason.

Hand-back — one direction

Composite asks mostly need no handoff: "add a module and build out its tasks' subtasks" is the orchestrator plus its own sub-agent, one domain. The direction that does need wiring is the reverse. While a builder is open, a chrome-shaped ask — rename this task, add another task to this module, build subtasks for every task in this module — reaches task-builder, which must hand back rather than refuse.

create_return_to_router_tool (shared/tools/handoff.py) is the single body: it sets active_domain = "router" and clears handoff_context. Both, in one place — a domain that flips one and not the other strands the turn on a stale handoff synthesis.

mermaid
sequenceDiagram
    participant U as User
    participant R as Router (Haiku)
    participant TB as task-builder
    participant PE as playbook-editor

    Note over U: A builder is open. Ask is "rename this task"
    U->>R: message
    R->>TB: classified to task-builder
    Note over TB: Viewed from, inside a playbook<br/>so hand back rather than refuse
    TB->>R: return_to_router
    Note over R: declined = task-builder<br/>same turn, hop 1 of 3
    R->>PE: re-classified
    PE-->>U: renames the task, one answer

The hop budget is per message. MAX_HANDOFF_HOPS = 3 and the declined set are function-locals in _dispatch_chat — nothing is persisted on agent_state, and the frontend does no handoff accounting at all. A normal turn spends zero hops. Answering a delete card costs zero too: resuming an interrupt calls the domain turn directly and bypasses the loop. Navigation is not a handoff either — a route change flips the UI bucket, which unpins active_domain so the router re-classifies for free.

From the library, task-builder does not hand back. Nothing else owns a library task's or a library module's own settings, so handing back reaches nobody: the user waits out a turn and gets a worse answer. It says plainly what it does not do and names where the change is made instead. The Viewed from: line that agents/task_builder/lifecycle.py mints from the resolved target — not re-derived from the payload — is what decides which arm applies.

The write seam

The agent emits events and never PUTs. taskBuilderStore selects the URL from the resolved context at turn-end flush, from AGENT_FLUSH_PATH_BY_CONTEXT (app/ui-organization/utils/constants.js):

Task contextFlush path
PLAYBOOKupdate_steps_from_draft
LIBRARY_MODULEupdate_library_steps
LIBRARY_TASKupdate_library_steps

Two endpoints rather than one, because the playbook path carries an edit lock, the playbook edit-metadata touch, and the pending-activity drain that the publish modal rolls up. Response bodies are identical, so everything downstream of the PUT is shared.

AI_AUTHORABLE_TASK_CONTEXTS is derived from that table so a surface cannot be routable without a write target — but PLAYBOOK is subtracted from it rather than absent. It is admitted one rung earlier, by isAiAuthorableTaskisDraftPlaybookTask, which requires the loaded playbook to be the task's own and to be a draft. Published playbook tasks that slip past it are refused server-side.

Anchor files

🧠 Agents
  • agents/playbooks/step_builder.py — the sub-agent
  • agents/playbooks/tools/step_tree.py — canvas target, additive gate
  • agents/playbooks/tools/canvas.py_refuse_if_linked
  • agents/task_builder/ — the routed domain
  • agents/task_builder/approval.py — the delete card
  • agents/task_builder/progress_rows.py — agent-owned rows
  • shared/tools/handoff.pyreturn_to_router
  • shared/utils/ui_context.pyopen_task_id, _FOCUS_FIELDS
  • runtimes/copilot/main.py — deciders 1 and 2, the hop loop
🖥 Frontend & backend
  • stores/taskBuilder.store.jsagentViewContext
  • stores/agentStepBridge.js — decider 3, mount counter
  • stores/playbook.store.js — the canvas step path
  • pages/private/Private.vueui_context assembly, event dispatch
  • utils/constants.js — the flush table
  • app/api/task/controllers/agent_task_builder_controller.py — builder context
  • app/api/task/services/library_task_step_service.py — library apply gate
  • app/api/playbooks/services/task_step_draft_service.py — the strict gate

Internal documentation — gated behind Cloudflare Access.