feat(agent): fusion routing — cloud orchestrator, local executor, complexity-gated - #162
Open
plombeer31 wants to merge 1 commit into
Open
feat(agent): fusion routing — cloud orchestrator, local executor, complexity-gated#162plombeer31 wants to merge 1 commit into
plombeer31 wants to merge 1 commit into
Conversation
…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.
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.modeinconfig.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:cloudShare > 0)fs.read→fs.editchains score low and stay local.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 nativetool_callsagainst a different GBNF.fusion.subRunners)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-replyre-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
buildPromptand beforeslotManager.acquire:A step goes cloud when
score >= 100 - cloudShare.cacheReusedis deliberately excluded: it is produced byslotManager.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.
supportsSlotAffinityis 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 atslotId: -1withcachePromptoff, 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.resolveSlotAffinityfixes 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 andadvanceFromscans forward from there. It now resumes from the chain head (the existingcandidate === fromIdguard keeps the failed link out of the scan). Health fallover still works end to end.Pinned providers survive an active swap.
swapActivecloses 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
resolveModelPricinglooked pricing up againstactiveTextProvider, so a local completion would be priced against the cloud provider's catalog — a silently wrongcost_usd. Pre-existing shape, newly reachable under fusion. It now prices against the link that answered, carried by the newCompletionResult.servedProviderId(same rationale as the existingservedTransport). Fixed here rather than deferred, so fusion cannot ship with wrong cost numbers.Verification
npm run lintandnpm run buildclean.npm test: 4147 passed. Failures are the same 6 files / 8 tests that already fail onmain @ 667dae1; three further files (aparallel-tool-callswall-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.