wisp-agents 0.18.0 → 0.20.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.
@@ -0,0 +1,76 @@
1
+ // One quick question to a cheap model, through whichever harness is installed: the scout and the
2
+ // judge use it. Read-only (plan mode), no wisp protocol, a hard time limit, and the reply's JSON.
3
+
4
+ import { adapters } from './adapters/index.js';
5
+
6
+ // Cheapest first: what the scout and judge run on when it's installed.
7
+ const CHEAP = [
8
+ ['claude', 'haiku'],
9
+ ['antigravity', 'gemini-3.8-flash-low'],
10
+ ['codex', ''],
11
+ ['opencode', ''],
12
+ ['grok', ''],
13
+ ['claude', ''],
14
+ ];
15
+
16
+ export function cheapest(agents) {
17
+ for (const [id, model] of CHEAP) {
18
+ const a = agents.find((x) => x.id === id && x.available);
19
+ if (a && (!model || (a.models || []).some((m) => m.id === model))) return { agent: id, model };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // Resolves with the parsed JSON object from the reply, or null (no harness, timeout, no JSON).
25
+ export function askJson({ agents, prompt, cwd, timeoutMs = 90_000, pick = cheapest(agents) }) {
26
+ if (!pick || !adapters[pick.agent]) return Promise.resolve(null);
27
+ return new Promise((resolve) => {
28
+ let text = '';
29
+ let handle;
30
+ try {
31
+ handle = adapters[pick.agent].run({
32
+ prompt,
33
+ cwd,
34
+ mode: 'plan',
35
+ model: pick.model,
36
+ effort: '',
37
+ systemPrompt: 'You are a helper inside wisp. Answer with one JSON object and nothing else.',
38
+ attachments: [],
39
+ onEvent: (e) => {
40
+ if (e.type === 'result') text = e.isError ? '' : e.text || '';
41
+ },
42
+ });
43
+ } catch {
44
+ return resolve(null);
45
+ }
46
+ const timer = setTimeout(() => handle.stop(), timeoutMs);
47
+ handle.done.then(() => {
48
+ clearTimeout(timer);
49
+ resolve(readJson(text));
50
+ });
51
+ });
52
+ }
53
+
54
+ export function readJson(text = '') {
55
+ const s = String(text);
56
+ for (let i = s.indexOf('{'); i >= 0; i = s.indexOf('{', i + 1)) {
57
+ let depth = 0;
58
+ let inStr = false;
59
+ for (let j = i; j < s.length; j++) {
60
+ const c = s[j];
61
+ if (inStr) {
62
+ if (c === '\\') j++;
63
+ else if (c === '"') inStr = false;
64
+ } else if (c === '"') inStr = true;
65
+ else if (c === '{') depth++;
66
+ else if (c === '}' && !--depth) {
67
+ try {
68
+ return JSON.parse(s.slice(i, j + 1));
69
+ } catch {
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
package/server/router.js CHANGED
@@ -1,6 +1,9 @@
1
- // Auto: wisp picks the harness and model for a task, and learns from how it went.
1
+ // Auto: wisp picks the harness, model and thinking level for a task, and learns from how it went.
2
2
  //
3
- // Every harness × model the user has is an "arm". For each kind of task (bug, ui, docs, …) wisp
3
+ // Every harness × model × thinking level the user has is an "arm" (claude:opus:high; no level is
4
+ // the harness's default). The goal picks the band of levels: Cheap runs low, Balanced the default
5
+ // or medium, Best high. Within the band the levels are learned like models are, and a model's
6
+ // record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
4
7
  // keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
5
8
  // (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
6
9
  // history on this kind. A launch draws a chance of success from each estimate (Thompson
@@ -25,14 +28,16 @@ const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import
25
28
  export const GOALS = {
26
29
  cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
27
30
  balanced: { label: 'Balanced', value: 5, hint: 'Best result per dollar' },
28
- best: { label: 'Best', value: 50, hint: 'Best result; cost only breaks ties' },
31
+ best: { label: 'Best', value: 50, hint: 'Frontier models only; cost only breaks ties' },
29
32
  };
30
33
  export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
31
34
  const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
32
- const PRIOR_CAP = 8; // shipped priors count as at most this many tasks, so your own soon outweigh them
35
+ const PRIOR_CAP = 4; // shipped priors count as at most this many tasks, so your own soon outweigh them
36
+ const COLD_START = 20; // until Auto has this many of your outcomes, Balanced and Best stay on stronger tiers
33
37
  const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
34
38
  const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
35
39
  const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
40
+ const LEVEL_WEIGHT = 0.5; // the same model's record at its other thinking levels counts half
36
41
  const MAX_ROUTES = 2000;
37
42
 
38
43
  // --- task features ---------------------------------------------------------------------------
@@ -58,15 +63,78 @@ export function classify(prompt = '', { attachments = 0 } = {}) {
58
63
 
59
64
  // --- arms ------------------------------------------------------------------------------------
60
65
 
61
- export const armKey = (agent, model = '') => `${agent}:${model || ''}`;
62
- const splitKey = (k) => {
66
+ // Thinking levels, by how hard they think, against the harness's default (''): a shift in the
67
+ // chance of success and multipliers on cost and time. Only a starting guess; outcomes take over.
68
+ export const EFFORTS = {
69
+ minimal: { chance: -0.1, usd: 0.55, min: 0.65 },
70
+ low: { chance: -0.04, usd: 0.6, min: 0.7 },
71
+ medium: { chance: -0.01, usd: 0.85, min: 0.85 },
72
+ '': { chance: 0, usd: 1, min: 1 },
73
+ high: { chance: 0.02, usd: 1.3, min: 1.3 },
74
+ xhigh: { chance: 0.02, usd: 1.7, min: 1.6 },
75
+ max: { chance: 0.03, usd: 2.2, min: 2 },
76
+ };
77
+ // The levels each goal may run at, where the harness has them; none of them → the default.
78
+ const BANDS = { cheap: ['minimal', 'low'], balanced: ['', 'medium'], best: ['high', 'xhigh'] };
79
+ export const levelsFor = (efforts = [], goal = 'balanced') => {
80
+ const have = (BANDS[goal] || BANDS.balanced).filter((e) => e === '' || efforts.includes(e));
81
+ return have.length ? have : [''];
82
+ };
83
+ const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
84
+
85
+ export const armKey = (agent, model = '', effort = '') => `${agent}:${model || ''}${effort ? `:${effort}` : ''}`;
86
+ // agent:model[:effort]. A trailing segment is a level only if it's one wisp knows, so a model id
87
+ // with a colon in it (ollama's llama3:8b) stays whole.
88
+ export const splitKey = (k) => {
63
89
  const i = k.indexOf(':');
64
- return { agent: k.slice(0, i), model: k.slice(i + 1) };
90
+ const rest = k.slice(i + 1);
91
+ const j = rest.lastIndexOf(':');
92
+ const effort = j >= 0 && rest.slice(j + 1) && Object.hasOwn(EFFORTS, rest.slice(j + 1)) ? rest.slice(j + 1) : '';
93
+ return { agent: k.slice(0, i), model: effort ? rest.slice(0, j) : rest, effort };
94
+ };
95
+ // The harness · model an arm runs on, whatever its level: probation, cooling and compare go by it.
96
+ export const baseOf = (k) => {
97
+ const { agent, model } = splitKey(k);
98
+ return armKey(agent, model);
65
99
  };
66
100
  // Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
67
101
  // starts from what the family already showed.
68
102
  const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
69
103
 
104
+ // How strong a model is, roughly: 1 small/fast/free, 2 everyday, 3 frontier. Best runs on tier 3
105
+ // only, hand-overs step up a tier, and until Auto has your own outcomes Balanced stays on tier 2+.
106
+ // The shipped priors come from a small pilot that flattered cheap models; tiers keep them honest.
107
+ const FRONTIER_DEFAULTS = new Set(['codex:']); // a harness whose default model is its frontier one
108
+ export function tierOf(key) {
109
+ const { agent, model } = splitKey(key);
110
+ const m = model.toLowerCase();
111
+ // Whole name segments only: "gemini" is not "mini".
112
+ const seg = m.split(/[-/.:_]/);
113
+ if (seg.some((x) => ['haiku', 'flash', 'mini', 'nano', 'lite', 'small', 'free', 'fast', 'low'].includes(x))) return 1;
114
+ if (seg.some((x) => ['opus', 'argon', 'ultra', 'pro', 'xhigh', 'high', 'max'].includes(x)) || FRONTIER_DEFAULTS.has(key) || (agent === 'grok' && /^grok-\d/.test(m))) return 3;
115
+ return 2;
116
+ }
117
+
118
+ // An error that says the harness can't run right now (a usage limit, a rate limit, no credit),
119
+ // not that the model got the task wrong. Returns { scope: 'agent' | 'arm', ms } or null.
120
+ export function infraError(text = '') {
121
+ const t = String(text);
122
+ if (/session limit|usage limit|limit reached|resets \d|quota|exceeded your|insufficient (credit|balance|quota)|balance exhausted|payment required|\b402\b|billing/i.test(t)) return { scope: 'agent', ms: resetIn(t) ?? 60 * 60_000 };
123
+ if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b|try again in a few minutes/i.test(t)) return { scope: 'arm', ms: 10 * 60_000 };
124
+ return null;
125
+ }
126
+ // "resets 9pm" / "resets at 21:00" → ms until then (today, else tomorrow), capped at 6 h.
127
+ function resetIn(t) {
128
+ const m = t.match(/resets?(?: at)? (\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
129
+ if (!m) return null;
130
+ let h = Number(m[1]) % 12 + (/pm/i.test(m[3] || '') ? 12 : 0);
131
+ if (!m[3]) h = Number(m[1]);
132
+ const at = new Date();
133
+ at.setHours(h, Number(m[2] || 0), 0, 0);
134
+ if (at <= new Date()) at.setDate(at.getDate() + 1);
135
+ return Math.min(6 * 3600_000, at - Date.now() + 60_000);
136
+ }
137
+
70
138
  // The arms wisp may route to, from listAgents(): each harness's default model, the models the
71
139
  // priors know, models with history here, and new models in a known family (on probation).
72
140
  function candidates(agents, known) {
@@ -77,7 +145,8 @@ function candidates(agents, known) {
77
145
  for (const id of models) {
78
146
  const k = armKey(a.id, id);
79
147
  if (id === '' || PRIORS.arms[k] || known.has(k) || [...known, ...Object.keys(PRIORS.arms)].some((x) => family(x) === family(k) && x !== k && x.startsWith(`${a.id}:`))) {
80
- out.push({ key: k, agent: a.id, model: id, label: `${a.name}${id ? ` · ${(a.models || []).find((m) => m.id === id)?.label || id}` : ''}`, delegates: a.delegates !== false });
148
+ const m = (a.models || []).find((x) => x.id === id);
149
+ out.push({ key: k, agent: a.id, model: id, label: `${a.name}${id ? ` · ${m?.label || id}` : ''}`, efforts: m?.efforts ?? a.efforts ?? [], delegates: a.delegates !== false });
81
150
  }
82
151
  }
83
152
  }
@@ -128,6 +197,15 @@ export class Router {
128
197
  }
129
198
 
130
199
  #prior(k) {
200
+ const { agent, model, effort } = splitKey(k);
201
+ if (effort) {
202
+ // A level starts from its model's record, shifted by how hard it thinks.
203
+ const base = this.#prior(armKey(agent, model)) || { a: 1, b: 1 };
204
+ const e = EFFORTS[effort];
205
+ const n = base.a + base.b;
206
+ const mean = Math.min(0.95, Math.max(0.05, base.a / n + e.chance));
207
+ return { ...base, a: mean * n, b: (1 - mean) * n, usd: (base.usd ?? 0.3) * e.usd, min: (base.min ?? 5) * e.min };
208
+ }
131
209
  const p = PRIORS.arms[k];
132
210
  if (p) return p;
133
211
  // A new version inherits its family's record, with less weight.
@@ -147,32 +225,75 @@ export class Router {
147
225
  return { a: s.a * f, b: s.b * f, n: s.n, usd: s.usdN ? s.usd / s.usdN : null, ms: s.n ? s.ms / s.n : null };
148
226
  }
149
227
 
228
+ // The same model at its other thinking levels, on this kind of task.
229
+ #levels(type, key) {
230
+ const base = baseOf(key);
231
+ const out = { a: 0, b: 0 };
232
+ for (const sk of Object.keys(this.state.stats)) {
233
+ const i = sk.indexOf('|');
234
+ const k = sk.slice(i + 1);
235
+ if (sk.slice(0, i) !== type || k === key || baseOf(k) !== base) continue;
236
+ const c = this.#counts(type, k);
237
+ out.a += c.a;
238
+ out.b += c.b;
239
+ }
240
+ return out;
241
+ }
242
+
150
243
  // Beta posterior for an arm on a kind of task, plus what it usually costs.
151
244
  estimate(type, key) {
152
245
  const prior = this.#prior(key) || { a: 1, b: 1 };
153
246
  const scale = Math.min(1, PRIOR_CAP / Math.max(1, prior.a + prior.b));
154
247
  const mine = this.#counts(type, key);
155
248
  const all = this.#counts('*', key);
156
- const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + mine.a;
157
- const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + mine.b;
249
+ const kin = this.#levels(type, key);
250
+ const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + kin.a * LEVEL_WEIGHT + mine.a;
251
+ const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + kin.b * LEVEL_WEIGHT + mine.b;
158
252
  // Cost and time: the prior's guess, pulled toward what this user's runs actually cost as they add up.
159
253
  const blend = (own, n, guess) => (own === null ? guess : (guess * 3 + own * n) / (3 + n));
160
254
  const usd = blend(all.usd, all.n, prior.usd ?? 0.3);
161
255
  const minutes = blend(all.ms === null ? null : all.ms / 60_000, all.n, prior.min ?? 5);
162
- return { a, b, mean: a / (a + b), n: mine.n, nAll: all.n, usd: round(usd, 4), minutes, prior: !!PRIORS.arms[key], inherited: !!prior.inherited };
256
+ return { a, b, mean: a / (a + b), n: mine.n, nAll: all.n, usd: round(usd, 4), minutes, prior: !!PRIORS.arms[baseOf(key)], inherited: !!prior.inherited };
163
257
  }
164
258
 
165
- // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best.
166
- // important: no exploring. exclude: arm keys already taken (compare). needDelegate: the lead
259
+ // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best, which
260
+ // also sets the band of thinking levels (levelsFor).
261
+ // important: no exploring. exclude: arm keys already taken (compare, hand-overs): every level of
262
+ // that harness · model is out. needDelegate: the lead
167
263
  // of a team must be a harness that can delegate. rng: for tests.
168
- choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, rng = Math.random } = {}) {
264
+ // scouted: { type, size } from the scout, which beats the keyword guess.
265
+ // minTier: only arms at least that strong (a hand-over steps up). Arms cooling down after a usage
266
+ // or rate limit are skipped while any other arm can run.
267
+ // Best runs frontier models only: if any is installed and allowed, Best never steps down a tier,
268
+ // even when they're all taken or at their limit (null then). With none installed, the strongest.
269
+ choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, scouted = null, minTier = 0, rng = Math.random } = {}) {
169
270
  if (!this.synced) this.sync(agents);
170
- const { type, size } = classify(prompt, { attachments });
271
+ const guess = classify(prompt, { attachments });
272
+ const type = scouted?.type || guess.type;
273
+ const size = scouted?.size || guess.size;
171
274
  const V = (GOALS[goal] || GOALS.balanced).value;
172
- let arms = candidates(agents, new Set(Object.keys(this.state.arms)))
173
- .filter((c) => !exclude.includes(c.key) && (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)))
174
- .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.key]?.state || 'new' }));
275
+ const out = new Set(exclude.map(baseOf));
276
+ const usable = candidates(agents, new Set(Object.keys(this.state.arms))).filter((c) => (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)));
277
+ const frontierOnly = goal === 'best' && usable.some((c) => tierOf(c.key) >= 3);
278
+ let arms = usable
279
+ .filter((c) => !out.has(c.key) && (!frontierOnly || tierOf(c.key) >= 3))
280
+ .flatMap((c) => levelsFor(c.efforts, goal).map((effort) => ({ ...c, key: armKey(c.agent, c.model, effort), effort, label: `${c.label}${effort ? ` · ${effortLabel(effort)}` : ''}`, base: c.key })))
281
+ .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.base]?.state || 'new' }));
175
282
  if (!arms.length) return null;
