Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/agent/routing/compute-step-complexity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";

import {
computeStepComplexity,
type StepComplexitySignals,
} from "./compute-step-complexity.js";

const base: StepComplexitySignals = {
promptTokens: 0,
stablePrefixTokens: 0,
stepIndex: 0,
maxSteps: 25,
conversationMaxTokens: 32_000,
hasTransientNotice: false,
};

const at = (over: Partial<StepComplexitySignals>): number =>
computeStepComplexity({ ...base, ...over });

describe("computeStepComplexity", () => {
it("scores a fresh, empty step at zero", () => {
expect(at({})).toBe(0);
});

it("saturates at 100 when every signal is maxed", () => {
expect(
at({
promptTokens: 64_000,
stablePrefixTokens: 0,
stepIndex: 25,
hasTransientNotice: true,
}),
).toBe(100);
});

it("always returns an integer inside 0-100", () => {
const samples = [
at({ promptTokens: 7_777, stablePrefixTokens: 1_234, stepIndex: 3 }),
at({ promptTokens: 31_999, stablePrefixTokens: 12_001, stepIndex: 7 }),
at({ promptTokens: 1, stablePrefixTokens: 0, stepIndex: 1 }),
];
for (const score of samples) {
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
}
});

it("is monotonic in context pressure", () => {
const low = at({ promptTokens: 4_000, stablePrefixTokens: 4_000 });
const high = at({ promptTokens: 16_000, stablePrefixTokens: 16_000 });
expect(high).toBeGreaterThan(low);
});

it("is monotonic in turn depth", () => {
expect(at({ stepIndex: 12 })).toBeGreaterThan(at({ stepIndex: 2 }));
});

it("is monotonic in tail growth at a fixed prompt size", () => {
const mostlyStable = at({ promptTokens: 20_000, stablePrefixTokens: 19_000 });
const mostlyTail = at({ promptTokens: 20_000, stablePrefixTokens: 1_000 });
expect(mostlyTail).toBeGreaterThan(mostlyStable);
});

it("adds exactly the transient-notice weight", () => {
const quiet = at({ promptTokens: 8_000, stablePrefixTokens: 6_000 });
const noisy = at({
promptTokens: 8_000,
stablePrefixTokens: 6_000,
hasTransientNotice: true,
});
expect(noisy - quiet).toBe(20);
});

it("treats a tail larger than the prompt as zero, never negative", () => {
expect(at({ promptTokens: 100, stablePrefixTokens: 5_000 })).toBe(0);
});

it("survives zero and non-finite budgets without producing NaN", () => {
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
const score = at({
promptTokens: 10_000,
conversationMaxTokens: bad,
maxSteps: bad,
stepIndex: 5,
});
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
}
});
});
94 changes: 94 additions & 0 deletions src/agent/routing/compute-step-complexity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Signals available at routing time — i.e. after `buildPrompt` but
* BEFORE `slotManager.acquire`, because the slot depends on which
* provider we route to.
*
* That ordering is why `cacheReused` is deliberately absent: it is
* produced by `slotManager.acquire`, so feeding it back into the
* routing decision would be circular. Do not add it.
*/
export interface StepComplexitySignals {
/** `prompt.tokens.total` for the step about to run. */
promptTokens: number;
/** `prompt.tokens.stablePrefix` — the KV-stable head of the prompt. */
stablePrefixTokens: number;
/** 0-based index of this step inside the current turn. */
stepIndex: number;
/** `config.agent.maxSteps` — the turn's step budget. */
maxSteps: number;
/** `config.agent.conversationMaxTokens` — the conversation budget. */
conversationMaxTokens: number;
/**
* Whether a one-shot notice is being rendered into this step's prompt
* (loop detector fired, or a tool batch was trimmed). The model just
* did something wrong, so the step deserves the stronger model.
*/
hasTransientNotice: boolean;
}

/**
* Weights sum to 100 so the score is directly comparable to the
* operator's `cloudShare` dial without any rescaling.
*/
const WEIGHT_CONTEXT_PRESSURE = 40;
const WEIGHT_TURN_DEPTH = 25;
const WEIGHT_TRANSIENT_NOTICE = 20;
const WEIGHT_TAIL_GROWTH = 15;

/**
* The tail is judged against half the conversation budget: a turn whose
* accumulated tool output has eaten that much is already synthesis-shaped,
* and waiting for the full budget would only escalate on the very last
* step or two.
*/
const TAIL_BUDGET_FRACTION = 2;

function clamp01(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0;
return value >= 1 ? 1 : value;
}

function ratio(numerator: number, denominator: number): number {
if (!Number.isFinite(denominator) || denominator <= 0) return 0;
return clamp01(numerator / denominator);
}

/**
* Score one step's difficulty on a bounded 0-100 scale.
*
* Deliberately a *heuristic over cheap signals*, not a model call: it
* runs before every inference in fusion mode, so it has to be free and
* deterministic. The four terms, in weight order:
*
* 1. **Context pressure** (40) — how full the context is. This is the
* dominant term on purpose. It is also how a final synthesis step
* ends up on the cloud without the loop being able to know a step is
* final: by the time the model is ready to answer, it is carrying the
* whole turn's context.
* 2. **Turn depth** (25) — later steps in a long turn are the ones that
* have to hold more state together.
* 3. **Transient notice** (20) — a binary "the model just misbehaved"
* signal from the loop detector / batch trimmer.
* 4. **Tail growth** (15) — how much of the prompt is accumulated tool
* output rather than the stable prefix, i.e. how much raw material
* this step has to reconcile.
*/
export function computeStepComplexity(
signals: StepComplexitySignals,
): number {
const tailTokens = Math.max(
0,
signals.promptTokens - signals.stablePrefixTokens,
);
const score =
WEIGHT_CONTEXT_PRESSURE *
ratio(signals.promptTokens, signals.conversationMaxTokens) +
WEIGHT_TURN_DEPTH * ratio(signals.stepIndex, signals.maxSteps) +
WEIGHT_TRANSIENT_NOTICE * (signals.hasTransientNotice ? 1 : 0) +
WEIGHT_TAIL_GROWTH *
ratio(
tailTokens,
signals.conversationMaxTokens / TAIL_BUDGET_FRACTION,
);
return Math.round(score);
}
86 changes: 86 additions & 0 deletions src/agent/routing/decide-routing-role.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";

import {
decideRoutingRole,
ROUTING_HYSTERESIS,
} from "./decide-routing-role.js";

describe("decideRoutingRole", () => {
it("keeps everything local at cloudShare 0, even a maximal score", () => {
expect(
decideRoutingRole({ score: 100, cloudShare: 0, stepIndex: 0 }),
).toBe("executor");
});

it("sends everything to the cloud at cloudShare 100, even a zero score", () => {
expect(
decideRoutingRole({ score: 0, cloudShare: 100, stepIndex: 9 }),
).toBe("orchestrator");
});

it("always orchestrates step 0 when the cloud leg is in play", () => {
expect(
decideRoutingRole({ score: 0, cloudShare: 1, stepIndex: 0 }),
).toBe("orchestrator");
});

it("routes on the cutoff at 100 - cloudShare with no prior role", () => {
// cloudShare 40 ⇒ cutoff 60.
expect(
decideRoutingRole({ score: 60, cloudShare: 40, stepIndex: 1 }),
).toBe("orchestrator");
expect(
decideRoutingRole({ score: 59, cloudShare: 40, stepIndex: 1 }),
).toBe("executor");
});

it("makes it harder to leave the local leg", () => {
// cutoff 60, previously executor ⇒ effective bar 70.
const args = { cloudShare: 40, stepIndex: 1, previousRole: "executor" } as const;
expect(decideRoutingRole({ ...args, score: 69 })).toBe("executor");
expect(decideRoutingRole({ ...args, score: 70 })).toBe("orchestrator");
});

it("makes it harder to leave the cloud leg", () => {
// cutoff 60, previously orchestrator ⇒ effective bar 50.
const args = {
cloudShare: 40,
stepIndex: 1,
previousRole: "orchestrator",
} as const;
expect(decideRoutingRole({ ...args, score: 50 })).toBe("orchestrator");
expect(decideRoutingRole({ ...args, score: 49 })).toBe("executor");
});

it("applies the hysteresis symmetrically", () => {
expect(ROUTING_HYSTERESIS).toBe(10);
const score = 55;
expect(
decideRoutingRole({
score,
cloudShare: 40,
stepIndex: 1,
previousRole: "executor",
}),
).toBe("executor");
expect(
decideRoutingRole({
score,
cloudShare: 40,
stepIndex: 1,
previousRole: "orchestrator",
}),
).toBe("orchestrator");
});

it("treats a null previous role like no prior state", () => {
expect(
decideRoutingRole({
score: 60,
cloudShare: 40,
stepIndex: 1,
previousRole: null,
}),
).toBe("orchestrator");
});
});
62 changes: 62 additions & 0 deletions src/agent/routing/decide-routing-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Which leg of a fusion pair serves one inference.
*
* `orchestrator` is the cloud provider (plans, reconciles, synthesises);
* `executor` is the local provider (mechanical continuation steps).
*/
export type RoutingRole = "orchestrator" | "executor";

/**
* Score margin applied against the direction of travel so a step near
* the cutoff does not flip the provider back and forth.
*
* This is load-bearing, not cosmetic. llama-server reuses its KV cache
* by longest common prefix, so every return to the local leg after a
* cloud step has to reprocess the tail that grew in between. Hysteresis
* produces RUNS of consecutive local steps, which is what makes the
* local cache pay for itself.
*/
export const ROUTING_HYSTERESIS = 10;

export interface RoutingDecisionArgs {
/** 0-100 from `computeStepComplexity`. */
score: number;
/** 0-100 operator dial from `llm.runMode.fusion.cloudShare`. */
cloudShare: number;
/** 0-based step index inside the turn. */
stepIndex: number;
/** Role the previous step of this session resolved to, if any. */
previousRole?: RoutingRole | null;
}

/**
* Map a complexity score onto a fusion leg.
*
* The dial sets a cutoff at `100 - cloudShare`: a bigger share means a
* lower bar for reaching the cloud. It is a DIAL, NOT A QUOTA — it does
* not promise that N% of steps go to the cloud, and it must not be
* turned into a running-counter scheduler, which would necessarily send
* some trivial steps to the cloud and keep some hard ones local.
*
* Two rules override the score:
* - `cloudShare` 0 / 100 short-circuit to pure local / pure cloud, so
* the extremes are exact rather than merely very likely.
* - Step 0 always orchestrates (when the cloud leg is in play at all):
* it forms the turn's plan and picks the first tool batch, which
* determines everything downstream. It is exactly one call per turn,
* so the cost is bounded and predictable.
*/
export function decideRoutingRole(args: RoutingDecisionArgs): RoutingRole {
if (args.cloudShare <= 0) return "executor";
if (args.cloudShare >= 100) return "orchestrator";
if (args.stepIndex === 0) return "orchestrator";

const cutoff = 100 - args.cloudShare;
const margin =
args.previousRole === "executor"
? ROUTING_HYSTERESIS
: args.previousRole === "orchestrator"
? -ROUTING_HYSTERESIS
: 0;
return args.score >= cutoff + margin ? "orchestrator" : "executor";
}
11 changes: 11 additions & 0 deletions src/agent/routing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { computeStepComplexity } from "./compute-step-complexity.js";
export type { StepComplexitySignals } from "./compute-step-complexity.js";
export { decideRoutingRole, ROUTING_HYSTERESIS } from "./decide-routing-role.js";
export type { RoutingDecisionArgs, RoutingRole } from "./decide-routing-role.js";
export { StepRouter } from "./step-router.js";
export type {
FusionRoutingSnapshot,
RouteStepArgs,
StepRouterDeps,
StepRouting,
} from "./step-router.js";
Loading