Skip to content

Commit f56eb0b

Browse files
Merge pull request #1311 from corbitsdev/cl-9835-dispatch-card-lets-the-dispatcher-implement-multi-file-work
fix(dispatch): delegate substantive work to specialists
2 parents 5008956 + 3431c79 commit f56eb0b

14 files changed

Lines changed: 273 additions & 33 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP
319319

320320
**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Shipped docs/design directors (shakespeare, designer, planner) mount write tools with no path-level lock. Lane routing is spawn policy (shakespeare = P/A/I docs, designer = DESIGN.md, planner = PRD/scope/build plan), not a file lock. There is no static per-package write-path declaration (CL-6952 removed it — no shipped director ever set one); instead spawn_agent records, without blocking, when two concurrently running dispatches land on the same cwd (see `intervention-log.ts`'s `conflict` class).
321321

322-
**Typical chain:** planner → coder → reviewer (explorer up front when the code is unfamiliar), with dispatch coordinating throughout.
322+
**Typical chain:** planner → coder → reviewer (explorer up front when the code is unfamiliar), with dispatch coordinating throughout. Dispatch delegates substantive work regardless of file count. Before editing, it checks that the change is an obvious mechanical correction needing no diagnosis, design, new behavior, or new tests; otherwise it routes to the specialist. Existing exploration is not an implementation exemption. Unfinished implementation goes back to coder, and non-trivial/risky single-file changes receive reviewer too. This is prompt policy, not a runtime file-tool ban.
323323

324324
**Reasoning effort by role** (`src/provider/reasoning-effort.ts` → `resolveEffortForRole` / `defaultEffortForDirector`): package `modelRole` defaults are orchestrator/plan/review → `high`, implement/explore/docs/test → `medium`. Spawn-time binary fallback is orchestrator → `high`, worker → `medium`, clamped to the model. Explicit profile inference pins win; parent session effort is only a fallback when the role default is unsupported. This keeps multi-agent fleets off the sol+high latency cliff.
325325

@@ -333,7 +333,7 @@ Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlug
333333

334334
### System Prompt (`src/agent/prompts.ts`)
335335

336-
The primary director is **dispatch** (`buildChatRole` → `createDispatchSystemPrompt`). Role: dispatcher — classify, DIY tiny/single-file/one-route product edits , spawn named closed directors via `spawn_agent` for substantial work, track the fleet, synthesize. The dispatch card is a ~3–5k operator-surface prompt; idle, mailbox delivery, and poll-avoidance live in the harness (tool descriptions and occupancy), not the card. Family residuals append via `@corbits/prompt-variance` at assembly (model-family policy) — do not inline family text in the card. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary session (CORE and `DISPATCH_TOOLS`) so dispatch can DIY bounded edits; spawn remains the default for substantial, multi-file, parallel, or specialist work. Shell file-writes stay denied by auto-shell policy. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-worker write-path lock; concurrent lanes sharing a cwd are instead flagged (not blocked) as a `conflict` intervention. A frontier model already knows how to code; the static prompt carries harness-specific facts and the closed-fleet routing policy. The base is three individually-exported sections:
336+
The primary director is **dispatch** (`buildChatRole` → `createDispatchSystemPrompt`). Role: coordinator — classify, spawn named closed directors via `spawn_agent` for substantive work, track the fleet, synthesize. DIY is limited to obvious mechanical corrections; single-file bug fixes and behavior changes still delegate. The dispatch card is a ~3–5k operator-surface prompt; idle, mailbox delivery, and poll-avoidance live in the harness (tool descriptions and occupancy), not the card. Family residuals append via `@corbits/prompt-variance` at assembly (model-family policy) — do not inline family text in the card. Product mutation tools (`write_file` / `edit_file` / `delete_file`) remain mounted on the primary session (CORE and `DISPATCH_TOOLS`) for mechanical corrections; the delegation boundary is prompt policy, not a toolset strip. Shell file-writes stay denied by auto-shell policy. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-worker write-path lock; concurrent lanes sharing a cwd are instead flagged (not blocked) as a `conflict` intervention. A frontier model already knows how to code; the static prompt carries harness-specific facts and the closed-fleet routing policy. The base is three individually-exported sections:
337337

338338
- `buildChatRole` — dispatch card (operator surface; classify; DIY tiny/bounded product edits; spawn named specialists).
339339
- `buildHarnessFacts` — the non-derivable rules: shell file-writes are blocked, path tools are the DIY surface on primary (spawn coder/shakespeare directors for substantial work), dependency installs and off-limits paths need approval, images are native multimodal input, core tools plus the advertised catalog (including `skill_search`) are resident (MCP and other unadvertised tools load via `tool_search`; use `search_agents` before dispatching specialists), workflows run only from slash-command steps, and session memory lives at `.corbits/MEMORY.md`.

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Twenty packages under `src/agent/directors/<id>/` register in `DIRECTOR_REGISTRY
161161
2. `packageToProfile` maps envelope (`tools.allow`/`deny`) to `AgentProfile.capabilities` and `spawn.maySpawn` → `orchestrator`. System prompts are prefixed with a stable identity block (`formatDirectorSystemPrompt`: agent id, model role, optional skills).
162162
3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `spawn_agent` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. `spawn_agent(agent=dispatch)` is refused (primary is not a spawned worker). Primary omits the list so plugin profiles stay reachable.
163163
4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus dispatch. Plugin and local `.agents/agents/` profiles still load, but closed `DIRECTOR_IDS` cannot be overridden or aliased.
164-
5. Primary chat role is dispatch: `buildChatRole()` → `createDispatchSystemPrompt()`. The dispatch card is a ~3–5k dispatcher (classify, tiny DIY, spawn named specialists); idle/mailbox/poll live in the harness, and family residuals come from `@corbits/prompt-variance`. Product mutation tools (`write_file` / `edit_file` / `delete_file`) live in CORE (and `DISPATCH_TOOLS`) so they are advertised on the primary without a `tool_search` round-trip. DIY tiny/bounded edits on the parent; spawn coder/shakespeare directors for substantial work — a prompt judgment call, not a toolset strip. `PRIMARY_DENIED_PRODUCT_TOOLS` is gone. Shell file-writes stay denied; MCP tools are not re-filtered by a product-write deny list. There is no static per-profile write-path lock (CL-6952).
164+
5. Primary chat role is dispatch: `buildChatRole()` → `createDispatchSystemPrompt()`. The dispatch card is a ~3–5k coordinator (classify, mechanical DIY, spawn named specialists); idle/mailbox/poll live in the harness, and family residuals come from `@corbits/prompt-variance`. Product mutation tools (`write_file` / `edit_file` / `delete_file`) live in CORE (and `DISPATCH_TOOLS`) so they are advertised on the primary without a `tool_search` round-trip. DIY is only for obvious mechanical corrections needing no diagnosis, design, new behavior, or new tests. Spawn coder/shakespeare or the relevant specialist for substantive work regardless of file count, including single-file bug fixes. Existing exploration is not an exemption. This is prompt policy, not a toolset strip. `PRIMARY_DENIED_PRODUCT_TOOLS` is gone. Shell file-writes stay denied; MCP tools are not re-filtered by a product-write deny list. There is no static per-profile write-path lock (CL-6952).
165165

166166
**Grok infer envelope.** `loadSessionChatPrompt` always appends `loadAgentContextExtensions` (`AGENTS.md`, capped at `MAX_AGENTS_MD_BYTES`) and advertises CORE+CATALOG full schemas via `advertisedToolNamesForSessionMode`. That assembly is family-agnostic — Grok does not substitute the trimmed director prompt (`buildSubAgentSystemPrompt` + `formatDirectorSystemPrompt`). Workers already use that trimmed path (no `AGENTS.md`, mounted-tool schemas only). Keep the infer envelope on Grok; do not strip `AGENTS.md` or core schemas. Measure in `src/agent/prompt-sizes.ts` (`assembleDirectorPrompt`).
167167

‎evals/capability/README.md‎

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ comparison a bait whose baseline no longer exceeds its threshold is flagged
5555
(`BAIT FLAG ... no longer reproduces its misbehavior`) rather than silently
5656
passing.
5757

58+
The med/hard/xhard cases also check primary Dispatch ownership:
59+
`spawnAgentToolCallCount >= 1` and `parentFileMutationToolCallCount == 0`.
60+
These cover substantive single-file fixes and multi-file implementation.
61+
The mutation metric counts primary file-edit calls (wire and engine names),
62+
not worker edits. These routing bounds apply to default and explicit Dispatch
63+
runs via `directors: ["dispatch"]`; leaf-director overlays skip them but retain
64+
the general discipline bounds. An easy
65+
case is not forced to spawn merely to raise a count.
66+
5867
### Graders are deterministic and cheat-resistant
5968

6069
No LLM judging. Every tier's `verify.sh` is shell plus `bun`, and each one was
@@ -100,20 +109,21 @@ turn stream — tool calls with arguments plus assistant content. Derivation is
100109
pure (`behaviors.ts`); command analysis is a quote-aware token scan, not a full
101110
shell parser.
102111

103-
| Metric | Meaning | Baseline direction |
104-
| ---------------------------- | --------------------------------------------------------------------------- | ------------------ |
105-
| `shellCommandCount` | `run_shell` calls | informational |
106-
| `envAssignmentCommandCount` | commands with a `FOO=bar cmd` prefix or `export` | lower is better |
107-
| `chainSegmentCount` | total chain segments (`&&`, `\|\|`, `;`, `\|`, newline) | informational |
108-
| `maxChainSegmentsPerCommand` | largest chain in one command | lower is better |
109-
| `networkCommandCount` | segments invoking curl/wget/nc/... | lower is better |
110-
| `webFetchToolCallCount` | `web_fetch` tool calls (0 when the tool is absent or unused) | informational |
111-
| `spawnAgentToolCallCount` | `spawn_agent` tool calls (0 when the tool is absent or unused) | informational |
112-
| `editViaShellCount` | sed/perl/awk `-i` edits or heredoc writes | lower is better |
113-
| `repeatedSearchCount` | tool calls repeating an earlier call's name with normalized-equal arguments | lower is better |
114-
| `longestToolOnlyStreak` | longest run of assistant turns with tool calls and no text | lower is better |
115-
| `maxTurnDurationMs` | slowest single turn (stall gap on slow commands) | lower is better |
116-
| `toolCallsByName` | per-tool-name call counts | informational |
112+
| Metric | Meaning | Baseline direction |
113+
| --------------------------------- | --------------------------------------------------------------------------- | ------------------ |
114+
| `shellCommandCount` | `run_shell` calls | informational |
115+
| `envAssignmentCommandCount` | commands with a `FOO=bar cmd` prefix or `export` | lower is better |
116+
| `chainSegmentCount` | total chain segments (`&&`, `\|\|`, `;`, `\|`, newline) | informational |
117+
| `maxChainSegmentsPerCommand` | largest chain in one command | lower is better |
118+
| `networkCommandCount` | segments invoking curl/wget/nc/... | lower is better |
119+
| `webFetchToolCallCount` | `web_fetch` tool calls (0 when the tool is absent or unused) | informational |
120+
| `spawnAgentToolCallCount` | `spawn_agent` tool calls (0 when the tool is absent or unused) | informational |
121+
| `parentFileMutationToolCallCount` | primary file edit/write/delete calls, excluding worker edits | lower is better |
122+
| `editViaShellCount` | sed/perl/awk `-i` edits or heredoc writes | lower is better |
123+
| `repeatedSearchCount` | tool calls repeating an earlier call's name with normalized-equal arguments | lower is better |
124+
| `longestToolOnlyStreak` | longest run of assistant turns with tool calls and no text | lower is better |
125+
| `maxTurnDurationMs` | slowest single turn (stall gap on slow commands) | lower is better |
126+
| `toolCallsByName` | per-tool-name call counts | informational |
117127

118128
## Prerequisites
119129

@@ -218,7 +228,7 @@ verify.sh # objective grader (exit 0 = pass)
218228
- `verify` — grader filename (default `verify.sh`)
219229
- `bait` — optional `{ metric, threshold }` marking the behavior metric this case reproduces (see the bait table above)
220230
- `httpFixture` — when `true`, the runner starts a hermetic HTTP server on `127.0.0.1` (ephemeral port, per-run token), substitutes `{{HTTP_URL}}` in the prompt, and passes `EVAL_HTTP_URL` / `EVAL_HTTP_TOKEN` to `verify.sh`. The server is stopped when the case run ends — nothing external is contacted
221-
- `requireBehaviors` — optional `[{ metric, min?, max? }]`. After the run, each listed metric must fall in range or the case fails. Missing capture with a non-empty list fails closed
231+
- `requireBehaviors` — optional `[{ metric, min?, max?, directors? }]`. Each applicable metric must fall in range or the case fails. `directors` optionally scopes a bound to a non-empty list of director ids; omission applies it to all directors. Runs without `--director` use `dispatch`. Missing capture with applicable requirements fails closed
222232

223233
## Results JSON (v3)
224234

‎evals/capability/behaviors.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,39 @@ describe("normalizeToolArguments", () => {
132132
});
133133

134134
describe("deriveBehaviorMetrics", () => {
135+
test("counts parent file mutations across wire and engine tool names", () => {
136+
const metrics = deriveBehaviorMetrics(
137+
summary([
138+
turn({
139+
toolCalls: [
140+
{ name: "read" },
141+
{ name: "edit" },
142+
{ name: "write_file" },
143+
{ name: "spawn_agent", arguments: { agent: "coder" } },
144+
{ name: "delete" },
145+
{ name: "edit_file" },
146+
{ name: "write" },
147+
{ name: "delete_file" },
148+
],
149+
}),
150+
]),
151+
);
152+
expect(metrics.parentFileMutationToolCallCount).toBe(6);
153+
expect(metrics.spawnAgentToolCallCount).toBe(1);
154+
});
155+
156+
test("delegation without parent file mutations reports zero parent writes", () => {
157+
const metrics = deriveBehaviorMetrics(
158+
summary([
159+
turn({ toolCalls: [{ name: "read" }, { name: "grep" }] }),
160+
turn({
161+
toolCalls: [{ name: "spawn_agent", arguments: { agent: "coder" } }],
162+
}),
163+
]),
164+
);
165+
expect(metrics.parentFileMutationToolCallCount).toBe(0);
166+
});
167+
135168
test("counts env-prefix and export commands", () => {
136169
const metrics = deriveBehaviorMetrics(
137170
summary([
@@ -302,6 +335,18 @@ describe("parseCapturedRunSummary", () => {
302335
});
303336

304337
describe("parseBehaviorMetrics", () => {
338+
test("derives parent mutations from stored per-name counts when the field is absent", () => {
339+
const metrics = deriveBehaviorMetrics(
340+
summary([
341+
turn({ toolCalls: [{ name: "edit" }, { name: "write_file" }] }),
342+
]),
343+
);
344+
const { parentFileMutationToolCallCount: _dropped, ...stored } = metrics;
345+
expect(parseBehaviorMetrics(stored)?.parentFileMutationToolCallCount).toBe(
346+
2,
347+
);
348+
});
349+
305350
test("round-trips a derived metrics object", () => {
306351
const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")]));
307352
expect(parseBehaviorMetrics(JSON.parse(JSON.stringify(metrics)))).toEqual(

‎evals/capability/behaviors.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export interface BehaviorMetrics {
5151
webFetchToolCallCount: number;
5252
/** spawn_agent tool calls (0 when the tool is absent or unused). */
5353
spawnAgentToolCallCount: number;
54+
/** File-edit calls on the captured primary session, not its workers. */
55+
parentFileMutationToolCallCount: number;
5456
/** Segments editing files via sed/perl/awk in-place or heredoc redirection. */
5557
editViaShellCount: number;
5658
/** Tool calls repeating an earlier call's name with normalized-equal arguments. */
@@ -72,6 +74,7 @@ export const NUMERIC_BEHAVIOR_METRICS = [
7274
"networkCommandCount",
7375
"webFetchToolCallCount",
7476
"spawnAgentToolCallCount",
77+
"parentFileMutationToolCallCount",
7578
"editViaShellCount",
7679
"repeatedSearchCount",
7780
"longestToolOnlyStreak",
@@ -102,6 +105,7 @@ export const BEHAVIOR_METRIC_DIRECTIONS: Record<
102105
networkCommandCount: "lower",
103106
webFetchToolCallCount: "neutral",
104107
spawnAgentToolCallCount: "neutral",
108+
parentFileMutationToolCallCount: "lower",
105109
editViaShellCount: "lower",
106110
repeatedSearchCount: "lower",
107111
longestToolOnlyStreak: "lower",
@@ -124,6 +128,21 @@ const SHELL_TOOL_NAME = "run_shell";
124128
const WEB_FETCH_TOOL_NAME = "web_fetch";
125129
const SPAWN_AGENT_TOOL_NAME = "spawn_agent";
126130
const LEGACY_TASK_TOOL_NAME = "task";
131+
const FILE_MUTATION_TOOLS = [
132+
"edit",
133+
"write",
134+
"delete",
135+
"edit_file",
136+
"write_file",
137+
"delete_file",
138+
];
139+
140+
function countParentFileMutations(callsByName: Record<string, number>): number {
141+
return FILE_MUTATION_TOOLS.reduce(
142+
(total, name) => total + (callsByName[name] ?? 0),
143+
0,
144+
);
145+
}
127146

128147
/**
129148
* Split a shell command into chain segments, using the same quote-aware
@@ -281,6 +300,7 @@ export function deriveBehaviorMetrics(
281300
networkCommandCount,
282301
webFetchToolCallCount: toolCallsByName[WEB_FETCH_TOOL_NAME] ?? 0,
283302
spawnAgentToolCallCount: toolCallsByName[SPAWN_AGENT_TOOL_NAME] ?? 0,
303+
parentFileMutationToolCallCount: countParentFileMutations(toolCallsByName),
284304
editViaShellCount,
285305
repeatedSearchCount,
286306
longestToolOnlyStreak,
@@ -297,6 +317,7 @@ const BehaviorMetricsType = type({
297317
networkCommandCount: "number",
298318
webFetchToolCallCount: "number",
299319
"spawnAgentToolCallCount?": "number",
320+
"parentFileMutationToolCallCount?": "number",
300321
"taskToolCallCount?": "number",
301322
editViaShellCount: "number",
302323
repeatedSearchCount: "number",
@@ -314,6 +335,9 @@ export function parseBehaviorMetrics(raw: unknown): BehaviorMetrics | null {
314335
// Frozen baseline files predate this field; derive from the per-name map.
315336
return {
316337
...current,
338+
parentFileMutationToolCallCount:
339+
parsed.parentFileMutationToolCallCount ??
340+
countParentFileMutations(parsed.toolCallsByName),
317341
spawnAgentToolCallCount:
318342
parsed.spawnAgentToolCallCount ??
319343
parsed.toolCallsByName[SPAWN_AGENT_TOOL_NAME] ??

‎evals/capability/cases/tier-hard/case.json‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
"verify": "verify.sh",
88
"requireBehaviors": [
99
{ "metric": "editViaShellCount", "max": 0 },
10-
{ "metric": "repeatedSearchCount", "max": 6 }
10+
{ "metric": "repeatedSearchCount", "max": 6 },
11+
{
12+
"metric": "spawnAgentToolCallCount",
13+
"min": 1,
14+
"directors": ["dispatch"]
15+
},
16+
{
17+
"metric": "parentFileMutationToolCallCount",
18+
"max": 0,
19+
"directors": ["dispatch"]
20+
}
1121
]
1222
}

0 commit comments

Comments
 (0)