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?
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
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.
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.
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 is | Operation | Agent shape | Address |
|---|---|---|---|
| On a module/task list, nothing open | Bulk fan-out over N tasks; single-task creation with nothing open | Sub-agent inside playbook-editor's turn | Canvas coordinates (module_index, task_index) |
| In a specific task builder — embedded or standalone | Focused authoring, refinement, deletion | Top-level handoff to task-builder | Task 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
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.
B · Handoff, task-addressed
A routed peer domain: domain: task-builder, display name Subtask Builder (agents/task_builder/manifest.yaml). No tool signature carries a task coordinate or id — _task_emitter reads the bound target per emission and re-asserts task_id after the spread.
Always registered, outside the capability gate, so a flag-off vendor still gets a coherent turn: emit_suggest_replies, update_progress (deliberately reduced to title-only — the boundary refuses model-invented rows rather than the prompt asking it not to send them), and return_to_router.
Behind task_builder_enabled: add_step, update_step, remove_steps (plural, takes a list — one deletion intent is one card), the form tools, get_task_builder_context (no arguments), the template readers, plan_form_edits, and build_subtask.
Progress. Agent-owned, server-derived: StepProgressRows (agents/task_builder/progress_rows.py) is a Strands hook over the model-call and tool-call events. Row state is derived from the tree, not from what the model claims: a non-FORM step is done once it exists, a FORM step stays pending until its elementsMap is non-empty.
Delete confirmation. remove_steps is the one tool that asks first (agents/task_builder/approval.py). A BeforeToolCallEvent hook calls event.interrupt(...) with a card naming the targets and the cascade count. No card is raised when nothing would be lost — if every named step was minted this turn, there is nothing to confirm. A fingerprint over the plan is re-checked on resume, so a tree that moved under an open card refuses instead of deleting the wrong thing.
Target resolution (agents/task_builder/lifecycle.py) fails closed through an ordered ladder: capability off → no target → hydration failed → unsupported surface → archived (the task's own flag or an archived module or playbook above it) → OK. Its authorable set now admits DRAFT_PLAYBOOK alongside the two library surfaces.
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:
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.
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.
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 · _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.
2 · _redirect_task_builder_off_the_playbook_list — narrowed, not deleted
runtimes/copilot/main.py. A deterministic post-router guard. It rewrites active_domain to playbook-editor only when all three hold:
- the router picked
task-builder, and - the frontend sent
is_playbook_editor_route, and - no builder is open
With a builder open, task-builder keeps the turn. The guard stays deterministic off a flag the frontend already sends rather than moving the discriminator into the router prompt — on the exact ambiguity the guard exists because the router got it wrong. It lives in code because conditional domain advertising does not exist: the known-domain list is static per runtime.
⚠ This is what makes the linked-module write refusal reachable. task-builder now dispatches on the playbook-editor route, so opening a linked module's task in the embedded builder hits resolve_task_for_write's refusal — keeping the handoff path in agreement with the canvas path's _refuse_if_linked.
3 · resolveStepEventTarget — a widening, not a consolidation
app/ui-organization/stores/agentStepBridge.js. On the playbook editor route both addressings are permanently live: coordinate-addressed events from the sub-agent fan-out, task-addressed events from the handoff. A turn can be classified to the playbook editor while a builder is open — "add another task to this module" is a legitimate ask there. So the branch has to discriminate, and it discriminates on the address key of the event:
| Outcome | When | Applied by |
|---|---|---|
SHELL | On the editor route, coordinate-addressed | playbook.store.js |
TASK_BUILDER | On the editor route, task-addressed, builder mounted, task AI-authorable | taskBuilder.store.js |
DROP | Task-addressed but not mounted, or not authorable | nobody |
CONTRIBUTION | Any other route — the shell stands down | the registered page contribution |
isTaskAddressedStepEvent narrows rather than testing truthiness, so junk (false, NaN, {}) cannot re-point ownership while task_id: 0 stays addressed. A task-addressed event on that route is never shell-owned: playbook.store.js cannot resolve a task_id-only address, and handing it one triggers a whole-playbook PUT with an empty dirty set.
Affordances, and why they differ
| Affordance | Sub-agent (list) | Handoff (builder open) |
|---|---|---|
| Delete confirmation | None — structurally impossible | approval.py interrupt card |
| Progress rows | Orchestrator-minted t{m}_{t} | Agent-owned StepProgressRows |
| Own conversation | No — inside the orchestrator's turn | Yes |
emit_suggest_replies chips | On the orchestrator, not the sub-agent | Yes |
narrate | Yes, intercepted into row detail | Yes, on the build_subtask sub-agent — same sink |
| Writes | Additive only (_refuse_if_persisted) | Full CRUD |
| Delete tool | remove_step (singular) | remove_steps (list) |
| Linked-module refusal | _refuse_if_linked, module-global | resolve_task_for_write, only from inside a playbook |
| Cross-task read by id | Yes (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.
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 answerThe 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 context | Flush path |
|---|---|
PLAYBOOK | update_steps_from_draft |
LIBRARY_MODULE | update_library_steps |
LIBRARY_TASK | update_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 isAiAuthorableTask → isDraftPlaybookTask, 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/playbooks/step_builder.py— the sub-agentagents/playbooks/tools/step_tree.py— canvas target, additive gateagents/playbooks/tools/canvas.py—_refuse_if_linkedagents/task_builder/— the routed domainagents/task_builder/approval.py— the delete cardagents/task_builder/progress_rows.py— agent-owned rowsshared/tools/handoff.py—return_to_routershared/utils/ui_context.py—open_task_id,_FOCUS_FIELDSruntimes/copilot/main.py— deciders 1 and 2, the hop loop
stores/taskBuilder.store.js—agentViewContextstores/agentStepBridge.js— decider 3, mount counterstores/playbook.store.js— the canvas step pathpages/private/Private.vue—ui_contextassembly, event dispatchutils/constants.js— the flush tableapp/api/task/controllers/agent_task_builder_controller.py— builder contextapp/api/task/services/library_task_step_service.py— library apply gateapp/api/playbooks/services/task_step_draft_service.py— the strict gate
Related
- Flipping
ai-task-builder— what the gate actually changes, and the pre-flip audit - Playbook Builder Agent Flow — transport, sessions, SSE
- Form Builder Architecture — the FORM document the tools author
- Views Agent Architecture — the router and the peer domain model