@chatpanel/events 0.85.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.js +249 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +13 -2
- package/job.js +149 -0
- package/model-candidates.js +358 -0
- package/model-ledger.js +228 -0
- package/model-picker.js +3 -1
- package/package.json +21 -1
- package/project.js +170 -0
- package/recruit.js +419 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
package/recruit.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// Recruiting — applying and evaluating, the step between a posted job and a run (F8 §12.2.4,
|
|
2
|
+
// architecture-pillars.md §13.4).
|
|
3
|
+
//
|
|
4
|
+
// Applications are COMPUTED, not asked for: every agent in the pool applies to every job at
|
|
5
|
+
// once, scored by `fit` (scorecard.js) on its skills, tools, grants and its attested record —
|
|
6
|
+
// the model-ADJUSTED rating when the engine rows say what its engines were worth. Recruiting
|
|
7
|
+
// costs one model turn per job, not one per applicant, and that turn is optional.
|
|
8
|
+
//
|
|
9
|
+
// What is recruited is an (agent, engine) PAIR. An agent's engine may be fixed (`model`,
|
|
10
|
+
// `harness`), the chat's (`assistant`), or `auto` with a policy; `routeFor` resolves it
|
|
11
|
+
// against the ENGINE ROWS the host knows — every model or harness it can run, with reach,
|
|
12
|
+
// capabilities, quality / latency / cost (the engine card's observed values over the router's
|
|
13
|
+
// guess, `engineRow`) and whether it is available right now. Requirements eliminate first
|
|
14
|
+
// (reach from the project's privacy setting — never learned, only typed; a work grant needs a
|
|
15
|
+
// harness; tools need `tools`), the policy orders what survives over observed values, and the
|
|
16
|
+
// agent's own record on each engine breaks ties. An agent whose engine does not clear is
|
|
17
|
+
// still an applicant — a person should see it — but is not recruitable now.
|
|
18
|
+
//
|
|
19
|
+
// The EVALUATOR is one structured call (RECRUIT_SCHEMA) over the top applications: the pick
|
|
20
|
+
// and why, or "none fits" with the agent that should exist. The call is the host's
|
|
21
|
+
// (`runStructured` in a client; a gateway without a model skips it): `decide` takes its parsed
|
|
22
|
+
// answer when there is one and falls back to the best fit above a floor when there is not, so
|
|
23
|
+
// a job is recruited with or without a model. The decision lands on the project record as
|
|
24
|
+
// events (`recruitEvents`) — the pick and the reasons on the job, a proposal as a decision a
|
|
25
|
+
// person reads — never as a mutation.
|
|
26
|
+
//
|
|
27
|
+
// Pure, dependency-free; imports only what the gateway already vendors (scorecard.js,
|
|
28
|
+
// model-ledger.js, engine.js, agent.js, job.js, team.js, structured.js, budget.js).
|
|
29
|
+
|
|
30
|
+
import { fit, engineKey, normalizeEngine } from './scorecard.js';
|
|
31
|
+
import { cardOverride, DEFAULT_MIN_CALLS } from './model-ledger.js';
|
|
32
|
+
import { normalizeEngineSpec, engineKeyOf, describeEngine } from './engine.js';
|
|
33
|
+
import { engineOf, agentFromForm } from './agent.js';
|
|
34
|
+
import { applyAll } from './job.js';
|
|
35
|
+
import { WORK_GRANTS } from './team.js';
|
|
36
|
+
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
37
|
+
import { normalizeBudget } from './budget.js';
|
|
38
|
+
|
|
39
|
+
export const MIN_FIT = 0.5;
|
|
40
|
+
export const TOP_APPLICANTS = 5;
|
|
41
|
+
export const MAX_BRIEF_IN_PROMPT = 1500;
|
|
42
|
+
const REACH_RANK = { device: 0, trusted: 1, any: 2 };
|
|
43
|
+
|
|
44
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
45
|
+
const r3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000);
|
|
46
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
47
|
+
const lower = (xs) => (Array.isArray(xs) ? xs : []).map((x) => String(x).toLowerCase());
|
|
48
|
+
|
|
49
|
+
// ── The evaluator's answer ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const RECRUIT_SCHEMA = defineSchema({
|
|
52
|
+
name: 'recruit',
|
|
53
|
+
purpose: 'which applicant gets the job, or that none fits and what agent should exist',
|
|
54
|
+
fields: {
|
|
55
|
+
// Not `required`: an emptied required field reads as "nothing" and would drop the proposal that rides beside it.
|
|
56
|
+
pick: { type: 'string', max: 64, describe: 'the id of the ONE applicant to recruit, or "" when none fits' },
|
|
57
|
+
why: { type: 'string', required: true, max: 400, describe: 'one or two sentences a person reads on the job page' },
|
|
58
|
+
confidence: { type: 'number', describe: '0 to 1' },
|
|
59
|
+
proposalName: { type: 'string', max: 60, describe: 'when none fits: the agent that should exist' },
|
|
60
|
+
proposalPurpose: { type: 'string', max: 300 },
|
|
61
|
+
proposalSkills: { type: 'string[]', maxItems: 12 },
|
|
62
|
+
proposalGrants: { type: 'string[]', maxItems: 8, describe: 'from: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr' },
|
|
63
|
+
},
|
|
64
|
+
nothing: { pick: '', why: 'none fits' },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Engine rows: what the host can run, as the recruiter reads it ─────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One row from a host's candidate (a router model — `inferCandidate`'s shape: `id`, `model`,
|
|
71
|
+
* `reach`, `capabilities`, `quality`, `latencyMs`, `costPer1k`, `available`, `classUsed`;
|
|
72
|
+
* or anything carrying an `engine` ref) with its engine CARD applied: observed quality,
|
|
73
|
+
* latency, cost and availability replace the guess where there is enough history, and a
|
|
74
|
+
* withdrawn capability is gone. A bridge agent (`classUsed 'A'` / `kind 'bridge'`) is a
|
|
75
|
+
* harness — `claude/opus` is the harness `claude` asked to run `opus` — keyed the way the
|
|
76
|
+
* desktop's appointer and the engine ledger key it.
|
|
77
|
+
*/
|
|
78
|
+
export function engineRow(candidate, { card = null, minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
|
|
79
|
+
if (!isRecord(candidate)) return null;
|
|
80
|
+
const c = candidate;
|
|
81
|
+
let engine = c.engine ? normalizeEngine(c.engine) : null;
|
|
82
|
+
if (!engine) {
|
|
83
|
+
const name = String(c.model || c.id || '');
|
|
84
|
+
if (!name) return null;
|
|
85
|
+
if (c.kind === 'bridge' || c.kind === 'harness' || c.classUsed === 'A') {
|
|
86
|
+
const slash = name.indexOf('/');
|
|
87
|
+
engine = slash > 0 ? { kind: 'harness', id: name.slice(0, slash), model: name.slice(slash + 1) } : { kind: 'harness', id: name };
|
|
88
|
+
} else engine = { kind: 'model', id: name };
|
|
89
|
+
}
|
|
90
|
+
const key = engineKey(engine);
|
|
91
|
+
if (!key) return null;
|
|
92
|
+
const { override, observed } = cardOverride(card, { minCalls, jobKind });
|
|
93
|
+
const withdrawn = new Set(card?.capabilities?.withdrawn || []);
|
|
94
|
+
const capabilities = [...new Set(lower(c.capabilities))].filter((x) => !withdrawn.has(x));
|
|
95
|
+
if (withdrawn.size && lower(c.capabilities).some((x) => withdrawn.has(x))) observed.push('capabilities');
|
|
96
|
+
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
|
|
97
|
+
return {
|
|
98
|
+
key,
|
|
99
|
+
engine,
|
|
100
|
+
label: clip(c.label || c.name || engineName(engine), 120),
|
|
101
|
+
reach: REACH_RANK[c.reach] != null ? c.reach : 'any',
|
|
102
|
+
capabilities,
|
|
103
|
+
quality: override.quality ?? num(c.quality),
|
|
104
|
+
latencyMs: override.latencyMs ?? num(c.latencyMs),
|
|
105
|
+
costPer1k: override.costPer1k ?? num(c.costPer1k),
|
|
106
|
+
costPerTask: num(card?.cost?.perTask),
|
|
107
|
+
availability: num(card?.availability?.rate),
|
|
108
|
+
available: override.available ?? (c.available !== false && c.usable !== false),
|
|
109
|
+
observed,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const engineName = (e) => `${e.id}${e.model && e.model !== e.id ? `/${e.model}` : ''}`;
|
|
113
|
+
|
|
114
|
+
/** Rows from a host's candidates and the cards it holds (by key). */
|
|
115
|
+
export function engineRows(candidates, { cards = {}, minCalls, jobKind } = {}) {
|
|
116
|
+
const out = []; const seen = new Set();
|
|
117
|
+
for (const c of Array.isArray(candidates) ? candidates : []) {
|
|
118
|
+
const row = engineRow(c, { card: null, minCalls, jobKind });
|
|
119
|
+
if (!row || seen.has(row.key)) continue;
|
|
120
|
+
seen.add(row.key);
|
|
121
|
+
out.push(cards[row.key] ? engineRow(c, { card: cards[row.key], minCalls, jobKind }) : row);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── What the job needs of an engine ───────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Requirements eliminate; they are never traded for cost or speed. A work grant (`shell`,
|
|
130
|
+
* `fs:write`, `scm:*`) can only be exercised by a harness — a chat model has no shell. Tools
|
|
131
|
+
* or any grant beyond `none` mean the turn may call tools. Reach is the project's privacy
|
|
132
|
+
* ceiling, typed, never learned.
|
|
133
|
+
*/
|
|
134
|
+
export function needForJob(job, { reach = 'any' } = {}) {
|
|
135
|
+
const grants = lower(job?.needs?.grants).filter((g) => g !== 'none');
|
|
136
|
+
const tools = lower(job?.needs?.tools);
|
|
137
|
+
const harness = grants.some((g) => WORK_GRANTS.includes(g));
|
|
138
|
+
const capabilities = [];
|
|
139
|
+
const why = [];
|
|
140
|
+
if (tools.length || grants.length) { capabilities.push('tools'); why.push('the job uses tools'); }
|
|
141
|
+
if (harness) why.push(`a work grant (${grants.filter((g) => WORK_GRANTS.includes(g)).join(', ')}) needs a harness`);
|
|
142
|
+
const r = REACH_RANK[reach] != null ? reach : 'any';
|
|
143
|
+
if (r !== 'any') why.push(`reach ≤ ${r} (the project's privacy setting)`);
|
|
144
|
+
return { capabilities, harness, reach: r, why };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Routing: the engine for this agent on this job ────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const meets = (row, need, policy) => {
|
|
150
|
+
const why = [];
|
|
151
|
+
if (row.available === false) why.push('unavailable right now');
|
|
152
|
+
if (REACH_RANK[row.reach] > REACH_RANK[need.reach]) why.push(`reach ${row.reach} exceeds ${need.reach}`);
|
|
153
|
+
if (need.harness && row.engine.kind !== 'harness') why.push('not a harness');
|
|
154
|
+
const missing = need.capabilities.filter((c) => !row.capabilities.includes(c));
|
|
155
|
+
// A harness brings its own tools; the capability list of a bridge agent is the host's guess.
|
|
156
|
+
if (missing.length && !(row.engine.kind === 'harness' && missing.every((c) => c === 'tools'))) why.push(`lacks ${missing.join(', ')}`);
|
|
157
|
+
if (policy) {
|
|
158
|
+
const matches = (refs) => (refs || []).some((k) => k === row.key || k === `${row.engine.kind}:${row.engine.id}` || k === row.engine.id || k === row.engine.model);
|
|
159
|
+
if (policy.allow?.length && !matches(policy.allow)) why.push('not on the policy\'s allow list');
|
|
160
|
+
if (policy.deny?.length && matches(policy.deny)) why.push('on the policy\'s deny list');
|
|
161
|
+
if (policy.floor?.quality != null && row.quality != null && row.quality < policy.floor.quality) why.push(`quality ${row.quality} under the floor ${policy.floor.quality}`);
|
|
162
|
+
if (policy.floor?.availability != null && row.availability != null && row.availability < policy.floor.availability) why.push(`availability ${row.availability} under the floor ${policy.floor.availability}`);
|
|
163
|
+
if (policy.ceiling?.costPerTask != null && row.costPerTask != null && row.costPerTask > policy.ceiling.costPerTask) why.push(`$${row.costPerTask}/task over the ceiling`);
|
|
164
|
+
if (policy.ceiling?.latencyMs != null && row.latencyMs != null && row.latencyMs > policy.ceiling.latencyMs) why.push(`${row.latencyMs} ms over the ceiling`);
|
|
165
|
+
}
|
|
166
|
+
return why;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const ownRating = (summary, key) => (summary?.byEngine || []).find((r) => r.key === key)?.rating?.avg ?? null;
|
|
170
|
+
|
|
171
|
+
/** Order the rows that clear by the policy's preference; the agent's own record on an engine breaks ties. */
|
|
172
|
+
function orderByPolicy(rows, prefer, summary) {
|
|
173
|
+
const q = (r) => r.quality ?? 0.5;
|
|
174
|
+
const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
|
|
175
|
+
const maxCost = Math.max(...rows.map((r) => cost(r) ?? 0), 0) || 1;
|
|
176
|
+
const maxLat = Math.max(...rows.map((r) => r.latencyMs ?? 0), 0) || 1;
|
|
177
|
+
const own = (r) => { const v = ownRating(summary, r.key); return v == null ? 0 : v - 0.5; };
|
|
178
|
+
const score = (r) => {
|
|
179
|
+
switch (prefer) {
|
|
180
|
+
case 'cheapest-that-clears': return -((cost(r) ?? maxCost) / maxCost) + q(r) * 0.01;
|
|
181
|
+
case 'best-quality': return q(r) - ((cost(r) ?? maxCost) / maxCost) * 0.01;
|
|
182
|
+
case 'fastest': return -((r.latencyMs ?? maxLat) / maxLat) + q(r) * 0.01;
|
|
183
|
+
default: return q(r) * 0.5 + (1 - (cost(r) ?? maxCost) / maxCost) * 0.25 + (1 - (r.latencyMs ?? maxLat) / maxLat) * 0.25;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
return rows.map((r) => ({ row: r, score: score(r) + own(r) * 0.05 })).sort((a, b) => b.score - a.score).map((x) => x.row);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rowLine = (r) => `${r.key} (quality ${r.quality ?? '?'}${r.observed.includes('quality') ? ' observed' : ''}${r.costPerTask != null ? `, $${r.costPerTask}/task` : r.costPer1k != null ? `, $${r.costPer1k}/1k` : ''}${r.latencyMs != null ? `, ${r.latencyMs} ms` : ''})`;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The engine this agent would run this job on, and why — `{ engine, key, reasons,
|
|
193
|
+
* alternatives, exploration, clears }`; `clears: false` (engine null) when nothing does, with
|
|
194
|
+
* the reasons. `rows` are the host's `engineRows`; an empty roster trusts a fixed spec and
|
|
195
|
+
* refuses `auto` (nothing to pick from). `explore` takes one tier cheaper than the policy's
|
|
196
|
+
* pick when a cheaper row clears — the project loop's bounded exploration (§13.4); never for
|
|
197
|
+
* a harness.
|
|
198
|
+
*/
|
|
199
|
+
export function routeFor(agent, job, { rows = [], summary = null, need = null, reach = 'any', chatModel = null, explore = false } = {}) {
|
|
200
|
+
const n = need || needForJob(job, { reach });
|
|
201
|
+
const spec = engineOf(agent, { chatModel });
|
|
202
|
+
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
|
203
|
+
const none = (reasons) => ({ engine: null, key: null, reasons, alternatives: [], exploration: false, clears: false });
|
|
204
|
+
if (spec.kind !== 'auto') {
|
|
205
|
+
const key = engineKeyOf(spec);
|
|
206
|
+
const exact = list.find((r) => r.key === key);
|
|
207
|
+
// A harness card without a model matches any row of that harness; a model spec without a
|
|
208
|
+
// provider matches the row that runs that model anywhere.
|
|
209
|
+
const near = exact || list.find((r) => r.engine.kind === spec.kind && (spec.kind === 'harness' ? r.engine.id === spec.harnessId && !spec.model : (r.engine.id === spec.model || r.engine.model === spec.model) && !spec.providerId));
|
|
210
|
+
if (!list.length) return { engine: normalizeEngine({ kind: spec.kind, id: spec.kind === 'harness' ? spec.harnessId : (spec.providerId || spec.model), model: spec.model }), key, reasons: [`${describeEngine(spec)} — pinned by the agent; no roster to check it against`], alternatives: [], exploration: false, clears: true };
|
|
211
|
+
if (!near) return none([`${describeEngine(spec)} is pinned by the agent but is not installed or configured here`]);
|
|
212
|
+
const why = meets(near, n, null);
|
|
213
|
+
if (why.length) return none([`${describeEngine(spec)} is pinned by the agent but ${why.join('; ')}`]);
|
|
214
|
+
return { engine: near.engine, key: near.key, reasons: ['pinned by the agent', ...n.why], alternatives: [], exploration: false, clears: true };
|
|
215
|
+
}
|
|
216
|
+
if (!list.length) return none(['engine is auto and the roster is empty']);
|
|
217
|
+
const policy = spec.policy || {};
|
|
218
|
+
const rejected = [];
|
|
219
|
+
const cleared = list.filter((r) => { const why = meets(r, n, policy); if (why.length) rejected.push(`${r.key}: ${why.join('; ')}`); return !why.length; });
|
|
220
|
+
if (!cleared.length) return none([`no engine clears ${n.harness ? 'a harness with ' : ''}${n.capabilities.join(', ') || 'the requirements'}${n.reach !== 'any' ? ` within reach ${n.reach}` : ''}${policy.prefer ? ` under ${policy.prefer}` : ''}`, ...rejected.slice(0, 4)]);
|
|
221
|
+
const ordered = orderByPolicy(cleared, policy.prefer || 'balanced', summary);
|
|
222
|
+
let pick = ordered[0];
|
|
223
|
+
let exploration = false;
|
|
224
|
+
const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
|
|
225
|
+
if (explore && pick.engine.kind !== 'harness') {
|
|
226
|
+
const cheaper = ordered.filter((r) => r.engine.kind !== 'harness' && cost(r) != null && cost(pick) != null && cost(r) < cost(pick)).sort((a, b) => cost(b) - cost(a));
|
|
227
|
+
if (cheaper.length) { exploration = true; pick = cheaper[0]; }
|
|
228
|
+
}
|
|
229
|
+
const reasons = [
|
|
230
|
+
exploration ? `exploration: one tier cheaper than the policy's pick (${ordered[0].key})` : `${(policy.prefer || 'balanced').replace(/-/g, ' ')}: ${rowLine(pick)}`,
|
|
231
|
+
...n.why,
|
|
232
|
+
];
|
|
233
|
+
const own = ownRating(summary, pick.key);
|
|
234
|
+
if (own != null) reasons.push(`this agent rated ${Math.round(own * 100)}% on it before`);
|
|
235
|
+
if (policy.floor?.quality != null || policy.ceiling?.costPerTask != null || policy.ceiling?.latencyMs != null) reasons.push(`${cleared.length} of ${list.length} engines clear the policy`);
|
|
236
|
+
return { engine: pick.engine, key: pick.key, reasons, alternatives: ordered.filter((r) => r !== pick).slice(0, 4).map((r) => r.engine), exploration, clears: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Applying: the whole pool at once ──────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/** `qualityOf` / `costOf` for `adjustSummary`, read from the rows: what each engine is worth. */
|
|
242
|
+
export function engineWorth(rows) {
|
|
243
|
+
const byKey = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
|
|
244
|
+
return {
|
|
245
|
+
qualityOf: (key) => byKey.get(key)?.quality ?? null,
|
|
246
|
+
costOf: (key) => { const r = byKey.get(key); return r ? (r.costPerTask ?? null) : null; },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Every eligible agent applies: `fit` (needs → adjusted record → size) plus the engine it
|
|
252
|
+
* would run on. `summaries` are scorecard cards by agent id (`summarize()`); `rows` are
|
|
253
|
+
* `engineRows`. Best first, recruitable (an engine clears) before not. Each application is
|
|
254
|
+
* `{ agentId, engine?, fit, reasons, pitch, at, covers }` — job.js's shape, the record's, plus
|
|
255
|
+
* `covers` (has a skill the job names) for `decide`; the record drops it.
|
|
256
|
+
*/
|
|
257
|
+
export function applications(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, adjust = true, now = Date.now() } = {}) {
|
|
258
|
+
const need = needForJob(job, { reach });
|
|
259
|
+
const worth = engineWorth(rows);
|
|
260
|
+
const fitFn = (j, agent, summary) => {
|
|
261
|
+
const f = fit(j, { ...agent, tools: agent.tools || agent.grants }, summary, adjust ? worth : { adjust: false });
|
|
262
|
+
const route = routeFor(agent, j, { rows, summary, need, chatModel });
|
|
263
|
+
return { score: f.score, reasons: [...f.reasons, ...route.reasons].slice(0, 8), ...(route.clears ? { engine: route.engine } : {}), covers: coversSkills(j, agent) };
|
|
264
|
+
};
|
|
265
|
+
// `covers` — has at least one skill the job names (or the job names none) — rides on the
|
|
266
|
+
// live application for `decide`; the record keeps job.js's shape and drops it.
|
|
267
|
+
const covered = new Map((pool || []).map((a) => [a?.id, coversSkills(job, a)]));
|
|
268
|
+
return applyAll(job, pool, fitFn, { cards: summaries, now }).map((a) => ({ ...a, covers: covered.get(a.agentId) !== false }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A job that names skills is not given to an agent with none of them on fit alone: tools and grants it never asked for count for nothing. */
|
|
272
|
+
function coversSkills(job, agent) {
|
|
273
|
+
const want = lower(job?.needs?.skills);
|
|
274
|
+
if (!want.length) return true;
|
|
275
|
+
const has = new Set(lower(agent?.skills));
|
|
276
|
+
return want.some((s) => has.has(s));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Evaluating: one structured call, or none ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** The evaluator's instruction: the job, the top applicants with their fit, engine and reasons, the shape to answer in. */
|
|
282
|
+
export function evaluatorPrompt(job, apps, pool = [], { rows = [], top = TOP_APPLICANTS } = {}) {
|
|
283
|
+
const byId = new Map((pool || []).map((a) => [a.id, a]));
|
|
284
|
+
const rowOf = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
|
|
285
|
+
const needs = job?.needs || {};
|
|
286
|
+
const lines = (apps || []).slice(0, top).map((a) => {
|
|
287
|
+
const agent = byId.get(a.agentId) || {};
|
|
288
|
+
const key = a.engine ? engineKey(a.engine) : null;
|
|
289
|
+
const row = key ? rowOf.get(key) : null;
|
|
290
|
+
return `- ${a.agentId} (fit ${Math.round((a.fit || 0) * 100)}%)${agent.purpose ? ` — ${clip(agent.purpose, 160)}` : ''}; skills: ${(agent.skills || []).join(', ') || 'none'}; grants: ${(agent.grants || []).join(', ') || 'none'}; ${key ? `engine: ${row ? rowLine(row) : key}` : 'NOT RECRUITABLE NOW — no engine clears'}; ${(a.reasons || []).slice(0, 4).join('; ')}`;
|
|
291
|
+
});
|
|
292
|
+
return [
|
|
293
|
+
`You are the evaluator for the job "${clip(job?.title, 200)}" on project ${job?.projectId || '?'}. Recruit ONE applicant, or say none fits.`,
|
|
294
|
+
`Brief: ${clip(job?.brief, MAX_BRIEF_IN_PROMPT)}`,
|
|
295
|
+
`Needs — skills: ${(needs.skills || []).join(', ') || 'none named'}; tools: ${(needs.tools || []).join(', ') || 'none named'}; grants: ${(needs.grants || []).join(', ') || 'none'}${job?.budget ? `; budget: ${Object.entries(job.budget).map(([k, v]) => `${v} ${k}`).join(', ')}` : ''}.`,
|
|
296
|
+
'',
|
|
297
|
+
'Applicants, best computed fit first (fit = the skills, tools and grants the job names, then the attested record, then size):',
|
|
298
|
+
...(lines.length ? lines : ['- (no one applied)']),
|
|
299
|
+
'',
|
|
300
|
+
'Rules: prefer the applicant that has what the job names and a record of clearing work like it on the engine shown; never pick one marked NOT RECRUITABLE NOW; a lower fit is right only when its reasons show the higher one lacks something the brief needs. When no applicant has the skills the job names, answer pick "" and propose the agent that should exist.',
|
|
301
|
+
'',
|
|
302
|
+
describeSchema(RECRUIT_SCHEMA),
|
|
303
|
+
].join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The evaluator's answer, read through the schema and checked against the applications: a
|
|
308
|
+
* pick must be a recruitable applicant, else it is "none". Returns `{ pick, why, confidence,
|
|
309
|
+
* proposal }` or null when the text is unreadable.
|
|
310
|
+
*/
|
|
311
|
+
export function parseEvaluation(text, apps = []) {
|
|
312
|
+
const got = coerce(text, RECRUIT_SCHEMA);
|
|
313
|
+
if (!got) return null;
|
|
314
|
+
const v = got.value;
|
|
315
|
+
const pick = String(v.pick || '').trim();
|
|
316
|
+
const app = pick ? (apps || []).find((a) => a.agentId === pick) : null;
|
|
317
|
+
const proposal = v.proposalName ? { name: clip(v.proposalName, 60), purpose: clip(v.proposalPurpose, 300), skills: (v.proposalSkills || []).map((s) => clip(s, 80)).filter(Boolean), grants: (v.proposalGrants || []).map((g) => clip(g, 64)).filter(Boolean) } : null;
|
|
318
|
+
if (app && app.engine) return { pick, why: clip(v.why, 400) || 'the evaluator\'s pick', confidence: r3(v.confidence), proposal: null };
|
|
319
|
+
return { pick: '', why: app ? `the evaluator picked ${pick}, which no engine can run right now` : clip(v.why, 400) || 'none fits', confidence: r3(v.confidence), proposal };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The decision: the evaluator's when one was made; else the best recruitable fit at or above
|
|
324
|
+
* `minFit`; else none, with the agent the job's needs describe as the proposal. Returns
|
|
325
|
+
* `{ kind: 'recruit', agentId, engine, fit, why, by }` or `{ kind: 'none', why, proposal, by }`.
|
|
326
|
+
*/
|
|
327
|
+
export function decide(job, apps, { evaluation = null, minFit = MIN_FIT } = {}) {
|
|
328
|
+
const list = apps || [];
|
|
329
|
+
if (evaluation && evaluation.pick) {
|
|
330
|
+
const app = list.find((a) => a.agentId === evaluation.pick && a.engine);
|
|
331
|
+
if (app) return { kind: 'recruit', agentId: app.agentId, engine: app.engine, fit: app.fit, why: evaluation.why, by: 'evaluator' };
|
|
332
|
+
}
|
|
333
|
+
if (evaluation && !evaluation.pick) return { kind: 'none', why: evaluation.why, proposal: evaluation.proposal || proposalFromNeeds(job), by: 'evaluator' };
|
|
334
|
+
const best = list.find((a) => a.engine && a.covers !== false);
|
|
335
|
+
if (best && best.fit >= minFit) return { kind: 'recruit', agentId: best.agentId, engine: best.engine, fit: best.fit, why: `best fit (${Math.round(best.fit * 100)}%): ${(best.reasons || [])[0] || 'meets the needs'}`, by: 'fit' };
|
|
336
|
+
const why = !list.length ? 'no one in the pool applies to jobs'
|
|
337
|
+
: !best ? (list.some((a) => a.engine) ? `no applicant has a skill the job names (${(job?.needs?.skills || []).join(', ')})` : `${list.length} applied but no engine clears for any of them: ${(list[0].reasons || []).find((r) => /engine|pinned|roster/.test(r)) || 'see the applications'}`)
|
|
338
|
+
: `the best fit is ${Math.round(best.fit * 100)}%, under the ${Math.round(minFit * 100)}% floor: ${(best.reasons || []).find((r) => /missing/.test(r)) || best.reasons?.[0] || ''}`;
|
|
339
|
+
return { kind: 'none', why, proposal: proposalFromNeeds(job), by: 'fit' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The agent a job's needs describe — what to propose when no one fits. */
|
|
343
|
+
export function proposalFromNeeds(job) {
|
|
344
|
+
const needs = job?.needs || {};
|
|
345
|
+
return { name: clip(job?.title, 60) || 'New agent', purpose: `Does jobs like "${clip(job?.title, 80)}".`, skills: [...(needs.skills || [])], grants: (needs.grants || []).length ? [...needs.grants] : ['none'] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A proposal as an agent card for a person to approve — validated by the pool's own form
|
|
350
|
+
* (agentFromForm), engine `auto`, `createdBy: 'evaluator'`, its origin the job. Nothing
|
|
351
|
+
* joins the pool without a decision (D-A2): this returns the card, it does not store it.
|
|
352
|
+
*/
|
|
353
|
+
export function proposalToAgent(proposal, job, { by = 'evaluator' } = {}) {
|
|
354
|
+
const p = proposal || proposalFromNeeds(job);
|
|
355
|
+
const known = new Set(['data', 'web', 'history', 'mcp', ...WORK_GRANTS]);
|
|
356
|
+
const grants = (p.grants || []).map((g) => String(g).toLowerCase()).filter((g) => known.has(g) || /^mcp:/.test(g));
|
|
357
|
+
return agentFromForm({
|
|
358
|
+
name: p.name, purpose: p.purpose, skills: p.skills,
|
|
359
|
+
prompt: `You are ${p.name}. ${p.purpose || ''}\n\nYou were proposed for the job "${clip(job?.title, 200)}" because no agent in the pool fit it. Do work like it well; ask on the thread when the brief is unclear.`,
|
|
360
|
+
grants: grants.length ? grants : ['none'], engine: { kind: 'auto', prefer: 'balanced' }, appliesTo: ['jobs'], createdBy: by,
|
|
361
|
+
origin: { kind: 'proposal', projectId: job?.projectId, jobId: job?.id },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Landing it on the record ──────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The budget a recruit gets: the job's own when it has one, else an equal share of what the
|
|
369
|
+
* project has left (its budget minus its spend) across the jobs still to be recruited — so
|
|
370
|
+
* one unbudgeted job cannot take the whole project. `record` is the project record
|
|
371
|
+
* (project.js `foldProject`).
|
|
372
|
+
*/
|
|
373
|
+
export function carveBudget(job, record = null) {
|
|
374
|
+
if (job?.budget && Object.keys(job.budget).length) return normalizeBudget(job.budget);
|
|
375
|
+
const cap = record?.page?.budget || {};
|
|
376
|
+
const spent = record?.spend || {};
|
|
377
|
+
const waiting = Math.max(1, (record?.jobs || []).filter((j) => ['open', 'evaluating'].includes(j.status)).length);
|
|
378
|
+
const out = {};
|
|
379
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
380
|
+
if (!(Number(cap[k]) > 0)) continue;
|
|
381
|
+
const left = Math.max(0, Number(cap[k]) - (Number(spent[k]) || 0));
|
|
382
|
+
if (left > 0) out[k] = k === 'usd' ? Math.round((left / waiting) * 100) / 100 : Math.max(1, Math.floor(left / waiting));
|
|
383
|
+
}
|
|
384
|
+
return Object.keys(out).length ? out : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The events that record a recruiting pass on the project (project.js fold): the job moves
|
|
389
|
+
* to `evaluating` with its applications, then to `recruited` with the pair, the budget and
|
|
390
|
+
* the why — or back to `open`, with the proposal as a decision a person reads.
|
|
391
|
+
*/
|
|
392
|
+
export function recruitEvents(job, apps, decision, { by = 'evaluator', at = Date.now(), record = null } = {}) {
|
|
393
|
+
const events = [{ type: 'job.updated', at, job: { id: job.id, status: 'evaluating', applications: apps }, by }];
|
|
394
|
+
if (decision?.kind === 'recruit') {
|
|
395
|
+
const budget = carveBudget(job, record);
|
|
396
|
+
events.push({ type: 'job.updated', at, job: { id: job.id, status: 'recruited', recruited: { agentId: decision.agentId, engine: decision.engine, ...(budget ? { budget } : {}), by: decision.by || by, at, why: clip(decision.why, 600) } }, by });
|
|
397
|
+
} else {
|
|
398
|
+
events.push({ type: 'job.updated', at, job: { id: job.id, status: 'open' }, by });
|
|
399
|
+
const p = decision?.proposal;
|
|
400
|
+
events.push({ type: 'project.decision', at, by, kind: 'proposal', text: `No one in the pool fits "${clip(job.title, 120)}": ${clip(decision?.why, 400)}${p ? ` Proposed: ${p.name}${p.skills?.length ? ` — skills ${p.skills.join(', ')}` : ''}${p.grants?.length ? `; grants ${p.grants.join(', ')}` : ''}.` : ''}`, refs: [`job:${job.id}`] });
|
|
401
|
+
}
|
|
402
|
+
return events;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* One pass, end to end: apply → (evaluate) → decide → the events. `ask(prompt) → text |
|
|
407
|
+
* null` is the host's structured call; absent or failing, the fit decides. Returns
|
|
408
|
+
* `{ applications, evaluation, decision, events, prompt }`.
|
|
409
|
+
*/
|
|
410
|
+
export async function recruitJob(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, record = null, ask = null, minFit = MIN_FIT, by = 'evaluator', now = Date.now() } = {}) {
|
|
411
|
+
const apps = applications(job, pool, { summaries, rows, reach, chatModel, now });
|
|
412
|
+
const prompt = evaluatorPrompt(job, apps, pool, { rows });
|
|
413
|
+
let evaluation = null;
|
|
414
|
+
if (ask && apps.some((a) => a.engine)) {
|
|
415
|
+
try { const text = await ask(prompt, RECRUIT_SCHEMA); evaluation = text == null ? null : (typeof text === 'string' ? parseEvaluation(text, apps) : parseEvaluation(JSON.stringify(text), apps)); } catch { evaluation = null; }
|
|
416
|
+
}
|
|
417
|
+
const decision = decide(job, apps, { evaluation, minFit });
|
|
418
|
+
return { applications: apps, evaluation, decision, events: recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record }), prompt };
|
|
419
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// HOW A MODEL IS CHOSEN — the strategies and the one step that is never optional.
|
|
2
|
+
//
|
|
3
|
+
// The routing CONTRACT (createModelRouter, signals, requirements, failover order) has lived
|
|
4
|
+
// in this package since it was written; the DECISIONS layered on it — escalate hard work,
|
|
5
|
+
// replace like with like, honour "use claude" — were typed in the extension, so the desktop
|
|
6
|
+
// was about to copy them. They are pure functions of the candidates and the need, so they
|
|
7
|
+
// belong here, and both clients build the same router from the same declarations.
|
|
8
|
+
//
|
|
9
|
+
// `needForTurn` builds the need from the turn's facts. The source guard (which page or
|
|
10
|
+
// record the turn is about, and what that caps reach at) is INJECTED as `guard`, because
|
|
11
|
+
// reading it means reading a client's settings; the ceiling itself is enforced here.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
defineMiddleware, defineRouteStrategy, signalsFrom, requirementsFor, preferenceFor,
|
|
15
|
+
failoverOrder, pinnedOrderOf,
|
|
16
|
+
} from './router.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Redaction is REQUIRED for anything that leaves the user's machine.
|
|
20
|
+
*
|
|
21
|
+
* Declared as middleware with `requiredFor` so the router refuses to route to a third party
|
|
22
|
+
* when it is not active. That is the difference between "we always redact" as a habit and as
|
|
23
|
+
* a property: a disabled plugin, a refactor or a new caller cannot quietly skip it.
|
|
24
|
+
*/
|
|
25
|
+
export const redactionStep = defineMiddleware({
|
|
26
|
+
id: 'redaction',
|
|
27
|
+
label: 'Redaction',
|
|
28
|
+
stage: 'request',
|
|
29
|
+
priority: 10, // before anything that reads the text
|
|
30
|
+
requiredFor: (model) => model.reach === 'any',
|
|
31
|
+
// The actual redaction still happens in streamChat's harness. This declares the
|
|
32
|
+
// REQUIREMENT; wiring the implementation through here is the next step, and doing both at
|
|
33
|
+
// once would mean changing what redaction does in the same commit that changes when it runs.
|
|
34
|
+
run: async (request) => request,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Escalate when the task is actually hard.
|
|
39
|
+
*
|
|
40
|
+
* The router was picking the cheapest eligible model for everything, which is right for
|
|
41
|
+
* "hello" and wrong for "draw a circle around Mickey" — a request needing spatial reasoning
|
|
42
|
+
* and a structured payload went to a 26B model because it was free. Cost is the correct
|
|
43
|
+
* tie-breaker among models that can all do the job; it is the wrong one when they cannot.
|
|
44
|
+
*
|
|
45
|
+
* Class R: length, code fences, image content and page tools are all readable for nothing.
|
|
46
|
+
* The escalation itself costs no model call — only the answer does, and that is the point.
|
|
47
|
+
*/
|
|
48
|
+
export const complexityStrategy = defineRouteStrategy({
|
|
49
|
+
id: 'escalate-on-complexity',
|
|
50
|
+
label: 'Escalate hard tasks',
|
|
51
|
+
classUsed: 'R',
|
|
52
|
+
decide: async (eligible, need) => {
|
|
53
|
+
const sig = need.signals;
|
|
54
|
+
// ASKED FOR NOTHING, ESCALATES TO NOTHING. This fired on 'hello' because the caller was
|
|
55
|
+
// passing `structured: structured || pageTools`, so every turn on a page with actions
|
|
56
|
+
// armed looked like exact structured work. Equipment is not demand — the same conflation
|
|
57
|
+
// that put a quality floor on a greeting, in a second place.
|
|
58
|
+
if (sig?.smalltalk) return null;
|
|
59
|
+
// NOR DOES BACKGROUND WORK ESCALATE. Dropping the quality floor for a topic pass and then
|
|
60
|
+
// letting escalation rank by quality anyway would move the same decision one step down
|
|
61
|
+
// and change nothing — the floor eliminated the local models, this would simply rank them
|
|
62
|
+
// last. Both read 'high' from the size of the material rather than the difficulty of the
|
|
63
|
+
// ask, so both have to abstain.
|
|
64
|
+
if (need.background) return null;
|
|
65
|
+
const hard = sig?.complexity === 'high' || sig?.modality === 'vision' || need.structured;
|
|
66
|
+
if (!hard) return null; // no opinion on easy work — let cost decide
|
|
67
|
+
// Prefer a model that claims what this task actually wants. Not a hard filter: declaring
|
|
68
|
+
// "reasoning" required would eliminate every model on a setup where nobody has ticked
|
|
69
|
+
// the box, and an empty candidate list is a worse answer than a merely adequate model.
|
|
70
|
+
const wants = new Set();
|
|
71
|
+
if (sig?.complexity === 'high') wants.add('reasoning');
|
|
72
|
+
if (need.structured) wants.add('tools');
|
|
73
|
+
if (sig?.modality === 'vision') wants.add('vision');
|
|
74
|
+
if (sig?.approxTokens > 20_000) wants.add('long-context');
|
|
75
|
+
const fit = (m) => [...wants].filter((c) => m.capabilities.includes(c)).length;
|
|
76
|
+
const best = Math.max(...eligible.map(fit));
|
|
77
|
+
let shortlist = best > 0 ? eligible.filter((m) => fit(m) === best) : eligible;
|
|
78
|
+
|
|
79
|
+
// STRUCTURED WORK WANTS A MODEL, NOT AN AGENT.
|
|
80
|
+
//
|
|
81
|
+
// A canvas or spreadsheet adapter is one call: hand it the data, it applies it, done. A
|
|
82
|
+
// CLI agent runs its OWN loop — it explores, reads files, decides what to do next — and
|
|
83
|
+
// having applied the shapes correctly it carries on, because finishing is not something
|
|
84
|
+
// its loop is told about. A user watched the circle appear and then waited until they
|
|
85
|
+
// killed the process.
|
|
86
|
+
//
|
|
87
|
+
// Not a hard filter: on a setup with only agents, an agent that overruns still beats no
|
|
88
|
+
// answer.
|
|
89
|
+
if (need.structured) {
|
|
90
|
+
const models = shortlist.filter((m) => m.classUsed !== 'A');
|
|
91
|
+
if (models.length) shortlist = models;
|
|
92
|
+
}
|
|
93
|
+
// Rank by declared quality — the axis this strategy exists to judge — then by the ORDER
|
|
94
|
+
// the user set, and only then by cost. A model with an unknown quality sits mid-table
|
|
95
|
+
// rather than last, so a newly added model is not permanently skipped.
|
|
96
|
+
//
|
|
97
|
+
// ORDER BEFORE COST, and this is the fix for a real complaint: three CLI agents of
|
|
98
|
+
// identical quality, one of them pinned to Order 1, and escalation picked a different one
|
|
99
|
+
// because it is cheaper per 1k. A hand-set order is a statement — they can see the prices
|
|
100
|
+
// and chose anyway — and it was being honoured in the score path and nowhere else, so
|
|
101
|
+
// the moment any strategy had an opinion the user's own preference stopped existing.
|
|
102
|
+
//
|
|
103
|
+
// Still only a TIE-BREAK: quality decides first, so a genuinely better model beats the
|
|
104
|
+
// pinned one, and an INFERRED order stays below cost where a guess belongs.
|
|
105
|
+
const q = (m) => (Number.isFinite(m.quality) ? m.quality : 0.5);
|
|
106
|
+
return [...shortlist].sort((a, b) => q(b) - q(a)
|
|
107
|
+
|| pinnedOrderOf(a) - pinnedOrderOf(b) || a.costPer1k - b.costPer1k);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* When a model declines, replace it with the closest thing available — not the cheapest.
|
|
113
|
+
*
|
|
114
|
+
* A frontier model that ran out of credits mid-task should be replaced by the same model at
|
|
115
|
+
* another provider, or by something comparably capable. Falling back to a small local model
|
|
116
|
+
* is how a drawing that was going well turns into a circle in the wrong place: the task did
|
|
117
|
+
* not get easier when the provider said no.
|
|
118
|
+
*
|
|
119
|
+
* Ranked by closeness to what failed, in the order that actually matters:
|
|
120
|
+
* 1. the SAME model somewhere else — identical capability, merely a different bill;
|
|
121
|
+
* 2. a model with every capability the failed one had, best quality first;
|
|
122
|
+
* 3. anything else, so the turn still completes rather than dying.
|
|
123
|
+
*/
|
|
124
|
+
export const failoverStrategy = defineRouteStrategy({
|
|
125
|
+
id: 'failover-to-similar',
|
|
126
|
+
label: 'Replace like with like',
|
|
127
|
+
classUsed: 'R',
|
|
128
|
+
decide: async (eligible, need) => {
|
|
129
|
+
const failed = need.like;
|
|
130
|
+
if (!failed) return null;
|
|
131
|
+
// THE ORDERING ITSELF LIVES IN @chatpanel/events, because two things need it: this
|
|
132
|
+
// strategy, and the projected chain the trace draws before any of it happens. A picture
|
|
133
|
+
// computed by a second implementation would eventually disagree with the real failover,
|
|
134
|
+
// and one that lies about what the router will do is worse than no picture. The strategy
|
|
135
|
+
// is the thin part — knowing there IS something to replace.
|
|
136
|
+
return failoverOrder(eligible, failed);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* "use claude" is an instruction, not a topic.
|
|
142
|
+
*
|
|
143
|
+
* A user naming a model in their message was being ignored entirely — the router read
|
|
144
|
+
* length, modality and tools, and not the one signal that is an explicit answer to the
|
|
145
|
+
* question it was asking. Asking for a specific model and being given another is the most
|
|
146
|
+
* annoying possible failure of a router, because it looks like the request was not read.
|
|
147
|
+
*
|
|
148
|
+
* DELIBERATELY CONSERVATIVE. Only imperative forms count — "use X", "with X", "ask X",
|
|
149
|
+
* "switch to X" — so "tell me about claude" stays a question about Claude rather than a
|
|
150
|
+
* routing instruction. A false positive here silently sends work to the wrong model, which
|
|
151
|
+
* is worse than missing an unusual phrasing.
|
|
152
|
+
*
|
|
153
|
+
* It still cannot widen reach: like every strategy it only ever chooses among candidates the
|
|
154
|
+
* hard constraints already allowed. A device-only request naming a cloud model still stays
|
|
155
|
+
* on-device.
|
|
156
|
+
*/
|
|
157
|
+
export const explicitModelStrategy = defineRouteStrategy({
|
|
158
|
+
id: 'named-by-user',
|
|
159
|
+
label: 'Use the model you asked for',
|
|
160
|
+
classUsed: 'R',
|
|
161
|
+
decide: async (eligible, need) => {
|
|
162
|
+
const text = String(need.requestText || '').toLowerCase();
|
|
163
|
+
if (!text) return null;
|
|
164
|
+
const directive = /\b(?:use|using|with|via|ask|switch to|route to|try)\s+([a-z0-9][a-z0-9.\- ]{1,28})/g;
|
|
165
|
+
const asked = [];
|
|
166
|
+
for (const m of text.matchAll(directive)) asked.push(m[1].trim());
|
|
167
|
+
if (!asked.length) return null;
|
|
168
|
+
|
|
169
|
+
const matches = eligible.filter((cand) => {
|
|
170
|
+
const names = [cand.label, cand.model, cand.id].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
171
|
+
return asked.some((want) => names.some((n) => n.includes(want) || want.includes(n.split(' · ')[0])));
|
|
172
|
+
});
|
|
173
|
+
if (!matches.length) return null; // named something we do not have? say nothing and let the rest decide
|
|
174
|
+
// WHICH ONE, when the name matches several. This took the FIRST match in score order, so
|
|
175
|
+
// "use claude" on a setup with three Claude routes picked whichever happened to score
|
|
176
|
+
// best — a cost-and-latency guess deciding a question the user had already answered
|
|
177
|
+
// twice: once by naming the model, and once by ordering the routes to it.
|
|
178
|
+
//
|
|
179
|
+
// Returned as a LIST rather than a single model, so the ones that also matched become the
|
|
180
|
+
// runners-up: if the first declines, failover replaces it with another route to the model
|
|
181
|
+
// that was actually asked for.
|
|
182
|
+
return [...matches].sort((a, b) => pinnedOrderOf(a) - pinnedOrderOf(b));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Ordered deliberately: an explicit request outranks every heuristic, because the user has
|
|
187
|
+
// answered the question the router was about to guess at. Failover next — a decline is newer
|
|
188
|
+
// information than the preference that made the original choice. Escalation is the general
|
|
189
|
+
// case.
|
|
190
|
+
export const ROUTE_STRATEGIES = [explicitModelStrategy, failoverStrategy, complexityStrategy];
|
|
191
|
+
export const ROUTE_MIDDLEWARE = [redactionStep];
|
|
192
|
+
|
|
193
|
+
export function needForTurn({ capabilities = [], request = null, structured = false, pageTools = false, force = false, background = false, guard = null } = {}) {
|
|
194
|
+
const signals = request ? signalsFrom(request) : {};
|
|
195
|
+
// REQUIREMENTS FIRST. What the work needs eliminates candidates; cost and speed only order
|
|
196
|
+
// what survives. A preference lets an unsuitable model win once the better ones decline,
|
|
197
|
+
// which is exactly how a chain of five ended on one that could not do the job.
|
|
198
|
+
const req = requirementsFor(signals, { structured, pageTools, hasTools: capabilities.includes('tools'), background });
|
|
199
|
+
// WHERE IT CAME FROM IS A CEILING, NOT A PREFERENCE. Routing asked what the work needed and
|
|
200
|
+
// never asked what it was about, so an internal page was summarised by a public inference
|
|
201
|
+
// host. This narrows reach and can only narrow it — reach is never relaxed (see the
|
|
202
|
+
// relaxation order in the router), so no later step can trade it away for capability.
|
|
203
|
+
// WHICH AXIS THIS REQUEST CARES ABOUT, read from the request rather than fixed at
|
|
204
|
+
// 'balanced' for everything. A greeting means fast — no answer to "hi" is improved by a
|
|
205
|
+
// frontier model thinking about it. A refactor means good — three seconds saved on an
|
|
206
|
+
// answer that has to be redone is not a saving. It only ever ORDERS what already
|
|
207
|
+
// qualifies; `req` above is what eliminates.
|
|
208
|
+
const pref = preferenceFor(signals, {
|
|
209
|
+
structured,
|
|
210
|
+
minQuality: req.minQuality,
|
|
211
|
+
// The same fact requirementsFor was given: a turn carrying tools is one that might use
|
|
212
|
+
// them, so it is never "answer fast at any quality".
|
|
213
|
+
hasTools: capabilities.includes('tools'),
|
|
214
|
+
background,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
prefer: pref.prefer,
|
|
218
|
+
reach: guard ? guard.reach : 'any',
|
|
219
|
+
capabilities: [...new Set([...capabilities, ...req.required])],
|
|
220
|
+
minQuality: req.minQuality,
|
|
221
|
+
// Which requirements may be given up if nothing qualifies — never `tools`, and never
|
|
222
|
+
// reach. See requirementsFor.
|
|
223
|
+
negotiable: req.negotiable,
|
|
224
|
+
requirementReasons: [...(guard ? [...req.why, guard.why] : req.why), pref.why],
|
|
225
|
+
sourceGuard: guard,
|
|
226
|
+
signals,
|
|
227
|
+
requestText: request ? String(request.text || (request.messages || []).map((m) => m?.content || '').join('\n')).slice(-2000) : '',
|
|
228
|
+
structured,
|
|
229
|
+
force,
|
|
230
|
+
background,
|
|
231
|
+
};
|
|
232
|
+
}
|