283
+ const free = arms.filter((x) => !this.cooling(x.key));
284
+ if (!free.length) return null; // everything allowed is at its limit: nothing to route to now
285
+ arms = free;
286
+ // Cold start: until there are enough of your own outcomes, lean to quality (the shipped priors
287
+ // are thin). Cheap is left alone: that goal asked for cheap.
288
+ const cold = this.learned() < COLD_START;
289
+ const floorTier = Math.max(minTier, goal === 'best' ? 3 : cold && goal !== 'cheap' ? 2 : 0);
290
+ for (let t = floorTier; t > 0; t--) {
291
+ const strong = arms.filter((x) => tierOf(x.key) >= t);
292
+ if (strong.length) {
293
+ arms = strong;
294
+ break;
295
+ }
296
+ }
176
297
  // Quality floor: an arm that keeps failing this kind of task here is left out.
177
298
  const floor = arms.filter((x) => !(x.est.n >= 5 && x.est.mean < 0.35));
178
299
  if (floor.length) arms = floor;
@@ -198,6 +319,7 @@ export class Router {
198
319
  return {
199
320
  agent: pick.agent,
200
321
  model: pick.model,
322
+ effort: pick.effort,
201
323
  key: pick.key,
202
324
  label: pick.label,
203
325
  route: {
@@ -205,6 +327,7 @@ export class Router {
205
327
  type,
206
328
  size,
207
329
  key: pick.key,
330
+ effort: pick.effort,
208
331
  label: pick.label,
209
332
  chance: round(pick.est.mean),
210
333
  n: pick.est.n,
@@ -212,6 +335,8 @@ export class Router {
212
335
  minutes: round(pick.est.minutes, 1),
213
336
  explored: byMean[0].key !== pick.key,
214
337
  trying: pick.state === 'new',
338
+ tier: tierOf(pick.key),
339
+ ...(cold && goal !== 'cheap' && goal !== 'best' && { coldStart: true }),
215
340
  propensity: round(hits / 200),
216
341
  alternatives: byMean.filter((x) => x.key !== pick.key).slice(0, 3).map((x) => ({ key: x.key, label: x.label, chance: round(x.est.mean), usd: round(x.est.usd) })),
217
342
  at: this.now(),
@@ -231,7 +356,7 @@ export class Router {
231
356
  // rolledBack, switched, costUsd, ms }. The reward is recomputed and the stats move by the difference.
232
357
  signal(id, patch) {
233
358
  const route = this.state.routes[id];
234
- if (!route) return null;
359
+ if (!route || route.void) return route || null;
235
360
  Object.assign(route.signals, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)));
236
361
  const r = reward(route.signals);
237
362
  if (r === null) return this.save(), route;
@@ -251,9 +376,11 @@ export class Router {
251
376
  s.at = this.now();
252
377
  }
253
378
  route.r = r;
254
- // Probation ends once an arm has a few outcomes of its own.
255
- const arm = this.state.arms[route.key];
256
- if (arm?.state === 'new' && (this.state.stats[`*|${route.key}`]?.n || 0) >= 3) arm.state = 'ready';
379
+ // Probation ends once a harness · model has a few outcomes of its own, at any level.
380
+ const base = baseOf(route.key);
381
+ const arm = this.state.arms[base];
382
+ const n = Object.entries(this.state.stats).reduce((sum, [k, x]) => sum + (k.startsWith('*|') && baseOf(k.slice(2)) === base ? x.n : 0), 0);
383
+ if (arm?.state === 'new' && n >= 3) arm.state = 'ready';
257
384
  this.save();
258
385
  return route;
259
386
  }
@@ -262,22 +389,63 @@ export class Router {
262
389
  return this.state.routes[id] || null;
263
390
  }
264
391
 
392
+ // Outcomes of your own tasks the router has learned from.
393
+ learned() {
394
+ return Object.values(this.state.routes).filter((r) => r.r !== null).length;
395
+ }
396
+
397
+ // A harness (or one model, at every level) hit a usage or rate limit: skip it until it resets.
398
+ cool(key, { scope = 'arm', ms = 10 * 60_000, why = '' } = {}) {
399
+ const target = scope === 'agent' ? `${splitKey(key).agent}:*` : baseOf(key);
400
+ (this.state.cooling ||= {})[target] = { until: this.now() + ms, why: String(why).slice(0, 160) };
401
+ this.save();
402
+ return target;
403
+ }
404
+
405
+ cooling(key) {
406
+ const c = this.state.cooling || {};
407
+ const hit = c[key] || c[baseOf(key)] || c[`${splitKey(key).agent}:*`];
408
+ return hit && hit.until > this.now() ? hit : null;
409
+ }
410
+
411
+ // The run never got to work (the harness was at its limit): forget it ever counted.
412
+ void(id) {
413
+ const route = this.state.routes[id];
414
+ if (!route) return;
415
+ if (route.r !== null) {
416
+ for (const type of [route.type, '*']) {
417
+ const s = this.state.stats[`${type}|${route.key}`];
418
+ if (!s) continue;
419
+ s.a -= route.r;
420
+ s.b -= 1 - route.r;
421
+ s.n = Math.max(0, s.n - 1);
422
+ }
423
+ }
424
+ route.r = null;
425
+ route.void = true;
426
+ this.save();
427
+ }
428
+
265
429
  reset() {
266
430
  this.state.stats = {};
267
431
  this.state.routes = {};
268
432
  this.save();
269
433
  }
270
434
 
271
- // Settings → Auto: every arm with its record overall and per kind, and the latest routes.
435
+ // Settings → Auto: every harness · model, the thinking levels it has a record at, each with its
436
+ // record overall and per kind, and the latest routes.
272
437
  report(agents = []) {
273
- const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key)]);
274
- const labels = Object.fromEntries(candidates(agents, keys).map((c) => [c.key, c.label]));
438
+ const levels = Object.keys(this.state.stats).map((sk) => sk.slice(sk.indexOf('|') + 1)).filter((k) => splitKey(k).effort);
439
+ const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key), ...levels]);
440
+ const names = Object.fromEntries(candidates(agents, new Set([...keys].map(baseOf))).map((c) => [c.key, c.label]));
441
+ const labels = Object.fromEntries([...keys].filter((k) => names[baseOf(k)]).map((k) => [k, `${names[baseOf(k)]}${splitKey(k).effort ? ` · ${effortLabel(splitKey(k).effort)}` : ''}`]));
275
442
  const arms = [...keys]
