wisp-agents 0.13.0 → 0.15.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 +25 -1
- package/README.md +17 -4
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +1 -1
- package/server/index.js +110 -9
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +2 -2
- package/server/openapi.js +7 -1
- package/server/protocol.js +4 -0
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +52 -7
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/web/css/app.css +41 -2
- package/web/js/app.js +173 -25
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
|
@@ -15,6 +15,7 @@ import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
|
15
15
|
const MAX_ACTIVITY = 400;
|
|
16
16
|
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
17
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
|
|
18
19
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
19
20
|
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
20
21
|
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
@@ -80,6 +81,8 @@ export function summarize(s, store) {
|
|
|
80
81
|
}),
|
|
81
82
|
),
|
|
82
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,
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -94,6 +97,7 @@ export class Runner extends EventEmitter {
|
|
|
94
97
|
this.interrupting = new Set();
|
|
95
98
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
96
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
|
|
97
101
|
|
|
98
102
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
99
103
|
for (const s of store.all()) {
|
|
@@ -108,7 +112,7 @@ export class Runner extends EventEmitter {
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
111
|
-
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 }) {
|
|
112
116
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
113
117
|
const now = Date.now();
|
|
114
118
|
const session = {
|
|
@@ -132,6 +136,7 @@ export class Runner extends EventEmitter {
|
|
|
132
136
|
...(origin && { origin }),
|
|
133
137
|
...(subagents.length && { subagents, children: {} }),
|
|
134
138
|
...(parentId && { parentId, member }),
|
|
139
|
+
...(route && { route }), // how Auto picked agent and model (server/router.js)
|
|
135
140
|
};
|
|
136
141
|
this.store.put(session);
|
|
137
142
|
if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
|
|
@@ -260,10 +265,17 @@ export class Runner extends EventEmitter {
|
|
|
260
265
|
child.mode = lead.mode; // and its mode, if a follow-up switched it
|
|
261
266
|
this.#startTurn(child, prompt);
|
|
262
267
|
} else {
|
|
263
|
-
|
|
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 });
|
|
264
276
|
(lead.children ||= {})[slot] = child.id;
|
|
265
277
|
}
|
|
266
|
-
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() };
|
|
267
279
|
(turn.delegations ||= []).push(d);
|
|
268
280
|
out.push(d);
|
|
269
281
|
}
|
|
@@ -380,16 +392,22 @@ export class Runner extends EventEmitter {
|
|
|
380
392
|
: {},
|
|
381
393
|
onEvent: (ev) => {
|
|
382
394
|
if (released) return; // a lingering process we already let go of
|
|
383
|
-
this.#onEvent(session, turn, ev);
|
|
395
|
+
const waiting = this.#onEvent(session, turn, ev) === 'waiting';
|
|
384
396
|
// A CLI can outlive its result (flushing, or a background process it spawned holding
|
|
385
|
-
// stdout open, so 'close' never fires). Don't hold the session hostage to that.
|
|
386
|
-
|
|
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
|
+
}
|
|
387
403
|
},
|
|
388
404
|
});
|
|
389
405
|
// Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
|
|
390
406
|
const release = () => {
|
|
391
407
|
if (released) return;
|
|
392
|
-
|
|
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.' });
|
|
393
411
|
released = true;
|
|
394
412
|
clearTimeout(linger);
|
|
395
413
|
handle.stop();
|
|
@@ -447,14 +465,41 @@ export class Runner extends EventEmitter {
|
|
|
447
465
|
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
448
466
|
return;
|
|
449
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
|
+
}
|
|
450
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
|
+
}
|
|
451
483
|
this.#finishTurn(session, turn, ev);
|
|
452
484
|
return;
|
|
453
485
|
}
|
|
454
486
|
}
|
|
455
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
|
+
|
|
456
499
|
#finishTurn(session, turn, ev, reason) {
|
|
457
500
|
turn.endedAt = Date.now();
|
|
501
|
+
delete turn.interim;
|
|
502
|
+
delete turn.background;
|
|
458
503
|
turn.stats = ev.stats || null;
|
|
459
504
|
delete session.ask;
|
|
460
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) {
|
package/web/css/app.css
CHANGED
|
@@ -1080,14 +1080,23 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1080
1080
|
|
|
1081
1081
|
/* trail */
|
|
1082
1082
|
.trail { margin-top: 18px; }
|
|
1083
|
-
.trail-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 8px 2px; border-top: 1px dashed var(--line); font-size: 12px; color: var(--faint); }
|
|
1083
|
+
.trail-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 14px; padding: 8px 2px; border-top: 1px dashed var(--line); font-size: 12px; color: var(--faint); }
|
|
1084
1084
|
.tb { display: inline-flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
|
|
1085
1085
|
.tb .i { width: 13px; height: 13px; }
|
|
1086
1086
|
.trail-bar .link { font-size: 12px; }
|
|
1087
|
+
/* Activity counts double as the Activity toggle, so the bar carries no separate "Activity" link. */
|
|
1088
|
+
.tb-acts { display: inline-flex; align-items: center; gap: 10px; margin: -3px -6px; padding: 3px 6px; border-radius: 6px; color: inherit; transition: color 0.15s, background 0.15s; }
|
|
1089
|
+
.tb-acts:hover, .tb-acts.on { color: var(--ink); background: var(--bg-2); }
|
|
1090
|
+
/* Secondary turn actions: icon-only, faint until the turn is hovered (same rule as the thumbs). */
|
|
1091
|
+
.tb-tools { display: inline-flex; align-items: center; gap: 1px; margin-left: -8px; }
|
|
1092
|
+
.tb-btn { display: inline-grid; place-items: center; width: 24px; height: 22px; border-radius: 6px; color: var(--faint); opacity: 0.5; transition: color 0.15s, background 0.15s, opacity 0.15s; }
|
|
1093
|
+
.tb-btn .i { width: 13px; height: 13px; }
|
|
1094
|
+
.tb-btn:hover, .tb-btn.on { color: var(--ink); background: var(--bg-2); }
|
|
1095
|
+
.turn:hover .tb-btn, .tb-btn:focus-visible, .tb-btn.on { opacity: 1; }
|
|
1087
1096
|
.trail-body { padding: 8px 0 0; animation: rise 0.4s var(--ease); }
|
|
1088
1097
|
.trail-body .presentation { gap: 10px; }
|
|
1089
1098
|
/* Thumbs: faint until the trail is hovered or the turn is rated, so a finished turn reads the same. */
|
|
1090
|
-
.rate { display: inline-flex; gap: 1px;
|
|
1099
|
+
.rate { display: inline-flex; gap: 1px; opacity: 0.5; transition: opacity 0.15s; }
|
|
1091
1100
|
.turn:hover .rate, .rate.rated, .rate:focus-within { opacity: 1; }
|
|
1092
1101
|
.rate-btn { display: inline-grid; place-items: center; width: 24px; height: 22px; border-radius: 6px; color: var(--faint); transition: color 0.15s, background 0.15s; }
|
|
1093
1102
|
.rate-btn .i { width: 13px; height: 13px; }
|
|
@@ -1869,3 +1878,33 @@ body.electron.mac .topbar { padding-left: 88px; }
|
|
|
1869
1878
|
.sub-task p { margin: 3px 0 0; font-size: 13px; color: var(--ink-2); white-space: pre-wrap; }
|
|
1870
1879
|
.sub-wait { margin: 0; font-size: 13px; }
|
|
1871
1880
|
@container (max-width: 520px) { .sub-files, .sub-dur, .sub-model { display: none; } }
|
|
1881
|
+
|
|
1882
|
+
/* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
|
|
1883
|
+
.auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
|
|
1884
|
+
.auto-mark .i { width: 100%; height: 100%; }
|
|
1885
|
+
.model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
|
|
1886
|
+
.pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
|
|
1887
|
+
.pk-note a { color: var(--accent-ink); }
|
|
1888
|
+
.ro-goal { flex: none; }
|
|
1889
|
+
.ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
|
|
1890
|
+
.chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
|
|
1891
|
+
.chip-tog:hover { color: var(--ink); }
|
|
1892
|
+
.chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
|
|
1893
|
+
.router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
|
|
1894
|
+
.router-modal .hint { margin: 0 0 12px; }
|
|
1895
|
+
.router-modal h4 { margin: 16px 0 6px; }
|
|
1896
|
+
.ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
|
|
1897
|
+
.ro-table .sx-state { justify-content: flex-end; }
|
|
1898
|
+
.sx-state.ok { color: var(--good); }
|
|
1899
|
+
.sx-state.warn { color: var(--warn); }
|
|
1900
|
+
.dot.warn { background: var(--warn); }
|
|
1901
|
+
.ro-recent { list-style: none; margin: 0; padding: 0; }
|
|
1902
|
+
.ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
|
|
1903
|
+
.ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
|
1904
|
+
.ro-recent small { flex: none; color: var(--faint); }
|
|
1905
|
+
.ro-recent .ok { color: var(--good); }
|
|
1906
|
+
.ro-recent .warn { color: var(--warn); }
|
|
1907
|
+
.ro-recent .bad { color: var(--bad); }
|
|
1908
|
+
.ro-recent .dim { color: var(--faint); }
|
|
1909
|
+
.ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
|
1910
|
+
.ro-foot .hint { margin: 0; }
|