Browse Source

merge: fleetflow model/effort routing doctrine

0xDarkMatter 1 week ago
parent
commit
0916e32806

+ 2 - 2
skills/fleet-worker/SKILL.md

@@ -127,8 +127,8 @@ routing needs a separate process, which is fleet-worker. Hence two loci:
 | **mechanical** | fleet-worker (GLM) or in-proc | `haiku` / GLM-4.5-Air | low |
 | **scout** | in-proc (fleet-worker if wide) | `sonnet` / GLM-5.2 | low |
 | **build** | in-proc | `sonnet`→`opus` | medium |
-| **synthesize** | in-proc only | `opus` | high |
-| **judge** | in-proc only | `opus` | high–max |
+| **synthesize** | in-proc only | inherit (session = Fable/Opus) | high |
+| **judge** | in-proc only | inherit (session = Fable/Opus) | high–max |
 
 **Locus rule:** shell out to fleet-worker only for a **large (≈12+), independent,
 file-mutating, cost-dominant** fan-out you can **gate before landing**; everything

+ 11 - 6
skills/fleet-worker/assets/route.js

@@ -13,20 +13,25 @@
 //     process — that's fleet-worker. Hence two loci, one taxonomy.
 
 // --- Work class → in-process tier. Omit fields to inherit the main-loop default. ---
+// Deciders (synthesize/judge) omit `model` ON PURPOSE: inheriting keeps them on the
+// session's premium brain (Fable > Opus) — pinning 'opus' would DOWNGRADE them on a
+// Fable session. Doctrine + cost evidence: fleetflow/references/native-model-routing.md.
 const TIERS = {
-  mechanical: { model: 'haiku',  effort: 'low'    }, // format, rename, regex sweep, file-by-file transform
+  mechanical: { model: 'haiku',  effort: 'low'    }, // StructuredOutput extraction, log scans, dedup, classification, format conversion
   scout:      { model: 'sonnet', effort: 'low'    }, // find, enumerate, read-and-extract, summarize
   build:      { model: 'sonnet', effort: 'medium' }, // implement a change needing judgment
-  synthesize: { model: 'opus',   effort: 'high'   }, // merge findings, write the report, design — in-proc only
-  judge:      { model: 'opus',   effort: 'high'   }, // adversarial verify, score, gate — in-proc only
+  synthesize: { effort: 'high' },                    // merge findings, write the report, design — inherit session model
+  judge:      { effort: 'high' },                    // adversarial verify, score, gate — inherit session model
 };
 
 // route(cls[, budget]) → opts fragment for agent(). Budget-aware: when the turn's
-// tokens run low it drops one tier and forces low effort, so long "+500k" runs
-// degrade gracefully instead of stalling at the budget ceiling.
+// tokens run low it drops COLLECT stages one tier and forces low effort, so long
+// "+500k" runs degrade gracefully instead of stalling at the budget ceiling.
+// Deciders are exempt — never under-power a judge, even under budget pressure.
 function route(cls, budget) {
   const base = TIERS[cls] ?? {};                       // {} → inherit session model+effort
-  if (budget?.total && budget.remaining() < 0.15 * budget.total) {
+  const decider = cls === 'synthesize' || cls === 'judge';
+  if (!decider && budget?.total && budget.remaining() < 0.15 * budget.total) {
     const down = { opus: 'sonnet', sonnet: 'haiku', haiku: 'haiku' };
     return { model: down[base.model] ?? base.model, effort: 'low' };
   }

+ 8 - 3
skills/fleet-worker/references/model-routing.md

@@ -36,13 +36,15 @@ processes. The taxonomy below assigns each work class a tier **and** a locus.
 | **mechanical** | format, rename, regex sweep, file-by-file transform | fleet-worker (GLM) or in-proc | `haiku` / GLM-4.5-Air | low |
 | **scout** | find, enumerate, read-and-extract, summarize | in-proc (fleet-worker if very wide) | `sonnet` / GLM-5.2 | low |
 | **build** | implement a change needing judgment | in-proc | `sonnet`→`opus` | medium |
-| **synthesize** | merge findings, write the report, design | **in-proc only** | `opus` | high |
-| **judge** | adversarial verify, score, gate a finding | **in-proc only** | `opus` | high–max |
+| **synthesize** | merge findings, write the report, design | **in-proc only** | inherit (session = Fable/Opus) | high |
+| **judge** | adversarial verify, score, gate a finding | **in-proc only** | inherit (session = Fable/Opus) | high–max |
 
 Two rules of thumb that keep this honest:
 
 - **Never under-power a judge.** A cheap verifier that rubber-stamps is worse than no
-  verifier — it launders bad findings as confirmed. Judges and synthesis stay on Opus.
+  verifier — it launders bad findings as confirmed. Judges and synthesis inherit the
+  session model — omit `opts.model` rather than pinning `'opus'`, which would
+  *downgrade* the decider on a Fable session.
 - **Effort is a finer knob than model.** Dropping a mechanical stage to `effort: 'low'` on
   the *same* model often saves more than is worth a model switch, with no quality cliff.
   Reach for the effort lever before the model lever.
@@ -123,6 +125,9 @@ while (budget.total && budget.remaining() > 50_000) {
 
 ## See also
 
+- [`fleetflow/references/native-model-routing.md`](../../fleetflow/references/native-model-routing.md)
+  — the in-process half in depth: cost evidence (7-day audit), full mechanism
+  (`opts.model`/`opts.effort`, `meta.phases[].model`, fork inheritance), caveats
 - [`assets/route.js`](../assets/route.js) — the paste-in helper
 - SKILL.md *"architecture crux"* — the process-global-provider constraint this is built on
 - [`fleet-ops`](../../fleet-ops/SKILL.md) — test-gated landing for the provider-worker branches

+ 16 - 0
skills/fleetflow/SKILL.md

@@ -65,6 +65,18 @@ Two guardrails carried over verbatim from the native tool's doctrine: reach for
 the **effort lever before the model lever**, and a cheap rubber-stamp verifier is
 worse than none.
 
+**The same routing applies INSIDE native Workflow scripts — and it defaults
+wrong.** A 7-day session audit (2026-06-28..07-05) found 75% of 10.1M subagent
+output tokens ran on Fable/Opus, including 729 StructuredOutput extract/verdict
+calls, because `agent()` inherits the session model unless overridden and nobody
+sets overrides. Doctrine: **the stage that decides stays premium (omit the
+override — inherit); the stages that collect go cheap** —
+`{ model: 'haiku', effort: 'low' }` for extraction/classification/log scans,
+`{ model: 'sonnet' }` for finder/reviewer sweeps. When unsure, inherit: a wrong
+cheap answer that survives verification costs more than it saves. Mechanism,
+routing table, and before/after snippets:
+[references/native-model-routing.md](references/native-model-routing.md).
+
 ## The run lifecycle
 
 ```
@@ -227,6 +239,10 @@ semantic exit codes (`0` ok, `2` usage, `3` cached/missing, `7` unreachable,
 - [references/worker-contracts.md](references/worker-contracts.md) — per-brain
   launch/collect/auth contracts (GLM env knobs, full `codex exec` flag map,
   Anthropic alias notes) and the Fable/Opus orchestrator probe.
+- [references/native-model-routing.md](references/native-model-routing.md) —
+  per-stage `opts.model`/`opts.effort` routing for native Workflow scripts: the
+  cost evidence, the collect-cheap/decide-premium table, caveats (aliases, fork
+  inheritance, effort = reasoning depth).
 
 ## See Also
 

+ 141 - 0
skills/fleetflow/references/native-model-routing.md

@@ -0,0 +1,141 @@
+# Per-Stage Model/Effort Routing in Native Workflow Scripts
+
+> Mechanism verified 2026-07-05 against the live Workflow and Agent tool schemas
+> (Claude Code v2.1.x session surface). Model strings are **aliases** the harness
+> resolves at spawn time — this doc deliberately never names a dated model ID.
+>
+> Scope: the **in-process** half of routing (native `agent()` calls sharing the
+> session's provider). The cross-provider half (GLM/Codex process workers) and
+> the shared work-class taxonomy live in
+> [fleet-worker/references/model-routing.md](../../fleet-worker/references/model-routing.md).
+
+## 1. Why — the cost evidence
+
+A 7-day session-log audit (2026-06-28..07-05, all projects on this machine):
+
+- Subagent transcripts produced **10.1M output tokens**. **75% ran on the two
+  premium tiers** — 4.8M on Opus, 2.9M on Fable — vs 2.2M on Sonnet and only
+  **0.3M on Haiku**.
+- **729 StructuredOutput tool calls** in the week — overwhelmingly mechanical
+  extract/verdict stages inside Workflow runs — every one billed at the premium
+  session model.
+
+Root cause is a default, not a decision: a Workflow `agent()` call **inherits
+the main-loop model and effort unless overridden**, and nobody sets overrides.
+On a Fable/Opus session, every "grep the logs and return JSON" stage runs on the
+most expensive brain available. The fix is one line per collect-stage.
+
+## 2. The mechanism
+
+```js
+await agent(prompt, {
+  model:  'haiku',   // 'sonnet' | 'opus' | 'haiku' | 'fable' — omit to inherit session model
+  effort: 'low',     // 'low' | 'medium' | 'high' | 'xhigh' | 'max' — omit to inherit session effort
+  // ...label, phase, schema, isolation, agentType as usual
+});
+```
+
+Facts that matter (all schema-sourced):
+
+- **Omission = inheritance.** No override → the agent runs at the session's
+  resolved model *and* effort. That is the correct default for stages whose
+  failure is expensive — and the silent cost sink for stages whose failure isn't.
+- **Aliases, not IDs.** `'opus'` means "the current Opus slot", resolved by the
+  harness. Never hard-code dated model IDs in a workflow script — they rot.
+- **`meta.phases[].model` is display metadata.** It annotates the phase in the
+  /workflows progress UI so the override is visible; it does **not** route
+  anything. `opts.model` on the `agent()` call is what routes. Set both — the
+  annotation is how a reader audits the run's routing at a glance.
+- **The Agent tool takes the same `model` param** for single subagent spawns,
+  and it composes with custom `agentType`/`subagent_type` (an `Explore` scout on
+  haiku is legal). Exception: **fork-type agents always inherit** — `model` is
+  ignored for `subagent_type: 'fork'`.
+- **`effort` is a reasoning-depth knob, not just a price knob.** Low effort
+  buys shallower thinking. Cheap and shallow is exactly right for extraction;
+  it is exactly wrong for a verdict.
+- **Schema-forced output is what makes cheap models safe here.** With
+  `opts.schema`, validation runs at the tool-call layer and re-asks on mismatch
+  — a haiku extractor can't hand back malformed JSON, only wrong content, which
+  is the verifier's job to catch anyway.
+
+## 3. Routing table — stage type → tier
+
+| Stage type | Typical stages | Override |
+|---|---|---|
+| **Mechanical collect** | StructuredOutput extraction, log scans, dedup, classification, format conversion, count/enumerate | `{ model: 'haiku', effort: 'low' }` |
+| **Broad sweep** | finders, per-dimension reviewers, read-and-summarize, multi-modal search legs | `{ model: 'sonnet' }` (+ `effort: 'low'` when the sweep is wide and mechanical) |
+| **Decide** | adversarial verifiers, judges, synthesis, final report, anything expensive-if-wrong | **omit both** — inherit the session's premium model |
+
+**The rule of thumb: the stage that DECIDES stays premium; the stages that
+COLLECT go cheap.** When unsure, omit the override — a wrong cheap answer that
+survives verification costs more than the override saves.
+
+Corollaries (carried over from the shared taxonomy, they bind here too):
+
+- **Never under-power a judge.** A cheap verifier that rubber-stamps launders
+  bad findings as confirmed — worse than no verifier. Deciders inherit; don't
+  even pin them to `'opus'`, because on a Fable session that's a *downgrade*.
+- **Reach for the effort lever before the model lever.** Same model at
+  `effort: 'low'` is often the bigger saving with no quality cliff.
+- **Budget pressure degrades collectors, never deciders.**
+  [route.js](../../fleet-worker/assets/route.js) encodes this: under 15%
+  remaining budget it steps collect-stages down a tier; judge/synthesize are
+  exempt.
+
+## 4. Before / after
+
+Extraction stage (the 729-calls-a-week shape):
+
+```js
+// before — inherits Fable/Opus for a grep-and-format job
+const flaky = await agent('Scan CI logs for retry markers; return the list.', { schema: FLAKY });
+
+// after — one line
+const flaky = await agent('Scan CI logs for retry markers; return the list.',
+  { schema: FLAKY, model: 'haiku', effort: 'low' });
+```
+
+Review fan-out — sweep cheap, verify premium:
+
+```js
+const results = await pipeline(
+  DIMENSIONS,
+  d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review',
+                         schema: FINDINGS, model: 'sonnet', effort: 'low' }),
+  review => parallel(review.findings.map(f => () =>
+    agent(`Adversarially verify: ${f.title}`,
+          { label: `verify:${f.file}`, phase: 'Verify', schema: VERDICT })
+          // no model/effort — the decider inherits the premium session model
+  ))
+);
+```
+
+Single Agent-tool spawn (same param, same doctrine):
+
+```js
+Agent({ subagent_type: 'Explore', model: 'haiku',
+        prompt: 'List every file that constructs a cache key…' })
+```
+
+## 5. Caveats — what this doctrine does NOT claim
+
+- **Not a fixed price list.** Aliases float; for actual $/token use
+  loop-ops' `loop-estimate.py` + `assets/model-pricing.json`.
+- **Not applicable to forks.** Fork-type agents inherit unconditionally.
+- **Not a provider switch.** `opts.model` picks a tier on the *session's*
+  provider; pointing a stage at GLM/Codex requires an OS-process worker
+  (fleetflow/fleet-worker) — see the locus rule in
+  [fleet-worker/references/model-routing.md](../../fleet-worker/references/model-routing.md).
+- **Not free accuracy.** A haiku collector will miss things a premium collector
+  wouldn't. That's acceptable *only* when a premium-tier verify/judge stage
+  stands downstream — which is why the [default posture](../SKILL.md) pairs
+  every cheap collect fan-out with a verify phase.
+
+## See also
+
+- [native-workflow-insights.md](native-workflow-insights.md) §5 — where
+  `opts.model`/`opts.effort` sit in the progress/structure surface.
+- [fleet-worker/references/model-routing.md](../../fleet-worker/references/model-routing.md)
+  — the shared work-class taxonomy and the in-process vs provider-worker locus rule.
+- [fleet-worker/assets/route.js](../../fleet-worker/assets/route.js) — paste-in
+  helper implementing this table (`route('mechanical')` etc.).

+ 3 - 0
skills/fleetflow/references/native-workflow-insights.md

@@ -107,6 +107,9 @@ notional `total_cost_usd`.
 - Agents get model (`opts.model`) and effort (`opts.effort`) overrides
   per-call; guidance: omit model (inherit) unless confident, use `effort` as
   the finer lever ("reach for the effort lever before the model lever").
+  When and how to actually set them — the collect-cheap/decide-premium
+  doctrine, with the cost evidence — is
+  [native-model-routing.md](native-model-routing.md).
 
 **fleetflow port:** run/lane naming (`--run`/`--id`) is the phase/label
 analogue; the orchestrator's status tables to the user play the /workflows UI