wisp-agents 0.12.0 → 0.14.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 +35 -1
- package/README.md +27 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +2 -2
- package/server/index.js +173 -17
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +5 -5
- package/server/openapi.js +25 -3
- package/server/protocol.js +4 -0
- package/server/remote.js +103 -17
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +66 -13
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/server/telemetry.js +58 -7
- package/web/css/app.css +83 -7
- package/web/index.html +2 -0
- package/web/js/app.js +459 -54
package/server/router.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Auto: wisp picks the harness and model for a task, and learns from how it went.
|
|
2
|
+
//
|
|
3
|
+
// Every harness × model the user has is an "arm". For each kind of task (bug, ui, docs, …) wisp
|
|
4
|
+
// keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
|
|
5
|
+
// (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
|
|
6
|
+
// history on this kind. A launch draws a chance of success from each estimate (Thompson
|
|
7
|
+
// sampling: arms with little data sometimes draw high and get tried) and picks the best value
|
|
8
|
+
// for the goal: value × chance − expected $ − a price on your waiting time.
|
|
9
|
+
//
|
|
10
|
+
// What happened afterwards is the reward, strongest signal first: merged (1) / PR closed (0.2),
|
|
11
|
+
// 👍 (1) / 👎 (0), rolled back (0.1), else the presentation's hero (done 0.6, partial 0.3,
|
|
12
|
+
// blocked 0.1, error 0) less 0.1 per follow-up. A later, stronger signal replaces an earlier one.
|
|
13
|
+
//
|
|
14
|
+
// State lives in ~/.wisp/router.json; nothing leaves the machine except the anonymous telemetry
|
|
15
|
+
// fields the caller adds. Routes are recorded for local sessions and for sessions launched on
|
|
16
|
+
// machines (id@machine): one router per user, fed by every place it runs.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import.meta.url), 'utf8'));
|
|
22
|
+
|
|
23
|
+
export const GOALS = {
|
|
24
|
+
cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
|
|
25
|
+
balanced: { label: 'Balanced', value: 5, hint: 'Best result per dollar' },
|
|
26
|
+
best: { label: 'Best', value: 50, hint: 'Best result; cost only breaks ties' },
|
|
27
|
+
};
|
|
28
|
+
export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
|
|
29
|
+
const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
|
|
30
|
+
const PRIOR_CAP = 8; // shipped priors count as at most this many tasks, so your own soon outweigh them
|
|
31
|
+
const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
|
|
32
|
+
const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
|
|
33
|
+
const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
|
|
34
|
+
const MAX_ROUTES = 2000;
|
|
35
|
+
|
|
36
|
+
// --- task features ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const RULES = [
|
|
39
|
+
['release', /\b(release|bump(ed)? (the )?version|publish|changelog for|tag v?\d)/i],
|
|
40
|
+
['docs', /\b(readme|docs?|documentation|typos?|comment(s)? in|jsdoc|docstring)\b/i],
|
|
41
|
+
['test', /\b(tests?|coverage|flaky|spec(s)?|e2e|unit test)\b/i],
|
|
42
|
+
['bug', /\b(fix|bug|broken|crash(es|ing)?|error|exception|fails?|failing|regression|doesn'?t work|not working|wrong|issue)\b/i],
|
|
43
|
+
['ui', /\b(ui|ux|button|css|layout|style|styling|color|colour|dark mode|dialog|modal|popup|screen|page|responsive|icon|animation|card|sidebar|toolbar)\b/i],
|
|
44
|
+
['refactor', /\b(refactor|clean ?up|rename|simplif(y|ies)|extract|reorgani[sz]e|dedupe|split)\b/i],
|
|
45
|
+
['question', /^(how|why|what|where|when|which|can|could|should|is|are|does|do)\b.*\?\s*$|\b(explain|investigate|look into|research|compare)\b/i],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// { type, size }: a coarse kind of task (the router learns per kind) and a size bucket.
|
|
49
|
+
export function classify(prompt = '', { attachments = 0 } = {}) {
|
|
50
|
+
const text = String(prompt);
|
|
51
|
+
const type = RULES.find(([, re]) => re.test(text))?.[0] || 'feature';
|
|
52
|
+
const files = (text.match(/[\w./-]+\.(js|ts|tsx|jsx|py|go|rs|rb|java|css|html|md|json|ya?ml)\b/g) || []).length;
|
|
53
|
+
const score = text.length / 400 + files + attachments;
|
|
54
|
+
return { type, size: score < 1 ? 'S' : score < 4 ? 'M' : 'L' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- arms ------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export const armKey = (agent, model = '') => `${agent}:${model || ''}`;
|
|
60
|
+
const splitKey = (k) => {
|
|
61
|
+
const i = k.indexOf(':');
|
|
62
|
+
return { agent: k.slice(0, i), model: k.slice(i + 1) };
|
|
63
|
+
};
|
|
64
|
+
// Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
|
|
65
|
+
// starts from what the family already showed.
|
|
66
|
+
const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
|
|
67
|
+
|
|
68
|
+
// The arms wisp may route to, from listAgents(): each harness's default model, the models the
|
|
69
|
+
// priors know, models with history here, and new models in a known family (on probation).
|
|
70
|
+
function candidates(agents, known) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const a of agents) {
|
|
73
|
+
if (!a.available) continue;
|
|
74
|
+
const models = new Set(['', ...(a.models || []).map((m) => m.id)]);
|
|
75
|
+
for (const id of models) {
|
|
76
|
+
const k = armKey(a.id, id);
|
|
77
|
+
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}:`))) {
|
|
78
|
+
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 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- the router ------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export class Router {
|
|
88
|
+
constructor(home, { now = Date.now } = {}) {
|
|
89
|
+
this.file = path.join(home, 'router.json');
|
|
90
|
+
this.now = now;
|
|
91
|
+
this.state = { version: 1, arms: {}, stats: {}, routes: {} };
|
|
92
|
+
try {
|
|
93
|
+
Object.assign(this.state, JSON.parse(fs.readFileSync(this.file, 'utf8')));
|
|
94
|
+
} catch {}
|
|
95
|
+
this.synced = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
save() {
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(this.file, JSON.stringify(this.state));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn(`wisp: router: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// New harnesses and models since the last look. The first look only learns what's there.
|
|
107
|
+
// Returns [{ key, label }] of arms that just showed up (they start on probation).
|
|
108
|
+
sync(agents) {
|
|
109
|
+
const first = !Object.keys(this.state.arms).length;
|
|
110
|
+
const known = new Set(Object.keys(this.state.arms));
|
|
111
|
+
const added = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const c of candidates(agents, known)) {
|
|
114
|
+
seen.add(c.key);
|
|
115
|
+
const arm = this.state.arms[c.key];
|
|
116
|
+
if (!arm) {
|
|
117
|
+
// Something new later on is on probation until it has shown what it can do.
|
|
118
|
+
this.state.arms[c.key] = { firstSeen: this.now(), state: first || this.#prior(c.key) ? 'ready' : 'new' };
|
|
119
|
+
if (!first) added.push({ key: c.key, label: c.label, state: this.state.arms[c.key].state });
|
|
120
|
+
} else if (arm.state === 'gone') arm.state = 'ready';
|
|
121
|
+
}
|
|
122
|
+
for (const [k, arm] of Object.entries(this.state.arms)) if (!seen.has(k) && arm.state !== 'gone' && agents.some((a) => a.id === splitKey(k).agent)) arm.state = 'gone';
|
|
123
|
+
this.synced = true;
|
|
124
|
+
this.save();
|
|
125
|
+
return added;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#prior(k) {
|
|
129
|
+
const p = PRIORS.arms[k];
|
|
130
|
+
if (p) return p;
|
|
131
|
+
// A new version inherits its family's record, with less weight.
|
|
132
|
+
const kin = Object.entries(PRIORS.arms).filter(([x]) => family(x) === family(k) && splitKey(x).agent === splitKey(k).agent);
|
|
133
|
+
if (!kin.length) return null;
|
|
134
|
+
const [, best] = kin.sort((a, b) => b[1].a + b[1].b - (a[1].a + a[1].b))[0];
|
|
135
|
+
const s = Math.min(1, 4 / (best.a + best.b));
|
|
136
|
+
return { ...best, a: best.a * s, b: best.b * s, inherited: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Decayed counts for (kind, arm) from this user's routes.
|
|
140
|
+
#counts(type, key) {
|
|
141
|
+
const s = this.state.stats[`${type}|${key}`];
|
|
142
|
+
if (!s) return { a: 0, b: 0, n: 0, usd: null, ms: null };
|
|
143
|
+
const age = this.now() - (s.at || this.now());
|
|
144
|
+
const f = Math.pow(0.5, age / HALF_LIFE_MS);
|
|
145
|
+
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 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Beta posterior for an arm on a kind of task, plus what it usually costs.
|
|
149
|
+
estimate(type, key) {
|
|
150
|
+
const prior = this.#prior(key) || { a: 1, b: 1 };
|
|
151
|
+
const scale = Math.min(1, PRIOR_CAP / Math.max(1, prior.a + prior.b));
|
|
152
|
+
const mine = this.#counts(type, key);
|
|
153
|
+
const all = this.#counts('*', key);
|
|
154
|
+
const a = 1 + prior.a * scale + (all.a - mine.a) * ALL_WEIGHT + mine.a;
|
|
155
|
+
const b = 1 + prior.b * scale + (all.b - mine.b) * ALL_WEIGHT + mine.b;
|
|
156
|
+
// Cost and time: the prior's guess, pulled toward what this user's runs actually cost as they add up.
|
|
157
|
+
const blend = (own, n, guess) => (own === null ? guess : (guess * 3 + own * n) / (3 + n));
|
|
158
|
+
const usd = blend(all.usd, all.n, prior.usd ?? 0.3);
|
|
159
|
+
const minutes = blend(all.ms === null ? null : all.ms / 60_000, all.n, prior.min ?? 5);
|
|
160
|
+
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 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best.
|
|
164
|
+
// important: no exploring. exclude: arm keys already taken (compare). needDelegate: the lead
|
|
165
|
+
// of a team must be a harness that can delegate. rng: for tests.
|
|
166
|
+
choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, rng = Math.random } = {}) {
|
|
167
|
+
if (!this.synced) this.sync(agents);
|
|
168
|
+
const { type, size } = classify(prompt, { attachments });
|
|
169
|
+
const V = (GOALS[goal] || GOALS.balanced).value;
|
|
170
|
+
let arms = candidates(agents, new Set(Object.keys(this.state.arms)))
|
|
171
|
+
.filter((c) => !exclude.includes(c.key) && (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)))
|
|
172
|
+
.map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.key]?.state || 'new' }));
|
|
173
|
+
if (!arms.length) return null;
|
|
174
|
+
// Quality floor: an arm that keeps failing this kind of task here is left out.
|
|
175
|
+
const floor = arms.filter((x) => !(x.est.n >= 5 && x.est.mean < 0.35));
|
|
176
|
+
if (floor.length) arms = floor;
|
|
177
|
+
const utility = (x, theta) => V * theta - x.est.usd - MINUTE_USD * x.est.minutes;
|
|
178
|
+
const draw = (r) => {
|
|
179
|
+
const pool = arms.filter((x) => x.state !== 'new' || (explore && !important && r() < NEW_ARM_SHARE));
|
|
180
|
+
const list = pool.length ? pool : arms;
|
|
181
|
+
let best = null;
|
|
182
|
+
let bu = -Infinity;
|
|
183
|
+
for (const x of list) {
|
|
184
|
+
const theta = important || !explore ? x.est.mean : betaSample(x.est.a, x.est.b, r);
|
|
185
|
+
const u = utility(x, theta);
|
|
186
|
+
if (u > bu) (best = x), (bu = u);
|
|
187
|
+
}
|
|
188
|
+
return best;
|
|
189
|
+
};
|
|
190
|
+
const pick = draw(rng);
|
|
191
|
+
// How likely this pick was: logged so the router's own choices can be corrected for later.
|
|
192
|
+
let hits = 0;
|
|
193
|
+
const sim = mulberry32(Math.floor(rng() * 2 ** 31));
|
|
194
|
+
for (let i = 0; i < 200; i++) if (draw(sim).key === pick.key) hits++;
|
|
195
|
+
const byMean = [...arms].sort((p, q) => utility(q, q.est.mean) - utility(p, p.est.mean));
|
|
196
|
+
return {
|
|
197
|
+
agent: pick.agent,
|
|
198
|
+
model: pick.model,
|
|
199
|
+
key: pick.key,
|
|
200
|
+
label: pick.label,
|
|
201
|
+
route: {
|
|
202
|
+
goal: GOALS[goal] ? goal : 'balanced',
|
|
203
|
+
type,
|
|
204
|
+
size,
|
|
205
|
+
key: pick.key,
|
|
206
|
+
label: pick.label,
|
|
207
|
+
chance: round(pick.est.mean),
|
|
208
|
+
n: pick.est.n,
|
|
209
|
+
usd: round(pick.est.usd),
|
|
210
|
+
minutes: round(pick.est.minutes, 1),
|
|
211
|
+
explored: byMean[0].key !== pick.key,
|
|
212
|
+
trying: pick.state === 'new',
|
|
213
|
+
propensity: round(hits / 200),
|
|
214
|
+
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) })),
|
|
215
|
+
at: this.now(),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A launch went out with this route. id: the session id (id@machine for one on a machine).
|
|
221
|
+
record(id, route) {
|
|
222
|
+
this.state.routes[id] = { ...route, signals: {}, r: null };
|
|
223
|
+
const ids = Object.keys(this.state.routes);
|
|
224
|
+
if (ids.length > MAX_ROUTES) for (const old of ids.slice(0, ids.length - MAX_ROUTES)) delete this.state.routes[old];
|
|
225
|
+
this.save();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Something happened to a routed session: { hero, error, followups, feedback, merged, closed,
|
|
229
|
+
// rolledBack, costUsd, ms }. The reward is recomputed and the stats move by the difference.
|
|
230
|
+
signal(id, patch) {
|
|
231
|
+
const route = this.state.routes[id];
|
|
232
|
+
if (!route) return null;
|
|
233
|
+
Object.assign(route.signals, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)));
|
|
234
|
+
const r = reward(route.signals);
|
|
235
|
+
if (r === null) return this.save(), route;
|
|
236
|
+
for (const type of [route.type, '*']) {
|
|
237
|
+
const k = `${type}|${route.key}`;
|
|
238
|
+
const s = (this.state.stats[k] ||= { a: 0, b: 0, n: 0, usd: 0, usdN: 0, ms: 0, at: this.now() });
|
|
239
|
+
if (route.r === null) {
|
|
240
|
+
s.n++;
|
|
241
|
+
if (typeof route.signals.costUsd === 'number') (s.usd += route.signals.costUsd), s.usdN++;
|
|
242
|
+
s.ms += route.signals.ms || 0;
|
|
243
|
+
} else {
|
|
244
|
+
s.a -= route.r;
|
|
245
|
+
s.b -= 1 - route.r;
|
|
246
|
+
}
|
|
247
|
+
s.a += r;
|
|
248
|
+
s.b += 1 - r;
|
|
249
|
+
s.at = this.now();
|
|
250
|
+
}
|
|
251
|
+
route.r = r;
|
|
252
|
+
// Probation ends once an arm has a few outcomes of its own.
|
|
253
|
+
const arm = this.state.arms[route.key];
|
|
254
|
+
if (arm?.state === 'new' && (this.state.stats[`*|${route.key}`]?.n || 0) >= 3) arm.state = 'ready';
|
|
255
|
+
this.save();
|
|
256
|
+
return route;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
route(id) {
|
|
260
|
+
return this.state.routes[id] || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
reset() {
|
|
264
|
+
this.state.stats = {};
|
|
265
|
+
this.state.routes = {};
|
|
266
|
+
this.save();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Settings → Auto: every arm with its record overall and per kind, and the latest routes.
|
|
270
|
+
report(agents = []) {
|
|
271
|
+
const keys = new Set([...Object.keys(this.state.arms), ...candidates(agents, new Set(Object.keys(this.state.arms))).map((c) => c.key)]);
|
|
272
|
+
const labels = Object.fromEntries(candidates(agents, keys).map((c) => [c.key, c.label]));
|
|
273
|
+
const arms = [...keys]
|
|
274
|
+
.map((k) => {
|
|
275
|
+
const e = this.estimate('*', k);
|
|
276
|
+
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) }]));
|
|
277
|
+
const state = this.state.arms[k]?.state || (labels[k] ? 'ready' : 'gone');
|
|
278
|
+
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 };
|
|
279
|
+
})
|
|
280
|
+
.filter((a) => a.state !== 'gone' || a.n)
|
|
281
|
+
.sort((p, q) => q.chance - p.chance);
|
|
282
|
+
const routes = Object.entries(this.state.routes).map(([id, r]) => ({ id, ...r }));
|
|
283
|
+
return {
|
|
284
|
+
priors: { version: PRIORS.version, source: PRIORS.source },
|
|
285
|
+
learned: routes.filter((r) => r.r !== null).length,
|
|
286
|
+
routed: routes.length,
|
|
287
|
+
arms,
|
|
288
|
+
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 })),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function reward(sig) {
|
|
294
|
+
if (sig.merged) return 1;
|
|
295
|
+
if (sig.feedback === 'up') return 1;
|
|
296
|
+
if (sig.feedback === 'down') return 0;
|
|
297
|
+
if (sig.closed) return 0.2;
|
|
298
|
+
if (sig.rolledBack) return 0.1;
|
|
299
|
+
if (sig.error) return 0;
|
|
300
|
+
const base = { done: 0.6, partial: 0.3, blocked: 0.1, info: 0.5 }[sig.hero];
|
|
301
|
+
if (base === undefined) return null;
|
|
302
|
+
return round(Math.max(0.05, base - 0.1 * (sig.followups || 0)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const round = (x, d = 2) => Math.round(x * 10 ** d) / 10 ** d;
|
|
306
|
+
|
|
307
|
+
function mulberry32(a) {
|
|
308
|
+
return () => {
|
|
309
|
+
a |= 0;
|
|
310
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
311
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
312
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
313
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Beta(a, b) from two Gamma draws (Marsaglia–Tsang).
|
|
318
|
+
function gammaSample(k, r) {
|
|
319
|
+
if (k < 1) return gammaSample(k + 1, r) * Math.pow(r() || 1e-12, 1 / k);
|
|
320
|
+
const d = k - 1 / 3;
|
|
321
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
322
|
+
for (;;) {
|
|
323
|
+
let x;
|
|
324
|
+
let v;
|
|
325
|
+
do {
|
|
326
|
+
const u1 = r() || 1e-12;
|
|
327
|
+
const u2 = r();
|
|
328
|
+
x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
329
|
+
v = 1 + c * x;
|
|
330
|
+
} while (v <= 0);
|
|
331
|
+
v = v * v * v;
|
|
332
|
+
const u = r() || 1e-12;
|
|
333
|
+
if (Math.log(u) < 0.5 * x * x + d - d * v + d * Math.log(v)) return d * v;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
export function betaSample(a, b, r = Math.random) {
|
|
337
|
+
const x = gammaSample(a, r);
|
|
338
|
+
const y = gammaSample(b, r);
|
|
339
|
+
return x / (x + y);
|
|
340
|
+
}
|
package/server/runner.js
CHANGED
|
@@ -13,7 +13,9 @@ import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js
|
|
|
13
13
|
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
14
14
|
|
|
15
15
|
const MAX_ACTIVITY = 400;
|
|
16
|
+
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
16
17
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
18
|
+
const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
|
|
17
19
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
18
20
|
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
19
21
|
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
@@ -70,7 +72,7 @@ export function summarize(s, store) {
|
|
|
70
72
|
toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
|
|
71
73
|
pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
|
|
72
74
|
groupId: s.groupId || null,
|
|
73
|
-
origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
|
|
75
|
+
origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
|
|
74
76
|
// The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
|
|
75
77
|
team: (s.subagents || []).flatMap((m) =>
|
|
76
78
|
copies(m).map((name) => {
|
|
@@ -79,6 +81,8 @@ export function summarize(s, store) {
|
|
|
79
81
|
}),
|
|
80
82
|
),
|
|
81
83
|
...(s.parentId && { parentId: s.parentId, member: s.member }),
|
|
84
|
+
// Auto: what it picked and why, for the card's tooltip.
|
|
85
|
+
route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives } : null,
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -93,6 +97,7 @@ export class Runner extends EventEmitter {
|
|
|
93
97
|
this.interrupting = new Set();
|
|
94
98
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
95
99
|
this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
|
|
100
|
+
this.routeAuto = null; // (prompt, { cwd }) => { agent, model, route } for subagents set to Auto
|
|
96
101
|
|
|
97
102
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
98
103
|
for (const s of store.all()) {
|
|
@@ -107,7 +112,7 @@ export class Runner extends EventEmitter {
|
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
110
|
-
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '' }) {
|
|
115
|
+
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
|
|
111
116
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
112
117
|
const now = Date.now();
|
|
113
118
|
const session = {
|
|
@@ -131,6 +136,7 @@ export class Runner extends EventEmitter {
|
|
|
131
136
|
...(origin && { origin }),
|
|
132
137
|
...(subagents.length && { subagents, children: {} }),
|
|
133
138
|
...(parentId && { parentId, member }),
|
|
139
|
+
...(route && { route }), // how Auto picked agent and model (server/router.js)
|
|
134
140
|
};
|
|
135
141
|
this.store.put(session);
|
|
136
142
|
if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
|
|
@@ -138,8 +144,11 @@ export class Runner extends EventEmitter {
|
|
|
138
144
|
return session;
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
// mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
|
|
148
|
+
// follow-up keeps its own, so the run before it isn't affected.
|
|
149
|
+
reply(session, text, attachments = [], { queue = false, mode } = {}) {
|
|
150
|
+
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
151
|
+
if (session.ask && !mode) {
|
|
143
152
|
this.answer(session, text);
|
|
144
153
|
return { answered: true };
|
|
145
154
|
}
|
|
@@ -149,22 +158,23 @@ export class Runner extends EventEmitter {
|
|
|
149
158
|
// The run already produced its result and the CLI is only lingering on its way out:
|
|
150
159
|
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
151
160
|
if (turn?.status !== 'running') {
|
|
152
|
-
session.queue.push({ id: newId(), text, attachments });
|
|
161
|
+
session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
|
|
153
162
|
handle.release();
|
|
154
163
|
return { queued: session.queue.length > 0 };
|
|
155
164
|
}
|
|
156
165
|
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
157
166
|
if (queue || !session.agentSessionId) {
|
|
158
|
-
session.queue.push({ id: newId(), text, attachments });
|
|
167
|
+
session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
|
|
159
168
|
this.#touch(session);
|
|
160
169
|
return { queued: true, ahead: session.queue.length - 1 };
|
|
161
170
|
}
|
|
162
171
|
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
163
172
|
const at = session.queue.findIndex((q) => !q.now);
|
|
164
|
-
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
|
|
173
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...(mode && { mode }) });
|
|
165
174
|
this.#interrupt(session);
|
|
166
175
|
return { interrupted: true };
|
|
167
176
|
}
|
|
177
|
+
if (mode) session.mode = mode;
|
|
168
178
|
this.#startTurn(session, text, attachments);
|
|
169
179
|
return { queued: false };
|
|
170
180
|
}
|
|
@@ -252,12 +262,20 @@ export class Runner extends EventEmitter {
|
|
|
252
262
|
let child = this.store.get(lead.children?.[slot]);
|
|
253
263
|
if (child) {
|
|
254
264
|
child.cwd = workdir(lead); // follows the lead if its worktree moved
|
|
265
|
+
child.mode = lead.mode; // and its mode, if a follow-up switched it
|
|
255
266
|
this.#startTurn(child, prompt);
|
|
256
267
|
} else {
|
|
257
|
-
|
|
268
|
+
// An Auto member is routed on its first task and keeps that pick, so later tasks resume it.
|
|
269
|
+
const pick = m.agent === 'auto' ? this.routeAuto?.(prompt, { cwd: workdir(lead) }) : null;
|
|
270
|
+
if (m.agent === 'auto' && !pick) {
|
|
271
|
+
out.push({ subagent: want, error: 'Auto found no harness to run this on.' });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const run = pick ? { agent: pick.agent, model: pick.model, effort: '', route: pick.route } : { agent: m.agent, model: m.model, effort: m.effort };
|
|
275
|
+
child = this.create({ prompt, cwd: workdir(lead), ...run, mode: lead.mode, parentId: lead.id, member: slot });
|
|
258
276
|
(lead.children ||= {})[slot] = child.id;
|
|
259
277
|
}
|
|
260
|
-
const d = { id: newId(), name: slot, member: m.name, agent:
|
|
278
|
+
const d = { id: newId(), name: slot, member: m.name, agent: child.agent, model: child.model, effort: child.effort, sessionId: child.id, turnId: child.turns.at(-1).id, prompt, startedAt: Date.now() };
|
|
261
279
|
(turn.delegations ||= []).push(d);
|
|
262
280
|
out.push(d);
|
|
263
281
|
}
|
|
@@ -317,6 +335,7 @@ export class Runner extends EventEmitter {
|
|
|
317
335
|
agent: session.agent,
|
|
318
336
|
model: session.model,
|
|
319
337
|
effort: session.effort || '',
|
|
338
|
+
mode: session.mode,
|
|
320
339
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
321
340
|
startedAt: Date.now(),
|
|
322
341
|
endedAt: null,
|
|
@@ -373,16 +392,22 @@ export class Runner extends EventEmitter {
|
|
|
373
392
|
: {},
|
|
374
393
|
onEvent: (ev) => {
|
|
375
394
|
if (released) return; // a lingering process we already let go of
|
|
376
|
-
this.#onEvent(session, turn, ev);
|
|
395
|
+
const waiting = this.#onEvent(session, turn, ev) === 'waiting';
|
|
377
396
|
// A CLI can outlive its result (flushing, or a background process it spawned holding
|
|
378
|
-
// stdout open, so 'close' never fires). Don't hold the session hostage to that.
|
|
379
|
-
|
|
397
|
+
// stdout open, so 'close' never fires). Don't hold the session hostage to that. A result
|
|
398
|
+
// that left background tasks running gets up to BACKGROUND_MAX_MS for its next one.
|
|
399
|
+
if (ev.type === 'result') {
|
|
400
|
+
clearTimeout(linger);
|
|
401
|
+
linger = setTimeout(release, waiting ? BACKGROUND_MAX_MS : LINGER_MS);
|
|
402
|
+
}
|
|
380
403
|
},
|
|
381
404
|
});
|
|
382
405
|
// Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
|
|
383
406
|
const release = () => {
|
|
384
407
|
if (released) return;
|
|
385
|
-
|
|
408
|
+
// Gone while waiting on background work (stopped, interrupted, or it died): the result it
|
|
409
|
+
// gave before waiting is the best we have.
|
|
410
|
+
if (turn.status === 'running') this.#finishTurn(session, turn, turn.interim || { isError: true, text: 'The agent exited without a result.' });
|
|
386
411
|
released = true;
|
|
387
412
|
clearTimeout(linger);
|
|
388
413
|
handle.stop();
|
|
@@ -391,6 +416,7 @@ export class Runner extends EventEmitter {
|
|
|
391
416
|
this.interrupting.delete(session.id);
|
|
392
417
|
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
393
418
|
const next = session.queue.shift();
|
|
419
|
+
if (next?.mode) session.mode = next.mode;
|
|
394
420
|
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
395
421
|
else this.#touch(session);
|
|
396
422
|
};
|
|
@@ -439,14 +465,41 @@ export class Runner extends EventEmitter {
|
|
|
439
465
|
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
440
466
|
return;
|
|
441
467
|
}
|
|
468
|
+
case 'background': {
|
|
469
|
+
turn.background = ev.tasks;
|
|
470
|
+
if (turn.interim) this.#waitItem(session, turn);
|
|
471
|
+
else this.store.save(session);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
442
474
|
case 'result':
|
|
475
|
+
// It ended its turn in plain text with background tasks still running ("I'll report once
|
|
476
|
+
// it's done"): the CLI wakes it up when they finish and it reports again, so the turn stays
|
|
477
|
+
// open. A full presentation is a finished task that left something up (a dev server).
|
|
478
|
+
if (ev.background > 0 && !ev.isError && parsePresentation(ev.text).fallback) {
|
|
479
|
+
turn.interim = { text: ev.text, stats: ev.stats };
|
|
480
|
+
this.#waitItem(session, turn);
|
|
481
|
+
return 'waiting';
|
|
482
|
+
}
|
|
443
483
|
this.#finishTurn(session, turn, ev);
|
|
444
484
|
return;
|
|
445
485
|
}
|
|
446
486
|
}
|
|
447
487
|
|
|
488
|
+
// The live line while a turn waits on its background tasks.
|
|
489
|
+
#waitItem(session, turn) {
|
|
490
|
+
const tasks = turn.background || [];
|
|
491
|
+
const label = !tasks.length
|
|
492
|
+
? 'Background work finished, wrapping up'
|
|
493
|
+
: tasks.length === 1
|
|
494
|
+
? `Waiting on background task: ${tasks[0]}`
|
|
495
|
+
: `Waiting on ${tasks.length} background tasks`;
|
|
496
|
+
this.#onEvent(session, turn, { type: 'tool', kind: 'background', label: label.slice(0, 200) });
|
|
497
|
+
}
|
|
498
|
+
|
|
448
499
|
#finishTurn(session, turn, ev, reason) {
|
|
449
500
|
turn.endedAt = Date.now();
|
|
501
|
+
delete turn.interim;
|
|
502
|
+
delete turn.background;
|
|
450
503
|
turn.stats = ev.stats || null;
|
|
451
504
|
delete session.ask;
|
|
452
505
|
if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
|
package/server/store.js
CHANGED
|
@@ -8,7 +8,7 @@ import { parsePresentation } from './protocol.js';
|
|
|
8
8
|
|
|
9
9
|
const DEFAULT_CONFIG = {
|
|
10
10
|
projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
|
|
11
|
-
defaults: { agent: '
|
|
11
|
+
defaults: { agent: 'auto', mode: 'edits', model: '', effort: '' }, // auto: wisp picks harness and model (server/router.js)
|
|
12
12
|
recipes: [], // saved prompts with {params}: server/recipes.js
|
|
13
13
|
triggers: [], // schedules and GitHub watchers: server/triggers.js
|
|
14
14
|
teams: [], // saved subagent presets: server/subagents.js
|
|
@@ -57,9 +57,17 @@ export class Store {
|
|
|
57
57
|
cfg.teams ||= [];
|
|
58
58
|
cfg.remote ||= {};
|
|
59
59
|
cfg.machines ||= [];
|
|
60
|
+
// Auto became the default harness: switch once, after that the user's pick sticks.
|
|
61
|
+
if (!cfg.autoIntroduced) {
|
|
62
|
+
if (cfg.defaults.agent !== 'auto') cfg.defaults.agentBeforeAuto = cfg.defaults.agent;
|
|
63
|
+
cfg.defaults.agent = 'auto';
|
|
64
|
+
cfg.defaults.model = cfg.defaults.models?.auto || '';
|
|
65
|
+
cfg.defaults.effort = '';
|
|
66
|
+
cfg.autoIntroduced = true;
|
|
67
|
+
}
|
|
60
68
|
return cfg;
|
|
61
69
|
} catch {
|
|
62
|
-
return structuredClone(DEFAULT_CONFIG);
|
|
70
|
+
return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
|
package/server/subagents.js
CHANGED
|
@@ -19,7 +19,8 @@ export function normalizeTeam(list, adapters) {
|
|
|
19
19
|
const taken = new Set();
|
|
20
20
|
const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
|
|
21
21
|
const agent = String(m?.agent || '');
|
|
22
|
-
|
|
22
|
+
// auto: wisp picks the harness and model when this member gets its first task.
|
|
23
|
+
if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
|
|
23
24
|
let base = slug(m.name) || agent;
|
|
24
25
|
let name = base;
|
|
25
26
|
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
@@ -45,7 +46,10 @@ export function normalizePreset(body, adapters, prev = {}) {
|
|
|
45
46
|
return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
const who = (m, adapters) =>
|
|
49
|
+
const who = (m, adapters) =>
|
|
50
|
+
m.agent === 'auto'
|
|
51
|
+
? 'Auto (wisp picks the harness and model for the first task you give it)'
|
|
52
|
+
: [adapters[m.agent]?.name || m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ');
|
|
49
53
|
|
|
50
54
|
// Appended to the lead's system prompt. `tool` is how its harness names the delegate tool.
|
|
51
55
|
export function teamNote(team, adapters, tool) {
|