Skip to content

fix(actor): make spawn the default and run the exception in the tool prompt - #1942

Merged
wqymi merged 3 commits into
mainfrom
fix/actor-spawn-first
Jul 29, 2026
Merged

fix(actor): make spawn the default and run the exception in the tool prompt#1942
wqymi merged 3 commits into
mainfrom
fix/actor-spawn-first

Conversation

@wqymi

@wqymi wqymi commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

Agents kept reaching for actor run, which blocks the whole conversation until the subagent finishes, instead of actor spawn, which returns an actor_id immediately and lets several subagents work in parallel while the agent keeps talking to the user.

The cause was the tool's own prompt: in the operations list run came first and read as the straightforward path ("spawn a subagent and BLOCK until completion; result returned inline"), and every single example in actor.txt used run. The model naturally defaulted to the blocking verb and lost parallelism.

Change

Description/prompt only — tool behavior is unchanged.

  • packages/opencode/src/tool/actor.txtspawn is now presented first and as THE DEFAULT (background, immediate actor_id, parallel); run is documented as a RARE EXCEPTION with a crisp admission test: only when you cannot make your very next decision without the result in THIS turn and the work is a tiny, fast lookup. Added a "Collecting spawned work" section (notification → waitstatus). Examples flipped: three spawn examples including a 3-way parallel fan-out, plus exactly one run example explicitly labelled THE EXCEPTION.
  • packages/opencode/src/tool/actor.shell.txt — same inversion for the shell invocation style: actor spawn documented first as the default, actor run second as the exception, parallel fan-out promoted into the primary examples, and the "When to use what" table rewritten.
  • packages/opencode/src/tool/actor.ts — the action literal describe() strings now say THE DEFAULT (spawn) / RARE EXCEPTION … BLOCKS the whole conversation (run), and spawnSchema precedes runSchema in the discriminated union so the model reads spawn first in the generated JSON schema. Discriminated-union parsing is keyed on action, so ordering is behavior-neutral for parsing — but it does change the anyOf branch order the model is handed.
  • packages/opencode/src/session/prompt/orchestrator.txt — corrected a factual error: it claimed actor run / actor spawn both "start a BLOCKING subagent". spawn is not blocking. Both remain forbidden there for real work (the orchestrator routes real work to session create), so orchestrator policy is unchanged.

Does it actually change behaviour? Measured, not asserted from wording

The original evidence for this PR was 14 regex assertions over the .txt files. Prompt-text assertions are not behavioural evidence — they show the file says "spawn is the default", not that an agent picks spawn. So the claim was measured with a paired A/B against a real model.

