Skip to content

feat(agent): fusion routing — cloud orchestrator, local executor, complexity-gated - #162

Open
plombeer31 wants to merge 1 commit into
feat/run-mode-configfrom
feat/fusion-routing
Open

feat(agent): fusion routing — cloud orchestrator, local executor, complexity-gated#162
plombeer31 wants to merge 1 commit into
feat/run-mode-configfrom
feat/fusion-routing

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

Stack: 2 of 3. Based on feat/run-mode-config (#161) — review that first; this PR's diff is only the routing engine.

Behaviour, no UI. Reachable today by setting llm.runMode.mode in config.json; PR 3 adds the operator surface.

The split, defined for this loop

The loop is one inference per step → validated tool batch → reply/finish. There is no planner/executor phase to exploit, so "orchestrate on cloud, execute on local" is defined per step:

Step Route Why
Step 0 cloud (whenever cloudShare > 0) Forms the plan and picks the first tool batch, which determines everything downstream. Exactly one call per turn, so cost is bounded and predictable.
Continuation complexity-scored, with hysteresis The bulk. Mechanical fs.readfs.edit chains score low and stay local.
Parse-repair retry same leg as the attempt it repairs Falls out for free — the repair call already spreads the original LlmStreamParams. That is exactly right: a repair must be judged by the model that made the mistake, against the same transport. Switching would let a local grammar model repair a cloud model's malformed native tool_calls against a different GBNF.
Memory sub-runners local by default (fusion.subRunners) Cold-path, fire-and-forget structured-JSON jobs riding the reserved reflection slot, already KV-warm locally. Cloud would multiply per-turn cost for no visible latency win.
MCP sampling not covered — still hard-wired local Bypasses the provider registry entirely. Called out in the docs so nobody assumes otherwise.

The final synthesis step is deliberately not special-cased. The loop cannot know a step is final until the model returns reply, so a flag for it would be a lie. Instead the score's dominant term is context pressure, so a step carrying the whole turn escalates on its own. Making "always synthesise on cloud" explicit would need a loop-level change (a post-reply re-synthesis pass), not a routing flag — documented as a known limitation rather than left as an oversight.

The score

Integer 0-100, weights summing to 100 so it compares directly against the dial, from signals in scope after buildPrompt and before slotManager.acquire:

40 × context pressure  (promptTokens / conversationMaxTokens)
25 × turn depth        (stepIndex / maxSteps)
20 × transient notice  (loop detector / trimmed batch fired)
15 × tail growth       (accumulated tool output vs the stable prefix)

A step goes cloud when score >= 100 - cloudShare. cacheReused is deliberately excluded: it is produced by slotManager.acquire, which now runs after routing, so using it would be circular — noted in the JSDoc so it does not get "helpfully" added.

ROUTING_HYSTERESIS (±10) is load-bearing, not cosmetic. llama-server reuses its KV cache by longest common prefix, so alternating legs every step forces it to reprocess the tail that grew in between. Hysteresis produces runs of consecutive local steps, which is what makes the local cache pay off.

Three things this had to get right

Slot affinity follows the routed provider. supportsSlotAffinity is a late-bound getter reading the active provider. Under fusion the active provider is the cloud leg, which reports no affinity — so every locally-routed step would have run at slotId: -1 with cachePrompt off, forcing a full prompt reprocess on llama-server each time. On a 30B model with a 20k prompt that was the single biggest performance hazard in the feature. StepDependencies.resolveSlotAffinity fixes it; with no router wired the path is byte-identical to today.

A preferred leg is a starting link, not a policy override. It is ignored while that specific provider is in cooldown, never sets or clears overrideId, and is never reported as a probe. One genuine gap surfaced while testing: a failure on a preferred start used to strand the turn, because the preferred leg is commonly the chain's tail and advanceFrom scans forward from there. It now resumes from the chain head (the existing candidate === fromId guard keeps the failed link out of the scan). Health fallover still works end to end.

Pinned providers survive an active swap. swapActive closes the previous active provider, so switching into fusion would close the leg it is about to route to. close() is a no-op on both shipped kinds today, so this is a contract hazard rather than a live crash — but the interface promises teardown, and the first provider kind that honours it would break fusion silently.

Bonus: a real cost-attribution defect

resolveModelPricing looked pricing up against activeTextProvider, so a local completion would be priced against the cloud provider's catalog — a silently wrong cost_usd. Pre-existing shape, newly reachable under fusion. It now prices against the link that answered, carried by the new CompletionResult.servedProviderId (same rationale as the existing servedTransport). Fixed here rather than deferred, so fusion cannot ship with wrong cost numbers.

Verification

npm run lint and npm run build clean. npm test: 4147 passed. Failures are the same 6 files / 8 tests that already fail on main @ 667dae1; three further files (a parallel-tool-calls wall-clock timing assertion and two git-tool suites) flaked under parallel CPU load and pass in isolation — verified.

New: 29 routing unit tests, 8 step-executor routing tests (including "no router ⇒ params identical to today", the slot-affinity fix and repair stickiness), 10 chain tests for the preferred pick, and 5 seam tests driving the real bootstrap factories.

…plexity-gated

Implements the Fusion run mode: the cloud leg plans, the local leg
executes, and a per-step complexity score decides which one serves each
inference. Behaviour only — no UI yet, so this is reachable by setting
`llm.runMode.mode` in config.json.

The loop is one inference per step, so "orchestrate on cloud, execute on
local" is defined per step:

  * step 0 always orchestrates (it forms the plan and picks the first
    tool batch — exactly one call per turn, so the cost is bounded),
  * continuation steps are scored, with hysteresis,
  * the parse-repair retry stays on the leg that produced the malformed
    call — it already inherits `preferredProviderId` by spreading the
    original params, which is exactly right: a repair must be judged by
    the model that made the mistake, against the same transport,
  * memory sub-runners default to the local leg,
  * MCP sampling is untouched (still hard-wired local).

The final synthesis step is deliberately NOT special-cased: the loop
cannot know a step is final until the model returns `reply`. Instead the
score's dominant term is context pressure, so a step carrying the whole
turn escalates on its own.

`cloudShare` sets a cutoff at `100 - cloudShare`. The ±10 hysteresis is
load-bearing rather than cosmetic: llama-server reuses its KV cache by
longest common prefix, so alternating legs every step forces it to
reprocess the tail that grew in between. Hysteresis produces runs of
consecutive local steps, which is what makes the local cache pay off.

Three things this had to get right:

  * Slot affinity now follows the ROUTED provider, not the active one.
    Under fusion the active provider is the cloud leg, which reports no
    affinity, so every locally-routed step would otherwise have run at
    slotId -1 with cachePrompt off — a full prompt reprocess per step.
  * A preferred leg is a starting link, not a policy override: health
    still wins. It is ignored while that specific provider is in
    cooldown, never sets or clears the sticky override, and is never a
    probe. A failure on a preferred start resumes the scan from the
    chain HEAD, because a preferred leg is commonly the chain's tail and
    advancing "after" it would strand a recoverable turn with the rest
    of the chain untried.
  * Providers pinned by fusion survive an active-provider swap. close()
    is a no-op on both shipped kinds today, so this is not a live crash,
    but the interface promises teardown and switching into fusion would
    otherwise close the leg it is about to route to.

Also fixes a real cost-attribution defect this made reachable:
`resolveModelPricing` looked pricing up against `activeTextProvider`, so
a local completion was priced against the cloud provider's catalog. It
now prices against the served link, which `servedProviderId` carries for
the same reason `servedTransport` already existed.

Verified: npm run lint and npm run build clean; npm test 4147 passed.
Failures are the same 6 files / 8 tests that already fail on main
@ 667dae1; three further files (parallel-tool-calls wall-clock timing,
two git-tool suites) flaked under parallel CPU load and pass in
isolation.
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