276
443
  .map((k) => {
277
444
  const e = this.estimate('*', k);
278
445
  const byType = Object.fromEntries(TYPES.map((t) => [t, this.#counts(t, k)]).filter(([, c]) => c.n).map(([t]) => [t, { n: this.#counts(t, k).n, chance: round(this.estimate(t, k).mean) }]));
279
- const state = this.state.arms[k]?.state || (labels[k] ? 'ready' : 'gone');
280
- return { key: k, ...splitKey(k), label: labels[k] || k, state: state === 'ready' && !e.prior && !e.inherited && !e.nAll ? 'untried' : state, chance: round(e.mean), n: e.n, usd: round(e.usd), minutes: round(e.minutes, 1), prior: e.prior, byType };
446
+ const state = this.state.arms[baseOf(k)]?.state || (labels[k] ? 'ready' : 'gone');
447
+ const cool = this.cooling(k);
448
+ return { key: k, ...splitKey(k), label: labels[k] || k, tier: tierOf(k), ...(cool && { coolingUntil: cool.until, coolingWhy: cool.why }), state: cool && state !== 'gone' ? 'cooling' : state === 'ready' && !e.prior && !e.inherited && !e.nAll ? 'untried' : state, chance: round(e.mean), n: e.n, usd: round(e.usd), minutes: round(e.minutes, 1), prior: e.prior, byType };
281
449
  })
282
450
  .filter((a) => a.state !== 'gone' || a.n)
283
451
  .sort((p, q) => q.chance - p.chance);
@@ -285,6 +453,7 @@ export class Router {
285
453
  return {
286
454
  priors: { version: PRIORS.version, source: PRIORS.source },
287
455
  learned: routes.filter((r) => r.r !== null).length,
456
+ coldStart: { needed: COLD_START, have: this.learned() },
288
457
  routed: routes.length,
289
458
  arms,
290
459
  recent: routes.sort((p, q) => q.at - p.at).slice(0, 30).map((r) => ({ id: r.id, key: r.key, label: r.label, type: r.type, goal: r.goal, chance: r.chance, r: r.r, explored: r.explored, trying: r.trying, at: r.at, signals: r.signals })),
@@ -300,7 +469,11 @@ export function reward(sig) {
300
469
  if (sig.feedback === 'down') return 0;
301
470
  if (sig.closed) return 0.2;
302
471
  if (sig.rolledBack) return 0.1;
303
- if (sig.error) return 0;
472
+ if (sig.error || sig.check === 'error') return 0;
473
+ // wisp's own test run beats the agent's word; needing a repair round costs some of the credit,
474
+ // and losing a best-of-2 to the other run a little more.
475
+ if (sig.check === 'fail') return 0.1;
476
+ if (sig.check === 'pass') return round(Math.max(0.3, (sig.repaired ? 0.6 : 0.8) - (sig.picked === false ? 0.15 : 0) - 0.1 * (sig.followups || 0)));
304
477
  const base = { done: 0.6, partial: 0.3, blocked: 0.1, info: 0.5 }[sig.hero];
305
478
  if (base === undefined) return null;
306
479
  return round(Math.max(0.05, base - 0.1 * (sig.followups || 0)));
@@ -1,4 +1,5 @@
1
- // Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses).
1
+ // Auto: what the router learned, and its settings (goal, trying new models, allowed harnesses,
2
+ // and the steps after a run: check, repairs, hand-over, best of two, scout).
2
3
 
3
4
  import { adapters } from '../adapters/index.js';
4
5
  import { GOALS } from '../router.js';
@@ -18,6 +19,12 @@ export function register(r, app) {
18
19
  d.router = { ...d.router };
19
20
  if (body.explore !== undefined) d.router.explore = body.explore ? 'on' : 'off';
20
21
  if (body.allowed !== undefined) d.router.allowed = Array.isArray(body.allowed) ? body.allowed.filter((a) => adapters[a]) : null;
22
+ for (const k of ['check', 'escalate', 'best2']) if (body[k] !== undefined) d.router[k] = body[k] ? 'on' : 'off';
23
+ if (body.repairs !== undefined) d.router.repairs = Math.max(0, Math.min(2, Math.round(Number(body.repairs)) || 0));
24
+ if (body.scout !== undefined) {
25
+ if (!['off', 'big', 'all'].includes(body.scout)) return c.send(400, { error: 'scout must be off, big or all' });
26
+ d.router.scout = body.scout;
27
+ }
21
28
  app.configChanged();
22
29
  c.send(200, await auto.view());
23
30
  });
@@ -157,6 +157,8 @@ export function register(r, app) {
157
157
  const body = await c.body();
158
158
  if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
159
159
  if (typeof body.color === 'string') p.color = body.color;
160
+ // Auto's test check: the command to run, '' to detect it, 'off' for none.
161
+ if (typeof body.testCommand === 'string') body.testCommand.trim() ? (p.testCommand = body.testCommand.trim().slice(0, 500)) : delete p.testCommand;
160
162
  app.configChanged();
161
163
  c.send(200, p);
162
164
  });
@@ -36,6 +36,7 @@ export function register(r, app) {
36
36
  const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
37
37
  try {
38
38
  s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr, onStep });
39
+ s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
39
40
  } finally {
40
41
  delete s.shipping;
41
42
  app.sessionChanged(s, { now: true });
@@ -53,16 +54,20 @@ export function register(r, app) {
53
54
  c.send(200, await app.ship.refreshPr(s));
54
55
  }),
55
56
  );
56
- // Try the automatic conflict fix again after it gave up (see services/ship.js).
57
+ // Fix conflicts on the PR row: send the task a follow-up to merge the base in now (see
58
+ // services/ship.js). Trusts the conflict wisp last saw, so it starts at once; asks GitHub only if it saw none.
57
59
  r.route(
58
60
  'POST',
59
61
  '/sessions/:id/fix-conflicts',
60
62
  one(async (c, s) => {
61
63
  if (s.pr?.state !== 'OPEN' || !s.worktree) return c.send(400, { error: 'No open pull request.' });
62
64
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
63
- s.conflictFix = { tries: 0 };
64
- await app.ship.refreshPr(s);
65
- if (s.conflictFix?.step !== 'fixing') return c.send(409, { error: s.pr.conflict ? 'It merges cleanly with the latest base here: GitHub may not have caught up yet.' : 'The PR has no merge conflicts now.' });
65
+ if (!s.pr.conflict) await app.ship.refreshPr(s);
66
+ if (s.conflictFix?.step !== 'fixing') await app.ship.fixConflicts(s, { force: true });
67
+ if (s.conflictFix?.step !== 'fixing') {
68
+ const why = !s.pr.conflict ? 'The PR has no merge conflicts now.' : !s.worktree.remote ? 'This repo has no origin remote to push to.' : s.ask ? 'The task is waiting for your answer first.' : s.shipping ? 'Already shipping: wait for it to land.' : 'Could not start the fix: try again in a moment.';
69
+ return c.send(409, { error: why });
70
+ }
66
71
  c.send(200, { conflictFix: s.conflictFix });
67
72
  }),
68
73
  );