Method: the real headless CLI (run --format json), model mimo/mimo-v2.5, 6 trials per prompt per arm (a perfect split at n=6 is p≈0.002 by Fisher's exact, and 6 tolerates one stochastic outlier where 5 would not). The two arms differ only in the four files this PR touches; everything else — prompt, model, working directory, config — is identical. The actor tool is denied by permission ({"actor": {"**": "deny"}}), so the call is recorded with its input but no subagent is actually launched. The emitted operation is read from the structured tool part ($.state.input.operation.action of the part row / the tool_use event), never from prose. The event-derived counts and the persisted part rows agreed for 36/36 sessions.

arm prompt spawn run neither trials emitting ≥2 parallel spawns
OLD (run-first, origin/main) clear delegation 0 3 3 0
NEW (spawn-first, this PR) clear delegation 6 0 0 0
OLD (run-first) 3-way parallel fan-out 0 6 0 0
NEW (spawn-first) 3-way parallel fan-out 6 0 0 6
OLD (run-first) blocking tiny lookup (run is CORRECT here) 0 1 5 0
NEW (spawn-first) blocking tiny lookup (run is CORRECT here) 0 2 4 0

spawn counts trials whose every emitted actor op was spawn; run counts trials that emitted at least one run; neither means the agent never called actor and did the work inline.

Verdict — the behaviour claim holds, and the effect is large. On a clear-delegation prompt the blocking path went from 3/6 to 0/6 while spawn went from 0/6 to 6/6 (p≈0.002). The fan-out prompt is the sharpest result: run-first emitted three sequential runs in 6/6 trials — precisely the lost parallelism this PR was written to fix — while spawn-first emitted three parallel spawns in 6/6 trials.

Over-correction check — negative, it did not over-correct. On the prompt where run is the right answer, spawn-first still chose run (2/6, versus 1/6 on run-first); it never refused the blocking path. Reported honestly: on that prompt most trials on both arms skipped actor entirely and just grepped, which is arguably the most correct answer of all for a one-key lookup, so this arm has low power — it can only support "NEW does not forbid run", not a finer claim.

Secondary observation: on the clear-delegation prompt the 3 run-first "neither" trials did the whole investigation inline with ~20 sequential grep/read calls (152–224 s), i.e. run-first lost the work to the main context rather than delegating it.

Approximate cost: 36 trials ≈ 1.46M input + 5.0M cached + 21k output tokens on mimo-v2.5, each trial killed as soon as the actor batch closed. The DB records $0.0000 because that model entry carries no pricing metadata.

Test

Two clearly separate tiers.

(a) Deterministic, runs in CI.

  • packages/opencode/test/tool/actor.test.tsnew flattened schema offers spawn ahead of run in the operation union. This asserts the wire format the model is actually handed: the anyOf branch order of the JSON schema after transformSchema. It is the machine-checkable half of "spawn is the default", and it was the real gap — the neighbouring test already pinned the union's length (7) but never its order.
  • packages/opencode/test/tool/actor-prompt-spawn-first.test.ts — the pre-existing 14 wording assertions, kept unchanged. They are cheap regression guards for the text, and that is now all they are cited as; they are no longer offered as proof of behaviour.

(b) Real-model, opt-in, a no-op in CI.

  • packages/opencode/test/tool/actor-spawn-preference.test.tsnew, gated RUN_ACTOR_SPAWN_AB=1 following the test/workflow/verify-wow.test.ts precedent (a RUN_* env var plus a placeholder test so the file is not empty in CI). It drives the real headless CLI and asserts, over 4 trials of a clear-delegation prompt, that no trial emits run and that at least half emit spawn. Credentials are read from ~/.config/mimocode/mimocode.json because test/preload.ts strips provider API keys from the environment — gating on a stripped key is the trap that leaves test/session/structured-output-integration.test.ts permanently skipped. Run it with:
    RUN_ACTOR_SPAWN_AB=1 bun test test/tool/actor-spawn-preference.test.ts

CI runs bun run test:ci (bun test --timeout 30000, 4 shards) with no credentials and no RUN_* set, so tier (a) runs and tier (b) skips.

The new deterministic test was proved real by pointing it at the OLD schema (git checkout origin/main -- src/tool/actor.ts), which fails as expected:

590 |         expect(actions).toContain("spawn")
591 |         expect(actions).toContain("run")
592 |         expect(actions[0]).toBe("spawn")
      ^
error: expect(received).toBe(expected)
Expected: "spawn"
Received: "run"
(fail) Actor tool subagent_type enum (F36) > flattened schema offers spawn ahead of run in the operation union

Verification

  • bun typecheck → exit 0
  • bun test test/tool --timeout 120000747 pass / 0 fail / 11 skip (758 tests, 55 files), against an identical-command baseline on the unmodified tree of 745 pass / 0 fail / 10 skip (755 tests, 54 files). Failure sets are identical (both empty); the delta is exactly the two new tests plus the gated file's skip. Wall time 165 s vs 170 s, so no load confounder.
  • Opt-in live test executed for real: RUN_ACTOR_SPAWN_AB=1 RUN_ACTOR_SPAWN_AB_TRIALS=2 → 1 pass, logging emitted actor operations per trial: [[],["spawn"]].
  • bun test test/session/orchestrator-prompt.test.ts test/agent/orchestrator.test.ts → pass (orchestrator wording assertions still hold)

Noted, not changed

  • src/tool/read.ts:237 and src/session/system.ts:113 suggest actor run <type> … --model <vision> for image analysis — that is a genuine one-shot lookup whose result gates the current turn, i.e. the sanctioned exception.
  • src/skill/compose/.bundle/subagent/SKILL.md:65,169 dispatches its implementer with actor run … --task T1. That skill's plan → implement → review flow is strictly sequential, so switching it would change workflow semantics rather than just wording.
  • The A/B measurement harness itself was not committed; the opt-in test above is the permanent instrument, and the one-off driver (arm swapping, the third prompt shape, the sqlite cross-check) stayed out of the tree.

Follow-up: bd0be88e9 — run-exception note moved off the JSON example line

Review nit, fixed in-branch (already an ancestor of this PR's head 9e7f022a2).
actor.txt:17 used to end a valid JSON example with a trailing prose annotation
(… }} ← exception only), while every other example line in the file is clean,
copy-pasteable JSON. Since the model imitates the shape of these lines, the
annotation was a real echo risk (this repo has measured mimo-v2.5 mangling
tool-call shapes). The note now sits on its own line above the example:

THE EXCEPTION — `run` blocks the conversation; only for a tiny lookup that gates this very turn:
{"operation":{"action":"run","subagent_type":"explore","description":"One tiny blocking lookup","prompt":"<full task>"}}

Diff is 1 file, +2/-1. Scope check: the "at most one action:run" assertion slices
from indexOf("## Examples"), so this top-of-file compact list was never counted —
no assertion needed changing and none was touched. actor.shell.txt's trailing
# annotations are that file's own declared line-comment syntax (:15), so they
are copy-safe and were deliberately left alone; orchestrator.txt contains no
JSON example lines at all.

Re-verified on the branch head after landing: bun typecheck exit 0 (12/12), and
bun test test/tool/actor-prompt-spawn-first.test.ts test/session/orchestrator-prompt.test.ts test/tool/actor.shell.test.ts test/tool/actor.test.ts
78 pass / 0 fail (182 expect() calls, 4 files, 179 s).

wqymi added 3 commits July 27, 2026 21:19
…prompt

Agents kept reaching for `actor run`, which blocks the whole conversation
until the subagent finishes, so parallelism was lost on ordinary delegation.
The cause was the prompt itself: `run` was listed first as the straightforward
path and nearly every example used it.

Reorder and rewrite the actor tool description so `spawn` is presented first
and as THE DEFAULT (background, returns actor_id immediately, subagents run in
parallel), and `run` is a narrow exception gated on a crisp test: only a tiny,
fast lookup whose result you cannot make your next decision without in this
turn. Examples flip to spawn (including a 3-way parallel fan-out) with one
labelled run exception, and a collection section documents notifications plus
wait/status.

Behavior is unchanged - description/prompt text, action describe() strings and
schema ordering only. Also corrects orchestrator.txt, which described `actor
spawn` as blocking; both remain forbidden there for real work.
Every other example in actor.txt is clean, copy-pasteable JSON. The run
example carried a trailing prose annotation on the same line, and the
model imitates the shape of these examples line-by-line rather than the
parsed JSON — so the arrow could be copied verbatim into a real call.
Move the note to its own line above, matching the prose convention the
EXCEPTION example block further down already uses.
The existing coverage for the spawn-first rewrite only asserted the wording of
actor.txt, which cannot show that an agent actually prefers spawn. Add the two
things that can:

- a deterministic assertion that the JSON schema handed to the provider offers
  spawn ahead of run (the wire-format consequence of moving spawnSchema ahead of
  runSchema; previously only the union's LENGTH was pinned, never its order)
- an opt-in RUN_ACTOR_SPAWN_AB=1 test that drives the real headless CLI against
  mimo/mimo-v2.5 and reads the emitted operation out of the structured tool part

A/B over 6 trials per prompt per arm: a clear-delegation prompt went 0/6 spawn
on the run-first description to 6/6 spawn on the spawn-first one, and a
three-way fan-out went from three sequential runs to three parallel spawns in
6/6 trials. The blocking-lookup prompt still chose run on both arms, so the
change did not over-correct.
@wqymi
wqymi merged commit 9cb1257 into main Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant