docs: propose background subagent tasks

This commit is contained in:
Yichen Jiang
2026-07-08 20:31:41 +08:00
parent dabc5e6225
commit 4dee9a8d9b
2 changed files with 99 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-code-mode.md) | 2026-06-15 |
| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 |
| [Background subagent tasks](proposed/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 |
| [Repeat-tool-call guard plugin](proposed/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
### Simplification

View File

@@ -0,0 +1,98 @@
# RFC: Background subagent tasks
Status: proposed
## Problem
The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) exposes `start() -> SubagentRun`, and the model-facing `dsh-tool-subagent` consumer collects that run synchronously: the parent turn blocks until the child returns one final result. That shape is simple and transport-neutral, but it makes slow delegation expensive for the parent. A model that wants two independent investigations must either run them serially or hold the parent step open for the entire child duration.
The harness already has one background-task precedent in bash. Bash has task ids, owner-token checks, output polling, stop, completion notifications, and prompt guidance. Subagents need the same user-facing habit, but not by copying bash's process-output semantics: a subagent does not expose an incremental stdout stream, and its child session remains the home for internal steps. The parent needs to start a child, keep working, later wait for or read the final answer, and stop the task when it is no longer relevant.
The design also has two lifecycle constraints that the synchronous cut does not face. First, a background subagent can outlive the tool call that started it, so the tool-call abort signal must not stay wired to the child after the id is returned. Second, a completion notice can only be injected into a live owner agent: once an ACP session closes or an agent handle is disposed, `agent.inject()` cannot append to that session. The feature must therefore define whether background subagents survive owner disposal.
## Proposal
Add a background mode to the existing model-facing subagent tools and add three companion tools: `subagent_wait`, `subagent_output`, and `subagent_stop`. The background task registry lives in `@deepseek-ai/dsh-subagent`, keyed by branded task ids and owner tokens, while `@deepseek-ai/dsh-tool-subagent` owns the model-facing schemas, text rendering, completion notice injection, and prompt guidance.
`dsh-tool-subagent` becomes a single multi-tool consumer plugin instead of one plugin instance per provider. Its config maps model-facing tool names to provider names, so one plugin instance can register `subagent`, `subagent_fork`, and any deployment-specific aliases such as `subagent_acp`, plus the shared background control tools. Providers remain named implementations on `ctx.subagents`: `spawn`, `fork`, `acp`, or future backends. This keeps provider implementation and model-facing exposure separate while avoiding a failure mode where one `subagent` tool exposes `run_in_background` but the companion wait/output/stop tools were never loaded.
The background task is scoped to the owner session, not durable across session closure. A background subagent starts only from a model-driven call with `exec.agent`; the service stores the caller's `session.header.id` as the owner token. `subagent_output`, `subagent_wait`, and `subagent_stop` compare that stored token with the caller's session id and reject cross-session access. When the owner agent is disposed, the service cancels any running background subagent tasks for that owner and discards their retained snapshots after quiescence. Completion notices are best-effort: if the owner agent is still registered, `dsh-tool-subagent` injects a short `context/message`; if the owner is gone, no notice is written.
## Tool surface
Each configured delegation tool may expose `run_in_background?: boolean`. The deployment can disable background mode per tool; a disabled tool does not include the parameter in its schema. A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
A background call validates that a parent agent exists, starts the provider run through `ctx.subagents`, registers a task, and returns `started background subagent task <task_id>`. It checks an already-aborted tool signal before starting, but after the id is returned it does not keep the tool-call signal connected to `run.cancel()`. The parent step may finish while the child continues.
`subagent_output` is a non-blocking status read for a background subagent task. While the task is `running` or `stopping`, it returns only a status line. Once terminal, it returns the final text output or error message plus the terminal status. Reading output is idempotent and does not consume the result; v1 deliberately exposes no incremental transcript cursor because the child session remains the detailed trace.
`subagent_wait` waits for a task to become terminal, bounded by a defaulted and capped timeout from `dsh-tool-subagent` config. A wait timeout returns `running` and leaves the child alive. Aborting the wait call cancels only the wait, not the background task.
`subagent_stop` requests cancellation of a running or stopping task and returns immediately. The task registry remains responsible for observing the run settle, recording the terminal state, and disposing the run. Calling stop on an already-terminal task reports that terminal state rather than failing.
## Runtime task model
`@deepseek-ai/dsh-subagent` adds a runtime-global task registry to `SubagentService`. Task ids and owner tokens are branded types. A task snapshot records the task id, provider name, child run id, owner token, status, started/finished timestamps, final output, and error message. The status vocabulary is `running`, `stopping`, and the existing terminal `SubagentStopReason` values (`completed`, `aborted`, `error`, `max-tokens`, `refusal`, plus merge-extensible provider values).
The registry owns task settlement. It attaches one continuation to `run.result`; on success it stores the final output and stop reason, on rejection it stores `error`, and in both cases it disposes the run and notifies task-done listeners. Listener failures are contained and logged so one consumer cannot starve cleanup.
The registry is runtime-global because `ctx.subagents` is a service shared by all live agents in the Cordis context. Session isolation is therefore explicit owner-token authorization, not an assumption about separate service instances. This mirrors the bash background-task fence: predictable ids are safe only when read/stop operations check the caller's owner token.
Owner disposal is a hard lifecycle boundary. `SubagentService` listens to `agent/disposed`, finds tasks owned by that agent's session id, and cancels running tasks. It does not attempt to persist unfinished task state, resume children, or inject into disposed sessions. A future durable job system can extend this boundary, but this feature intentionally keeps background subagents tied to live sessions.
## Model guidance
`dsh-tool-subagent` registers a system-prompt section that teaches the background-task habit:
- Keep track of every task id returned by a background subagent call.
- Do not produce a final answer while a relevant background subagent is still running.
- While waiting, continue independent exploration or use other tools when useful.
- Before summarizing or handing work back, call `subagent_wait` or `subagent_output` to collect finished tasks.
- Call `subagent_stop` for a background task that is no longer needed.
- End without collecting a task only when its result is irrelevant or the task was explicitly stopped.
This prompt guidance is not the enforcement boundary. Runtime enforcement is owner-token authorization and cancellation on owner disposal. The guidance keeps ordinary model behavior from accidentally abandoning relevant work while still allowing explicit stop or irrelevance.
## Relationship to generic long-running tools
The generic long-running tool runtime RFC ([Extract a generic long-running tool runtime](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)) remains the larger direction for shared task ids, owner tokens, cancellation, completion notices, and presentation. Background subagents should not block on that extraction because subagents have a narrower result model than bash: no incremental stdout, no spill files, and no process exit markers. The implementation should keep the subagent registry small and shaped so it can later migrate into a generic runtime without changing the model-facing `run_in_background`, `subagent_wait`, `subagent_output`, and `subagent_stop` contract.
## Alternatives considered
### Why not keep one `dsh-tool-subagent` instance per provider?
The existing one-instance-per-provider shape makes aliasing simple, but companion tools become ambiguous. If each instance registers `subagent_wait`, duplicate tool names collide. If only one instance registers them, deployments can accidentally expose `run_in_background` without the tools required to collect or stop the task. A single multi-tool consumer config keeps provider selection in deployment config and makes the background control plane atomic.
### Why not put wait/output/stop in a separate plugin?
A separate plugin has the same half-loaded failure mode: `subagent` could advertise background mode while the control tools are absent. The control tools are part of the model-facing subagent contract, so they should be registered by the same consumer plugin that adds `run_in_background`.
### Why not let background subagents survive owner session closure?
Survival after owner closure requires durable task state, child-session recovery, a way to surface late results into a reopened session, and policy for tasks whose owning client never returns. The current agent runtime unregisters disposed agents, and `agent.inject()` intentionally rejects disposed targets. Tying background tasks to live owner sessions makes the v1 lifecycle explicit and avoids orphaned child agents.
### Why not skip owner-token checks because ACP sessions are isolated?
ACP sessions isolate their logs and agents, but services such as `ctx.agents`, `ctx.tools`, and `ctx.subagents` are shared within the runtime. A background-task id is a global resource handle. Without an owner check, another live session in the same runtime could guess or receive a task id and read or stop it.
### Why not expose incremental subagent transcript output?
The child session is already the trace for internal reasoning, tool calls, and intermediate messages. Streaming that transcript into the parent would blur the parent/child log boundary that makes in-process and ACP providers equivalent. The first background surface returns status and final output only; richer observation belongs to UI/session tooling or a separate observation RFC.
## Acceptance criteria
- A deployment config can expose `subagent` and `subagent_fork` from one `dsh-tool-subagent` instance while binding them to different providers.
- A configured delegation tool exposes `run_in_background` only when that tool enables background mode.
- A background call returns a task id immediately and the parent can continue using other tools before collecting the result.
- `subagent_output`, `subagent_wait`, and `subagent_stop` enforce owner-token access and reject cross-session task ids.
- A task that finishes while the owner agent is live injects a durable completion notice into the owner session; a task whose owner is disposed does not throw while trying to notify.
- Disposing the owner agent cancels all of that owner's running background subagent tasks and reaches quiescence without leaking child agents.
- Snapshot coverage proves the changed tool schemas and the completion-notice path; unit coverage pins foreground compatibility, background settlement, timeout, stop, owner isolation, and owner-disposal cleanup.
## Risks
The multi-tool config reshapes how deployments expose provider aliases, so examples and generated tool catalogs must move together with the implementation. The pre-release policy allows this churn, but the migration must update every shipped config in one change.
The prompt guidance can reduce abandoned tasks but cannot force a model to collect every background result. Runtime cleanup on owner disposal is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient.
The task registry duplicates some concepts named by the generic long-running-tool RFC. Keeping the subagent registry final-output-only and service-local limits that duplication, but a later generic runtime extraction will still need a careful migration.