KestrelDocs

Agents

Kestrel scores each step before it runs and sends it to the smallest model that can do it. Renames go to a small model; only the hard reasoning reaches a large one.

Fig. 1Model routing
Model routing Prompts flow into a router that sends most of them to a small model, some to a medium one, and the rare hard prompt to a large one, which flares as it lands. const vec2 GATE = vec2(252.0, 230.0); const float IN0 = 24.0; // the ribbon's left end const float GX = 236.0; // where it meets the gate const float SPEED = 212.0; // route of prompt i: 0 small, 1 medium, 2 large (seven, two, one) float route(int i) { return i == 1 || i == 5 ? 1.0 : i == 8 ? 2.0 : 0.0; } float exitY(float r) { return r < 0.5 ? 150.0 : r < 1.5 ? 230.0 : 310.0; } vec2 poolC(float r) { return r < 0.5 ? vec2(390.0, 92.0) : r < 1.5 ? vec2(500.0, 200.0) : vec2(590.0, 344.0); } float poolR(float r) { return r < 0.5 ? 32.0 : r < 1.5 ? 48.0 : 68.0; } vec2 ctrl(float r) { return r < 0.5 ? vec2(336.0, 152.0) : r < 1.5 ? vec2(390.0, 228.0) : vec2(380.0, 312.0); } float travel(float r) { return r < 0.5 ? 0.42 : r < 1.5 ? 0.72 : 1.2; } // a bigger model is further and slower vec2 bez(vec2 a, vec2 c, vec2 b, float u) { return mix(mix(a, c, u), mix(c, b, u), u); } vec2 chanAt(float r, float u) { return bez(vec2(270.0, exitY(r)), ctrl(r), poolC(r), u); } // A channel from its gate exit to its pool, as far as the pool's rim. float channel(vec2 p, float r, float w, float k) { float v = 0.0; for (int i = 0; i < 8; i++) v = max(v, seg(p, chanAt(r, float(i) / 8.0 * 0.9), chanAt(r, float(i + 1) / 8.0 * 0.9), w, k)); return v; } // A pool: a disc with a bright rim, a ring per layer of the model, a core as hot as it is busy, and a flare. float pool(vec2 p, float r, float busy, float flare) { vec2 c = poolC(r); float R = poolR(r); float d = length(p - c) / R; float inside = 1.0 - sm(0.92, 1.0, d); float v = 0.46 * exp(-pow(d - 1.0, 2.0) / 0.006); float layers = r + 1.0; float q = d * (layers + 1.0); v += inside * 0.26 * exp(-pow(q - floor(q + 0.5), 2.0) / 0.03) * step(0.5, q) * step(q, layers + 0.5); v += inside * 0.13; v += blob(p, c, R * 0.55, busy); v += flare * (inside * (0.55 - 0.35 * d) + blob(p, c, R * 0.6, 0.6)); v += flare * 0.7 * exp(-pow(d - 1.05 - 0.5 * (1.0 - flare), 2.0) / 0.01); return v; } float field(vec2 p, float t) { if (p.x < 18.0 || p.x > 702.0) return 0.0; float ph = TAU * t / T; // the ribbon of prompts, a flow of tokens running right float rib = exp(-pow(p.y - 230.0, 2.0) / 70.0) * sm(IN0 - 6.0, IN0 + 40.0, p.x) * step(p.x, GX); float v = rib * (0.3 + 0.25 * pow(0.5 + 0.5 * sin(p.x * 0.0674 - ph * 10.0), 3.0)); // the gate: two rails, a faint interior, open where the ribbon comes in and at its three exits if (p.y > 110.0 && p.y < 350.0) { float openL = 1.0 - exp(-pow(p.y - 230.0, 2.0) / 60.0); float openR = 1.0 - exp(-pow(p.y - 150.0, 2.0) / 50.0) - exp(-pow(p.y - 230.0, 2.0) / 50.0) - exp(-pow(p.y - 310.0, 2.0) / 50.0); v += 0.46 * exp(-pow(p.x - 235.0, 2.0) / 8.0) * openL + 0.46 * exp(-pow(p.x - 270.0, 2.0) / 8.0) * max(openR, 0.0); v += 0.14 * step(238.0, p.x) * step(p.x, 266.0); } // the channels: wide and busy to the small pool, thin to the large one, each with its flow v += channel(p, 0.0, 8.0, 0.42 + 0.2 * pow(0.5 + 0.5 * sin(p.x * 0.09 - ph * 8.0), 2.0)); v += channel(p, 1.0, 6.5, 0.32 + 0.14 * pow(0.5 + 0.5 * sin(p.x * 0.07 - ph * 4.0), 2.0)); v += channel(p, 2.0, 5.0, 0.24); float hitS = 0.0, hitM = 0.0, flare = 0.0; for (int i = 0; i < 10; i++) { float r = route(i); float a0 = 0.22 + 0.44 * float(i); for (int w = -1; w <= 1; w++) { float a = a0 + float(w) * T; // when it reaches the gate // in the ribbon: as long and as dense as it is hard; the hard one runs three lines deep if (t > a - 1.0 && t < a) { vec2 c = vec2(GX - (a - t) * SPEED, 230.0); float len = r < 0.5 ? 9.0 : r < 1.5 ? 16.0 : 30.0; float ex = exp(-pow((p.x - c.x) / len, 4.0)); if (r > 1.5) v += ex * 1.3 * (exp(-pow(p.y - 220.6, 2.0) / 14.0) + exp(-pow(p.y - 230.0, 2.0) / 14.0) + exp(-pow(p.y - 239.4, 2.0) / 14.0)); else v += ex * (r < 0.5 ? 0.9 : 1.1) * exp(-pow(p.y - 230.0, 2.0) / 22.0); } // in the gate: it settles to its exit, the heavy one sinking float g = 0.2; if (t >= a && t < a + g) { float y = mix(230.0, exitY(r), ease((t - a) / g)); v += blob(p, vec2(GATE.x, y), r > 1.5 ? 12.0 : 8.0, r > 1.5 ? 1.35 : 1.1); } // down its channel to its pool float D = travel(r); if (t >= a + g && t < a + g + D) { float u = (t - a - g) / D; u = r > 1.5 ? mix(u, ease(u), 0.5) : u; float fade = 1.0 - sm(0.82, 1.0, u); if (r > 1.5) { v += blob(p, chanAt(r, u), 13.0, 1.4 * fade) + seg(p, chanAt(r, max(0.0, u - 0.16)), chanAt(r, u), 5.0, 0.55 * fade); } else v += blob(p, chanAt(r, u), r < 0.5 ? 7.5 : 9.0, 1.15 * fade); } float arr = a + g + D; float h = exp(-pow(t - arr, 2.0) / 0.012); if (r < 0.5) hitS += h; else if (r < 1.5) hitM += h; else flare += sm(arr - 0.04, arr + 0.06, t) * (1.0 - sm(arr + 0.25, arr + 1.5, t)); } } v += pool(p, 0.0, 0.8 + 0.35 * hitS, 0.0); v += pool(p, 1.0, 0.55 + 0.4 * hitM, 0.0); v += pool(p, 2.0, 0.28 + 0.6 * flare, flare); v += 0.6 * hitS * exp(-pow(length(p - poolC(0.0)) - 36.0, 2.0) / 20.0); return v; }
Steps flow into the router. Most go to the small model, some to the medium one, and the rare hard step goes to the large one.

How a step is scored

Before each step, Kestrel scores how hard it is from 0 to 1, from what the step asks for and how earlier steps like it went. Then it picks the model for that score.

kestrel.yaml
routing:
  small:  score < 0.4    # 72% of steps
  medium: score < 0.8    # 21%
  large:  score >= 0.8   # 7%, the hard ones

Most of a run is reading files, renaming things and running tests. Those steps score low, which is why routing cuts the token part of a run's cost by about two thirds.

Pick the models

kestrel.yaml
routing:
  models:
    small: claude-haiku
    medium: claude-sonnet
    large: claude-opus

Force a model for one run

await kestrel.runs.create({
  repo: "acme/web-app",
  task: "Untangle the billing state machine",
  routing: { floor: "medium" }, // never use the small model on this run
});
Note

Every step in a trace shows its score and the model it went to, so you can see where the tokens went.

Was this page helpful?

Open the dashboard