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.
- package/CHANGELOG.md +51 -1
- package/README.md +19 -7
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +2 -1
- package/server/adapters/codex.js +2 -1
- package/server/adapters/commands.js +808 -0
- package/server/adapters/cursor.js +2 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/opencode.js +2 -1
- package/server/check.js +205 -0
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/openapi.js +3 -3
- package/server/pipeline.js +217 -0
- package/server/quick.js +76 -0
- package/server/router.js +201 -28
- package/server/routes/auto.js +8 -1
- package/server/routes/settings.js +2 -0
- package/server/routes/ship.js +9 -4
- package/server/runner.js +59 -13
- package/server/scout.js +71 -0
- package/server/services/auto.js +103 -14
- package/server/services/launch.js +11 -6
- package/server/services/ship.js +36 -14
- package/web/css/auto.css +22 -0
- package/web/css/ship.css +5 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/features/actions.js +2 -4
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +31 -1
- package/web/js/features/live.js +2 -7
- package/web/js/features/projects.js +2 -5
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +30 -3
- package/web/js/features/turns.js +4 -3
- package/web/js/ui/popover.js +10 -0
package/server/quick.js
ADDED
|
@@ -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
|
|
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"
|
|
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: '
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
255
|
-
const
|
|
256
|
-
|
|
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
|
|
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
|
|
274
|
-
const
|
|
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
|
-
|
|
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)));
|
package/server/routes/auto.js
CHANGED
|
@@ -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
|
});
|
package/server/routes/ship.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
64
|
-
await app.ship.
|
|
65
|
-
if (s.conflictFix?.step !== 'fixing')
|
|
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
|
);
|