feat(coder): coding agents hand work to read-only workers, choosing each one's model and effort

Merged
DA
8d ago
+1376-113

Coding agents can hand a scoped slice of their work to a read-only worker and choose each worker's model and effort. Server and coder service only; the desktop draws it in S2.

What changed

  • A worker subagent, apps/web/coder/subagents/worker/, in the planning worker's shape: Luke's direct providers through the coder's own model path, admitted for the parent agent on ownership alone (host.admitDelegated), read-only in the parent's own sandbox and checkout (defineParentSandbox, uncommitted edits included), authored exactly the agent's reads (bash, read_file, glob, grep, web_fetch, web_search; no write_file, apply_patch, or publishing), told to change nothing, and bounded per session (CODER.WORKER_INPUT_TOKENS/WORKER_OUTPUT_TOKENS).
  • The agent chooses the pair. eve's own subagent tool carries { message, taskId? } and nothing else, and AgentSendOptions has no metadata slot, so the worker is declared hidden (tool: false) and the model sees delegate (apps/web/coder/tools/delegate.ts), an authored workflow tool whose input is { task, model?, effort? }. Its step fills the pair in from the agent's own row, checks it against the same catalog validator Start and PATCH use (coder-host/delegation.ts), records it, and the body opens the worker with ctx.agent("worker"). An invalid choice fails the call with one sentence listing the valid model ids, or the named model's efforts, so the model retries.
  • How the pair reaches the worker's steps. eve hands a dynamic model resolver no lineage (DynamicResolveContext.session has no parent), so the pair is recorded ahead of the child session under the child conversation the call names (childConversationId(agent conversation, callId), as the planning worker's is) in a new table coding_agent_delegation (migration 0066); the worker's hook, which does see session.parent, pins the call id in the worker's own state as the session starts, and the model resolver reads it at every step (host.delegatedModel).
  • Waiting. execute, so the call blocks the agent's turn until the worker answers; several calls in one step run at once. eve's task form would have been equally simple, but a blocking call is what the plan asked for and keeps the result in the tool part.
  • Sandbox. Confirmed: a declared subagent with defineParentSandbox() reuses the parent's sandbox, so workers read the parent's working tree. delegate is not among SANDBOX_TOOLS: a refused or web-only delegation opens none, and counting it would have the turn's end open a sandbox only to stop it; a sandbox a worker opened is stopped at the end of the first turn in which the agent runs a sandbox tool of its own.
  • Recording and reading. The worker's stream is relayed into its child conversation as a child turn naming the pair (host.relayChild). New owner-only route GET /api/agents/{id}/subagents/{callId}/messages?after=, the same UIMessage page shape and hold as the agent's own transcript; callId is eve's id for the delegation call, which the parent's tool-delegate part carries as toolCallId, so S2 can open the tab from the moment the call is requested. The part's input is { task, model, effort } and its output { model, effort, result }. Wire additions in packages/hosted/src/coding-agent-wire.ts: CODING_AGENT_DELEGATE_TOOL, codingAgentDelegationInputSchema, codingAgentDelegationOutputSchema, codingAgentCallIdSchema.
  • Instructions. One paragraph for the parent (when to delegate, how to choose the model, workers are read-only) and the worker's own standing rules.
  • Eval. The scripted coder model delegates when told to, and the eval runs a delegation end to end offline: the parent's tool part with the pair, the child turn on that pair, the worker's answer read back.

For S2

The parent transcript's tool-delegate part: toolCallId names the child; input.task/model/effort; output.model/effort/result once settled. Read the worker at /api/agents/{id}/subagents/{toolCallId}/messages.

Tests

  • coder-host-delegation.test.ts: no choice uses the parent's pair; a valid pair is taken; an invalid model or effort is refused with the valid list.
  • coder-host.test.ts: a delegation with no choice records the parent's pair and the worker's step is told it; an explicit pair is what the worker runs on and what its relayed turn records, readable as a transcript.
  • coder-agent.test.ts: the worker is hidden, bounded, and authored no write tool; the parent is told when to delegate.
  • coding-agents-app.test.ts: a worker's transcript reads for the owner and 404s for another account or an unrecorded call.
  • coder.eval.ts: the delegation end to end under the scripted model (ran green locally against Postgres 16).

Notes

  • A worker's child conversation is kind child, so the sweep's generic hour bound applies to its turn rows, as it does to the planning worker's; a worker running past an hour reads as abandoned in the store while eve keeps it running.
  • Verification: ./scripts/check.sh locally. No UI changes, so no screenshots and nothing for verify.sh; CI builds nothing for the Mac and cannot verify it.

šŸ¤– Generated with Claude Code