Repository navigation
Expand file tree
/
Copy pathroute2.js
More file actions
344 lines (325 loc) · 32 KB
/
Copy pathroute2.js
File metadata and controls
344 lines (325 loc) · 32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
// Task-aware model routing (ROUTER_MODE="two"; Captain 2026-10-08, notes/2026-10-08-privacy-routing/PROMPT.md). Replaces the two-model
// Sol/Luna rule. Per message from the person, ONE decider call (decider.js; its model is the saved setting of the job "decider") answers
// three questions on the last few messages: is it part of a task (stats only), which task CATEGORY it is (pool.js CATEGORIES), and
// whether an earlier result fell short (quality recovery). The category's saved list (the job "chat.<category>", model-jobs.js), filtered
// by the account's AI data privacy level (privacy.js), gives the ordered models. The decider never chooses reasoning effort: each model in
// the list carries its own saved reasoning level, applied on the wire in model.js twoChat, fallbacks included.
// No decider call: unattended runs (scheduled jobs, follow-ups, helpers) and a bare "keep going" with work open reuse the task's
// category (stored per session), else "general". A decider error/timeout/garbage → the task's category, else "general" (cheapest).
// One model per turn (switching breaks the prompt cache), except a deliberate quality-recovery switch:
// - before the turn: the person says an earlier result fell short (decider `unmet`) or asks for the best model → pool.js RECOVERY by
// category (apps → Haiku, technical/spreadsheet → Sol, directly; research/general → a corrected approach, research may also take
// the chain's alternate). Requirements, corrections and tool state are in the context the new model reads.
// - inside the turn: concrete failures (the same tool failing twice, stalled steps, two malformed calls, "I'm stuck" after tool
// work) → Sol for the rest of the turn, once. No walk down the availability chain.
// Availability fallback (model.js twoChat): the chain for the category at the CURRENT privacy level (re-read on every dispatch),
// filtered by endpoint eligibility, deduplicated, bounded; each attempt carries the destination model's fixed effort.
// Prompt cache: one key per channel (Resident.callModel), so the shared prefix stays warm across the channel's tasks.
// Chores never use the pool routing: hand-off notes go to gpt-oss (choreRouteFor, CHORE_MODEL); the browser agent, summaries and
// labellers keep their own routes, always on zero-data-retention endpoints (stricter than any level).
// The classic router (router.js) stays behind ROUTER_MODE="classic". Jev Router (typesafe/jev-router) is never used in two mode.
import { testModelFor, testRoute } from "../internal-test-model.js"; // dev test workspaces: no decider call, one model (TEST_MODEL)
import { deciderFetch, deciderModel, deciderProvider } from "./decider.js";
import { askDecider, usesSystemone } from "./text-decision.js";
import { modelSettingsEnv } from "./model-settings.js";
import { settingsOf, jobSetting, entriesOf, allowedEntries, chatJobId, configuredHosts, choreRouteOf, jobRoute, factsOf } from "./model-jobs.js";
import { startShadow, shadowOn, residentWaitUntil } from "./decision-shadow.js"; // dev only: another decider next to the live one, never decides
import { privacyOf, normalizePrivacy, eligibleHosts, ineligibleWhy } from "../privacy.js";
import { CATEGORIES, CATEGORY_LABEL, DEFAULT_CATEGORY, RECOVERY, IN_TURN_JOB, MAX_MODELS, normalizeCategory, guessCategory } from "./pool.js";
export const ROUTER_TWO = "two";
export const route2On = (env) => env?.ONEHELM_EE === "1hc" && String(env?.ROUTER_MODE || "classic").trim().toLowerCase() === ROUTER_TWO;
// The person asks for the best model → treated as an explicit request for a more thorough attempt (RECOVERY), never an effort change.
export const BEST_RE = /\b(use (your|the) (best|smartest|strongest|top|most powerful|biggest) (model|brain|ai)|your (best|smartest|strongest|most powerful) (model|ai))\b/i;
// A bare go-on ("Keep going.", "continue", "ok, go ahead") that carries on work already under way keeps the task's category, no decider call.
export const CONTINUE_RE = /^\s*(?:(?:ok(?:ay)?|yes|sure)[,.!]?\s+)?(?:please\s+)?(?:keep going|continue|carry on|go on|go ahead|proceed|resume|keep at it|finish (?:it|up))(?:,?\s*please)?[.!]*\s*$/i;
// Background jobs (chores: nobody waits on them or reads them directly) each have their own saved setting (model-jobs.js choreRouteOf):
// the model, its reasoning level and its fallbacks. They run on zero-data-retention endpoints at every privacy level.
export const JEV_URL = "https://openrouter.ai/api/v1/systemone";
export const JEV_TIMEOUT_MS = 4000; // a slow decider never holds the reply: timeout → the task's category / general
export const JEV_CONVO_BELOW = 0.35, JEV_TASK_FROM = 0.65; // is_task bands (stats only)
export const UNMET_FROM = 0.6; // P(unmet) at or above this → quality recovery
export const JEV_CONTEXT_MSGS = 4; // last few user/assistant messages the decider sees
export const JEV_MSG_CHARS = 800, JEV_TEXT_CHARS = 2000;
export const JEV_MARKUP = 1.15; // billed like every model call: usage.cost × 1.15 (resident.js MARKUP)
export const JEV_QUESTION = "Is the latest message from the person part of a task, rather than plain conversation? "
+ "A task needs tools, planning, browsing, code, files, money or several steps. "
+ "A short reply such as done, ok, yes, go ahead, try again or a correction that continues an open task (previous_turn_used_tools is true, or open_work is not none) is a task. "
+ "Plain conversation is a quick question, chit-chat, thanks or a short answer that needs no tools.";
export const CATEGORY_QUESTION = "What kind of work does the latest message ask for? Judge the specific task, not the broad topic: making or editing a spreadsheet is spreadsheet even when it's about money; "
+ "researching financial, legal or tax sources is research; a difficult code migration is technical; app building means building software, not using Gmail or another connected app. "
+ "A short reply that continues open work (ok, go ahead, try again, a correction) belongs to the work already under way in the conversation.";
export const CATEGORY_CRITERIA = {
general: "Everyday conversation and general help: questions, chat, advice, writing, email, calendar, reminders, using connected apps.",
research: "Researching or analysing information, especially finance, investing, legal, contracts or tax: finding, reading and comparing sources.",
apps_economical: "Building software (a simple app, website, script, game or small tool) where a quick, economical working version is fine.",
skills: "Carrying out a routine multi-step job with the helper's skills: travel planning, booking, shopping, forms, or a document, deck or PDF from a clear brief.",
spreadsheet: "Creating or editing a spreadsheet or workbook: formulas, financial models, budgets, CSV or Excel files.",
technical: "Demanding technical work: real coding or debugging, terminal and server work, failing builds or tests, data or code migrations.",
apps_complete: "Building software where the person stresses it must be complete and fully working, with every feature functional.",
};
export const UNMET_QUESTION = "Does the latest message say an earlier result in this conversation fell short: it doesn't work, something is missing, wrong or incomplete, it isn't what they asked for, "
+ "they repeat a correction, or they ask for a more thorough attempt? A plain follow-up question, thanks, or a new request is No.";
// Route objects: what callModel / openRouterChat read. reason/floor keep the "[reason/floor]" label in turn stats, e.g.
// "deepseek-flash general·zdr decider". level is informational: dispatch re-reads the current level.
// settings: the saved model settings (env.MODEL_SETTINGS). The route records the first model that would run and, for a turn that may
// switch models mid-way, the saved "escalate" model; the model list itself is read again from the settings on every dispatch.
export const modelLabel = (settings, model) => String(factsOf(settings, model)?.name || String(model).split("/").pop()).toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/^-|-$/g, "");
const entryOf = (settings, id) => entriesOf(jobSetting(settings, id))[0];
export function policyRoute(settings, category, level, why, { recoveryJob = null, alternate = false, recovery = null, recoveryEffort = null, note = null } = {}) {
category = normalizeCategory(category); level = normalizePrivacy(level);
const job = jobSetting(settings, chatJobId(category)), list = allowedEntries(settings, job, level), all = entriesOf(job);
let rec = null;
if (recoveryJob) rec = entryOf(settings, recoveryJob);
else if (alternate) rec = list[1] || null;
else if (recovery) rec = { model: recovery, effort: recoveryEffort };
const first = rec || list[0] || all[0], esc = settings.jobs?.[IN_TURN_JOB] ? entryOf(settings, IN_TURN_JOB) : null;
return { two: true, policy: true, category, level, why, primary: first.model, model: first.model, effort: first.effort, ...(rec ? { recovery: rec.model, recoveryEffort: rec.effort ?? null } : {}), ...(note ? { note } : {}),
...(esc ? { escalate: { model: esc.model, effort: esc.effort ?? null } } : {}),
fallbacks: all.map((e) => e.model).filter((m) => m !== first.model), only: true, label: `${modelLabel(settings, first.model)}/${category} ${why}`, reason: "policy", floor: `${modelLabel(settings, first.model)} ${category}·${level} ${why}` };
}
// Legacy: a route stored on a turn journaled before the saved settings existed names its model directly; that model goes first.
export function twoRoute(settings, model, why) { return policyRoute(settings, DEFAULT_CATEGORY, "zdr", why, { recovery: model }); }
// openRouterChat calls this around each attempt so the label always names the model that actually answered.
export function markServed(route, model, fellBack = false, settings = null) {
if (!route?.two) return; const k = modelLabel(settings, model), why = fellBack ? "fallback" : route.why;
route.served = model; route.floor = `${k} ${route.category || DEFAULT_CATEGORY}·${route.level || "zdr"} ${why}`; route.label = `${k}/${route.category || DEFAULT_CATEGORY} ${why}`;
if (quirkOfModel(settings, model, "reasoning_replay")) route.replayReasoning = true; else delete route.replayReasoning;
}
const quirkOfModel = (settings, model, name) => !!factsOf(settings, model)?.quirks?.[name];
export function verdict(p) {
if (typeof p !== "number" || !Number.isFinite(p) || p < 0 || p > 1) return "unsure";
return p < JEV_CONVO_BELOW ? "conversation" : p >= JEV_TASK_FROM ? "task" : "unsure";
}
// The questions and the systemone body. The decision shadow (decision-shadow.js) and the router lab send these same questions.
export const JEV_QUESTIONS = { is_task: { type: "noul", instructions: JEV_QUESTION }, category: { type: "choice", instructions: CATEGORY_QUESTION, criteria: CATEGORY_CRITERIA },
unmet: { type: "noul", instructions: UNMET_QUESTION } };
export const jevBody = (state, env) => ({ model: deciderModel(env), provider: deciderProvider(deciderModel(env)), state, questions: JEV_QUESTIONS });
// A choice answer → a category id, or null (missing/invalid). Accepts { choice } or { probabilities } (the most likely offered id).
export function categoryOf(a) {
if (!a || typeof a !== "object") return null;
if (typeof a.choice === "string" && CATEGORIES.includes(a.choice)) return a.choice;
if (a.probabilities && typeof a.probabilities === "object") { const best = Object.entries(a.probabilities).filter(([k, p]) => CATEGORIES.includes(k) && typeof p === "number").sort((x, y) => y[1] - x[1])[0]; return best ? best[0] : null; }
return null;
}
// One decider call, three questions. Never throws: { p, category, unmet, cost, ms, model, error }.
export async function jevAsk(env, state, { fetchImpl = globalThis.fetch, timeoutMs = JEV_TIMEOUT_MS, meta = {} } = {}) {
const t0 = Date.now(), ac = new AbortController(), timer = setTimeout(() => ac.abort(Object.assign(new Error("timeout"), { name: "TimeoutError" })), timeoutMs);
try {
env = await modelSettingsEnv(env);
if (!usesSystemone(env.MODEL_SETTINGS, "decider") || jobSetting(env.MODEL_SETTINGS, "decider").fallbacks?.length) { // any other chat model answers the same questions as strict JSON (text-decision.js)
const a = await askDecider(env, state, JEV_QUESTIONS, { timeoutMs, meta });
if (a.error && !a.answers) return { p: null, cost: Number(a.nano || 0) / 1e9, ms: a.ms, error: `decider: ${a.error}` };
const category = categoryOf(a.answers?.category);
return { p: a.answers?.is_task?.noul ?? null, category, unmet: a.answers?.unmet?.noul ?? null, cost: Number(a.nano || 0) / 1e9, ms: a.ms, model: a.model || null, ...(category ? {} : { error: "decider: no category" }) };
}
const r = await deciderFetch(env, JSON.stringify(jevBody(state, env)), { signal: ac.signal, title: "1Helm router", meta, fetchImpl });
const j = await r.json().catch(() => null);
if (!r.ok || !j) return { p: null, cost: 0, ms: Date.now() - t0, error: `decider ${r.status}: ${JSON.stringify(j?.error || j || "").slice(0, 160)}` };
const p = j.answers?.is_task?.noul, u = j.answers?.unmet?.noul, category = categoryOf(j.answers?.category);
return { p: typeof p === "number" ? p : null, category, unmet: typeof u === "number" ? u : null, cost: Number(j.usage?.cost) || 0, ms: Date.now() - t0, model: j.model || null,
...(category ? {} : { error: "decider: no category" }) };
} catch (e) { return { p: null, cost: 0, ms: Date.now() - t0, error: `decider: ${String(e?.name === "TimeoutError" || ac.signal.aborted ? "timeout" : e?.message || e).slice(0, 160)}` }; }
finally { clearTimeout(timer); }
}
// The decider's state: the last few messages (context notes left out), whether the previous turn used tools, open work, and the task's
// category so far (so "it still doesn't work" is read against the work under way).
export function jevState({ hist = [], text = "", prevTools = 0, openWork = [], prevCategory = null }) {
const flat = (c) => Array.isArray(c) ? c.filter((x) => x?.type === "text").map((x) => x.text).join(" ") : String(c ?? "");
const conversation = hist.filter((m) => (m.role === "user" || m.role === "assistant") && !flat(m.content).startsWith("(Context note") && flat(m.content) !== "Noted.")
.slice(-JEV_CONTEXT_MSGS).map((m) => ({ role: m.role, content: flat(m.content).slice(0, JEV_MSG_CHARS) }));
return { conversation, previous_turn_used_tools: prevTools > 0, open_work: openWork.length ? openWork.join("; ") : "none",
...(prevCategory ? { task_so_far: CATEGORY_LABEL[prevCategory] || prevCategory } : {}), latest_message: String(text).slice(0, JEV_TEXT_CHARS) };
}
// The decider's answers → the route. Pure: chooseTwo, the decision shadow and the router lab all use it.
// category: the decider's choice; missing → the task's category so far, else general.
// recovery: unmet ≥ UNMET_FROM, or "use your best model" → RECOVERY for the task's category (the work that fell short).
export function routeFromAnswers(env, text, { p = null, category = null, unmet = null, error = null } = {}, { level = "zdr", prevCategory = null } = {}) {
const settings = settingsOf(env);
const v = error && !category ? "unsure" : verdict(p);
const cat = normalizeCategory(category || prevCategory || DEFAULT_CATEGORY);
const best = BEST_RE.test(String(text || "")), fell = typeof unmet === "number" && unmet >= UNMET_FROM;
if (fell || best) {
const base = normalizeCategory(prevCategory || cat), rc = RECOVERY[base], why = fell ? `recover:${base}` : "ask:best";
return { route: policyRoute(settings, base, level, why, { recoveryJob: rc.job || null, alternate: !!rc.alternate, note: rc.note || null }), verdict: "recover" };
}
return { route: policyRoute(settings, cat, level, category ? "decider" : error ? "decider:unsure" : "default"), verdict: v };
}
// What the dashboard shows for one side (decision-shadow.js judge): the route's model is the comparison key, the label the second one.
export function routeJudge(env, text, ctx = {}) {
return (a) => {
const err = a?.error || null, ans = err ? { error: err } : { p: a?.is_task?.noul ?? null, category: categoryOf(a?.category), unmet: a?.unmet?.noul ?? null };
const { route, verdict: label } = routeFromAnswers(env, text, ans, ctx);
return { key: route.model, label, route: modelLabel(settingsOf(env), route.model), why: route.why, category: route.category, effort: route.effort, p: ans.p ?? null, unmet: ans.unmet ?? null };
};
}
// The routes decided without asking the decider (a job, a bare go-on with work open), or null when the message goes to the decider.
// The decision shadow's test-mode probe uses the same gates, so it only probes messages the decider would really see.
export function jevGate(env, { kind = "chat", text, prevTools = 0, openWork = [], prevCategory = null, level = "zdr" }) {
if (kind !== "chat") return policyRoute(settingsOf(env), prevCategory || DEFAULT_CATEGORY, level, "job");
if (CONTINUE_RE.test(String(text || "")) && (prevTools > 0 || openWork.length)) return policyRoute(settingsOf(env), prevCategory || DEFAULT_CATEGORY, level, "continue");
return null;
}
// The decider's answer in the decision shadow's shape (decision-shadow.js startShadow finish / jevProbe).
export const jevOutcome = (jev) => jev.error && !jev.category ? { error: jev.error, ms: jev.ms } : { answers: { is_task: { noul: jev.p }, category: { choice: jev.category }, unmet: { noul: jev.unmet } }, ms: jev.ms, nano: Math.round((jev.cost || 0) * 1e9), src: jev.cost ? "reported" : null, model: jev.model || null };
export async function chooseTwo(env, { kind = "chat", text, hist, prevTools = 0, openWork = [], prevCategory = null, level = "zdr" }, opts = {}) {
env = await modelSettingsEnv(env);
const g = jevGate(env, { kind, text, prevTools, openWork, prevCategory, level }); if (g) return { route: g, jev: null };
const state = jevState({ hist, text, prevTools, openWork, prevCategory }), jevP = jevAsk(env, state, opts); // the decider's request is on its way first
let sh = null; try { sh = opts.shadow?.({ kind: "route", state, questions: JEV_QUESTIONS, judge: routeJudge(env, text, { level, prevCategory }), preview: state.latest_message }) || null; } catch {}
const jev = await jevP, d = routeFromAnswers(env, text, jev, { level, prevCategory });
sh?.finish?.(jevOutcome(jev));
return { route: d.route, jev: { ...jev, verdict: d.verdict } };
}
// Open work in this task, read from the Resident: unfinished to-dos (not ones ready for the person to tick), helpers still running.
export function openWorkOf(R, session) {
const out = [];
try { const items = session ? R.todoRow(session).items || [] : [], left = items.filter((x) => x.status !== "done" && !x.ready).length; if (left) out.push(`${left} unfinished to-do item${left > 1 ? "s" : ""}`); } catch {}
try { if (session && R.helperNote?.(session)) out.push("a helper is still working"); } catch {}
return out;
}
export function prevToolsOf(R, session) {
try { return Number(R.sql.exec("SELECT tool_calls FROM turns WHERE session=? AND kind='chat' ORDER BY at DESC LIMIT 1", session).toArray()[0]?.tool_calls || 0); } catch { return 0; }
}
// The task's category so far (kv `rcat:<session>`), carried through its turns, helpers, follow-ups and scheduled runs.
export const CAT_KEY = (session) => `rcat:${session}`;
export function sessionCategory(R, session) { try { const v = session ? R.get?.(CAT_KEY(session)) : null; return CATEGORIES.includes(v) ? v : null; } catch { return null; } }
export function saveSessionCategory(R, session, category) { try { if (session && CATEGORIES.includes(category)) R.put?.(CAT_KEY(session), category); } catch {} }
// The one place the turn's route is written, whether it lives on the Resident (this._route) or the turn (tc.route).
export function setTurnRoute(R, tc, route) { R._route = route; if (tc) tc.route = route; }
// Resident hook (resident.js runTurnInner, two mode): decide, bill the decider call like any model call, set the route.
// Returns the decision for turn stats; never throws (any failure → the task's category, else general).
export async function routeTwoTurn(R, tc, { kind, text, hist, stats }) {
let d;
const env = await modelSettingsEnv(R.env); // unreadable settings stop the turn here, with a clear error, before anything is sent
const tenant = R.get?.("tenant"), level = await privacyOf(env, tenant), prevCategory = sessionCategory(R, tc?.session);
// Dev test workspace (test-model.js): no decider call; the test model runs the whole turn and never escalates. The route the policy
// WOULD have taken (task category by keyword guess / the task's category, the account's current level) rides on the route for labels.
const tm = await testModelFor(env, tenant);
if (tm) {
const would = policyRoute(env.MODEL_SETTINGS, kind === "chat" ? (prevCategory && CONTINUE_RE.test(String(text || "")) ? prevCategory : guessCategory(text)) : prevCategory || DEFAULT_CATEGORY, level, "would");
d = { route: { ...testRoute(tm), category: would.category, level, would: { model: would.model, effort: would.effort, chain: [would.model, ...would.fallbacks] } }, jev: null };
d.route.label = `${d.route.label} would:${modelLabel(env.MODEL_SETTINGS, would.model)}@${would.effort} ${would.category}·${level}`;
testShadow(R, tc, d.route, { kind, text, hist, level, prevCategory }, env);
}
else try { d = await chooseTwo(env, { kind, text, hist, prevTools: prevToolsOf(R, tc?.session), openWork: openWorkOf(R, tc?.session), prevCategory, level }, { shadow: routeShadow(R, tc, env), meta: { tenant, channel: R.get?.("channel") } }); }
catch (e) { d = { route: policyRoute(env.MODEL_SETTINGS, prevCategory || DEFAULT_CATEGORY, level, "decider:unsure"), jev: { error: String(e?.message || e).slice(0, 160), verdict: "unsure" } }; }
const nano = Math.ceil((d.jev?.cost || 0) * 1e9 * JEV_MARKUP);
if (nano > 0) { try { const rid = `rtr:${crypto.randomUUID()}`; if ((await R.ledger().reserve(rid, nano)).ok) { await R.ledger().settle(rid, nano); if (stats) stats.charge += nano; } } catch (e) { console.log("decider charge", e.message); } }
setTurnRoute(R, tc, d.route);
saveSessionCategory(R, tc?.session, d.route.category);
if (stats) stats.route = { label: d.route.label, category: d.route.category, level, p: d.jev?.p ?? null, unmet: d.jev?.unmet ?? null, ms: d.jev?.ms ?? null, error: d.jev?.error || null };
if (tc) { tc.esc = newEsc(); tc.routeStats = stats?.route || null; }
return d;
}
// The decision shadow's starter for this turn (dev + DECISION_SHADOW="on" only; decision-shadow.js), or undefined.
function routeShadow(R, tc, env) {
const wu = residentWaitUntil(R); if (!wu || !shadowOn(env)) return undefined;
const meta = { tenant: R.get?.("tenant") || null, channel: R.get?.("channel") || null, session: tc?.session || null };
return ({ preview, ...spec }) => startShadow(env, wu, { ...spec, meta: { ...meta, preview } });
}
// Dev test override (decision-shadow.js both_shadow): the test model runs the turn exactly as before (testRoute, no decider call in the
// turn); for a message the decider would have seen (same gates), the live and shadow deciders are asked in the background on the
// exact state. Nothing is billed, nothing waits, the route never changes.
function testShadow(R, tc, route, { kind, text, hist, level, prevCategory }, env) {
try {
const wu = residentWaitUntil(R); if (!wu || !shadowOn(env)) return;
const prevTools = prevToolsOf(R, tc?.session), openWork = openWorkOf(R, tc?.session);
if (jevGate(env, { kind, text, prevTools, openWork, prevCategory, level })) return;
const state = jevState({ hist, text, prevTools, openWork, prevCategory });
const sh = startShadow(env, wu, { kind: "route", state, questions: JEV_QUESTIONS, judge: routeJudge(env, text, { level, prevCategory }), jevProbe: () => jevAsk(env, state).then(jevOutcome),
meta: { tenant: R.get?.("tenant") || null, channel: R.get?.("channel") || null, session: tc?.session || null, preview: state.latest_message } });
sh.actual({ model: route.model, route: route.label });
} catch (e) { console.log("decision shadow (test mode)", String(e?.message || e).slice(0, 160)); }
}
// ---- In-turn quality recovery (both engines: Resident.callModel calls these around every main-chat model call) ----
// State on tc.esc. Before a call, the tool results of the previous step's calls (found in the messages by call id) are judged:
// - the same tool failed in two steps in a row (an "error…" result, invalid/cut-off JSON, a command killed or not found) → "tool-errors"
// - ESCALATE_STALL steps in a row with no progress (every call repeats one made earlier this turn, or every result failed) → "stalled"
// After a call: two malformed tool calls this turn → "bad-calls"; a final answer saying it's stuck after tool work → that same step
// is redone on the saved "escalate" model. Every switch moves the rest of the turn to that model (model-jobs.js), once; a turn already on it never switches.
export const ESCALATE_FAILS = 2, ESCALATE_STALL = 3;
export const STUCK_RE = /\b(I'?m|I am|I've been|I have been) (stuck|going (around )?in circles)\b|\bkeep(s)? (getting|hitting|running into) (the same |this |that )?(error|failure|problem)|\bI (can'?t|cannot|couldn'?t|could not) (figure out|get (it|this|that) to work|make (it|this) work)\b|\b(ran|run) out of (ideas|things to try)\b/i;
export const newEsc = () => ({ pending: [], seen: new Set(), failTool: null, fails: 0, stall: 0, bad: 0, tools: 0, done: null });
const sigOf = (c) => { let a = {}; try { a = JSON.parse(c.function?.arguments || "{}"); } catch {} const { step_title, ...rest } = a || {}; return `${c.function?.name}:${JSON.stringify(rest)}`; };
export function failedResult(text) {
const t = String(text ?? "").replace(/^(\[[^\]]*\]\n)+/, "").trimStart(); // egress / Sentinel notes come first
if (/^(error\b|Your tool arguments were not valid JSON|Validation failed for tool|Tool \S+ not found)/i.test(t)) return true; // classic + pi
const m = t.match(/^exit (\d+)/); return !!m && [2, 124, 126, 127, 137].includes(Number(m[1])); // exit 1 is often just "no match"
}
const recoverable = (tc) => !!tc?.route?.two && !tc.route.taskAffinity && !tc.route.test && !!tc.esc && !tc.esc.done && !!tc.route.escalate && (tc.route.served || tc.route.primary) !== tc.route.escalate.model;
export function escalateTurn(R, tc, reason) {
if (!recoverable(tc)) return false;
tc.esc.done = reason;
const to = tc.route.escalate, k = modelLabel(null, to.model), why = `escalated:${reason}`, { served, ...keep } = tc.route;
setTurnRoute(R, tc, { ...keep, why, model: to.model, primary: to.model, effort: to.effort, recovery: to.model, recoveryEffort: to.effort, label: `${k}/${keep.category} ${why}`, floor: `${k} ${keep.category}·${keep.level} ${why}` });
if (tc.routeStats) tc.routeStats.escalated = reason;
console.log("router: rest of the turn on the recovery model", JSON.stringify({ reason, session: tc.session || null, kind: tc.kind || null, category: tc.route.category }));
return true;
}
export function escalationBefore(R, tc, messages) {
if (!recoverable(tc)) return null;
const e = tc.esc, ids = new Map(e.pending.map((p) => [p.id, p])); if (!ids.size) return null;
const res = (messages || []).filter((m) => m?.role === "tool" && ids.has(m.tool_call_id));
if (!res.length) return null; // e.g. a checkpoint call: nothing new to judge
e.pending = [];
const failed = res.filter((m) => failedResult(typeof m.content === "string" ? m.content : JSON.stringify(m.content))).map((m) => ids.get(m.tool_call_id).name);
const allFailed = failed.length === res.length, allRepeat = res.every((m) => ids.get(m.tool_call_id).repeat);
if (failed.length) { const t = failed.includes(e.failTool) ? e.failTool : failed[0]; e.fails = t === e.failTool ? e.fails + 1 : 1; e.failTool = t; } else { e.fails = 0; e.failTool = null; }
e.stall = allFailed || allRepeat ? e.stall + 1 : 0;
const why = e.fails >= ESCALATE_FAILS ? "tool-errors" : e.stall >= ESCALATE_STALL ? "stalled" : null;
return why && escalateTurn(R, tc, why) ? why : null;
}
// Returns the reason when this step must be redone on the recovery model (the switch is already made), else null.
export function escalationAfter(R, tc, r) {
if (!recoverable(tc) || !r) return null;
const e = tc.esc, calls = r.calls || [];
for (const c of calls) { const s = sigOf(c); e.pending.push({ id: c.id, name: c.function?.name, repeat: e.seen.has(s) }); e.seen.add(s); }
e.tools += calls.length;
if (calls.some((c) => c.bad_args != null) && ++e.bad >= ESCALATE_FAILS) { escalateTurn(R, tc, "bad-calls"); return null; }
if (!calls.length && e.tools > 0 && STUCK_RE.test(r.content || "")) return escalateTurn(R, tc, "stuck") ? "stuck" : null;
return null;
}
// Hand-off notes (written after the reply, read only by the helper's future self) are a chore: gpt-oss-120b, not the pool.
// Matched on the prompt resident.js sends for them (test/route2.test.mjs checks it's still there).
export const HANDOFF_PREFIX = "(Turn finished; the person has your reply.";
export const isHandoff = (route, body) => {
if (!route?.two || body?.tools) return false;
const last = (body?.messages || []).at(-1);
return last?.role === "user" && typeof last.content === "string" && last.content.startsWith(HANDOFF_PREFIX);
};
// The hand-off job's route from the saved settings, or (without settings) a marker route that names the job.
export function choreRouteFor(route, body, env = null) {
if (!isHandoff(route, body)) return null;
return env?.MODEL_SETTINGS ? choreRouteOf(env.MODEL_SETTINGS, "handoff") : jobRoute("handoff");
}
// The models one main-chat dispatch may try, in order (model.js twoChat): a recovery/legacy model first, then the category's chain at the
// CURRENT level; each kept only if some endpoint of it is eligible at that level for this payload (privacy.js), and not a Meta model
// when the request forces a tool choice (Meta accepts only "auto"). Deduplicated, at most MAX_MODELS. skipped: why each was left out.
export function dispatchOrder(route, level, { prov = null, body = null, settings = null } = {}) {
if (route?.taskAffinity) {
const model = route.taskProfile?.requestedModel;
return model ? { models: [model], attempts: [{ model, effort: route.taskProfile?.effort ?? null }], skipped: [] } : { models: [], attempts: [], skipped: [{ model: null, why: "missing task profile" }] };
}
level = normalizePrivacy(level);
const job = jobSetting(settings, chatJobId(route?.category)), own = entriesOf(job);
// A model named directly on a route (a recovery switch, or a route saved before these settings) goes first with its saved effort.
const effortOf = (model) => [...Object.values(settings.jobs)].flatMap((j) => j ? entriesOf(j) : []).find((e) => e.model === model)?.effort ?? null;
const lead = [route?.recovery, route?.policy ? null : route?.primary].filter(Boolean).map((model) => ({ model, effort: route?.recovery === model && route.recoveryEffort !== undefined ? route.recoveryEffort : effortOf(model) }));
const list = [...lead, ...own];
const forced = body?.tool_choice != null && body.tool_choice !== "auto";
const attempts = [], skipped = [];
for (const e of list) {
const m = e.model;
if (attempts.some((a) => a.model === m) || skipped.some((s) => s.model === m)) continue;
const meta = settings?.catalog?.[m];
if (forced && meta?.quirks?.auto_tool_choice_only) { skipped.push({ model: m, why: "forced tool choice (this model accepts only auto)" }); continue; }
const choice = typeof body?.tool_choice === "object" ? "function" : body?.tool_choice;
if (meta?.tool_choice && choice && !meta.tool_choice.includes(choice)) { skipped.push({ model: m, why: "unsupported tool choice" }); continue; }
const images = body?.messages?.some((msg) => Array.isArray(msg.content) && msg.content.some((part) => part.type === "image_url"));
if (meta?.input_modalities && images && !meta.input_modalities.includes("image")) { skipped.push({ model: m, why: "no image input" }); continue; }
if (!configuredHosts(settings, m, level).length) { skipped.push({ model: m, why: ineligibleWhy(m, level, { prov }) || "no eligible configured endpoint" }); continue; }
attempts.push({ model: m, effort: e.effort ?? null });
}
const kept = attempts.slice(0, MAX_MODELS);
return { models: kept.map((a) => a.model), attempts: kept, skipped };
}