From badea39bd0e78f138f4defc8b15062b0f9602745 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Wed, 19 Aug 2026 11:47:28 +0400 Subject: [PATCH 1/9] doc: add design doc on anonymous subagents --- docs/designs/anonymous_subagents.md | 534 ++++++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 docs/designs/anonymous_subagents.md diff --git a/docs/designs/anonymous_subagents.md b/docs/designs/anonymous_subagents.md new file mode 100644 index 00000000..52e784c5 --- /dev/null +++ b/docs/designs/anonymous_subagents.md @@ -0,0 +1,534 @@ +# Design: Anonymous Subagents + +- **Status:** Approved +- **Dependencies:** None + +## Problem Statement + +Multi-step work in a QuickApp usually completes — but it gets more expensive and worse the longer it runs. +Every step's raw material stays in the conversation: fetched pages, SQL dumps, file contents, retries that +failed before one worked. That history is resent on every subsequent LLM call, so token spend grows +superlinearly with task length, and the model's attention is increasingly spent on material that stopped +being relevant many steps ago. The app builder sees a bill that scales badly and answers that get vaguer and +driftier on long tasks; nothing surfaces *why*, and there is no setting to turn. Context degradation is +invisible to a non-technical user — it looks like the model is just not very good. + +Structurally, a QuickApp is one orchestrator loop: one system prompt, one model, one tool set, one context +window. There is nowhere to put work so it doesn't accumulate. Every sub-task also carries the full tool +catalogue on the app's primary deployment regardless of what it needs, and the only delegation primitive we +ship — the DIAL deployment tool — targets an already-configured deployment, so every helper agent must be +foreseen, built, and deployed in advance. Other agentic frameworks address this with a spawn primitive: an +ephemeral, caller-configured worker that does the work in its own context and returns only the result. We +have no equivalent. + +## Concepts + +**Subagent** — an agent instance the coordinator invokes to carry out one scoped task. It runs its own +orchestrator loop with its own conversation; the coordinator hands over a task description and gets back a +single result. Tool calls, fetched documents, and retries live and die inside the subagent. + +**Anonymous** — the subagent is not a deployment. It has no id anyone can call, nothing registered in DIAL +Core, and no state that survives the call. It exists only for the duration of one spawn. This is the +contrast with today's DIAL deployment tool, which calls a separate app that was built and deployed in +advance. + +**Coordinator** — the QuickApp that owns the user's conversation. It decides how to split the work, spawns +subagents, integrates their results, and is the only party that talks to the user. A role, not a new +component: any QuickApp becomes one once its manifest declares subagents. + +### Hub-and-spoke + +The coordinator is the hub; each spawned subagent is a spoke. All communication is radial: + +- A spoke's only input is the task the hub writes for it. It does not see the user's conversation, the hub's + history, or any other spoke. +- A spoke's only output is one result returned to the hub. It does not stream to the user, and the user + never sees a spoke's intermediate steps. +- Spokes never talk to each other. Two subagents that need the same fact each derive it; the hub is the only + place results combine. +- Spokes are stateless across spawns. Nothing carries over — spawning the same subagent twice yields two + unrelated runs. + +The hub holds the only durable state. That is the point: the token cost and attention cost of a sub-task +stay in the spoke and are dropped when it returns. + +```mermaid +flowchart LR + user([User]) + coord[Coordinator
hub] + s1[Subagent A
spoke] + s2[Subagent B
spoke] + s3[Subagent C
spoke] + + user <-->|conversation| coord + coord -->|task| s1 + coord -->|task| s2 + coord -->|task| s3 + s1 -->|result| coord + s2 -->|result| coord + s3 -->|result| coord + + s1 -.->|no user conversation| user +``` + +Every arrow is radial: tasks flow hub→spoke, results flow spoke→hub, and only the hub talks to the user. +There are no spoke-to-spoke edges, and the dashed edge is the one that does *not* exist — a spoke never +reaches the user. + +### Declaring a subagent + +The app builder declares subagent *types* in the manifest, the same shape Claude Code uses for +`.claude/agents`. The LLM does not invent a subagent; it chooses a declared one and writes its task. + +| Field | Meaning | +| --- | --- | +| `name` | Identifier the coordinator uses to select this type. | +| `description` | *When to use this subagent.* Surfaced to the coordinator's LLM — this is the routing mechanism. | +| `system_prompt` | The spoke's instructions. Replaces the app's system prompt; it is not appended to it. | +| `tool_sets` | Names of the app's tool sets this spoke may use. Omitted = inherit all. | +| `deployment_id` | DIAL deployment (model) for this spoke. Omitted = inherit the coordinator's. | +| `max_iterations` | The spoke's own budget, independent of the coordinator's. | + +At runtime the coordinator gets one tool — `spawn_subagent(subagent_type, task)` — whose `subagent_type` +enumerates the declared types. It returns the spoke's final message as a string. + +**Why the allowlist is toolset-level, not tool-level.** An MCP toolset has no static list of tools — they +are discovered when the session connects, long after the manifest is compiled — so there is nothing to +match a tool-name allowlist against at declaration time. Narrowing by toolset is the finest granularity +available uniformly across all four tool types. A tool-level allowlist would have to be applied after +initialization, which is a different (and larger) mechanism; see *Out of Scope*. + +## Design Goals + +The design succeeds when all of the following hold. Each is independently verifiable, and several already +have spike tests in `src/tests/unit_tests/subagent_tooling_tests/`. + +- **G1 — Declared-only routing.** A coordinator declares its subagent types in the manifest; at runtime the + LLM selects one *declared* type per spawn and cannot name a type that was not declared. *(Verifiable: the + `spawn_subagent` tool exposes `subagent_type` as an enum of declared names; an unknown value is rejected — + see UC-4.)* +- **G2 — Context confinement.** A subagent's intermediate work — its tool calls, fetched documents, retries, + and per-turn LLM messages — never enters the coordinator's context window. The coordinator receives only + the subagent's final answer as a string. *(Verifiable: after a spawn, none of the spoke's messages appear + in the coordinator's `_RequestContext.messages`.)* +- **G3 — Non-accumulating cost.** The token and attention cost of a sub-task is spent inside the spoke and + dropped when it returns; it is not resent on the coordinator's subsequent LLM calls. +- **G4 — No advance registration.** Spawning a subagent requires no separate DIAL deployment and no prior + registration in DIAL Core. The spoke's manifest is compiled from the coordinator's own manifest at call + time. +- **G5 — Independent budget and scope.** A spoke runs with its own system prompt, its own model + (`deployment_id`), its own `max_iterations`, and a tool set narrowed to the declared allowlist — each + independent of the coordinator's. +- **G6 — Depth capped at 1.** A spoke cannot spawn. The compiled subagent manifest has `subagents = None`. + *(Verifiable: `compile_subagent_manifest` clears `subagents`.)* +- **G7 — Isolated parallelism.** A coordinator may issue several spawns that run concurrently; each runs in + its own request scope with no shared state and no cross-talk. *(Verifiable: + `test_parallel_spawns_do_not_share_scope`.)* + +--- + +## Use Cases + +### UC-1: Fan-out to parallel subagents + +**Trigger:** The user asks the coordinator to compare the current weather in three cities. The coordinator's +system prompt instructs it to delegate each independent piece. +**Behavior:** In a single turn the LLM issues three `spawn_subagent` calls, each selecting `weather_scout` +with a task naming one city. The three spokes run concurrently, each in its own scope with only the location +and weather tool sets; each resolves coordinates, fetches weather, and returns one line. +**Outcome:** The coordinator receives three short answer strings, ranks the cities, and replies. The user +sees the coordinator's stages and final ranking — never the spokes' tool calls. The coordinator's context +never held the intermediate location/weather traffic. *(Goals G2, G7.)* + +### UC-2: Research delegation with a narrowed tool set + +**Trigger:** The user asks a question that needs current external information. +**Behavior:** The coordinator spawns `web_researcher`, whose declared `tool_sets` is `["Web search toolset"]` +— so the spoke can search the web but cannot reach the coordinator's other tools. The spoke runs multiple +searches inside its own loop. +**Outcome:** The coordinator gets back a lead answer plus a few supporting bullets. The (potentially many) +search results and transcripts stayed inside the spoke and were dropped when it returned; only the distilled +answer entered the coordinator's context. *(Goals G2, G3, G5.)* + +### UC-3: Compute delegation over caller-supplied inputs + +**Trigger:** Having gathered five temperatures (via UC-1 spokes), the coordinator needs statistics. +**Behavior:** The coordinator spawns `analyst` (tool set: the Python interpreter only) and puts the five +numbers *in the task text* — the spoke sees nothing of the conversation, so the task must be self-contained. +The spoke runs Python and returns the result. +**Outcome:** The coordinator reports the mean, spread, and outlier. Illustrates the hub-and-spoke rule that a +spoke's only input is the task the hub writes for it. + +### UC-4: Error paths + +Three cases the design must handle: + +- **Undeclared subagent type.** The LLM calls `spawn_subagent` with a `subagent_type` not in the enum. + `_SubagentTool` raises `InvalidToolCallParameterException` naming the available types; this returns to the + coordinator's LLM as a tool error it can correct. No spoke runs. +- **Dangling tool-set reference (build time).** A declared subagent names a tool set the app does not define. + `SubagentToolingModule` contributes a `ToolInitializationException` at initialization, so the builder sees + the bad reference *by name* before any spawn is attempted. +- **Allowlist resolves to nothing (spawn time).** If a non-empty allowlist resolves to zero tool sets, + `compile_subagent_manifest` raises `SubagentToolSetResolutionError` and fails the spawn rather than running + a tool-less spoke that would confabulate an answer from the task text. An *explicitly empty* allowlist + (`[]`) is a deliberate no-tools subagent and is allowed. + +--- + +## Proposed Design + +Two implementations are on the table. They share the whole user-facing surface described in *Concepts* — +the builder declares subagent types in the manifest, the coordinator's LLM calls one `spawn_subagent` tool — +and differ only in **where the spoke runs**. + +### Shared: a subagent is an `ApplicationConfig` + +Both approaches compile a declared subagent type plus the coordinator's task into a full QuickApp manifest: + +| Manifest field | Source | +|---|---| +| `orchestrator.deployment.deployment_id` | subagent `deployment_id`, else inherited from the coordinator | +| `orchestrator.system_prompt` | subagent `system_prompt` (replaces, never appends) | +| `orchestrator.max_iterations` | subagent `max_iterations`, else inherited | +| `tool_sets` | the coordinator's tool sets, narrowed to the declared allowlist | +| `tool_defaults` | **inherited** from the coordinator (deep-copied) | +| `contexts` | **inherited** from the coordinator (deep-copied) | +| `skills` | **inherited** from the coordinator | +| `hooks` | **inherited** from the coordinator | +| `features` | **inherited** from the coordinator | +| `subagents` | always `None` — depth 1, a spoke cannot spawn | +| `starters`, `conversation_starters` | always **cleared** — coordinator↔user UI concerns; a spoke has no user conversation to seed | + +**Inherited vs. cleared, resolved.** `compile_subagent_manifest` deep-copies the coordinator's manifest, then +overrides only what the subagent declares and clears exactly three fields: `subagents` (the depth cap), and +`starters` / `conversation_starters` (both are coordinator-facing conversation UI, meaningless to a spoke +that never talks to the user). Everything else — `contexts`, `skills`, `hooks`, and `features` — is inherited +wholesale. This is a deliberate default: a spoke sees the same attached files, skill library, lifecycle +hooks, and feature toggles as the coordinator. Per-subagent narrowing of `contexts` / `skills` / `hooks` is a +larger, separate mechanism and is deferred (see *Out of Scope*). + +Two consequences worth naming up front. First, the tool allowlist needs no new filtering machinery in either +approach: it is expressed by narrowing `tool_sets` in the compiled manifest. Second, running a spoke is +exactly "run one QuickApp request against this manifest" — so the two approaches are two answers to *where +that request executes*, not two different feature designs. + +### Approach A — in-process spawn + +**What.** The spoke runs inside the coordinator's Python process as an `asyncio` task with its own DI +request scope. + +**Owner.** A new `subagent_tooling/` module: `_SubagentTool` (a `StagedBaseTool`), `SubagentSpawner`, and a +subagent output sink. + +**Semantics.** + +1. The LLM calls `spawn_subagent(subagent_type, task)`. `ToolExecutor` dispatches to `_SubagentTool` like any + other tool, and a stage opens on the coordinator's choice. +2. `SubagentSpawner` compiles the manifest and enters a fresh scope via `RequestScopeFactory.create_scope()` + inside an `asyncio.Task`. The scope key is a `ContextVar` and + `asyncio` copies context per task, so the child gets its own `_RequestContext`, `StateHolder`, tool + instances, and `PerformanceTimer` — every `request_scope` binding — with no leakage in either direction. +3. The child `_RequestContext` is populated **directly**: `api_key`, `bearer`, and forwarded headers copied + from the parent; `application_config` set to the compiled manifest. No SDK `Request` is synthesized — the + spawner already holds these as typed values, so it sets them on the context rather than round-tripping + them through `_RequestContextSetup.setup_context`, whose job is to *extract* them from an HTTP request. +4. `invoke_initializers(injector, InitializerType.completion)` runs in the child scope — the spoke builds + its own tools from its own manifest. This step is **mandatory**: the message-transformer chain reads + `OrchestratorCapabilities`, which only exists once `_OrchestratorDeploymentInitializer.initialize()` has + run. A spoke can neither skip initialization nor borrow the coordinator's. +5. `_RequestContextSetup.setup_messages([task])` runs **after** initializers (the transformer chain needs the + feature contexts populated during initialization), setting the task as the spoke's sole user message. +6. `injector.get(Orchestrator)` → `await invoke()`. +7. The final assistant message becomes `ToolCallResult.content`. The child scope exits, + `RequestAsyncCloseRegistry` closes its MCP sessions, and the rest is garbage. + +**Current state — spike vs. target.** Steps 2–7 are built and validated as a spike in +`src/quickapp/subagent_tooling/` (`SubagentSpawner`), including the scope-isolation claim in step 2 for +parallel spawns. The spike runs the **unmodified** orchestrator by handing the child scope a throwaway +`Choice` (`_headless_choice()`, marked *SPIKE ONLY*) whose chunks drain into a queue nobody reads. That +stand-in is the interim, not the shipping design: what the spike proves is scope isolation and manifest +compilation; what it defers is the output sink. Before the feature ships, the orchestrator must stop writing +to `Choice` directly — the **output-sink abstraction** below is the required production change, and it +removes `_headless_choice()`. + +**Change.** + +- **Output-sink abstraction (the largest change, still to build).** `Orchestrator` writes to `Choice` + directly — `set_state`, `add_attachment`, `create_function_tool_call`, and as the stream `destination` + (`orchestrator.py:143,279,290,313`). A spoke has no user `Choice`. Introduce a sink interface with two + implementations: today's choice-backed one, and a subagent one that buffers content, forwards attachments + to the parent tool result, drops `set_state` (spokes are stateless), and rejects external tool calls (a + spoke cannot surface client-side tool calls to a user it has no channel to). +- `AppModule.__provide_stage` derives `Stage` from `Choice` (`app_module.py:96`); the child scope must bind + the spoke's own stage instead. +- **Message and manifest setup reuse the existing lifecycle, no shared extraction required.** The spawner + mirrors the sequence `_QuickAppCompletion.chat_completion` runs (setup → initializers → messages → + orchestrator), but populates `_RequestContext` directly and calls `setup_messages` rather than adding a + fake-`Request` entry point to `setup_context`. *(Decision: direct population, not a synthetic request — + see Semantics step 3. This supersedes the earlier "non-HTTP entry point" sketch.)* + +**Costs and risks.** + +- Initializers re-run per spawn (see semantics step 4 — this is not optional): MCP sessions reconnect, REST + clients rebuild, DIAL app resolution repeats. Deployment metadata is the exception — it is served from + `OrchestratorDeploymentCacheService`, a **singleton** (`agent_module.py:115`), so that lookup is cached + across scopes and costs nothing after the first spawn. The per-spawn cost is therefore connection setup, + not metadata resolution. Shared with Approach B, but only A can mitigate it further by reusing selected + parent tool instances. +- **Timeouts are out of scope for the initial ship.** `asyncio.wait_for` around the spawn is the intended + mechanism, but enforcing it (and surfacing a clean timeout error to the coordinator) is deferred — see + *Out of Scope*. Until then a runaway spoke is bounded only by its own `max_iterations`. +- No process isolation. A runaway spoke consumes the coordinator's process; there is no HTTP layer to fall + back on for cancellation. +- Spokes contend with the coordinator for the event loop and for memory, inside one replica. +- Scope leakage is a silent-correctness hazard: any dependency accidentally resolved against the parent + scope from inside a child task is cross-contamination that tests will not obviously catch. + +### Approach B — out-of-process spawn + +**What.** The spoke is a real QuickApp chat-completion request against a deployment, configured entirely by +the coordinator at call time. + +**Owner.** `subagent_tooling/` on the caller side; `_RequestContextSetup` on the callee side. + +**Semantics.** + +1. The LLM calls `spawn_subagent(subagent_type, task)` — identical surface to Approach A. +2. `SubagentTool` compiles the manifest and delegates to the existing `DialCompletionService`, targeting the + subagent deployment with the task as the user message and the manifest in the request body. +3. That request reaches a QuickApp instance and runs the ordinary lifecycle, with one difference: + `_RequestContextSetup.setup_context` merges the injected manifest over the one resolved from application + properties. +4. The spoke streams back. The coordinator's stream handler renders it into the tool stage — this already + works for deployment tools — and the final content becomes the tool result. + +**The manifest channel.** Two candidates: + +- **`custom_fields.configuration` (request body)** — DIAL's standard request-time config channel, already + modelled in `DialDeploymentParameters.custom_fields` (`config/dial_deployment.py:49`). Requires the + subagent deployment to publish a configuration schema. *Preferred.* +- **`X-DIAL-Application-Properties` (header)** — the SDK already honors this as a full manifest override + (`aidial_sdk/deployment/from_request_mixin.py`), so the callee needs no change at all. But QuickApp + opts out of Core injecting properties into sub-calls (`config/application.py:247`), and whether Core + forwards a caller-supplied value is a Core policy question outside our control. + +**Which deployment?** Either a dedicated "subagent runner" QuickApp deployment with a trivial manifest, or +the coordinator's own deployment id (self-call). Self-call needs no extra deployment but requires a +recursion guard and depends on Core permitting self-routing. + +**Change.** + +- `ApplicationConfig` gains `subagents` (shared with A). +- New `SubagentTool` that compiles a manifest and delegates to `DialCompletionService`. +- `_RequestContextSetup` merges a request-supplied manifest over the resolved one. **This is the trust + boundary** — see below. +- The runner deployment publishes a configuration schema via `configuration_support/` if the body channel is + used. + +**The trust boundary.** In our own flow the manifest is server-authored: the builder declares the subagent +types, and the LLM only picks a type and writes a task string. But the callee cannot tell the difference. +Once a deployment merges caller-supplied manifests, anyone holding an API key can POST an arbitrary manifest +— naming any deployment and any tool — and have it executed under their own key. This needs the same +two-tier gate shape used for external fetch: an admin env switch plus a per-app feature flag. Following the +external-fetch naming (`EXTERNAL_URL_FETCH_ENABLED` / `features.external_url_fetch.enabled`), the proposed +fields are an admin switch `SUBAGENT_MANIFEST_INJECTION_ENABLED` and a per-app +`features.subagents.accept_injected_manifest`, with the admin switch as a hard cap. These fields exist only +for Approach B (Approach A never exposes an injection endpoint) and are proposals to be finalized if/when B +is built. + +**Costs and risks.** + +- Extra HTTP hop and a full application bootstrap per spawn. +- Config injection is a genuine new attack surface (above). +- Not a pure code change: it alters how QuickApps is deployed and depends on Core routing behavior. +- Debuggability — a spoke failure is a different request's stack trace; correlation needs a threaded trace id. + +**Gains.** + +- Process isolation. A spoke that hangs or OOMs does not take the coordinator down, and HTTP timeouts apply + for free. +- Horizontal scale: spokes are load-balanced across replicas like any other request. +- **No orchestrator changes at all.** The spoke has a real `Choice`, real streaming, and real state because + it is a real request — Approach A's largest change simply does not exist here. +- Per-spawn cost and usage are already visible to Core as an ordinary deployment call. + +### Comparison + +| Dimension | A — in-process | B — out-of-process | +|---|---|---| +| Orchestrator changes | Output-sink abstraction required | None | +| New code | Spawner + scope plumbing + sink | Spawn tool + manifest merge | +| Deployment/ops change | None | New deployment (or self-call) + Core policy | +| Isolation | None — shares process, loop, memory | Full — separate request, separate replica | +| Scaling | Bounded by one replica | Load-balanced like any request | +| Timeouts / cancellation | Ours to build (deferred initial ship) | HTTP layer, free | +| Latency per spawn | Tool init only | Tool init + HTTP hop + app bootstrap | +| Security surface | None new — manifest never leaves the process | Caller-supplied manifest execution, needs gating | +| Observability | In-process; parent's perf timer can nest | Separate request; needs trace correlation | +| Parallel spawns | `asyncio.gather` (validated) | Concurrent HTTP calls | +| Tool init cost per spawn | Connection setup only — deployment metadata is singleton-cached; mitigable further by reusing parent tools | Connection setup, not mitigable; deployment metadata cached per replica | + +**Recommendation: build A first, keep B as a swappable backend.** A's cost is an internal refactor we +control and arguably want anyway — decoupling `Orchestrator` from `Choice` is the change that makes an +orchestrator run anywhere. B's cost is an externally visible endpoint that executes caller-supplied +manifests plus a deployment-topology change, which is a larger commitment to make before the feature has +proven itself. B is the better long-term answer for scale and isolation, and switching is not a rewrite: +manifest compilation, the `subagents` config, and the `spawn_subagent` tool surface are identical in both, so +only the execution backend behind `SubagentSpawner` changes. + +--- + +## Secondary Fixes + +- **`Stage` decoupled from `Choice`.** `AppModule.__provide_stage` builds a `Stage` from the request `Choice` + (`app_module.py:96`). When the output-sink abstraction lands, the child scope must bind a spoke-owned stage + that targets the sink rather than a user choice. This is a prerequisite of the sink change, not an + independent fix, but it is the concrete DI seam that has to move. +- **Build-time validation of `tool_sets` references (already implemented).** `SubagentToolingModule` + contributes an `InitializationException` for every subagent `tool_sets` entry that names a nonexistent app + tool set, so a typo surfaces as a named error at app initialization rather than as a silently tool-less + spoke at spawn time. This falls directly out of the manifest-compilation design and is guarded by + `test_dangling_tool_set_reference_is_reported_at_initialization`. + +--- + +## Out of Scope + +Items considered but intentionally deferred, each with the reason and what a future pass would need. + +### Rich error propagation from a failed spoke + +A spoke that errors — exhausts `max_iterations`, or its orchestrator raises — currently surfaces to the +coordinator as an ordinary tool-call failure or an empty result, not a typed taxonomy that distinguishes "no +answer", "timed out", and "tool failure". A structured error contract is deferred until DIAL settles the +shared error types it would build on; until then the coordinator sees a plain tool error and can retry or +reword the task. + +### Per-spawn timeout / cancellation + +`asyncio.wait_for` around the spawn is the intended enforcement point, but it is not in the initial Approach A +ship. A spoke is currently bounded only by its own `max_iterations`. Addressing it needs a decision on the +timeout source (per-app config vs. an env default) and on the coordinator-facing error a timeout produces. + +### Per-subagent `contexts` / `skills` / `hooks` + +A spoke inherits all three wholesale from the coordinator (see *Proposed Design*). Letting a subagent +declaration override or narrow them — e.g. a spoke that must *not* see the coordinator's attachments — +requires a per-field merge/override policy and new schema surface on `SubagentConfig`, and is deferred. + +### Tool-level allowlists + +A subagent narrows its tools by toolset, not by individual tool name. Deferred because MCP tools are +discovered when the session connects, so a tool-name allowlist cannot be resolved at manifest-compile time +the way a toolset allowlist can. Addressing it means filtering `list[StagedBaseTool]` *after* initialization +— a post-init filter in the child scope rather than a manifest transformation — which also raises the +question of what to do when a named tool turns out not to exist on the connected server. + +--- + +## Configuration / Usage Examples + +**Preview gating.** `subagents` is a `PreviewField`: it is silently nullified during config validation unless +the QuickApps backend runs with `ENABLE_PREVIEW_FEATURES=true`. An app that declares subagents while preview +is off behaves exactly as if the field were absent — no `spawn_subagent` tool is offered. + +**Minimal coordinator manifest** with one subagent type: + +```json +{ + "orchestrator": { + "deployment": { "deployment_id": "gpt-4.1-2025-04-14" }, + "system_prompt": { "type": "custom", "content": "You coordinate; delegate research to subagents.", "variables": {} }, + "max_iterations": 20 + }, + "contexts": [], + "tool_sets": [ + { + "name": "Web search toolset", + "type": "dial-deployment", + "tools": [ { "type": "predefined-tool", "template_name": "web_search" } ] + } + ], + "subagents": [ + { + "name": "web_researcher", + "description": "Researches a question on the web and reports what it found.", + "system_prompt": "You research questions using web search. Reply with the answer and at most five supporting bullets.", + "tool_sets": ["Web search toolset"], + "max_iterations": 12 + } + ] +} +``` + +`tool_sets` on a subagent matches app tool sets by their `name` (the resolved name, e.g. `"Web search +toolset"`), not by template id. + +**The spawn round-trip.** The coordinator's LLM calls the generated tool: + +```json +{ + "name": "spawn_subagent", + "arguments": { + "subagent_type": "web_researcher", + "task": "What problem does the Model Context Protocol solve, and what are its main primitives? Answer in five bullets." + } +} +``` + +and receives back only the spoke's final message: + +```json +{ "content": "MCP standardises how apps feed context and tools to an LLM ...", "content_type": "text/markdown" } +``` + +The spoke's own web-search calls and intermediate turns are not in this result and never entered the +coordinator's context. + +**A full, runnable example** ships in `docker_compose_files/core/configuration/applications.json` as the +`subagent_demo` app: a coordinator with three subagent types (`weather_scout`, `web_researcher`, `analyst`), +per-subagent `deployment_id` / `tool_sets` / `max_iterations` narrowing, and conversation starters that +exercise single-city, multi-city fan-out, research, and mixed spawns. It requires +`ENABLE_PREVIEW_FEATURES=true` on the backend. + +--- + +## Migration + +### Breaking changes + +None. `subagents` is a new optional field; every existing manifest validates and behaves exactly as before. + +### Non-breaking changes + +`subagents` is an optional, preview-gated (`PreviewField`) addition to `ApplicationConfig`. Apps that do not +declare it are unaffected. When `ENABLE_PREVIEW_FEATURES` is unset, the field is nullified during config +validation, so even a manifest that *does* include it degrades gracefully to today's behavior rather than +erroring. Enabling the feature requires no migration of existing apps. + +## Summary of Changes + +**Config (`src/quickapp/config/`)** + +- `subagent.py` — new `SubagentConfig`: `name`, `description`, `system_prompt`, `tool_sets?`, + `deployment_id?`, `max_iterations?`. +- `application.py` — `ApplicationConfig.subagents: list[SubagentConfig] | None` (preview field). + +**New module (`src/quickapp/subagent_tooling/`)** + +- `SubagentToolingModule` (`@preview_module`) — provides the `spawn_subagent` tool when subagents are + declared; contributes build-time `tool_sets` validation. +- `compile_subagent_manifest` — compiles a `SubagentConfig` + parent manifest into a narrowed + `ApplicationConfig` (inherits `contexts` / `skills` / `hooks` / `features`; clears `subagents` / + `starters` / `conversation_starters`). +- `SubagentSpawner` — runs a spoke in-process in an isolated request scope (Approach A). +- `_SubagentTool` / `_SubagentStageWrapper` — the `spawn_subagent` `StagedBaseTool` and its stage rendering. +- `SubagentToolSetResolutionError` — raised when a non-empty allowlist resolves to no tool sets. + +**Wiring** + +- `app_factory.py` — registers `SubagentToolingModule`. +- `docs/generated-app-schema.json` — regenerated for the new field. + +See *Approach A — Current state — spike vs. target* for what remains to build before the feature ships. From 26bbbb31e8762f19758781120b4499bf3639d195 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Wed, 19 Aug 2026 11:49:14 +0400 Subject: [PATCH 2/9] feat: implement subagent tool set --- src/quickapp/app_factory.py | 2 + src/quickapp/config/application.py | 8 + src/quickapp/config/subagent.py | 36 +++ src/quickapp/subagent_tooling/__init__.py | 3 + src/quickapp/subagent_tooling/_exceptions.py | 16 ++ .../subagent_tooling/_manifest_compiler.py | 52 ++++ .../subagent_tooling/_subagent_spawner.py | 90 ++++++ .../_subagent_stage_wrapper.py | 18 ++ .../subagent_tooling/_subagent_tool.py | 66 +++++ src/quickapp/subagent_tooling/_tool_config.py | 54 ++++ .../subagent_tooling_module.py | 71 +++++ .../subagent_tooling_tests/__init__.py | 0 .../test_subagent_spike.py | 271 ++++++++++++++++++ 13 files changed, 687 insertions(+) create mode 100644 src/quickapp/config/subagent.py create mode 100644 src/quickapp/subagent_tooling/__init__.py create mode 100644 src/quickapp/subagent_tooling/_exceptions.py create mode 100644 src/quickapp/subagent_tooling/_manifest_compiler.py create mode 100644 src/quickapp/subagent_tooling/_subagent_spawner.py create mode 100644 src/quickapp/subagent_tooling/_subagent_stage_wrapper.py create mode 100644 src/quickapp/subagent_tooling/_subagent_tool.py create mode 100644 src/quickapp/subagent_tooling/_tool_config.py create mode 100644 src/quickapp/subagent_tooling/subagent_tooling_module.py create mode 100644 src/tests/unit_tests/subagent_tooling_tests/__init__.py create mode 100644 src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py diff --git a/src/quickapp/app_factory.py b/src/quickapp/app_factory.py index 5b2651a4..37767f84 100644 --- a/src/quickapp/app_factory.py +++ b/src/quickapp/app_factory.py @@ -30,6 +30,7 @@ from quickapp.shared import shared_module from quickapp.skills.skills_module import SkillsModule from quickapp.starters.starters_module import StartersModule +from quickapp.subagent_tooling import SubagentToolingModule from quickapp.timestamp_tooling.timestamp_module import TimestampModule from quickapp.web_tooling.web_tooling_module import WebToolingModule @@ -63,6 +64,7 @@ def build_di_modules() -> list[Module]: DialFilesToolingModule(), WebToolingModule(), RepresentationToolingModule(), + SubagentToolingModule(), ] if FeatureSettings().enable_preview_features: logging.getLogger(__name__).info( diff --git a/src/quickapp/config/application.py b/src/quickapp/config/application.py index e1b7b280..1f1f7b8b 100644 --- a/src/quickapp/config/application.py +++ b/src/quickapp/config/application.py @@ -21,6 +21,7 @@ from quickapp.config.prompt import AgentSystemPromptConfig, CustomSystemPromptConfig from quickapp.config.skill import SkillConfig from quickapp.config.starters import ConversationStartersConfig +from quickapp.config.subagent import SubagentConfig from quickapp.config.timestamp import TimestampConfig, ToolCallTimestampConfig from quickapp.config.toolsets.toolset import ToolSet from quickapp.config.web_fetch import WebFetchConfig @@ -264,6 +265,13 @@ class ApplicationConfig(BaseApplicationTypeConfig): default=None, description="Config-driven hooks fired at named orchestrator seams.", ) + subagents: list[SubagentConfig] | None = PreviewField( # type: ignore[assignment] + default=None, + description=( + "Subagent types this app may spawn. Each spawn runs its own orchestrator " + "loop in an isolated context and returns a single result." + ), + ) features: Features | None = Field( default_factory=Features, description="QuickApps Agent features configuration.", diff --git a/src/quickapp/config/subagent.py b/src/quickapp/config/subagent.py new file mode 100644 index 00000000..629855f3 --- /dev/null +++ b/src/quickapp/config/subagent.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, Field + + +class SubagentConfig(BaseModel): + """A subagent type declared by the app builder. + + A spoke inherits the coordinator's ``contexts`` / ``skills`` / ``hooks`` / + ``features`` wholesale (see ``compile_subagent_manifest``), so there is no + field for them here. Per-subagent *narrowing* of those, and a tool-level + (rather than toolset-level) allowlist, are intentionally out of scope — see + ``docs/designs/anonymous_subagents.md``. + """ + + name: str = Field(description="Identifier the coordinator uses to select this subagent.") + description: str = Field( + description="When to use this subagent. Surfaced to the coordinator's LLM for routing." + ) + system_prompt: str = Field( + description="The subagent's instructions. Replaces the app system prompt, never appends." + ) + tool_sets: list[str] | None = Field( + default=None, + description=( + "Names of the app's tool sets this subagent may use. " + "When unset, the subagent inherits every tool set." + ), + ) + deployment_id: str | None = Field( + default=None, + description="Deployment for this subagent. When unset, the coordinator's is inherited.", + ) + max_iterations: int | None = Field( + default=None, + gt=0, + description="Iteration budget for this subagent. When unset, the coordinator's is inherited.", + ) diff --git a/src/quickapp/subagent_tooling/__init__.py b/src/quickapp/subagent_tooling/__init__.py new file mode 100644 index 00000000..704783fa --- /dev/null +++ b/src/quickapp/subagent_tooling/__init__.py @@ -0,0 +1,3 @@ +from .subagent_tooling_module import SubagentToolingModule + +__all__ = ["SubagentToolingModule"] diff --git a/src/quickapp/subagent_tooling/_exceptions.py b/src/quickapp/subagent_tooling/_exceptions.py new file mode 100644 index 00000000..e945cb5f --- /dev/null +++ b/src/quickapp/subagent_tooling/_exceptions.py @@ -0,0 +1,16 @@ +class SubagentToolSetResolutionError(RuntimeError): + """Raised when a subagent's declared tool sets resolve to nothing. + + Spawning anyway would run an agent with no tools, which does not fail — it + answers from the task text alone and sounds confident doing it. That is worse + than an error, so this is fatal to the spawn. + """ + + def __init__(self, subagent_name: str, requested: list[str], available: list[str]) -> None: + super().__init__( + f"Subagent '{subagent_name}' declares tool sets {requested}, none of which exist " + f"in this app. Available tool sets: {available or '(none)'}." + ) + self.subagent_name = subagent_name + self.requested = requested + self.available = available diff --git a/src/quickapp/subagent_tooling/_manifest_compiler.py b/src/quickapp/subagent_tooling/_manifest_compiler.py new file mode 100644 index 00000000..dc624d5c --- /dev/null +++ b/src/quickapp/subagent_tooling/_manifest_compiler.py @@ -0,0 +1,52 @@ +import logging + +from quickapp.config.application import ApplicationConfig +from quickapp.config.prompt import CustomSystemPromptConfig +from quickapp.config.subagent import SubagentConfig + +from ._exceptions import SubagentToolSetResolutionError + +logger = logging.getLogger(__name__) + + +def compile_subagent_manifest( + parent: ApplicationConfig, subagent: SubagentConfig +) -> ApplicationConfig: + """Compile a declared subagent type into a full manifest the orchestrator can run. + + The spoke is just a QuickApp with a narrowed manifest — which is why the tool + allowlist needs no dedicated filtering machinery. + """ + manifest = parent.model_copy(deep=True) + + manifest.orchestrator.system_prompt = CustomSystemPromptConfig( + content=subagent.system_prompt, variables={} + ) + if subagent.max_iterations is not None: + manifest.orchestrator.max_iterations = subagent.max_iterations + if subagent.deployment_id is not None: + manifest.orchestrator.deployment.deployment_id = subagent.deployment_id + + if subagent.tool_sets is not None: + allowed = set(subagent.tool_sets) + manifest.tool_sets = [ts for ts in manifest.tool_sets if ts.name in allowed] + unknown = allowed - {ts.name for ts in parent.tool_sets} + if unknown: + logger.warning( + "Subagent %s references unknown tool sets: %s", subagent.name, sorted(unknown) + ) + if allowed and not manifest.tool_sets: + # A subagent that asked for tools and got none would run anyway and + # confabulate an answer from the task text alone. Fail instead. + raise SubagentToolSetResolutionError( + subagent_name=subagent.name, + requested=sorted(allowed), + available=sorted(ts.name for ts in parent.tool_sets), + ) + + # Depth 1: a spoke cannot spawn. Starters are a coordinator-only concern. + manifest.subagents = None + manifest.starters = None + manifest.conversation_starters = None + + return manifest diff --git a/src/quickapp/subagent_tooling/_subagent_spawner.py b/src/quickapp/subagent_tooling/_subagent_spawner.py new file mode 100644 index 00000000..4b97d491 --- /dev/null +++ b/src/quickapp/subagent_tooling/_subagent_spawner.py @@ -0,0 +1,90 @@ +import asyncio +import logging + +from aidial_sdk.chat_completion import Choice, Message, Role +from fastapi_injector import RequestScopeFactory +from injector import Injector, inject + +from quickapp.common import DIAL_API_KEY, DIAL_BEARER, ForwardedHeaders +from quickapp.common.base_initializer import InitializerType, invoke_initializers +from quickapp.config.application import ApplicationConfig +from quickapp.config.subagent import SubagentConfig +from quickapp.core.agent.orchestrator import Orchestrator +from quickapp.core.application._request_context import _RequestContext +from quickapp.core.application._request_context_setup import _RequestContextSetup + +from ._manifest_compiler import compile_subagent_manifest + +logger = logging.getLogger(__name__) + + +def _headless_choice() -> Choice: + """A Choice whose chunks go nowhere. + + SPIKE ONLY. The design calls for an output-sink abstraction so the orchestrator + stops depending on Choice at all; this stand-in lets the loop run unmodified by + draining into a queue nobody consumes. + """ + choice = Choice(asyncio.Queue(), 0) + choice.open() + return choice + + +@inject +class SubagentSpawner: + """Runs a subagent in this process, inside its own DI request scope.""" + + def __init__( + self, + injector: Injector, + scope_factory: RequestScopeFactory, + parent_config: ApplicationConfig, + api_key: DIAL_API_KEY, + bearer: DIAL_BEARER, + forwarded_headers: ForwardedHeaders, + ) -> None: + self.__injector = injector + self.__scope_factory = scope_factory + self.__parent_config = parent_config + self.__api_key = api_key + self.__bearer = bearer + self.__forwarded_headers = forwarded_headers + + async def spawn(self, subagent: SubagentConfig, task: str) -> str: + # Run in a dedicated task: the request scope key is a ContextVar, and asyncio + # copies context per task, so the child scope cannot leak into the caller's. + return await asyncio.create_task(self.__run(subagent, task)) + + async def __run(self, subagent: SubagentConfig, task: str) -> str: + manifest = compile_subagent_manifest(self.__parent_config, subagent) + logger.info( + "Spawning subagent %s: deployment=%s, tool_sets=%d, max_iterations=%d", + subagent.name, + manifest.orchestrator.deployment.deployment_id, + len(manifest.tool_sets), + manifest.orchestrator.max_iterations, + ) + + async with self.__scope_factory.create_scope(): + context = self.__injector.get(_RequestContext) + context.api_key = self.__api_key + context.bearer = self.__bearer + context.forwarded_headers = self.__forwarded_headers + context.application_config = manifest + context.choice = _headless_choice() + + setup = self.__injector.get(_RequestContextSetup) + await invoke_initializers(self.__injector, InitializerType.completion) + await setup.setup_messages([Message(role=Role.USER, content=task)]) + + orchestrator = self.__injector.get(Orchestrator) # type: ignore[type-abstract] + await orchestrator.invoke() + + return self.__final_answer(context) + + @staticmethod + def __final_answer(context: _RequestContext) -> str: + for message in reversed(context.messages): + if message.role == Role.ASSISTANT and not message.tool_calls: + return message.content or "" + return "" diff --git a/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py b/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py new file mode 100644 index 00000000..7d7b8ed6 --- /dev/null +++ b/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py @@ -0,0 +1,18 @@ +from typing import Any + +from injector import inject + +from quickapp.common import TimedStageWrapper, ToolCallResult + + +@inject +class _SubagentStageWrapper(TimedStageWrapper): + + def _get_formatted_parameters(self, parameters: dict[str, Any]) -> str: + return f"**Task:** {parameters.get('task', '')}\n\n" + + def _build_debug_info_from_exception(self, exception: Exception) -> str: + return f"### Exception:\n\r{exception}\n\r" + + def _build_debug_info_from_result(self, result: ToolCallResult) -> str: + return f"### Result:\n\r{result.content}\n\r" diff --git a/src/quickapp/subagent_tooling/_subagent_tool.py b/src/quickapp/subagent_tooling/_subagent_tool.py new file mode 100644 index 00000000..66631d45 --- /dev/null +++ b/src/quickapp/subagent_tooling/_subagent_tool.py @@ -0,0 +1,66 @@ +from typing import Any + +from injector import AssistedBuilder, inject + +from quickapp.common import StagedBaseTool, ToolCallResult +from quickapp.common.base_stage_wrapper import BaseStageWrapper +from quickapp.common.exceptions import InvalidToolCallParameterException +from quickapp.common.perf_timer.perf_timer import PerformanceTimer +from quickapp.config.application import StageDisplayLevel +from quickapp.config.subagent import SubagentConfig +from quickapp.config.tools.internal import InternalTool + +from ._subagent_spawner import SubagentSpawner +from ._subagent_stage_wrapper import _SubagentStageWrapper + + +@inject +class _SubagentTool(StagedBaseTool): + + def __init__( + self, + stage_wrapper_builder: AssistedBuilder[_SubagentStageWrapper], + tool_config: InternalTool, + perf_timer: PerformanceTimer, + spawner: SubagentSpawner, + subagents: list[SubagentConfig], + stage_display_level: StageDisplayLevel = StageDisplayLevel.INFO, + **kwargs: Any, + ) -> None: + super().__init__( + stage_wrapper_builder=stage_wrapper_builder, # type: ignore[arg-type] + tool_config=tool_config, + perf_timer=perf_timer, + stage_display_level=stage_display_level, + **kwargs, + ) + self.__spawner = spawner + self.__subagents = {s.name: s for s in subagents} + + async def _run_in_stage_async( + self, + stage_wrapper: BaseStageWrapper | None = None, + tool_call_id: str | None = None, + *args: Any, + **kwargs: Any, + ) -> ToolCallResult: + subagent_type = kwargs.get("subagent_type") + task = kwargs.get("task") + + subagent = self.__subagents.get(str(subagent_type)) + if subagent is None: + raise InvalidToolCallParameterException( + parameter_name="subagent_type", + message=f"Unknown subagent '{subagent_type}'. Available: {sorted(self.__subagents)}", + ) + if not task: + raise InvalidToolCallParameterException( + parameter_name="task", message="A task description is required." + ) + + answer = await self.__spawner.spawn(subagent, str(task)) + + result = ToolCallResult(content=answer, content_type="text/markdown") + if stage_wrapper: + stage_wrapper.add_result(result) + return result diff --git a/src/quickapp/subagent_tooling/_tool_config.py b/src/quickapp/subagent_tooling/_tool_config.py new file mode 100644 index 00000000..06688f26 --- /dev/null +++ b/src/quickapp/subagent_tooling/_tool_config.py @@ -0,0 +1,54 @@ +from quickapp.config.subagent import SubagentConfig +from quickapp.config.tools.base import ( + ConfigurableSchemaSimpleType, + JsonTypeEnum, + OpenAiToolConfig, + OpenAiToolFunction, + OpenAiToolFunctionParameters, +) +from quickapp.config.tools.display.tool import ToolDisplayConfig, ToolStageConfig +from quickapp.config.tools.internal import InternalTool + +SPAWN_TOOL_NAME = "spawn_subagent" + + +def build_spawn_tool_config(subagents: list[SubagentConfig]) -> InternalTool: + """One tool for every declared subagent type, selected by ``subagent_type``. + + Matches Claude Code's shape: a flat tool catalogue that does not grow as the + builder adds subagent types, with routing carried by the enum descriptions. + """ + catalogue = "\n".join(f"- {s.name}: {s.description}" for s in subagents) + return InternalTool( + open_ai_tool=OpenAiToolConfig( + function=OpenAiToolFunction( + name=SPAWN_TOOL_NAME, + description=( + "Delegate a self-contained task to a subagent. The subagent works in " + "its own isolated context and returns only its final answer — its " + "intermediate steps never enter this conversation. Available subagents:\n" + f"{catalogue}" + ), + parameters=OpenAiToolFunctionParameters( + type=JsonTypeEnum.object, + properties={ + "subagent_type": ConfigurableSchemaSimpleType( + type=JsonTypeEnum.string, + description="Which subagent to spawn.", + enum=[s.name for s in subagents], + ), + "task": ConfigurableSchemaSimpleType( + type=JsonTypeEnum.string, + description=( + "The complete task for the subagent. It sees nothing but " + "this text — no conversation history, no other subagent's " + "work — so state everything it needs." + ), + ), + }, + required=["subagent_type", "task"], + ), + ) + ), + display=ToolDisplayConfig(stage=ToolStageConfig(name="Subagent")), + ) diff --git a/src/quickapp/subagent_tooling/subagent_tooling_module.py b/src/quickapp/subagent_tooling/subagent_tooling_module.py new file mode 100644 index 00000000..86a7cb26 --- /dev/null +++ b/src/quickapp/subagent_tooling/subagent_tooling_module.py @@ -0,0 +1,71 @@ +import logging + +from fastapi_injector import request_scope +from injector import AssistedBuilder, Binder, Module, multiprovider + +from quickapp.common import StagedBaseTool +from quickapp.common.exceptions import InitializationException, ToolInitializationException +from quickapp.common.preview import preview_module +from quickapp.config.application import ApplicationConfig +from quickapp.config.subagent import SubagentConfig + +from ._subagent_spawner import SubagentSpawner +from ._subagent_stage_wrapper import _SubagentStageWrapper +from ._subagent_tool import _SubagentTool +from ._tool_config import SPAWN_TOOL_NAME, build_spawn_tool_config + +logger = logging.getLogger(__name__) + + +@preview_module +class SubagentToolingModule(Module): + """In-process subagent spawning.""" + + def configure(self, binder: Binder) -> None: + binder.bind(SubagentSpawner, to=SubagentSpawner, scope=request_scope) + binder.bind(_SubagentStageWrapper, to=_SubagentStageWrapper) + logger.debug("SubagentTooling module configuration completed") + + @multiprovider + def _provide_subagents(self, app_config: ApplicationConfig) -> list[SubagentConfig]: + return list(app_config.subagents or []) + + @multiprovider + def _provide_initialization_exceptions( + self, app_config: ApplicationConfig + ) -> list[InitializationException]: + """Surface dangling `tool_sets` references before the LLM can spawn. + + Caught here, the app builder sees a named bad reference. Caught at spawn + time, they see a subagent that answered without tools. + """ + available = {ts.name for ts in app_config.tool_sets} + exceptions: list[InitializationException] = [] + for subagent in app_config.subagents or []: + unknown = sorted(set(subagent.tool_sets or []) - available) + if unknown: + exceptions.append( + ToolInitializationException( + message=( + f"Subagent '{subagent.name}' references tool sets that do not exist " + f"in this app: {unknown}. Available: {sorted(available) or '(none)'}." + ), + tool_name=SPAWN_TOOL_NAME, + ) + ) + return exceptions + + @multiprovider + def _provide_subagent_tools( + self, + subagents: list[SubagentConfig], + tool_builder: AssistedBuilder[_SubagentTool], + ) -> list[StagedBaseTool]: + if not subagents: + return [] + return [ + tool_builder.build( + tool_config=build_spawn_tool_config(subagents), + name=SPAWN_TOOL_NAME, + ) + ] diff --git a/src/tests/unit_tests/subagent_tooling_tests/__init__.py b/src/tests/unit_tests/subagent_tooling_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py new file mode 100644 index 00000000..8f1738e3 --- /dev/null +++ b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py @@ -0,0 +1,271 @@ +"""SPIKE validation for Approach A (in-process subagents). + +These tests exist to answer one question: can a spawned subagent run its own +orchestrator loop, against its own manifest, in a DI request scope that is fully +isolated from the coordinator's — without touching the coordinator's state? +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aidial_sdk.chat_completion import Message, Role +from fastapi_injector import RequestScopeFactory +from injector import Binder, Injector, inject +from pydantic import SecretStr + +from quickapp.common import StagedBaseTool +from quickapp.common.base_initializer import InitializerType, invoke_initializers +from quickapp.common.exceptions import InitializationException +from quickapp.common.messages_mixin import MessagesMixin +from quickapp.config.application import ApplicationConfig +from quickapp.config.context import UserDefinedContextConfig +from quickapp.config.starters import ConversationStarter, ConversationStartersConfig +from quickapp.config.subagent import SubagentConfig +from quickapp.config.toolsets.internal import InternalToolSet +from quickapp.core.agent.orchestrator import Orchestrator +from quickapp.core.application._request_context import _RequestContext +from quickapp.dial_core_services.tool_config_service import ToolConfigCoreService +from quickapp.subagent_tooling._exceptions import SubagentToolSetResolutionError +from quickapp.subagent_tooling._manifest_compiler import compile_subagent_manifest +from quickapp.subagent_tooling._subagent_spawner import SubagentSpawner +from quickapp.subagent_tooling._tool_config import SPAWN_TOOL_NAME +from tests.unit_tests.common.common import create_app_configuration + +RESEARCHER = SubagentConfig( + name="researcher", + description="Digs through sources and reports findings.", + system_prompt="You are a researcher. Answer tersely.", + tool_sets=["research"], + max_iterations=3, +) + + +def _parent_config() -> ApplicationConfig: + config = create_app_configuration( + [ + InternalToolSet(name="research", tools=[]), + InternalToolSet(name="reporting", tools=[]), + ] + ) + config.subagents = [RESEARCHER] + return config + + +class _FakeOrchestrator: + """Records the manifest the child scope resolved, and answers.""" + + seen: list[ApplicationConfig] = [] + + @inject + def __init__(self, config: ApplicationConfig, messages: MessagesMixin) -> None: + self._config = config + self._messages = messages + + async def invoke(self) -> None: + type(self).seen.append(self._config) + self._messages.append_message( + Message(role=Role.ASSISTANT, content="42 sources, one answer.") + ) + + +def _test_injector() -> Injector: + from quickapp.app_factory import AppFactory + + # The real completion initializers run in the child scope — only the DIAL Core + # round-trip for deployment metadata is stubbed out. + core_service = MagicMock(spec=ToolConfigCoreService) + core_service.get_deployment_metadata = AsyncMock(return_value=MagicMock(defaults=None)) + + def overrides(binder: Binder) -> None: + binder.bind(Orchestrator, to=_FakeOrchestrator) # type: ignore[type-abstract,arg-type] + binder.bind(ToolConfigCoreService, to=core_service) + + return Injector([*AppFactory.build_di_modules(), overrides]) + + +def test_manifest_compilation_narrows_the_spoke(): + parent = _parent_config() + parent.contexts = [UserDefinedContextConfig(content="shared background")] + parent.starters = ["deprecated starter"] + parent.conversation_starters = ConversationStartersConfig( + starters=[ConversationStarter(title="Go", text="do the thing")] + ) + + manifest = compile_subagent_manifest(parent, RESEARCHER) + + assert manifest.orchestrator.system_prompt.content == RESEARCHER.system_prompt + assert manifest.orchestrator.max_iterations == 3 + assert [ts.name for ts in manifest.tool_sets] == ["research"] + assert manifest.subagents is None, "a spoke must not be able to spawn" + # Coordinator↔user conversation UI is cleared: a spoke has no user to seed. + assert manifest.starters is None + assert manifest.conversation_starters is None + # contexts (and skills / hooks / features) are inherited wholesale — deep-copied, + # so equal by value but a distinct object from the parent's. + assert manifest.contexts == parent.contexts + assert manifest.contexts is not parent.contexts + # The coordinator's own manifest is untouched. + assert [ts.name for ts in parent.tool_sets] == ["research", "reporting"] + assert parent.orchestrator.system_prompt.content == "test" + assert parent.starters == ["deprecated starter"] + assert parent.conversation_starters is not None + + +@pytest.mark.asyncio +async def test_spawn_runs_in_an_isolated_request_scope(monkeypatch): + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + _FakeOrchestrator.seen = [] + + injector = _test_injector() + scope_factory = injector.get(RequestScopeFactory) + parent_config = _parent_config() + + async with scope_factory.create_scope(): # the coordinator's request + parent_context = injector.get(_RequestContext) + parent_context.api_key = SecretStr("key") + parent_context.bearer = None + parent_context.forwarded_headers = {} + parent_context.application_config = parent_config + parent_context.messages = [Message(role=Role.USER, content="do the thing")] + + spawner = injector.get(SubagentSpawner) + answer = await spawner.spawn(RESEARCHER, "Find out who broke the build.") + + # The spoke returned only its final answer. + assert answer == "42 sources, one answer." + + # The spoke ran against the compiled manifest, not the coordinator's. + assert len(_FakeOrchestrator.seen) == 1 + child_config = _FakeOrchestrator.seen[0] + assert child_config is not parent_config + assert [ts.name for ts in child_config.tool_sets] == ["research"] + assert child_config.orchestrator.system_prompt.content == RESEARCHER.system_prompt + + # The coordinator's scope is untouched: same context object, same manifest, + # and none of the spoke's messages leaked in. + assert injector.get(_RequestContext) is parent_context + assert parent_context.application_config is parent_config + assert [m.content for m in parent_context.messages] == ["do the thing"] + + +@pytest.mark.asyncio +async def test_spawn_tool_is_offered_to_the_coordinator(monkeypatch): + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + + injector = _test_injector() + scope_factory = injector.get(RequestScopeFactory) + + async with scope_factory.create_scope(): + context = injector.get(_RequestContext) + context.api_key = SecretStr("key") + context.bearer = None + context.forwarded_headers = {} + context.application_config = _parent_config() + context.messages = [Message(role=Role.USER, content="do the thing")] + await invoke_initializers(injector, InitializerType.completion) + + tools = injector.get(list[StagedBaseTool]) + spawn_tools = [t for t in tools if t.openai_function_name() == SPAWN_TOOL_NAME] + + assert len(spawn_tools) == 1 + function = spawn_tools[0].tool_config.open_ai_tool.function + assert function.parameters.properties["subagent_type"].enum == ["researcher"] + assert "researcher: Digs through sources" in function.description + + +@pytest.mark.asyncio +async def test_no_spawn_tool_without_declared_subagents(monkeypatch): + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + + injector = _test_injector() + scope_factory = injector.get(RequestScopeFactory) + + async with scope_factory.create_scope(): + context = injector.get(_RequestContext) + context.api_key = SecretStr("key") + context.bearer = None + context.forwarded_headers = {} + context.application_config = create_app_configuration([]) + context.messages = [Message(role=Role.USER, content="do the thing")] + await invoke_initializers(injector, InitializerType.completion) + + tools = injector.get(list[StagedBaseTool]) + + assert [t for t in tools if t.openai_function_name() == SPAWN_TOOL_NAME] == [] + + +@pytest.mark.asyncio +async def test_parallel_spawns_do_not_share_scope(monkeypatch): + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + _FakeOrchestrator.seen = [] + + other = RESEARCHER.model_copy(update={"name": "reporter", "tool_sets": ["reporting"]}) + injector = _test_injector() + scope_factory = injector.get(RequestScopeFactory) + + async with scope_factory.create_scope(): + context = injector.get(_RequestContext) + context.api_key = SecretStr("key") + context.bearer = None + context.forwarded_headers = {} + context.application_config = _parent_config() + context.messages = [Message(role=Role.USER, content="do the thing")] + + spawner = injector.get(SubagentSpawner) + await asyncio.gather( + spawner.spawn(RESEARCHER, "task one"), + spawner.spawn(other, "task two"), + ) + + assert len(_FakeOrchestrator.seen) == 2 + tool_sets = sorted(ts.name for config in _FakeOrchestrator.seen for ts in config.tool_sets) + assert tool_sets == ["reporting", "research"] + + +def test_unresolvable_tool_sets_fail_the_spawn(): + """A subagent that asked for tools and got none must not run: it would answer + from the task text alone and sound confident doing it.""" + parent = _parent_config() + dangling = RESEARCHER.model_copy(update={"tool_sets": ["Fetch MCP toolset"]}) + + with pytest.raises(SubagentToolSetResolutionError) as excinfo: + compile_subagent_manifest(parent, dangling) + + assert "Fetch MCP toolset" in str(excinfo.value) + assert "research" in str(excinfo.value) + + +def test_empty_declared_tool_sets_is_allowed(): + """An explicitly empty allowlist is a deliberate no-tools subagent, not a typo.""" + parent = _parent_config() + toolless = RESEARCHER.model_copy(update={"tool_sets": []}) + + manifest = compile_subagent_manifest(parent, toolless) + + assert manifest.tool_sets == [] + + +@pytest.mark.asyncio +async def test_dangling_tool_set_reference_is_reported_at_initialization(monkeypatch): + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + + config = _parent_config() + config.subagents = [RESEARCHER.model_copy(update={"tool_sets": ["Fetch MCP toolset"]})] + + injector = _test_injector() + scope_factory = injector.get(RequestScopeFactory) + + async with scope_factory.create_scope(): + context = injector.get(_RequestContext) + context.api_key = SecretStr("key") + context.bearer = None + context.forwarded_headers = {} + context.application_config = config + context.messages = [Message(role=Role.USER, content="do the thing")] + await invoke_initializers(injector, InitializerType.completion) + + exceptions = injector.get(list[InitializationException]) + + messages = [str(e) for e in exceptions] + assert any("Fetch MCP toolset" in m and "researcher" in m for m in messages), messages From 4b48ae3a50f5160a6249fe1dd12cd099af4317e8 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 11:55:47 +0400 Subject: [PATCH 3/9] doc: refine resign doc --- docs/designs/anonymous_subagents.md | 423 +++++++++++++++++++++------- 1 file changed, 318 insertions(+), 105 deletions(-) diff --git a/docs/designs/anonymous_subagents.md b/docs/designs/anonymous_subagents.md index 52e784c5..048b0cf9 100644 --- a/docs/designs/anonymous_subagents.md +++ b/docs/designs/anonymous_subagents.md @@ -1,25 +1,42 @@ # Design: Anonymous Subagents -- **Status:** Approved +- **Status:** Draft - **Dependencies:** None +> **What changed in this revision.** The problem statement now names the gap this feature actually closes +> (authoring: declaring a helper inline instead of deploying one) rather than context economics, which +> `DeploymentTool` already delivers against a second QuickApp. A third implementation — **Approach C**, +> out-of-process via self-call with a *selector* instead of an injected manifest — is added, and it changes +> the recommendation: C wins on every operational dimension and carries none of Approach B's security cost. +> The recommendation is therefore no longer "build A"; it is "answer one Core question, then pick". + ## Problem Statement -Multi-step work in a QuickApp usually completes — but it gets more expensive and worse the longer it runs. -Every step's raw material stays in the conversation: fetched pages, SQL dumps, file contents, retries that -failed before one worked. That history is resent on every subsequent LLM call, so token spend grows -superlinearly with task length, and the model's attention is increasingly spent on material that stopped -being relevant many steps ago. The app builder sees a bill that scales badly and answers that get vaguer and -driftier on long tasks; nothing surfaces *why*, and there is no setting to turn. Context degradation is -invisible to a non-technical user — it looks like the model is just not very good. - -Structurally, a QuickApp is one orchestrator loop: one system prompt, one model, one tool set, one context -window. There is nowhere to put work so it doesn't accumulate. Every sub-task also carries the full tool -catalogue on the app's primary deployment regardless of what it needs, and the only delegation primitive we -ship — the DIAL deployment tool — targets an already-configured deployment, so every helper agent must be -foreseen, built, and deployed in advance. Other agentic frameworks address this with a spawn primitive: an -ephemeral, caller-configured worker that does the work in its own context and returns only the result. We -have no equivalent. +Multi-step work in a QuickApp gets more expensive and worse the longer it runs. Every step's raw material +stays in the conversation: fetched pages, SQL dumps, file contents, retries that failed before one worked. +That history is resent on every subsequent LLM call, so token spend grows superlinearly with task length, +and the model's attention is increasingly spent on material that stopped being relevant many steps ago. + +**We can already fix that, and the fix is the problem.** `DeploymentTool` (`dial_deployment_tooling/`) takes +a `query` string, calls another DIAL deployment, and returns only that deployment's final content. Point it +at a second QuickApp and you have delegation with every property this design wants: the callee runs its own +system prompt, model, tool set, and iteration budget, in its own context window, and the caller sees only +the answer. Context confinement is not the gap. **Authoring is.** + +To use that today, an app builder who wants three helpers must create, permission, and deploy three +QuickApps in DIAL Core; keep three manifests in sync with the parent that calls them; and re-deploy to change +a helper's prompt by one sentence. Every helper must be foreseen and provisioned before the app that needs it +exists. For a non-technical builder working in the configurator that is not a workflow — it is a reason not +to decompose the work at all, which leaves them with the single long-context loop and the bill that comes +with it. + +So the problem is not *"we cannot confine a sub-task's context."* It is: **a builder cannot declare a helper +agent in the manifest they are already editing.** Other agentic frameworks solve this with a spawn primitive +— an ephemeral, caller-configured worker with no separate registration. We have no equivalent, and that +absence is what this design fills. + +The context-economics benefit follows for free, because it is the same benefit the deployment tool already +delivers. It is the reason the feature is worth having; it is not the reason it needs building. ## Concepts @@ -89,9 +106,13 @@ The app builder declares subagent *types* in the manifest, the same shape Claude | `deployment_id` | DIAL deployment (model) for this spoke. Omitted = inherit the coordinator's. | | `max_iterations` | The spoke's own budget, independent of the coordinator's. | -At runtime the coordinator gets one tool — `spawn_subagent(subagent_type, task)` — whose `subagent_type` +At runtime the coordinator gets one tool — `task(subagent_type, prompt)` — whose `subagent_type` enumerates the declared types. It returns the spoke's final message as a string. +> **Naming.** The tool name `task` and its `prompt` parameter deliberately mirror Anthropic's Claude Code +> "Task" tool, which spawns subagents the same way. We follow that surface so builders and models already +> familiar with Claude Code's delegation primitive find the same shape here. + **Why the allowlist is toolset-level, not tool-level.** An MCP toolset has no static list of tools — they are discovered when the session connects, long after the manifest is compiled — so there is nothing to match a tool-name allowlist against at declaration time. Narrowing by toolset is the finest granularity @@ -103,27 +124,44 @@ initialization, which is a different (and larger) mechanism; see *Out of Scope*. The design succeeds when all of the following hold. Each is independently verifiable, and several already have spike tests in `src/tests/unit_tests/subagent_tooling_tests/`. -- **G1 — Declared-only routing.** A coordinator declares its subagent types in the manifest; at runtime the - LLM selects one *declared* type per spawn and cannot name a type that was not declared. *(Verifiable: the - `spawn_subagent` tool exposes `subagent_type` as an enum of declared names; an unknown value is rejected — - see UC-4.)* -- **G2 — Context confinement.** A subagent's intermediate work — its tool calls, fetched documents, retries, - and per-turn LLM messages — never enters the coordinator's context window. The coordinator receives only - the subagent's final answer as a string. *(Verifiable: after a spawn, none of the spoke's messages appear - in the coordinator's `_RequestContext.messages`.)* -- **G3 — Non-accumulating cost.** The token and attention cost of a sub-task is spent inside the spoke and - dropped when it returns; it is not resent on the coordinator's subsequent LLM calls. -- **G4 — No advance registration.** Spawning a subagent requires no separate DIAL deployment and no prior - registration in DIAL Core. The spoke's manifest is compiled from the coordinator's own manifest at call - time. -- **G5 — Independent budget and scope.** A spoke runs with its own system prompt, its own model +**G1 and G2 are the goals that distinguish this feature from a DIAL deployment tool pointed at a second +QuickApp. G3–G5 restate properties the deployment-tool route already has** — they are listed because the +design must not lose them, not because building it is how we get them. + +- **G1 — No advance registration.** Spawning a subagent requires no separate DIAL deployment, no prior + registration in DIAL Core, and no second manifest to keep in sync. The spoke's manifest is compiled from + the coordinator's own manifest at call time. *This is the feature.* +- **G2 — Declared in the manifest the builder is already editing.** A helper agent is a `subagents[]` entry + next to the tool sets it uses; changing its prompt is a manifest edit, not a re-deploy. At runtime the LLM + selects one *declared* type per spawn and cannot name a type that was not declared. *(Verifiable: the + `task` tool exposes `subagent_type` as an enum of declared names; an unknown value is rejected — see + UC-4.)* +- **G3 — Context confinement.** A subagent's intermediate work — its tool calls, fetched documents, retries, + and per-turn LLM messages — never enters the coordinator's context window; the coordinator receives only + the subagent's final answer as a string, and its token and attention cost is dropped when the spoke + returns. *(Verifiable: after a spawn, none of the spoke's messages appear in the coordinator's + `_RequestContext.messages`.)* +- **G4 — Independent budget and scope.** A spoke runs with its own system prompt, its own model (`deployment_id`), its own `max_iterations`, and a tool set narrowed to the declared allowlist — each independent of the coordinator's. -- **G6 — Depth capped at 1.** A spoke cannot spawn. The compiled subagent manifest has `subagents = None`. - *(Verifiable: `compile_subagent_manifest` clears `subagents`.)* -- **G7 — Isolated parallelism.** A coordinator may issue several spawns that run concurrently; each runs in +- **G5 — Isolated parallelism.** A coordinator may issue several spawns that run concurrently; each runs in its own request scope with no shared state and no cross-talk. *(Verifiable: `test_parallel_spawns_do_not_share_scope`.)* +- **G6 — A failed spoke never reads as a successful one.** A spawn that produces no final answer surfaces to + the coordinator as a tool error, never as an empty-but-successful result. Handing the coordinator's LLM an + empty string it believes is an answer is the same confabulation failure the tool-set checks exist to + prevent. *(Verifiable: `test_spawn_without_a_final_answer_fails_the_tool_call`.)* +- **G7 — Depth capped at 1.** A spoke cannot spawn: the compiled subagent manifest has `subagents = None`. + *(Verifiable: `compile_subagent_manifest` clears `subagents`.)* + + **Why cap it.** Two honest reasons, one good and one temporary. The good one: a depth cap makes the cost of + a turn bounded and predictable — with recursion, one coordinator decision can fan out into an unbounded + tree, which is exactly the runaway spend the feature exists to reduce. The temporary one: under Approach A + the spokes share the coordinator's process and event loop, and there is no timeout yet (see *Out of + Scope*), so depth is the only structural bound we have. The cap is cheap to relax later — it is one + assignment in `compile_subagent_manifest` — and relaxing it should wait until per-spawn timeouts and a + concurrency bound exist. Its cost is real and should be stated: a builder who genuinely needs two levels of + decomposition is pushed back to deployed apps, which is the workflow G1 exists to remove. --- @@ -133,12 +171,12 @@ have spike tests in `src/tests/unit_tests/subagent_tooling_tests/`. **Trigger:** The user asks the coordinator to compare the current weather in three cities. The coordinator's system prompt instructs it to delegate each independent piece. -**Behavior:** In a single turn the LLM issues three `spawn_subagent` calls, each selecting `weather_scout` +**Behavior:** In a single turn the LLM issues three `task` calls, each selecting `weather_scout` with a task naming one city. The three spokes run concurrently, each in its own scope with only the location and weather tool sets; each resolves coordinates, fetches weather, and returns one line. **Outcome:** The coordinator receives three short answer strings, ranks the cities, and replies. The user sees the coordinator's stages and final ranking — never the spokes' tool calls. The coordinator's context -never held the intermediate location/weather traffic. *(Goals G2, G7.)* +never held the intermediate location/weather traffic. *(Goals G3, G5.)* ### UC-2: Research delegation with a narrowed tool set @@ -148,7 +186,7 @@ never held the intermediate location/weather traffic. *(Goals G2, G7.)* searches inside its own loop. **Outcome:** The coordinator gets back a lead answer plus a few supporting bullets. The (potentially many) search results and transcripts stayed inside the spoke and were dropped when it returned; only the distilled -answer entered the coordinator's context. *(Goals G2, G3, G5.)* +answer entered the coordinator's context. *(Goals G3, G4.)* ### UC-3: Compute delegation over caller-supplied inputs @@ -161,9 +199,17 @@ spoke's only input is the task the hub writes for it. ### UC-4: Error paths -Three cases the design must handle: +Four cases the design must handle: + +- **A spoke that produces no answer.** The spoke exhausts `max_iterations` mid-tool-loop, so its conversation + ends on a tool call and there is no final assistant message to return. `SubagentSpawner` raises + `SubagentToolErrorException` rather than returning `""`. This matters more than it looks: an empty string + would reach the coordinator's LLM as a *successful* tool result, and the coordinator would compose an + answer out of nothing — indistinguishable, from the user's side, from a spoke that genuinely had nothing to + say. Failing loudly turns a silent wrong answer into a tool error the coordinator can retry or reword. + *(Goal G6.)* -- **Undeclared subagent type.** The LLM calls `spawn_subagent` with a `subagent_type` not in the enum. +- **Undeclared subagent type.** The LLM calls `task` with a `subagent_type` not in the enum. `_SubagentTool` raises `InvalidToolCallParameterException` naming the available types; this returns to the coordinator's LLM as a tool error it can correct. No spoke runs. - **Dangling tool-set reference (build time).** A declared subagent names a tool set the app does not define. @@ -178,9 +224,10 @@ Three cases the design must handle: ## Proposed Design -Two implementations are on the table. They share the whole user-facing surface described in *Concepts* — -the builder declares subagent types in the manifest, the coordinator's LLM calls one `spawn_subagent` tool — -and differ only in **where the spoke runs**. +Three implementations are on the table. They share the whole user-facing surface described in *Concepts* — +the builder declares subagent types in the manifest, the coordinator's LLM calls one `task` tool — and +differ only in **where the spoke runs**, and (for the two out-of-process variants) **what crosses the wire to +get it there**. ### Shared: a subagent is an `ApplicationConfig` @@ -204,14 +251,32 @@ Both approaches compile a declared subagent type plus the coordinator's task int overrides only what the subagent declares and clears exactly three fields: `subagents` (the depth cap), and `starters` / `conversation_starters` (both are coordinator-facing conversation UI, meaningless to a spoke that never talks to the user). Everything else — `contexts`, `skills`, `hooks`, and `features` — is inherited -wholesale. This is a deliberate default: a spoke sees the same attached files, skill library, lifecycle -hooks, and feature toggles as the coordinator. Per-subagent narrowing of `contexts` / `skills` / `hooks` is a -larger, separate mechanism and is deferred (see *Out of Scope*). - -Two consequences worth naming up front. First, the tool allowlist needs no new filtering machinery in either -approach: it is expressed by narrowing `tool_sets` in the compiled manifest. Second, running a spoke is -exactly "run one QuickApp request against this manifest" — so the two approaches are two answers to *where -that request executes*, not two different feature designs. +wholesale, so a spoke sees the same attached files, skill library, lifecycle hooks, and feature toggles as +the coordinator. + +**Inherit-everything is the wrong default, and we are shipping it anyway.** Two of the four inherited fields +argue against it: + +- **`contexts` are attached files** — the single largest source of the token bloat this feature exists to + reduce. Under inheritance, a spoke spawned to average five numbers still carries every document attached to + the coordinator. That does not break G3 (the spoke's *own* traffic is still confined) but it does blunt it: + the per-spawn floor is the coordinator's whole attachment set, paid once per spawn, and it rises as the + builder attaches more. +- **`hooks` are lifecycle callbacks written against a user conversation.** A spoke has no user and no real + `Choice`. A hook that assumes either is a latent failure inside a spawn, not a missing feature. + +The confinement-first default would be the opposite: inherit nothing but what the subagent declares. We are +not shipping that, for one reason — it would make `contexts` and `skills` required fields on every subagent +declaration, and the declaration surface is the thing this feature is trying to keep small (G2). Getting the +default right needs a per-field merge/override policy, which is a larger change than the one being made here +(see *Out of Scope*). Until then this is a known cost, not a considered preference: builders should assume a +spoke pays for the coordinator's attachments, and hooks should be reviewed for spoke-safety before being +combined with subagents. + +Two consequences worth naming up front. First, the tool allowlist needs no new filtering machinery in any of +the approaches: it is expressed by narrowing `tool_sets` in the compiled manifest. Second, running a spoke is +exactly "run one QuickApp request against this manifest" — so the three approaches are three answers to +*where that request executes and how the manifest gets there*, not three different feature designs. ### Approach A — in-process spawn @@ -223,7 +288,7 @@ subagent output sink. **Semantics.** -1. The LLM calls `spawn_subagent(subagent_type, task)`. `ToolExecutor` dispatches to `_SubagentTool` like any +1. The LLM calls `task(subagent_type, prompt)`. `ToolExecutor` dispatches to `_SubagentTool` like any other tool, and a stage opens on the coordinator's choice. 2. `SubagentSpawner` compiles the manifest and enters a fresh scope via `RequestScopeFactory.create_scope()` inside an `asyncio.Task`. The scope key is a `ContextVar` and @@ -246,11 +311,19 @@ subagent output sink. **Current state — spike vs. target.** Steps 2–7 are built and validated as a spike in `src/quickapp/subagent_tooling/` (`SubagentSpawner`), including the scope-isolation claim in step 2 for parallel spawns. The spike runs the **unmodified** orchestrator by handing the child scope a throwaway -`Choice` (`_headless_choice()`, marked *SPIKE ONLY*) whose chunks drain into a queue nobody reads. That -stand-in is the interim, not the shipping design: what the spike proves is scope isolation and manifest -compilation; what it defers is the output sink. Before the feature ships, the orchestrator must stop writing -to `Choice` directly — the **output-sink abstraction** below is the required production change, and it -removes `_headless_choice()`. +`Choice` (`_headless_choice()`, marked *SPIKE ONLY*) whose chunks are discarded where they are produced. +What the spike proves is scope isolation and manifest compilation; what it defers is the output sink. + +**What the placeholder currently costs, stated plainly.** Everything the orchestrator writes to a spoke's +`Choice` is dropped: its streamed content (harmless — the final answer is read back off +`_RequestContext.messages` instead), its `set_state` (harmless — spokes are stateless by design), and **any +attachment it produced (not harmless)**. A spoke that generates a chart or a file has no way to return it; +only text crosses back to the coordinator. That is a real capability gap, not a cosmetic one, and it is why +the `subagent_demo` app's `analyst` is declared text-only rather than advertising charts. Approaches B and C +do not have this gap at all — a spoke there has a real `Choice` because it is a real request. + +Before the feature ships on Approach A, the orchestrator must stop writing to `Choice` directly — the +**output-sink abstraction** below is the required production change, and it removes `_headless_choice()`. **Change.** @@ -274,7 +347,7 @@ removes `_headless_choice()`. clients rebuild, DIAL app resolution repeats. Deployment metadata is the exception — it is served from `OrchestratorDeploymentCacheService`, a **singleton** (`agent_module.py:115`), so that lookup is cached across scopes and costs nothing after the first spawn. The per-spawn cost is therefore connection setup, - not metadata resolution. Shared with Approach B, but only A can mitigate it further by reusing selected + not metadata resolution. Shared with B and C, but only A can mitigate it further by reusing selected parent tool instances. - **Timeouts are out of scope for the initial ship.** `asyncio.wait_for` around the spawn is the intended mechanism, but enforcing it (and surfacing a clean timeout error to the coordinator) is deferred — see @@ -285,16 +358,16 @@ removes `_headless_choice()`. - Scope leakage is a silent-correctness hazard: any dependency accidentally resolved against the parent scope from inside a child task is cross-contamination that tests will not obviously catch. -### Approach B — out-of-process spawn +### Approach B — out-of-process spawn, manifest injected **What.** The spoke is a real QuickApp chat-completion request against a deployment, configured entirely by -the coordinator at call time. +the coordinator at call time: the coordinator compiles a manifest and sends it with the request. **Owner.** `subagent_tooling/` on the caller side; `_RequestContextSetup` on the callee side. **Semantics.** -1. The LLM calls `spawn_subagent(subagent_type, task)` — identical surface to Approach A. +1. The LLM calls `task(subagent_type, prompt)` — identical surface to Approach A. 2. `SubagentTool` compiles the manifest and delegates to the existing `DialCompletionService`, targeting the subagent deployment with the task as the user message and the manifest in the request body. 3. That request reaches a QuickApp instance and runs the ordinary lifecycle, with one difference: @@ -313,9 +386,9 @@ the coordinator at call time. opts out of Core injecting properties into sub-calls (`config/application.py:247`), and whether Core forwards a caller-supplied value is a Core policy question outside our control. -**Which deployment?** Either a dedicated "subagent runner" QuickApp deployment with a trivial manifest, or -the coordinator's own deployment id (self-call). Self-call needs no extra deployment but requires a -recursion guard and depends on Core permitting self-routing. +**Which deployment?** A dedicated "subagent runner" QuickApp deployment with a trivial manifest. (Self-call +— the coordinator's own deployment id — is also possible, and turns out to remove the need to inject a +manifest at all; that is Approach C below.) **Change.** @@ -333,9 +406,11 @@ Once a deployment merges caller-supplied manifests, anyone holding an API key ca two-tier gate shape used for external fetch: an admin env switch plus a per-app feature flag. Following the external-fetch naming (`EXTERNAL_URL_FETCH_ENABLED` / `features.external_url_fetch.enabled`), the proposed fields are an admin switch `SUBAGENT_MANIFEST_INJECTION_ENABLED` and a per-app -`features.subagents.accept_injected_manifest`, with the admin switch as a hard cap. These fields exist only -for Approach B (Approach A never exposes an injection endpoint) and are proposals to be finalized if/when B -is built. +`features.subagents.accept_injected_manifest`, with the admin switch as a hard cap. + +These fields exist only for Approach B. Approach A never exposes an injection endpoint, and **Approach C +removes the boundary rather than gating it** — it sends a declared name, not a manifest, so there is nothing +to gate. If out-of-process is the direction, C is the way to get there. **Costs and risks.** @@ -353,29 +428,106 @@ is built. it is a real request — Approach A's largest change simply does not exist here. - Per-spawn cost and usage are already visible to Core as an ordinary deployment call. +### Approach C — out-of-process spawn, selector not manifest + +**What.** Approach B without the manifest channel. The coordinator calls **its own deployment id**, passing +only the *name* of a declared subagent plus the task. The callee resolves its manifest the way every +QuickApp request already does — `_RequestContextSetup` reads it from the deployment's own application +properties (`_request_context_setup.py:59`) — and because the callee *is* the coordinator's deployment, that +manifest already contains the `subagents[]` declarations. It looks the name up and calls the same +`compile_subagent_manifest` the other approaches call, on the callee side. + +**Owner.** `subagent_tooling/` on the caller side; `_RequestContextSetup` on the callee side. + +**Semantics.** + +1. The LLM calls `task(subagent_type, prompt)` — identical surface to A and B. +2. `SubagentTool` delegates to `DialCompletionService`, targeting the coordinator's own deployment id, with + the task as the user message and `custom_fields.configuration = {"subagent_type": "", "depth": 1}`. +3. `setup_context` sees a `subagent_type`, looks it up in the manifest it just resolved for itself, and + applies `compile_subagent_manifest`. An unknown name is rejected there. +4. The spoke streams back; the coordinator's stream handler renders it into the tool stage — this already + works for deployment tools — and the final content becomes the tool result. + +**Why this is the interesting option: the trust boundary disappears.** Approach B's whole cost is that a +deployment which merges caller-supplied manifests will execute *any* manifest anyone with an API key posts to +it — naming any deployment and any tool, under their own key. That is what forces the two-tier gate +(`SUBAGENT_MANIFEST_INJECTION_ENABLED` plus a per-app feature flag) and makes B a bigger commitment than A. + +Approach C never accepts a manifest. The only caller-supplied value is a string that must match a name the +app's *own server-authored* manifest declares. The worst an attacker with a valid key can do is run a +subagent the app already offers — which they could equally get by asking the coordinator to delegate. **No +new privilege, so no new gate, no new env var, and no new per-app feature flag.** The entire security +argument that decided this design against B does not apply to C. + +**Change.** + +- `ApplicationConfig` gains `subagents` (shared with A and B). +- New `SubagentTool` that passes a selector — strictly less code than B's manifest compiler-and-serializer, + because compilation moves to the callee and is the function we already have. +- `_RequestContextSetup` applies `compile_subagent_manifest` when the request carries a `subagent_type`. +- The deployment publishes a configuration schema via `configuration_support/` for the two selector fields. + +**Costs and risks.** + +- **Depends on Core permitting a deployment to call itself.** This is the one genuinely open question and it + should be answered before this comparison is treated as settled — it is a single experiment, not a design + problem. +- Extra HTTP hop and a full application bootstrap per spawn (shared with B). +- Debuggability: a spoke failure is a different request's stack trace; correlation needs a threaded trace id + (shared with B). +- Recursion guard is required but trivial and lands in the same place as the depth cap: when + `subagent_type` is set, the compiled manifest has `subagents = None`, so a spoke cannot spawn. + +**Gains.** Every gain of B — process isolation, HTTP timeouts for free, horizontal scale, backpressure from +the HTTP layer, no orchestrator changes, per-spawn usage visible to Core — with none of B's security surface +and no new deployment to operate. + ### Comparison -| Dimension | A — in-process | B — out-of-process | -|---|---|---| -| Orchestrator changes | Output-sink abstraction required | None | -| New code | Spawner + scope plumbing + sink | Spawn tool + manifest merge | -| Deployment/ops change | None | New deployment (or self-call) + Core policy | -| Isolation | None — shares process, loop, memory | Full — separate request, separate replica | -| Scaling | Bounded by one replica | Load-balanced like any request | -| Timeouts / cancellation | Ours to build (deferred initial ship) | HTTP layer, free | -| Latency per spawn | Tool init only | Tool init + HTTP hop + app bootstrap | -| Security surface | None new — manifest never leaves the process | Caller-supplied manifest execution, needs gating | -| Observability | In-process; parent's perf timer can nest | Separate request; needs trace correlation | -| Parallel spawns | `asyncio.gather` (validated) | Concurrent HTTP calls | -| Tool init cost per spawn | Connection setup only — deployment metadata is singleton-cached; mitigable further by reusing parent tools | Connection setup, not mitigable; deployment metadata cached per replica | - -**Recommendation: build A first, keep B as a swappable backend.** A's cost is an internal refactor we -control and arguably want anyway — decoupling `Orchestrator` from `Choice` is the change that makes an -orchestrator run anywhere. B's cost is an externally visible endpoint that executes caller-supplied -manifests plus a deployment-topology change, which is a larger commitment to make before the feature has -proven itself. B is the better long-term answer for scale and isolation, and switching is not a rewrite: -manifest compilation, the `subagents` config, and the `spawn_subagent` tool surface are identical in both, so -only the execution backend behind `SubagentSpawner` changes. +| Dimension | A — in-process | B — out-of-process, manifest | C — out-of-process, selector | +|---|---|---|---| +| Orchestrator changes | Output-sink abstraction required | None | None | +| New code | Spawner + scope plumbing + sink | Spawn tool + manifest serialize/merge | Spawn tool + callee-side lookup | +| Deployment/ops change | None | New runner deployment | None — self-call | +| Core dependency | None | Config channel policy | Self-routing permitted | +| Isolation | None — shares process, loop, memory | Full | Full | +| Scaling | Bounded by one replica | Load-balanced | Load-balanced | +| Timeouts / cancellation | Ours to build (deferred initial ship) | HTTP layer, free | HTTP layer, free | +| Concurrency backpressure | None — unbounded fan-out in one replica | HTTP layer / Core | HTTP layer / Core | +| Latency per spawn | Tool init only | Tool init + HTTP hop + app bootstrap | Tool init + HTTP hop + app bootstrap | +| Security surface | None new — manifest never leaves the process | Caller-supplied manifest execution; needs a two-tier gate | None new — only a declared name crosses the wire | +| Attachments from a spoke | Dropped until the sink lands | Work — real `Choice` | Work — real `Choice` | +| Observability | In-process; parent's perf timer can nest | Separate request; needs trace correlation | Separate request; needs trace correlation | +| Parallel spawns | `asyncio.gather` (validated) | Concurrent HTTP calls | Concurrent HTTP calls | +| Tool init cost per spawn | Connection setup only — deployment metadata is singleton-cached; mitigable further by reusing parent tools | Connection setup, not mitigable | Connection setup, not mitigable | + +**Recommendation: C is the target; A is what is built.** The table has one clear winner and it is not the +approach with the spike behind it. C takes every operational property of B — isolation, timeouts, +backpressure, scale, working attachments, zero orchestrator changes — and drops the one thing that made B +expensive, because a selector is not a manifest and carries no new privilege. It also needs no new +deployment. Its only genuinely open question is whether Core permits a deployment to call itself, and that is +one experiment, not a design. + +A's honest case is narrower than the earlier draft claimed, and it is a case about *sequencing*, not about +which design is better: + +- The work A requires — decoupling `Orchestrator` from `Choice` via an output sink — is a refactor we want on + its own merits. It is the change that lets an orchestrator run anywhere, and it is a prerequisite for + in-process anything. That argument stands on its own; it should not be used to justify shipping A as the + subagent runtime. +- A is already spiked and validated (scope isolation, manifest compilation, parallel spawns), and it depends + on nothing outside this repository. C is blocked on a Core behavior nobody has tested yet. + +So: **run the self-routing experiment before committing further to A.** If Core permits it, C is the +shipping runtime and A's spike becomes what it always was — the thing that proved manifest compilation and +scope isolation work, plus a `Choice` refactor tracked as its own piece of work. If Core forbids it, A ships, +and the output sink, a per-spawn timeout, and a concurrency bound become prerequisites of that ship rather +than deferrals (see *Out of Scope*). + +Switching between any two of these is not a rewrite. Manifest compilation, the `subagents` config, and the +`task` tool surface are identical across all three; only the execution backend behind `SubagentSpawner` +changes. --- @@ -397,25 +549,71 @@ only the execution backend behind `SubagentSpawner` changes. Items considered but intentionally deferred, each with the reason and what a future pass would need. -### Rich error propagation from a failed spoke - -A spoke that errors — exhausts `max_iterations`, or its orchestrator raises — currently surfaces to the -coordinator as an ordinary tool-call failure or an empty result, not a typed taxonomy that distinguishes "no -answer", "timed out", and "tool failure". A structured error contract is deferred until DIAL settles the -shared error types it would build on; until then the coordinator sees a plain tool error and can retry or -reword the task. +### Structured error contract for a failed spoke + +**In scope and already built:** a spoke that fails surfaces to the coordinator as a *tool error* rather than +a successful empty result (G6, UC-4). That is the correctness floor, not a nicety — see G6 for why an empty +string is worse than an error. + +**Out of scope:** giving that error *structure*. Today the coordinator's LLM receives one prose string and +must infer from wording whether the spawn is worth retrying. It cannot distinguish "the spoke ran out of +iterations" (retry with a narrower task) from "a tool the spoke needed was down" (retry later, or not at all) +from "the task was malformed" (reword, never retry verbatim). The LLM guesses, and a guess costs a whole +extra spawn. + +**Anthropic's API solves this by making failure part of the tool-result shape rather than part of its text.** +A tool result carries an explicit `is_error` flag alongside its content, so a failure is machine-readable +before anything parses the message. Its hosted tool results go further and carry a *typed* error code from a +closed set — `max_uses_exceeded`, `execution_time_exceeded`, `overloaded`, `unavailable`, and so on — which +is a category, not a sentence. Retryability then follows from the category rather than being re-derived per +call site: the same taxonomy underpins its documented split between retryable conditions (rate limits, +overload, transient server errors) and terminal ones (malformed request, not found). The shape is worth +copying: **a flag saying it failed, a category saying what kind, and a signal saying whether trying again +could plausibly work.** + +The equivalent for a spawn would put three fields on `ToolCallResult`: + +| Field | Meaning | Example values here | +| --- | --- | --- | +| `is_error` | The call failed. Machine-readable, independent of the message text. | `true` / `false` | +| `error_category` | What kind of failure, from a closed set. | `no_answer` (budget exhausted), `timeout`, `tool_failure`, `invalid_task` | +| `is_retryable` | Whether re-running the same spawn could plausibly succeed. | `timeout` → yes; `invalid_task` → no | + +With those, the coordinator's prompt can carry one rule ("retry a retryable failure once with a narrower +task; otherwise report it") instead of relying on the LLM to read intent out of an error sentence. + +This is deferred rather than dismissed, for two reasons. It is not subagent-shaped: `ToolCallResult` is +shared by all four tool types, so adding these fields is a change to the tool contract every tool implements, +and the categories should be settled against DIAL's own error types rather than invented here. And the +categories only become distinguishable once the failures are — `timeout` cannot be a category before +per-spawn timeouts exist (below). A future pass should do the two together. ### Per-spawn timeout / cancellation `asyncio.wait_for` around the spawn is the intended enforcement point, but it is not in the initial Approach A ship. A spoke is currently bounded only by its own `max_iterations`. Addressing it needs a decision on the -timeout source (per-app config vs. an env default) and on the coordinator-facing error a timeout produces. +timeout source (per-app config vs. an env default) and on the coordinator-facing error a timeout produces — +which is the `timeout` category above, so the two should land together. + +### Concurrency bound on parallel spawns + +UC-1 actively encourages fan-out, and nothing limits it. An LLM that decides to spawn twelve scouts gets +twelve spokes, each running a full initializer pass — MCP sessions reconnecting, REST clients rebuilding — +with no semaphore and, until the item above lands, no timeout either. + +**This is Approach A's gap specifically.** Both out-of-process approaches get backpressure free from the HTTP +layer and spread the load across replicas; A concentrates all of it in the coordinator's single process and +event loop. If A ships, a spawn semaphore is a prerequisite of that ship rather than a deferral, and it needs +a decision on what the coordinator sees when it hits the cap (queue, or fail the excess spawns). ### Per-subagent `contexts` / `skills` / `hooks` -A spoke inherits all three wholesale from the coordinator (see *Proposed Design*). Letting a subagent -declaration override or narrow them — e.g. a spoke that must *not* see the coordinator's attachments — -requires a per-field merge/override policy and new schema surface on `SubagentConfig`, and is deferred. +A spoke inherits all three wholesale from the coordinator. *Proposed Design* argues this is the wrong default +— `contexts` are attachments, so inheritance sets the per-spawn token floor at the coordinator's whole +attachment set, and `hooks` written for a user conversation are a latent failure inside a spoke. Fixing it +needs a per-field merge/override policy plus new schema surface on `SubagentConfig`, and the cheap version +(make the fields required) trades away the small declaration surface that is half the point of the feature +(G2). Deferred as a known cost, not as a preference. ### Tool-level allowlists @@ -431,7 +629,7 @@ question of what to do when a named tool turns out not to exist on the connected **Preview gating.** `subagents` is a `PreviewField`: it is silently nullified during config validation unless the QuickApps backend runs with `ENABLE_PREVIEW_FEATURES=true`. An app that declares subagents while preview -is off behaves exactly as if the field were absent — no `spawn_subagent` tool is offered. +is off behaves exactly as if the field were absent — no `task` tool is offered. **Minimal coordinator manifest** with one subagent type: @@ -469,10 +667,10 @@ toolset"`), not by template id. ```json { - "name": "spawn_subagent", + "name": "task", "arguments": { "subagent_type": "web_researcher", - "task": "What problem does the Model Context Protocol solve, and what are its main primitives? Answer in five bullets." + "prompt": "What problem does the Model Context Protocol solve, and what are its main primitives? Answer in five bullets." } } ``` @@ -492,6 +690,11 @@ per-subagent `deployment_id` / `tool_sets` / `max_iterations` narrowing, and con exercise single-city, multi-city fan-out, research, and mixed spawns. It requires `ENABLE_PREVIEW_FEATURES=true` on the backend. +`analyst` is declared text-only — it computes and reports numbers, and its prompt tells it not to offer +charts. That is a constraint of the current runtime, not of the design: until the output sink lands, a +spoke's attachments are dropped (see *Current state — spike vs. target*), so a demo that advertised plots +would silently fail to deliver them. + --- ## Migration @@ -517,18 +720,28 @@ erroring. Enabling the feature requires no migration of existing apps. **New module (`src/quickapp/subagent_tooling/`)** -- `SubagentToolingModule` (`@preview_module`) — provides the `spawn_subagent` tool when subagents are +- `SubagentToolingModule` (`@preview_module`) — provides the `task` tool when subagents are declared; contributes build-time `tool_sets` validation. - `compile_subagent_manifest` — compiles a `SubagentConfig` + parent manifest into a narrowed `ApplicationConfig` (inherits `contexts` / `skills` / `hooks` / `features`; clears `subagents` / - `starters` / `conversation_starters`). + `starters` / `conversation_starters`). Callee-side in Approach C, caller-side in A and B — same function. +- `tool_set_names` / `unknown_tool_sets` — one definition of "which tool sets does this app have" and "which + ones did this subagent name that don't exist", shared by the module's build-time check (hard failure) and + the compiler (log only). Both tolerate the unresolved `PredefinedToolSet` shape the config type admits. - `SubagentSpawner` — runs a spoke in-process in an isolated request scope (Approach A). -- `_SubagentTool` / `_SubagentStageWrapper` — the `spawn_subagent` `StagedBaseTool` and its stage rendering. +- `_SubagentTool` / `_SubagentStageWrapper` — the `task` `StagedBaseTool` and its stage rendering. - `SubagentToolSetResolutionError` — raised when a non-empty allowlist resolves to no tool sets. +- `SubagentToolErrorException` — a `ToolErrorException` raised when a spawn produces no final answer, so an + answerless spoke reaches the coordinator as a tool error rather than an empty success (G6). **Wiring** - `app_factory.py` — registers `SubagentToolingModule`. - `docs/generated-app-schema.json` — regenerated for the new field. -See *Approach A — Current state — spike vs. target* for what remains to build before the feature ships. +**Open before this ships** + +1. **Test whether DIAL Core permits a deployment to call itself.** This one answer decides between Approach C + (preferred on every operational dimension) and Approach A (built, but needs the items below). +2. If Approach A ships: the output-sink abstraction, a per-spawn timeout, and a concurrency bound are + prerequisites, not deferrals. See *Approach A — Current state — spike vs. target* and *Out of Scope*. From ee5b396bb0a74f1bd7e7f086ce2d507c18a1c9c3 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 11:56:16 +0400 Subject: [PATCH 4/9] feat: refine subagent implementation --- src/quickapp/subagent_tooling/_exceptions.py | 16 +++++ .../subagent_tooling/_manifest_compiler.py | 42 ++++++++++++-- .../subagent_tooling/_subagent_spawner.py | 58 ++++++++++++++++--- .../_subagent_stage_wrapper.py | 2 +- .../subagent_tooling/_subagent_tool.py | 8 +-- src/quickapp/subagent_tooling/_tool_config.py | 15 +++-- .../subagent_tooling_module.py | 13 +++-- .../test_subagent_spike.py | 55 ++++++++++++++++-- 8 files changed, 175 insertions(+), 34 deletions(-) diff --git a/src/quickapp/subagent_tooling/_exceptions.py b/src/quickapp/subagent_tooling/_exceptions.py index e945cb5f..184b4bc5 100644 --- a/src/quickapp/subagent_tooling/_exceptions.py +++ b/src/quickapp/subagent_tooling/_exceptions.py @@ -1,3 +1,19 @@ +from quickapp.common.exceptions.tool_error import ToolErrorException + + +class SubagentToolErrorException(ToolErrorException): + """Raised when a spawn fails to produce an answer the coordinator can use. + + A spoke that exhausts ``max_iterations`` mid-tool-loop leaves no final assistant + message behind. Returning that as an empty string would reach the coordinator's LLM + as a *successful* tool result, and it would then answer from nothing — the same + confabulation failure ``SubagentToolSetResolutionError`` exists to prevent. Raising + routes the spawn through the ordinary tool-error path instead. + """ + + tool_kind = "Subagent" + + class SubagentToolSetResolutionError(RuntimeError): """Raised when a subagent's declared tool sets resolve to nothing. diff --git a/src/quickapp/subagent_tooling/_manifest_compiler.py b/src/quickapp/subagent_tooling/_manifest_compiler.py index dc624d5c..2ff2e8dc 100644 --- a/src/quickapp/subagent_tooling/_manifest_compiler.py +++ b/src/quickapp/subagent_tooling/_manifest_compiler.py @@ -3,12 +3,44 @@ from quickapp.config.application import ApplicationConfig from quickapp.config.prompt import CustomSystemPromptConfig from quickapp.config.subagent import SubagentConfig +from quickapp.config.toolsets.predefined import PredefinedToolSet +from quickapp.config.toolsets.toolset import ToolSet from ._exceptions import SubagentToolSetResolutionError logger = logging.getLogger(__name__) +def _tool_set_name(tool_set: ToolSet) -> str | None: + """The name of a resolved tool set, or ``None`` for a predefined reference. + + A ``PredefinedToolSet`` is a template pointer with a ``template_name`` and no + ``name``. ``_PredefinedConfigResolver`` expands every one into a concrete tool set + during config resolution, well before a subagent manifest is compiled — so this + returns ``None`` only for a shape that cannot reach us at runtime. The branch exists + because the declared type of ``ApplicationConfig.tool_sets`` still admits it. + """ + if isinstance(tool_set, PredefinedToolSet): + return None + return tool_set.name + + +def tool_set_names(config: ApplicationConfig) -> list[str]: + """Names of the app's resolved tool sets, sorted.""" + return sorted(name for ts in config.tool_sets if (name := _tool_set_name(ts)) is not None) + + +def unknown_tool_sets(parent: ApplicationConfig, subagent: SubagentConfig) -> list[str]: + """Tool set names a subagent declares that the app does not define. + + One definition, two call sites with deliberately different severities: + ``SubagentToolingModule`` turns a non-empty result into an initialization error, so + the builder sees the typo by name before any spawn; ``compile_subagent_manifest`` + only logs, because by the time it runs initialization has already vetoed the app. + """ + return sorted(set(subagent.tool_sets or []) - set(tool_set_names(parent))) + + def compile_subagent_manifest( parent: ApplicationConfig, subagent: SubagentConfig ) -> ApplicationConfig: @@ -29,19 +61,17 @@ def compile_subagent_manifest( if subagent.tool_sets is not None: allowed = set(subagent.tool_sets) - manifest.tool_sets = [ts for ts in manifest.tool_sets if ts.name in allowed] - unknown = allowed - {ts.name for ts in parent.tool_sets} + manifest.tool_sets = [ts for ts in manifest.tool_sets if _tool_set_name(ts) in allowed] + unknown = unknown_tool_sets(parent, subagent) if unknown: - logger.warning( - "Subagent %s references unknown tool sets: %s", subagent.name, sorted(unknown) - ) + logger.warning("Subagent %s references unknown tool sets: %s", subagent.name, unknown) if allowed and not manifest.tool_sets: # A subagent that asked for tools and got none would run anyway and # confabulate an answer from the task text alone. Fail instead. raise SubagentToolSetResolutionError( subagent_name=subagent.name, requested=sorted(allowed), - available=sorted(ts.name for ts in parent.tool_sets), + available=tool_set_names(parent), ) # Depth 1: a spoke cannot spawn. Starters are a coordinator-only concern. diff --git a/src/quickapp/subagent_tooling/_subagent_spawner.py b/src/quickapp/subagent_tooling/_subagent_spawner.py index 4b97d491..063fa448 100644 --- a/src/quickapp/subagent_tooling/_subagent_spawner.py +++ b/src/quickapp/subagent_tooling/_subagent_spawner.py @@ -1,7 +1,9 @@ import asyncio import logging +from typing import Any from aidial_sdk.chat_completion import Choice, Message, Role +from aidial_sdk.chat_completion.request import MessageContentTextPart from fastapi_injector import RequestScopeFactory from injector import Injector, inject @@ -13,19 +15,44 @@ from quickapp.core.application._request_context import _RequestContext from quickapp.core.application._request_context_setup import _RequestContextSetup +from ._exceptions import SubagentToolErrorException from ._manifest_compiler import compile_subagent_manifest logger = logging.getLogger(__name__) +class _DiscardingQueue(asyncio.Queue): # type: ignore[type-arg] + """A chunk queue that drops everything put into it. + + ``Choice.send_chunk`` calls ``put_nowait``; overriding it discards the spoke's + chunks where they are produced, so none accumulate for the life of a spawn. + """ + + def put_nowait(self, item: object) -> None: + return + + +def _as_text(content: str | list[Any] | None) -> str: + """Flatten a message's content down to the text a tool result can carry.""" + if content is None: + return "" + if isinstance(content, str): + return content + return "".join(part.text for part in content if isinstance(part, MessageContentTextPart)) + + def _headless_choice() -> Choice: """A Choice whose chunks go nowhere. SPIKE ONLY. The design calls for an output-sink abstraction so the orchestrator - stops depending on Choice at all; this stand-in lets the loop run unmodified by - draining into a queue nobody consumes. + stops depending on Choice at all; this stand-in lets the loop run unmodified. + + Note what it costs while it stands: everything the orchestrator writes to the + choice — the spoke's streamed content, its ``set_state``, and any attachment it + produced — is dropped here rather than forwarded to the coordinator. Forwarding + attachments is the concrete capability the real sink adds; see the design doc. """ - choice = Choice(asyncio.Queue(), 0) + choice = Choice(_DiscardingQueue(), 0) choice.open() return choice @@ -80,11 +107,28 @@ async def __run(self, subagent: SubagentConfig, task: str) -> str: orchestrator = self.__injector.get(Orchestrator) # type: ignore[type-abstract] await orchestrator.invoke() - return self.__final_answer(context) + return self.__final_answer(context, subagent.name) @staticmethod - def __final_answer(context: _RequestContext) -> str: + def __final_answer(context: _RequestContext, subagent_name: str) -> str: for message in reversed(context.messages): if message.role == Role.ASSISTANT and not message.tool_calls: - return message.content or "" - return "" + # `content` widens to a list of content parts for multimodal messages. + # A spoke returns one string to its caller, so join the text parts and + # let anything else (images, files) fall through to the error below — + # the tool result has no channel to carry them until the output sink + # lands. See `_headless_choice`. + text = _as_text(message.content) + if text: + return text + break + # A spoke that exhausted its iteration budget mid-tool-loop leaves no final + # message. Returning "" here would reach the coordinator's LLM as a successful + # tool result and it would answer from nothing; fail the call instead. + raise SubagentToolErrorException( + tool_name=subagent_name, + error_message=( + "The subagent produced no answer. It most likely exhausted its " + "max_iterations budget before finishing. Retry with a narrower task." + ), + ) diff --git a/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py b/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py index 7d7b8ed6..a5b43f61 100644 --- a/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py +++ b/src/quickapp/subagent_tooling/_subagent_stage_wrapper.py @@ -9,7 +9,7 @@ class _SubagentStageWrapper(TimedStageWrapper): def _get_formatted_parameters(self, parameters: dict[str, Any]) -> str: - return f"**Task:** {parameters.get('task', '')}\n\n" + return f"**Task:** {parameters.get('prompt', '')}\n\n" def _build_debug_info_from_exception(self, exception: Exception) -> str: return f"### Exception:\n\r{exception}\n\r" diff --git a/src/quickapp/subagent_tooling/_subagent_tool.py b/src/quickapp/subagent_tooling/_subagent_tool.py index 66631d45..e03c9585 100644 --- a/src/quickapp/subagent_tooling/_subagent_tool.py +++ b/src/quickapp/subagent_tooling/_subagent_tool.py @@ -45,7 +45,7 @@ async def _run_in_stage_async( **kwargs: Any, ) -> ToolCallResult: subagent_type = kwargs.get("subagent_type") - task = kwargs.get("task") + prompt = kwargs.get("prompt") subagent = self.__subagents.get(str(subagent_type)) if subagent is None: @@ -53,12 +53,12 @@ async def _run_in_stage_async( parameter_name="subagent_type", message=f"Unknown subagent '{subagent_type}'. Available: {sorted(self.__subagents)}", ) - if not task: + if not prompt: raise InvalidToolCallParameterException( - parameter_name="task", message="A task description is required." + parameter_name="prompt", message="A task description is required." ) - answer = await self.__spawner.spawn(subagent, str(task)) + answer = await self.__spawner.spawn(subagent, str(prompt)) result = ToolCallResult(content=answer, content_type="text/markdown") if stage_wrapper: diff --git a/src/quickapp/subagent_tooling/_tool_config.py b/src/quickapp/subagent_tooling/_tool_config.py index 06688f26..75f74d60 100644 --- a/src/quickapp/subagent_tooling/_tool_config.py +++ b/src/quickapp/subagent_tooling/_tool_config.py @@ -9,20 +9,23 @@ from quickapp.config.tools.display.tool import ToolDisplayConfig, ToolStageConfig from quickapp.config.tools.internal import InternalTool -SPAWN_TOOL_NAME = "spawn_subagent" +# Tool name and the free-text parameter (``prompt``) mirror Anthropic's Claude Code +# "Task" tool, whose shape this feature deliberately follows (see the design doc). +TASK_TOOL_NAME = "task" def build_spawn_tool_config(subagents: list[SubagentConfig]) -> InternalTool: """One tool for every declared subagent type, selected by ``subagent_type``. - Matches Claude Code's shape: a flat tool catalogue that does not grow as the - builder adds subagent types, with routing carried by the enum descriptions. + Matches Claude Code's "Task" tool shape: a flat tool catalogue that does not grow + as the builder adds subagent types, with routing carried by the enum descriptions, + and a ``prompt`` parameter carrying the delegated task. """ catalogue = "\n".join(f"- {s.name}: {s.description}" for s in subagents) return InternalTool( open_ai_tool=OpenAiToolConfig( function=OpenAiToolFunction( - name=SPAWN_TOOL_NAME, + name=TASK_TOOL_NAME, description=( "Delegate a self-contained task to a subagent. The subagent works in " "its own isolated context and returns only its final answer — its " @@ -37,7 +40,7 @@ def build_spawn_tool_config(subagents: list[SubagentConfig]) -> InternalTool: description="Which subagent to spawn.", enum=[s.name for s in subagents], ), - "task": ConfigurableSchemaSimpleType( + "prompt": ConfigurableSchemaSimpleType( type=JsonTypeEnum.string, description=( "The complete task for the subagent. It sees nothing but " @@ -46,7 +49,7 @@ def build_spawn_tool_config(subagents: list[SubagentConfig]) -> InternalTool: ), ), }, - required=["subagent_type", "task"], + required=["subagent_type", "prompt"], ), ) ), diff --git a/src/quickapp/subagent_tooling/subagent_tooling_module.py b/src/quickapp/subagent_tooling/subagent_tooling_module.py index 86a7cb26..bd8b0b87 100644 --- a/src/quickapp/subagent_tooling/subagent_tooling_module.py +++ b/src/quickapp/subagent_tooling/subagent_tooling_module.py @@ -9,10 +9,11 @@ from quickapp.config.application import ApplicationConfig from quickapp.config.subagent import SubagentConfig +from ._manifest_compiler import tool_set_names, unknown_tool_sets from ._subagent_spawner import SubagentSpawner from ._subagent_stage_wrapper import _SubagentStageWrapper from ._subagent_tool import _SubagentTool -from ._tool_config import SPAWN_TOOL_NAME, build_spawn_tool_config +from ._tool_config import TASK_TOOL_NAME, build_spawn_tool_config logger = logging.getLogger(__name__) @@ -39,18 +40,18 @@ def _provide_initialization_exceptions( Caught here, the app builder sees a named bad reference. Caught at spawn time, they see a subagent that answered without tools. """ - available = {ts.name for ts in app_config.tool_sets} + available = tool_set_names(app_config) exceptions: list[InitializationException] = [] for subagent in app_config.subagents or []: - unknown = sorted(set(subagent.tool_sets or []) - available) + unknown = unknown_tool_sets(app_config, subagent) if unknown: exceptions.append( ToolInitializationException( message=( f"Subagent '{subagent.name}' references tool sets that do not exist " - f"in this app: {unknown}. Available: {sorted(available) or '(none)'}." + f"in this app: {unknown}. Available: {available or '(none)'}." ), - tool_name=SPAWN_TOOL_NAME, + tool_name=TASK_TOOL_NAME, ) ) return exceptions @@ -66,6 +67,6 @@ def _provide_subagent_tools( return [ tool_builder.build( tool_config=build_spawn_tool_config(subagents), - name=SPAWN_TOOL_NAME, + name=TASK_TOOL_NAME, ) ] diff --git a/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py index 8f1738e3..3b39d3cb 100644 --- a/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py +++ b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py @@ -26,10 +26,13 @@ from quickapp.core.agent.orchestrator import Orchestrator from quickapp.core.application._request_context import _RequestContext from quickapp.dial_core_services.tool_config_service import ToolConfigCoreService -from quickapp.subagent_tooling._exceptions import SubagentToolSetResolutionError +from quickapp.subagent_tooling._exceptions import ( + SubagentToolErrorException, + SubagentToolSetResolutionError, +) from quickapp.subagent_tooling._manifest_compiler import compile_subagent_manifest from quickapp.subagent_tooling._subagent_spawner import SubagentSpawner -from quickapp.subagent_tooling._tool_config import SPAWN_TOOL_NAME +from quickapp.subagent_tooling._tool_config import TASK_TOOL_NAME from tests.unit_tests.common.common import create_app_configuration RESEARCHER = SubagentConfig( @@ -166,7 +169,7 @@ async def test_spawn_tool_is_offered_to_the_coordinator(monkeypatch): await invoke_initializers(injector, InitializerType.completion) tools = injector.get(list[StagedBaseTool]) - spawn_tools = [t for t in tools if t.openai_function_name() == SPAWN_TOOL_NAME] + spawn_tools = [t for t in tools if t.openai_function_name() == TASK_TOOL_NAME] assert len(spawn_tools) == 1 function = spawn_tools[0].tool_config.open_ai_tool.function @@ -192,7 +195,7 @@ async def test_no_spawn_tool_without_declared_subagents(monkeypatch): tools = injector.get(list[StagedBaseTool]) - assert [t for t in tools if t.openai_function_name() == SPAWN_TOOL_NAME] == [] + assert [t for t in tools if t.openai_function_name() == TASK_TOOL_NAME] == [] @pytest.mark.asyncio @@ -269,3 +272,47 @@ async def test_dangling_tool_set_reference_is_reported_at_initialization(monkeyp messages = [str(e) for e in exceptions] assert any("Fetch MCP toolset" in m and "researcher" in m for m in messages), messages + + +class _SilentOrchestrator: + """A spoke that ends its run without a final assistant message. + + This is what exhausting ``max_iterations`` mid-tool-loop leaves behind. + """ + + @inject + def __init__(self) -> None: + pass + + async def invoke(self) -> None: + return + + +@pytest.mark.asyncio +async def test_spawn_without_a_final_answer_fails_the_tool_call(monkeypatch): + """An answerless spoke must surface as a tool error, not an empty success. + + Returning "" would reach the coordinator's LLM as a successful result, and it + would then answer from nothing. + """ + monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true") + + injector = _test_injector() + injector.binder.bind(Orchestrator, to=_SilentOrchestrator) # type: ignore[type-abstract,arg-type] + scope_factory = injector.get(RequestScopeFactory) + + async with scope_factory.create_scope(): + context = injector.get(_RequestContext) + context.api_key = SecretStr("key") + context.bearer = None + context.forwarded_headers = {} + context.application_config = _parent_config() + context.messages = [Message(role=Role.USER, content="do the thing")] + + spawner = injector.get(SubagentSpawner) + + with pytest.raises(SubagentToolErrorException) as excinfo: + await spawner.spawn(RESEARCHER, "Find out who broke the build.") + + assert "max_iterations" in excinfo.value.user_facing_message + assert "researcher" in excinfo.value.user_facing_message From f842342f05f6dcd046d404a99fce1c7b1d5aaa9c Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 11:57:18 +0400 Subject: [PATCH 5/9] feat: regenerate app schema with subagents config --- docs/generated-app-schema.json | 95 +++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/docs/generated-app-schema.json b/docs/generated-app-schema.json index 3ffd8214..3d80c25f 100644 --- a/docs/generated-app-schema.json +++ b/docs/generated-app-schema.json @@ -3202,6 +3202,76 @@ "title": "StopStrategyModel", "type": "object" }, + "SubagentConfig": { + "description": "A subagent type declared by the app builder.\n\nA spoke inherits the coordinator's ``contexts`` / ``skills`` / ``hooks`` /\n``features`` wholesale (see ``compile_subagent_manifest``), so there is no\nfield for them here. Per-subagent *narrowing* of those, and a tool-level\n(rather than toolset-level) allowlist, are intentionally out of scope — see\n``docs/designs/anonymous_subagents.md``.", + "properties": { + "name": { + "description": "Identifier the coordinator uses to select this subagent.", + "title": "Name", + "type": "string" + }, + "description": { + "description": "When to use this subagent. Surfaced to the coordinator's LLM for routing.", + "title": "Description", + "type": "string" + }, + "system_prompt": { + "description": "The subagent's instructions. Replaces the app system prompt, never appends.", + "title": "System Prompt", + "type": "string" + }, + "tool_sets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Names of the app's tool sets this subagent may use. When unset, the subagent inherits every tool set.", + "title": "Tool Sets" + }, + "deployment_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Deployment for this subagent. When unset, the coordinator's is inherited.", + "title": "Deployment Id" + }, + "max_iterations": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Iteration budget for this subagent. When unset, the coordinator's is inherited.", + "title": "Max Iterations" + } + }, + "required": [ + "name", + "description", + "system_prompt" + ], + "title": "SubagentConfig", + "type": "object" + }, "TTLRefreshCondition": { "properties": { "kind": { @@ -3757,6 +3827,27 @@ "dial:propertyOrder": 7 } }, + "subagents": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/SubagentConfig" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Subagent types this app may spawn. Each spawn runs its own orchestrator loop in an isolated context and returns a single result.", + "title": "Subagents", + "x-preview": true, + "dial:meta": { + "dial:propertyKind": "server", + "dial:propertyOrder": 8 + } + }, "features": { "anyOf": [ { @@ -3769,7 +3860,7 @@ "description": "QuickApps Agent features configuration.", "dial:meta": { "dial:propertyKind": "server", - "dial:propertyOrder": 8 + "dial:propertyOrder": 9 } }, "tool_defaults": { @@ -3795,7 +3886,7 @@ "type": "object", "dial:meta": { "dial:propertyKind": "server", - "dial:propertyOrder": 9 + "dial:propertyOrder": 10 } } }, From 884e04b9cd5c53d7792f1685c94d04d30f23dc75 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 16:15:18 +0400 Subject: [PATCH 6/9] doc: refine resign doc --- docs/designs/anonymous_subagents.md | 343 +++++++++++----------------- 1 file changed, 133 insertions(+), 210 deletions(-) diff --git a/docs/designs/anonymous_subagents.md b/docs/designs/anonymous_subagents.md index 048b0cf9..e0bf1067 100644 --- a/docs/designs/anonymous_subagents.md +++ b/docs/designs/anonymous_subagents.md @@ -3,46 +3,37 @@ - **Status:** Draft - **Dependencies:** None -> **What changed in this revision.** The problem statement now names the gap this feature actually closes -> (authoring: declaring a helper inline instead of deploying one) rather than context economics, which -> `DeploymentTool` already delivers against a second QuickApp. A third implementation — **Approach C**, -> out-of-process via self-call with a *selector* instead of an injected manifest — is added, and it changes -> the recommendation: C wins on every operational dimension and carries none of Approach B's security cost. -> The recommendation is therefore no longer "build A"; it is "answer one Core question, then pick". - ## Problem Statement -Multi-step work in a QuickApp gets more expensive and worse the longer it runs. Every step's raw material -stays in the conversation: fetched pages, SQL dumps, file contents, retries that failed before one worked. -That history is resent on every subsequent LLM call, so token spend grows superlinearly with task length, -and the model's attention is increasingly spent on material that stopped being relevant many steps ago. - -**We can already fix that, and the fix is the problem.** `DeploymentTool` (`dial_deployment_tooling/`) takes -a `query` string, calls another DIAL deployment, and returns only that deployment's final content. Point it -at a second QuickApp and you have delegation with every property this design wants: the callee runs its own -system prompt, model, tool set, and iteration budget, in its own context window, and the caller sees only -the answer. Context confinement is not the gap. **Authoring is.** +Long multi-step work degrades any agent that runs it in one conversation. Every tool result stays in the +history — fetched pages, query dumps, file contents, and the failed calls that preceded a working one — and +is resent on every subsequent LLM call, so cost grows superlinearly with task length while the model's +attention drifts to results that stopped mattering many steps ago. -To use that today, an app builder who wants three helpers must create, permission, and deploy three -QuickApps in DIAL Core; keep three manifests in sync with the parent that calls them; and re-deploy to change -a helper's prompt by one sentence. Every helper must be foreseen and provisioned before the app that needs it -exists. For a non-technical builder working in the configurator that is not a workflow — it is a reason not -to decompose the work at all, which leaves them with the single long-context loop and the bill that comes -with it. +The general fix is settled: delegate a sub-task to a separate agent with its own context window, and take +back only its result. Agentic frameworks ship this as a **spawn primitive** — an ephemeral, caller-configured +worker, declared inline by the agent that uses it and registered nowhere. -So the problem is not *"we cannot confine a sub-task's context."* It is: **a builder cannot declare a helper -agent in the manifest they are already editing.** Other agentic frameworks solve this with a spawn primitive -— an ephemeral, caller-configured worker with no separate registration. We have no equivalent, and that -absence is what this design fills. +QuickApps has the delegation half and not the declaration half. `DeploymentTool` +(`dial_deployment_tooling/`) already calls another DIAL deployment with a `query` and returns only its final +content; pointed at a second QuickApp, it gives the callee its own system prompt, model, tool set, and +iteration budget, and confines every intermediate step. But to use it, a builder who wants three helpers must +create, permission, and deploy three QuickApps in DIAL Core, keep three manifests in sync with the parent, +and re-deploy to change a helper's prompt by one sentence — every helper foreseen and provisioned before the +app that needs it exists. For a non-technical builder in the configurator, that is a reason not to decompose +the work at all. -The context-economics benefit follows for free, because it is the same benefit the deployment tool already -delivers. It is the reason the feature is worth having; it is not the reason it needs building. +So the gap is not confinement. It is authoring: **a builder cannot declare a helper agent in the manifest +they are already editing.** That absence is what this design fills; the context savings follow from +delegation itself. ## Concepts -**Subagent** — an agent instance the coordinator invokes to carry out one scoped task. It runs its own -orchestrator loop with its own conversation; the coordinator hands over a task description and gets back a -single result. Tool calls, fetched documents, and retries live and die inside the subagent. +**Subagent** — a separate agent run the coordinator starts to carry out one scoped task. It executes its own +orchestrator loop over its own conversation; the coordinator hands over a task description and gets back a +single result. Tool calls, fetched documents, and retries live and die inside the subagent. What is separate +is the conversation and the orchestrator loop, not necessarily the process — where a spoke executes is an +implementation choice (see *Proposed Design*). **Anonymous** — the subagent is not a deployment. It has no id anyone can call, nothing registered in DIAL Core, and no state that survives the call. It exists only for the duration of one spawn. This is the @@ -53,6 +44,9 @@ advance. subagents, integrates their results, and is the only party that talks to the user. A role, not a new component: any QuickApp becomes one once its manifest declares subagents. +**Hub-and-spoke** — the multi-agent architecture this design adopts: one coordinator at the center, subagents +around it, and every exchange running between the center and one spoke. + ### Hub-and-spoke The coordinator is the hub; each spawned subagent is a spoke. All communication is radial: @@ -84,27 +78,21 @@ flowchart LR s1 -->|result| coord s2 -->|result| coord s3 -->|result| coord - - s1 -.->|no user conversation| user ``` -Every arrow is radial: tasks flow hub→spoke, results flow spoke→hub, and only the hub talks to the user. -There are no spoke-to-spoke edges, and the dashed edge is the one that does *not* exist — a spoke never -reaches the user. - ### Declaring a subagent The app builder declares subagent *types* in the manifest, the same shape Claude Code uses for `.claude/agents`. The LLM does not invent a subagent; it chooses a declared one and writes its task. -| Field | Meaning | -| --- | --- | -| `name` | Identifier the coordinator uses to select this type. | -| `description` | *When to use this subagent.* Surfaced to the coordinator's LLM — this is the routing mechanism. | -| `system_prompt` | The spoke's instructions. Replaces the app's system prompt; it is not appended to it. | -| `tool_sets` | Names of the app's tool sets this spoke may use. Omitted = inherit all. | -| `deployment_id` | DIAL deployment (model) for this spoke. Omitted = inherit the coordinator's. | -| `max_iterations` | The spoke's own budget, independent of the coordinator's. | +| Field | Purpose | +|------------------|-----------------------------------------------------------------------------------------------| +| `name` | Identifier the coordinator uses to select this type. | +| `description` | When to use this subagent. Surfaced to the coordinator's LLM — this is the routing mechanism. | +| `system_prompt` | The spoke's instructions. Replaces the app's system prompt; it is not appended to it. | +| `tool_sets` | Names of the app's tool sets this spoke may use. Omitted = inherit all. | +| `deployment_id` | DIAL deployment (model) for this spoke. Omitted = inherit the coordinator's. | +| `max_iterations` | The spoke's own budget, independent of the coordinator's. | At runtime the coordinator gets one tool — `task(subagent_type, prompt)` — whose `subagent_type` enumerates the declared types. It returns the spoke's final message as a string. @@ -121,12 +109,11 @@ initialization, which is a different (and larger) mechanism; see *Out of Scope*. ## Design Goals -The design succeeds when all of the following hold. Each is independently verifiable, and several already +The design succeeds when all the following hold. Each is independently verifiable, and several already have spike tests in `src/tests/unit_tests/subagent_tooling_tests/`. -**G1 and G2 are the goals that distinguish this feature from a DIAL deployment tool pointed at a second -QuickApp. G3–G5 restate properties the deployment-tool route already has** — they are listed because the -design must not lose them, not because building it is how we get them. +G1 and G2 distinguish this feature from a DIAL deployment tool pointed at a second QuickApp. G3–G5 are +properties the deployment-tool route already has; they are listed because the design must not lose them. - **G1 — No advance registration.** Spawning a subagent requires no separate DIAL deployment, no prior registration in DIAL Core, and no second manifest to keep in sync. The spoke's manifest is compiled from @@ -154,14 +141,14 @@ design must not lose them, not because building it is how we get them. - **G7 — Depth capped at 1.** A spoke cannot spawn: the compiled subagent manifest has `subagents = None`. *(Verifiable: `compile_subagent_manifest` clears `subagents`.)* - **Why cap it.** Two honest reasons, one good and one temporary. The good one: a depth cap makes the cost of - a turn bounded and predictable — with recursion, one coordinator decision can fan out into an unbounded - tree, which is exactly the runaway spend the feature exists to reduce. The temporary one: under Approach A - the spokes share the coordinator's process and event loop, and there is no timeout yet (see *Out of - Scope*), so depth is the only structural bound we have. The cap is cheap to relax later — it is one - assignment in `compile_subagent_manifest` — and relaxing it should wait until per-spawn timeouts and a - concurrency bound exist. Its cost is real and should be stated: a builder who genuinely needs two levels of - decomposition is pushed back to deployed apps, which is the workflow G1 exists to remove. + **Why cap it.** A depth cap makes the cost of a turn bounded and predictable — with recursion, one + coordinator decision can fan out into an unbounded tree, which is exactly the runaway spend the feature + exists to reduce. Under Approach A there is a second, temporary reason: spokes share the coordinator's + process and event loop, and there is no timeout yet (see *Out of Scope*), so depth is the only structural + bound available. The cap is cheap to relax later — one assignment in `compile_subagent_manifest` — and + relaxing it should wait until per-spawn timeouts and a concurrency bound exist. Its cost: a builder who + needs two levels of decomposition is pushed back to deployed apps, which is the workflow G1 exists to + remove. --- @@ -203,7 +190,7 @@ Four cases the design must handle: - **A spoke that produces no answer.** The spoke exhausts `max_iterations` mid-tool-loop, so its conversation ends on a tool call and there is no final assistant message to return. `SubagentSpawner` raises - `SubagentToolErrorException` rather than returning `""`. This matters more than it looks: an empty string + `SubagentToolErrorException` rather than returning `""`. An empty string would reach the coordinator's LLM as a *successful* tool result, and the coordinator would compose an answer out of nothing — indistinguishable, from the user's side, from a spoke that genuinely had nothing to say. Failing loudly turns a silent wrong answer into a tool error the coordinator can retry or reword. @@ -224,14 +211,14 @@ Four cases the design must handle: ## Proposed Design -Three implementations are on the table. They share the whole user-facing surface described in *Concepts* — +Two implementations are on the table. They share the whole user-facing surface described in *Concepts* — the builder declares subagent types in the manifest, the coordinator's LLM calls one `task` tool — and -differ only in **where the spoke runs**, and (for the two out-of-process variants) **what crosses the wire to -get it there**. +differ only in **where the spoke runs**. ### Shared: a subagent is an `ApplicationConfig` -Both approaches compile a declared subagent type plus the coordinator's task into a full QuickApp manifest: +Both approaches compile a declared subagent type plus the coordinator's task into a full QuickApp +manifest: | Manifest field | Source | |---|---| @@ -247,15 +234,14 @@ Both approaches compile a declared subagent type plus the coordinator's task int | `subagents` | always `None` — depth 1, a spoke cannot spawn | | `starters`, `conversation_starters` | always **cleared** — coordinator↔user UI concerns; a spoke has no user conversation to seed | -**Inherited vs. cleared, resolved.** `compile_subagent_manifest` deep-copies the coordinator's manifest, then +**Inherited vs. cleared.** `compile_subagent_manifest` deep-copies the coordinator's manifest, then overrides only what the subagent declares and clears exactly three fields: `subagents` (the depth cap), and `starters` / `conversation_starters` (both are coordinator-facing conversation UI, meaningless to a spoke that never talks to the user). Everything else — `contexts`, `skills`, `hooks`, and `features` — is inherited wholesale, so a spoke sees the same attached files, skill library, lifecycle hooks, and feature toggles as the coordinator. -**Inherit-everything is the wrong default, and we are shipping it anyway.** Two of the four inherited fields -argue against it: +**The cost of inherit-everything.** Two of the four inherited fields argue against this default: - **`contexts` are attached files** — the single largest source of the token bloat this feature exists to reduce. Under inheritance, a spoke spawned to average five numbers still carries every document attached to @@ -265,18 +251,17 @@ argue against it: - **`hooks` are lifecycle callbacks written against a user conversation.** A spoke has no user and no real `Choice`. A hook that assumes either is a latent failure inside a spawn, not a missing feature. -The confinement-first default would be the opposite: inherit nothing but what the subagent declares. We are -not shipping that, for one reason — it would make `contexts` and `skills` required fields on every subagent -declaration, and the declaration surface is the thing this feature is trying to keep small (G2). Getting the -default right needs a per-field merge/override policy, which is a larger change than the one being made here -(see *Out of Scope*). Until then this is a known cost, not a considered preference: builders should assume a -spoke pays for the coordinator's attachments, and hooks should be reviewed for spoke-safety before being -combined with subagents. +The confinement-first default would be the opposite: inherit nothing but what the subagent declares. That +default is not taken here because it would make `contexts` and `skills` required fields on every subagent +declaration, and keeping the declaration surface small is half the point of G2. Getting the default right +needs a per-field merge/override policy, which is a larger change than the one made here (see *Out of +Scope*). Until then this is a known cost: builders should assume a spoke pays for the coordinator's +attachments, and hooks should be reviewed for spoke-safety before being combined with subagents. -Two consequences worth naming up front. First, the tool allowlist needs no new filtering machinery in any of -the approaches: it is expressed by narrowing `tool_sets` in the compiled manifest. Second, running a spoke is -exactly "run one QuickApp request against this manifest" — so the three approaches are three answers to -*where that request executes and how the manifest gets there*, not three different feature designs. +Two consequences follow. First, the tool allowlist needs no new filtering machinery in either approach: it is +expressed by narrowing `tool_sets` in the compiled manifest. Second, running a spoke is exactly "run one +QuickApp request against this manifest" — so the two approaches are two answers to *where that request +executes*, not two different feature designs. ### Approach A — in-process spawn @@ -312,15 +297,14 @@ subagent output sink. `src/quickapp/subagent_tooling/` (`SubagentSpawner`), including the scope-isolation claim in step 2 for parallel spawns. The spike runs the **unmodified** orchestrator by handing the child scope a throwaway `Choice` (`_headless_choice()`, marked *SPIKE ONLY*) whose chunks are discarded where they are produced. -What the spike proves is scope isolation and manifest compilation; what it defers is the output sink. +The spike covers scope isolation and manifest compilation; it defers the output sink. -**What the placeholder currently costs, stated plainly.** Everything the orchestrator writes to a spoke's -`Choice` is dropped: its streamed content (harmless — the final answer is read back off -`_RequestContext.messages` instead), its `set_state` (harmless — spokes are stateless by design), and **any -attachment it produced (not harmless)**. A spoke that generates a chart or a file has no way to return it; -only text crosses back to the coordinator. That is a real capability gap, not a cosmetic one, and it is why -the `subagent_demo` app's `analyst` is declared text-only rather than advertising charts. Approaches B and C -do not have this gap at all — a spoke there has a real `Choice` because it is a real request. +**What the placeholder costs.** Everything the orchestrator writes to a spoke's `Choice` is dropped: its +streamed content (harmless — the final answer is read back off `_RequestContext.messages` instead), its +`set_state` (harmless — spokes are stateless by design), and **any attachment it produced (not harmless)**. +A spoke that generates a chart or a file has no way to return it; only text crosses back to the coordinator. +That capability gap is why the `subagent_demo` app's `analyst` is declared text-only rather than advertising +charts. Approach B does not have this gap — a spoke there has a real `Choice` because it is a real request. Before the feature ships on Approach A, the orchestrator must stop writing to `Choice` directly — the **output-sink abstraction** below is the required production change, and it removes `_headless_choice()`. @@ -339,7 +323,7 @@ Before the feature ships on Approach A, the orchestrator must stop writing to `C mirrors the sequence `_QuickAppCompletion.chat_completion` runs (setup → initializers → messages → orchestrator), but populates `_RequestContext` directly and calls `setup_messages` rather than adding a fake-`Request` entry point to `setup_context`. *(Decision: direct population, not a synthetic request — - see Semantics step 3. This supersedes the earlier "non-HTTP entry point" sketch.)* + see Semantics step 3.)* **Costs and risks.** @@ -347,8 +331,8 @@ Before the feature ships on Approach A, the orchestrator must stop writing to `C clients rebuild, DIAL app resolution repeats. Deployment metadata is the exception — it is served from `OrchestratorDeploymentCacheService`, a **singleton** (`agent_module.py:115`), so that lookup is cached across scopes and costs nothing after the first spawn. The per-spawn cost is therefore connection setup, - not metadata resolution. Shared with B and C, but only A can mitigate it further by reusing selected - parent tool instances. + not metadata resolution. Shared with B, but only A can mitigate it further by reusing selected parent tool + instances. - **Timeouts are out of scope for the initial ship.** `asyncio.wait_for` around the spawn is the intended mechanism, but enforcing it (and surfacing a clean timeout error to the coordinator) is deferred — see *Out of Scope*. Until then a runaway spoke is bounded only by its own `max_iterations`. @@ -358,10 +342,11 @@ Before the feature ships on Approach A, the orchestrator must stop writing to `C - Scope leakage is a silent-correctness hazard: any dependency accidentally resolved against the parent scope from inside a child task is cross-contamination that tests will not obviously catch. -### Approach B — out-of-process spawn, manifest injected +### Approach B — separate QuickApp deployment, configured per request -**What.** The spoke is a real QuickApp chat-completion request against a deployment, configured entirely by -the coordinator at call time: the coordinator compiles a manifest and sends it with the request. +**What.** The spoke is a real chat-completion request against a *second QuickApp deployment* — a generic +"subagent runner" whose own manifest is trivial, because the coordinator configures it at call time: the +coordinator compiles a manifest and sends it with the request. **Owner.** `subagent_tooling/` on the caller side; `_RequestContextSetup` on the callee side. @@ -386,9 +371,9 @@ the coordinator at call time: the coordinator compiles a manifest and sends it w opts out of Core injecting properties into sub-calls (`config/application.py:247`), and whether Core forwards a caller-supplied value is a Core policy question outside our control. -**Which deployment?** A dedicated "subagent runner" QuickApp deployment with a trivial manifest. (Self-call -— the coordinator's own deployment id — is also possible, and turns out to remove the need to inject a -manifest at all; that is Approach C below.) +**Which deployment?** A dedicated runner: one generic QuickApp deployment, operated alongside the +coordinator, whose own manifest is a placeholder because every spawn overrides it. It is provisioned once, +not per subagent type — all of an installation's spokes, from every coordinator, run against the same runner. **Change.** @@ -408,9 +393,8 @@ external-fetch naming (`EXTERNAL_URL_FETCH_ENABLED` / `features.external_url_fet fields are an admin switch `SUBAGENT_MANIFEST_INJECTION_ENABLED` and a per-app `features.subagents.accept_injected_manifest`, with the admin switch as a hard cap. -These fields exist only for Approach B. Approach A never exposes an injection endpoint, and **Approach C -removes the boundary rather than gating it** — it sends a declared name, not a manifest, so there is nothing -to gate. If out-of-process is the direction, C is the way to get there. +These fields exist only for Approach B — Approach A never exposes an injection endpoint, because the manifest +never leaves the process. **Costs and risks.** @@ -428,106 +412,47 @@ to gate. If out-of-process is the direction, C is the way to get there. it is a real request — Approach A's largest change simply does not exist here. - Per-spawn cost and usage are already visible to Core as an ordinary deployment call. -### Approach C — out-of-process spawn, selector not manifest - -**What.** Approach B without the manifest channel. The coordinator calls **its own deployment id**, passing -only the *name* of a declared subagent plus the task. The callee resolves its manifest the way every -QuickApp request already does — `_RequestContextSetup` reads it from the deployment's own application -properties (`_request_context_setup.py:59`) — and because the callee *is* the coordinator's deployment, that -manifest already contains the `subagents[]` declarations. It looks the name up and calls the same -`compile_subagent_manifest` the other approaches call, on the callee side. - -**Owner.** `subagent_tooling/` on the caller side; `_RequestContextSetup` on the callee side. - -**Semantics.** - -1. The LLM calls `task(subagent_type, prompt)` — identical surface to A and B. -2. `SubagentTool` delegates to `DialCompletionService`, targeting the coordinator's own deployment id, with - the task as the user message and `custom_fields.configuration = {"subagent_type": "", "depth": 1}`. -3. `setup_context` sees a `subagent_type`, looks it up in the manifest it just resolved for itself, and - applies `compile_subagent_manifest`. An unknown name is rejected there. -4. The spoke streams back; the coordinator's stream handler renders it into the tool stage — this already - works for deployment tools — and the final content becomes the tool result. - -**Why this is the interesting option: the trust boundary disappears.** Approach B's whole cost is that a -deployment which merges caller-supplied manifests will execute *any* manifest anyone with an API key posts to -it — naming any deployment and any tool, under their own key. That is what forces the two-tier gate -(`SUBAGENT_MANIFEST_INJECTION_ENABLED` plus a per-app feature flag) and makes B a bigger commitment than A. - -Approach C never accepts a manifest. The only caller-supplied value is a string that must match a name the -app's *own server-authored* manifest declares. The worst an attacker with a valid key can do is run a -subagent the app already offers — which they could equally get by asking the coordinator to delegate. **No -new privilege, so no new gate, no new env var, and no new per-app feature flag.** The entire security -argument that decided this design against B does not apply to C. - -**Change.** - -- `ApplicationConfig` gains `subagents` (shared with A and B). -- New `SubagentTool` that passes a selector — strictly less code than B's manifest compiler-and-serializer, - because compilation moves to the callee and is the function we already have. -- `_RequestContextSetup` applies `compile_subagent_manifest` when the request carries a `subagent_type`. -- The deployment publishes a configuration schema via `configuration_support/` for the two selector fields. - -**Costs and risks.** - -- **Depends on Core permitting a deployment to call itself.** This is the one genuinely open question and it - should be answered before this comparison is treated as settled — it is a single experiment, not a design - problem. -- Extra HTTP hop and a full application bootstrap per spawn (shared with B). -- Debuggability: a spoke failure is a different request's stack trace; correlation needs a threaded trace id - (shared with B). -- Recursion guard is required but trivial and lands in the same place as the depth cap: when - `subagent_type` is set, the compiled manifest has `subagents = None`, so a spoke cannot spawn. - -**Gains.** Every gain of B — process isolation, HTTP timeouts for free, horizontal scale, backpressure from -the HTTP layer, no orchestrator changes, per-spawn usage visible to Core — with none of B's security surface -and no new deployment to operate. - ### Comparison -| Dimension | A — in-process | B — out-of-process, manifest | C — out-of-process, selector | -|---|---|---|---| -| Orchestrator changes | Output-sink abstraction required | None | None | -| New code | Spawner + scope plumbing + sink | Spawn tool + manifest serialize/merge | Spawn tool + callee-side lookup | -| Deployment/ops change | None | New runner deployment | None — self-call | -| Core dependency | None | Config channel policy | Self-routing permitted | -| Isolation | None — shares process, loop, memory | Full | Full | -| Scaling | Bounded by one replica | Load-balanced | Load-balanced | -| Timeouts / cancellation | Ours to build (deferred initial ship) | HTTP layer, free | HTTP layer, free | -| Concurrency backpressure | None — unbounded fan-out in one replica | HTTP layer / Core | HTTP layer / Core | -| Latency per spawn | Tool init only | Tool init + HTTP hop + app bootstrap | Tool init + HTTP hop + app bootstrap | -| Security surface | None new — manifest never leaves the process | Caller-supplied manifest execution; needs a two-tier gate | None new — only a declared name crosses the wire | -| Attachments from a spoke | Dropped until the sink lands | Work — real `Choice` | Work — real `Choice` | -| Observability | In-process; parent's perf timer can nest | Separate request; needs trace correlation | Separate request; needs trace correlation | -| Parallel spawns | `asyncio.gather` (validated) | Concurrent HTTP calls | Concurrent HTTP calls | -| Tool init cost per spawn | Connection setup only — deployment metadata is singleton-cached; mitigable further by reusing parent tools | Connection setup, not mitigable | Connection setup, not mitigable | - -**Recommendation: C is the target; A is what is built.** The table has one clear winner and it is not the -approach with the spike behind it. C takes every operational property of B — isolation, timeouts, -backpressure, scale, working attachments, zero orchestrator changes — and drops the one thing that made B -expensive, because a selector is not a manifest and carries no new privilege. It also needs no new -deployment. Its only genuinely open question is whether Core permits a deployment to call itself, and that is -one experiment, not a design. - -A's honest case is narrower than the earlier draft claimed, and it is a case about *sequencing*, not about -which design is better: - -- The work A requires — decoupling `Orchestrator` from `Choice` via an output sink — is a refactor we want on - its own merits. It is the change that lets an orchestrator run anywhere, and it is a prerequisite for - in-process anything. That argument stands on its own; it should not be used to justify shipping A as the - subagent runtime. -- A is already spiked and validated (scope isolation, manifest compilation, parallel spawns), and it depends - on nothing outside this repository. C is blocked on a Core behavior nobody has tested yet. - -So: **run the self-routing experiment before committing further to A.** If Core permits it, C is the -shipping runtime and A's spike becomes what it always was — the thing that proved manifest compilation and -scope isolation work, plus a `Choice` refactor tracked as its own piece of work. If Core forbids it, A ships, -and the output sink, a per-spawn timeout, and a concurrency bound become prerequisites of that ship rather -than deferrals (see *Out of Scope*). - -Switching between any two of these is not a rewrite. Manifest compilation, the `subagents` config, and the -`task` tool surface are identical across all three; only the execution backend behind `SubagentSpawner` -changes. +| Dimension | A — in-process | B — separate QuickApp, configured per request | +|---|---|---| +| Orchestrator changes | Output-sink abstraction required | None | +| New code | Spawner + scope plumbing + sink | Spawn tool + manifest serialize/merge | +| Deployment/ops change | None | New runner deployment | +| Core dependency | None | Config channel policy | +| Isolation | None — shares process, loop, memory | Full | +| Scaling | Bounded by one replica | Load-balanced | +| Timeouts / cancellation | Ours to build (deferred initial ship) | HTTP layer, free | +| Concurrency backpressure | None — unbounded fan-out in one replica | HTTP layer / Core | +| Latency per spawn | Tool init only | Tool init + HTTP hop + app bootstrap | +| Security surface | None new — manifest never leaves the process | Caller-supplied manifest execution; needs a two-tier gate | +| Attachments from a spoke | Dropped until the sink lands | Work — real `Choice` | +| Observability | In-process; parent's perf timer can nest | Separate request; needs trace correlation | +| Parallel spawns | `asyncio.gather` (validated) | Concurrent HTTP calls | +| Tool init cost per spawn | Connection setup only — deployment metadata is singleton-cached; mitigable further by reusing parent tools | Connection setup, not mitigable | + +**Recommendation: A.** B is the better runtime on every operational dimension — isolation, free HTTP +timeouts, backpressure, horizontal scale, working attachments, and no orchestrator changes at all — and none +of that is disputed. It is not the recommendation because of what it costs to get there: + +- **A new trust boundary.** Merging caller-supplied manifests means the runner executes whatever manifest an + API key posts to it, which forces the two-tier gate (`SUBAGENT_MANIFEST_INJECTION_ENABLED` plus a per-app + flag) and a security review of a genuinely new attack surface. A never opens that endpoint. +- **A new deployment to operate.** The runner has to be provisioned, permissioned, monitored, and kept in + step with the coordinator's QuickApps version in every installation. +- **A dependency on Core policy.** Whether the config channel carries a manifest into a sub-call is not ours + to decide. + +Against that, A's costs are all inside this repository and all bounded: the output sink, a per-spawn timeout, +and a concurrency bound. The sink in particular is a refactor worth having on its own merits — it is the +change that lets an orchestrator run anywhere. A is also already spiked and validated (scope isolation, +manifest compilation, parallel spawns). + +So: **A ships first, and the output sink, a per-spawn timeout, and a concurrency bound are prerequisites of +that ship rather than deferrals** (see *Out of Scope*). B stays on the table as the migration target once +subagents earn the operational investment — and the switch is not a rewrite: manifest compilation, the +`subagents` config, and the `task` tool surface are identical in both, so only the execution backend behind +`SubagentSpawner` changes. --- @@ -552,8 +477,8 @@ Items considered but intentionally deferred, each with the reason and what a fut ### Structured error contract for a failed spoke **In scope and already built:** a spoke that fails surfaces to the coordinator as a *tool error* rather than -a successful empty result (G6, UC-4). That is the correctness floor, not a nicety — see G6 for why an empty -string is worse than an error. +a successful empty result (G6, UC-4). That is the correctness floor — see G6 for why an empty string is worse +than an error. **Out of scope:** giving that error *structure*. Today the coordinator's LLM receives one prose string and must infer from wording whether the spawn is worth retrying. It cannot distinguish "the spoke ran out of @@ -582,7 +507,7 @@ The equivalent for a spawn would put three fields on `ToolCallResult`: With those, the coordinator's prompt can carry one rule ("retry a retryable failure once with a narrower task; otherwise report it") instead of relying on the LLM to read intent out of an error sentence. -This is deferred rather than dismissed, for two reasons. It is not subagent-shaped: `ToolCallResult` is +Deferred for two reasons. It is not subagent-shaped: `ToolCallResult` is shared by all four tool types, so adding these fields is a change to the tool contract every tool implements, and the categories should be settled against DIAL's own error types rather than invented here. And the categories only become distinguishable once the failures are — `timeout` cannot be a category before @@ -601,19 +526,19 @@ UC-1 actively encourages fan-out, and nothing limits it. An LLM that decides to twelve spokes, each running a full initializer pass — MCP sessions reconnecting, REST clients rebuilding — with no semaphore and, until the item above lands, no timeout either. -**This is Approach A's gap specifically.** Both out-of-process approaches get backpressure free from the HTTP -layer and spread the load across replicas; A concentrates all of it in the coordinator's single process and -event loop. If A ships, a spawn semaphore is a prerequisite of that ship rather than a deferral, and it needs -a decision on what the coordinator sees when it hits the cap (queue, or fail the excess spawns). +**This is Approach A's gap specifically.** B gets backpressure free from the HTTP layer and spreads the load +across replicas; A concentrates all of it in the coordinator's single process and event loop. Since A ships +first, a spawn semaphore is a prerequisite of that ship rather than a deferral, and it needs a decision on +what the coordinator sees when it hits the cap (queue, or fail the excess spawns). ### Per-subagent `contexts` / `skills` / `hooks` -A spoke inherits all three wholesale from the coordinator. *Proposed Design* argues this is the wrong default -— `contexts` are attachments, so inheritance sets the per-spawn token floor at the coordinator's whole +A spoke inherits all three wholesale from the coordinator. As *Proposed Design* notes, this has a real cost — +`contexts` are attachments, so inheritance sets the per-spawn token floor at the coordinator's whole attachment set, and `hooks` written for a user conversation are a latent failure inside a spoke. Fixing it needs a per-field merge/override policy plus new schema surface on `SubagentConfig`, and the cheap version (make the fields required) trades away the small declaration surface that is half the point of the feature -(G2). Deferred as a known cost, not as a preference. +(G2). ### Tool-level allowlists @@ -724,7 +649,7 @@ erroring. Enabling the feature requires no migration of existing apps. declared; contributes build-time `tool_sets` validation. - `compile_subagent_manifest` — compiles a `SubagentConfig` + parent manifest into a narrowed `ApplicationConfig` (inherits `contexts` / `skills` / `hooks` / `features`; clears `subagents` / - `starters` / `conversation_starters`). Callee-side in Approach C, caller-side in A and B — same function. + `starters` / `conversation_starters`). Caller-side in both approaches — the same function either way. - `tool_set_names` / `unknown_tool_sets` — one definition of "which tool sets does this app have" and "which ones did this subagent name that don't exist", shared by the module's build-time check (hard failure) and the compiler (log only). Both tolerate the unresolved `PredefinedToolSet` shape the config type admits. @@ -741,7 +666,5 @@ erroring. Enabling the feature requires no migration of existing apps. **Open before this ships** -1. **Test whether DIAL Core permits a deployment to call itself.** This one answer decides between Approach C - (preferred on every operational dimension) and Approach A (built, but needs the items below). -2. If Approach A ships: the output-sink abstraction, a per-spawn timeout, and a concurrency bound are - prerequisites, not deferrals. See *Approach A — Current state — spike vs. target* and *Out of Scope*. +The output-sink abstraction, a per-spawn timeout, and a concurrency bound are prerequisites of the Approach A +ship, not deferrals. See *Approach A — Current state — spike vs. target* and *Out of Scope*. From 7376a5f5ce68f206fc50f6f74316297ef9a88e7e Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 16:45:00 +0400 Subject: [PATCH 7/9] feat: refine subagent implementation --- src/quickapp/config/subagent.py | 11 +++++------ .../subagent_tooling/_subagent_spawner.py | 16 +++++++++------- src/quickapp/subagent_tooling/_tool_config.py | 8 ++++---- ...ubagent_spike.py => test_subagent_tooling.py} | 8 ++++---- 4 files changed, 22 insertions(+), 21 deletions(-) rename src/tests/unit_tests/subagent_tooling_tests/{test_subagent_spike.py => test_subagent_tooling.py} (97%) diff --git a/src/quickapp/config/subagent.py b/src/quickapp/config/subagent.py index 629855f3..c7ac6648 100644 --- a/src/quickapp/config/subagent.py +++ b/src/quickapp/config/subagent.py @@ -2,13 +2,12 @@ class SubagentConfig(BaseModel): - """A subagent type declared by the app builder. + """A subagent type this app may spawn. - A spoke inherits the coordinator's ``contexts`` / ``skills`` / ``hooks`` / - ``features`` wholesale (see ``compile_subagent_manifest``), so there is no - field for them here. Per-subagent *narrowing* of those, and a tool-level - (rather than toolset-level) allowlist, are intentionally out of scope — see - ``docs/designs/anonymous_subagents.md``. + A subagent runs its own orchestrator loop with its own system prompt, model, + iteration budget, and tool sets, and returns a single result. It inherits the + app's contexts, skills, hooks, and features. Its tools are narrowed per tool + set, not per individual tool. """ name: str = Field(description="Identifier the coordinator uses to select this subagent.") diff --git a/src/quickapp/subagent_tooling/_subagent_spawner.py b/src/quickapp/subagent_tooling/_subagent_spawner.py index 063fa448..19481bf4 100644 --- a/src/quickapp/subagent_tooling/_subagent_spawner.py +++ b/src/quickapp/subagent_tooling/_subagent_spawner.py @@ -44,13 +44,15 @@ def _as_text(content: str | list[Any] | None) -> str: def _headless_choice() -> Choice: """A Choice whose chunks go nowhere. - SPIKE ONLY. The design calls for an output-sink abstraction so the orchestrator - stops depending on Choice at all; this stand-in lets the loop run unmodified. - - Note what it costs while it stands: everything the orchestrator writes to the - choice — the spoke's streamed content, its ``set_state``, and any attachment it - produced — is dropped here rather than forwarded to the coordinator. Forwarding - attachments is the concrete capability the real sink adds; see the design doc. + A subagent has no user conversation to stream into, but ``Orchestrator`` writes + to a ``Choice`` directly. This stand-in lets the loop run unmodified. + + Known limitation: everything the orchestrator writes to the choice is dropped + rather than forwarded to the coordinator. That is harmless for streamed content + (the final answer is read back off ``_RequestContext.messages``) and for + ``set_state`` (subagents are stateless), but **attachments a subagent produces + are lost** — only text crosses back. Lifting that requires decoupling the + orchestrator from ``Choice``. """ choice = Choice(_DiscardingQueue(), 0) choice.open() diff --git a/src/quickapp/subagent_tooling/_tool_config.py b/src/quickapp/subagent_tooling/_tool_config.py index 75f74d60..5bd933f3 100644 --- a/src/quickapp/subagent_tooling/_tool_config.py +++ b/src/quickapp/subagent_tooling/_tool_config.py @@ -10,16 +10,16 @@ from quickapp.config.tools.internal import InternalTool # Tool name and the free-text parameter (``prompt``) mirror Anthropic's Claude Code -# "Task" tool, whose shape this feature deliberately follows (see the design doc). +# "Task" tool, so builders and models familiar with it find the same shape here. TASK_TOOL_NAME = "task" def build_spawn_tool_config(subagents: list[SubagentConfig]) -> InternalTool: """One tool for every declared subagent type, selected by ``subagent_type``. - Matches Claude Code's "Task" tool shape: a flat tool catalogue that does not grow - as the builder adds subagent types, with routing carried by the enum descriptions, - and a ``prompt`` parameter carrying the delegated task. + A flat tool catalogue that does not grow as the builder adds subagent types: + routing is carried by the enum and the per-subagent descriptions, and the + ``prompt`` parameter carries the delegated task. """ catalogue = "\n".join(f"- {s.name}: {s.description}" for s in subagents) return InternalTool( diff --git a/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_tooling.py similarity index 97% rename from src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py rename to src/tests/unit_tests/subagent_tooling_tests/test_subagent_tooling.py index 3b39d3cb..9a84b352 100644 --- a/src/tests/unit_tests/subagent_tooling_tests/test_subagent_spike.py +++ b/src/tests/unit_tests/subagent_tooling_tests/test_subagent_tooling.py @@ -1,8 +1,8 @@ -"""SPIKE validation for Approach A (in-process subagents). +"""Tests for in-process subagent spawning. -These tests exist to answer one question: can a spawned subagent run its own -orchestrator loop, against its own manifest, in a DI request scope that is fully -isolated from the coordinator's — without touching the coordinator's state? +Cover the guarantees the feature rests on: a spawned subagent runs its own +orchestrator loop against its own compiled manifest, in a DI request scope +isolated from the coordinator's, and never touches the coordinator's state. """ import asyncio From 1e9865ec483d2ec9f27dc2738354fe842f02af60 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Thu, 20 Aug 2026 16:45:13 +0400 Subject: [PATCH 8/9] feat: regenerate app schema with subagents config --- docs/generated-app-schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/generated-app-schema.json b/docs/generated-app-schema.json index 3d80c25f..1628b108 100644 --- a/docs/generated-app-schema.json +++ b/docs/generated-app-schema.json @@ -3203,7 +3203,7 @@ "type": "object" }, "SubagentConfig": { - "description": "A subagent type declared by the app builder.\n\nA spoke inherits the coordinator's ``contexts`` / ``skills`` / ``hooks`` /\n``features`` wholesale (see ``compile_subagent_manifest``), so there is no\nfield for them here. Per-subagent *narrowing* of those, and a tool-level\n(rather than toolset-level) allowlist, are intentionally out of scope — see\n``docs/designs/anonymous_subagents.md``.", + "description": "A subagent type this app may spawn.\n\nA subagent runs its own orchestrator loop with its own system prompt, model,\niteration budget, and tool sets, and returns a single result. It inherits the\napp's contexts, skills, hooks, and features. Its tools are narrowed per tool\nset, not per individual tool.", "properties": { "name": { "description": "Identifier the coordinator uses to select this subagent.", From c28ada9fe17824eca6bee9ec3e894dc9ee7056a2 Mon Sep 17 00:00:00 2001 From: Vadim Sofin Date: Tue, 25 Aug 2026 11:25:20 +0400 Subject: [PATCH 9/9] chore: add demo config --- .../core/configuration/applications.json | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/docker_compose_files/core/configuration/applications.json b/docker_compose_files/core/configuration/applications.json index c971c188..5a6429f5 100644 --- a/docker_compose_files/core/configuration/applications.json +++ b/docker_compose_files/core/configuration/applications.json @@ -349,6 +349,110 @@ ] } }, + "subagent_demo": { + "displayName": "Subagent Demo", + "description": "QuickApp that spawns anonymous subagents. Each subagent runs its own orchestrator loop over a narrowed tool set and returns only its final answer — the coordinator never sees the intermediate tool traffic. Requires ENABLE_PREVIEW_FEATURES=true on the QuickApps backend.", + "applicationTypeSchemaId": "https://mydial.epam.com/custom_application_schemas/quickapps2", + "inputAttachmentTypes": [ + "*/*" + ], + "applicationProperties": { + "orchestrator": { + "deployment": { + "deployment_id": "gpt-4.1-2025-04-14" + }, + "system_prompt": { + "type": "custom", + "variables": {}, + "content": "You are a coordinator. You do not do research or computation yourself — you delegate it.\n\nWhen a request breaks into independent pieces, spawn one subagent per piece with the `task` tool, and spawn them in a single turn so they run in parallel. Each subagent starts with no knowledge of this conversation: the `prompt` you write is everything it will ever see, so state the full task, the exact output you want back, and any values it needs. Never ask a subagent to 'continue' earlier work — there is no earlier work.\n\nWhen the results come back, combine them yourself and answer the user. Do not re-run a subagent's work to check it. Always format your responses in markdown." + }, + "max_iterations": 20 + }, + "contexts": [], + "tool_sets": [ + { + "type": "predefined", + "template_name": "location" + }, + { + "type": "predefined", + "template_name": "weather" + }, + { + "type": "predefined", + "template_name": "py_interpreter" + }, + { + "name": "Web search toolset", + "description": "Grounded web search.", + "type": "dial-deployment", + "tools": [ + { + "type": "predefined-tool", + "template_name": "web_search" + } + ] + } + ], + "subagents": [ + { + "name": "weather_scout", + "description": "Looks up the current weather for ONE named place. Spawn one per city — never ask it about several places at once.", + "system_prompt": "You report weather for exactly one place.\n\nResolve the place name to coordinates with the location tool, then fetch the current weather for those coordinates. Reply with a single line and nothing else:\n\n°C, wind km/h\n\nIf the place cannot be resolved, reply exactly: — not found.", + "tool_sets": [ + "Location rest-api toolset", + "Weather rest-api toolset" + ], + "deployment_id": "gpt-4.1-mini-2025-04-14", + "max_iterations": 8 + }, + { + "name": "web_researcher", + "description": "Researches a question on the web and reports what it found. Use for anything needing current or external information.", + "system_prompt": "You research questions using web search.\n\nSearch as many times as you need to actually answer the question you were given — narrow follow-up queries beat one broad one. Your reply is the only thing the caller will ever see: no search transcripts, no describing what you looked up, no offers to continue. Lead with the answer, then at most five supporting bullets. If the searches do not settle it, say exactly what remains unknown.", + "tool_sets": [ + "Web search toolset" + ], + "max_iterations": 12 + }, + { + "name": "analyst", + "description": "Runs Python for calculations and data wrangling, and reports the numbers. Give it the inputs in the task — it cannot see the conversation. It cannot return files or charts, only text.", + "system_prompt": "You compute with Python.\n\nThe task contains every input you need; do not ask for more. Write and run the code, then reply with the result and a one-line note on how you got it. Do not paste the code unless the caller asked for it.\n\nYour reply is text only — the caller cannot receive files, images, or charts from you. Report figures in the reply itself; do not offer to plot anything.", + "tool_sets": [ + "internal-tool-set" + ], + "max_iterations": 10 + } + ], + "features": { + "stage_display": { + "level": "info" + } + }, + "conversation_starters": { + "intro_text": "Each of these fans out to subagents — watch the stages", + "starters": [ + { + "title": "Three cities, three subagents", + "text": "Compare the current weather in Lisbon, Reykjavik and Singapore. Spawn a separate weather_scout for each city, all in one turn, then rank the three by temperature." + }, + { + "title": "Five cities, then crunch the numbers", + "text": "Get the current temperature in Warsaw, Cairo, Oslo, Nairobi and Tokyo — one weather_scout per city, all spawned together. Then hand the five numbers to the analyst and ask it for the mean, the spread, and which city is furthest from the mean." + }, + { + "title": "Research a topic", + "text": "Spawn a web_researcher to find out what problem the Model Context Protocol solves and what its main primitives are." + }, + { + "title": "Mixed fan-out", + "text": "In one turn: spawn a weather_scout for Reykjavik, and a web_researcher to find what Reykjavik's weather is normally like in August. Then tell me whether today is typical, using both answers." + } + ] + } + } + }, "quickapp_with_code_interpreter": { "displayName": "QuickApp with code interpreter", "description": "Sample QuickApp that has access to PyInterpreter tool",