wisp-agents 0.18.0 → 0.21.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 +62 -1
- package/README.md +23 -7
- package/bin/wisp.js +4 -3
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +7 -5
- package/server/adapters/codex.js +7 -3
- 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/client.js +3 -2
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +4 -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/sessions.js +15 -8
- package/server/routes/settings.js +2 -0
- package/server/routes/ship.js +27 -27
- package/server/runner.js +83 -17
- package/server/scout.js +71 -0
- package/server/services/auto.js +111 -14
- package/server/services/launch.js +45 -8
- package/server/services/ship.js +132 -23
- package/skills/wisp/SKILL.md +2 -1
- package/web/css/auto.css +22 -0
- package/web/css/feed.css +3 -0
- package/web/css/launcher.css +12 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +17 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/core/state.js +4 -2
- package/web/js/features/actions.js +3 -5
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +40 -10
- package/web/js/features/launcher.js +62 -18
- package/web/js/features/live.js +2 -7
- package/web/js/features/places.js +10 -0
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +6 -8
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +95 -31
- package/web/js/features/turns.js +6 -4
- package/web/js/ui/popover.js +10 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// What Auto does after the agent says it's done, before the task lands in your queue:
|
|
2
|
+
//
|
|
3
|
+
// check wisp runs the project's tests itself (server/check.js). Only new failures count.
|
|
4
|
+
// repair tests fail → the same session gets the failures back, up to `repairs` rounds.
|
|
5
|
+
// escalate still failing (or the run errored) → the task goes to a stronger harness · model,
|
|
6
|
+
// in its own worktree, next to the first run (one task, two rows).
|
|
7
|
+
// judge best-of-2 (goal Best): both runs are checked, then the better one is marked
|
|
8
|
+
// (server/judge.js): tests first, then a cheap model reading both diffs.
|
|
9
|
+
//
|
|
10
|
+
// The turn's presentation gets wisp's own tests block, and when the agent said "all green" but
|
|
11
|
+
// wisp's run disagrees, a callout saying so. The router learns from the verdicts.
|
|
12
|
+
//
|
|
13
|
+
// State lives on the session as `auto`: { strategy, busy, stage, label, repairs, checks[],
|
|
14
|
+
// escalatedTo, escalatedFrom, pick }. The runner keeps a session in Working while `busy`.
|
|
15
|
+
|
|
16
|
+
import { check as runCheck } from './check.js';
|
|
17
|
+
import { judge, diffOf } from './judge.js';
|
|
18
|
+
import { workdir } from './runner.js';
|
|
19
|
+
import { infraError, tierOf } from './router.js';
|
|
20
|
+
|
|
21
|
+
// What an errored turn said (the terminal block the runner puts on it).
|
|
22
|
+
const errorText = (turn) => (turn.presentation?.blocks || []).filter((b) => b.type === 'terminal').map((b) => b.output || '').join('\n');
|
|
23
|
+
const MAX_MOVES = 4; // a task moved this many times because harnesses were at their limit stops there
|
|
24
|
+
|
|
25
|
+
export const AUTO_DEFAULTS = { check: 'on', repairs: 1, escalate: 'on', best2: 'off', scout: 'big' };
|
|
26
|
+
|
|
27
|
+
export function autoSettings(d = {}) {
|
|
28
|
+
const r = d.router || {};
|
|
29
|
+
return {
|
|
30
|
+
check: r.check !== 'off',
|
|
31
|
+
repairs: [0, 1, 2].includes(Number(r.repairs)) ? Number(r.repairs) : AUTO_DEFAULTS.repairs,
|
|
32
|
+
escalate: r.escalate !== 'off',
|
|
33
|
+
best2: r.best2 === 'on', // opt-in: it runs every Best task twice
|
|
34
|
+
scout: ['off', 'big', 'all'].includes(r.scout) ? r.scout : AUTO_DEFAULTS.scout,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
39
|
+
|
|
40
|
+
export class Pipeline {
|
|
41
|
+
// deps: { store, runner, router, settings(), agents(), projectOf(session), launchSibling(session, pick),
|
|
42
|
+
// onSettled(session), broadcast(event, data) }
|
|
43
|
+
constructor(deps) {
|
|
44
|
+
Object.assign(this, deps);
|
|
45
|
+
this.tokens = new Map(); // sessionId -> token of the step in flight; a user's move cancels it
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Called by the runner as a turn ends: true keeps the session in Working while wisp checks.
|
|
49
|
+
wants(session, turn, ev) {
|
|
50
|
+
const a = session.auto;
|
|
51
|
+
if (!a || session.parentId || ev.stopped || session.mode === 'plan') return false;
|
|
52
|
+
const s = this.settings();
|
|
53
|
+
// At its limit: not the model's fault, so the task moves on whatever the hand-over setting says.
|
|
54
|
+
if (ev.isError) return !!infraError(ev.text) || (s.escalate && this.#canEscalate(session));
|
|
55
|
+
return s.check;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A follow-up or Stop from the user: whatever wisp was about to do is off.
|
|
59
|
+
cancel(session) {
|
|
60
|
+
if (!session.auto?.busy) return;
|
|
61
|
+
this.tokens.delete(session.id);
|
|
62
|
+
Object.assign(session.auto, { busy: false, stage: 'cancelled', label: '' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async run(session, turn) {
|
|
66
|
+
const a = session.auto;
|
|
67
|
+
const token = Symbol('step');
|
|
68
|
+
this.tokens.set(session.id, token);
|
|
69
|
+
const live = () => this.tokens.get(session.id) === token && this.store.get(session.id) === session;
|
|
70
|
+
const s = this.settings();
|
|
71
|
+
try {
|
|
72
|
+
let result;
|
|
73
|
+
const limit = turn.status === 'error' && infraError(errorText(turn));
|
|
74
|
+
if (limit) return await this.#moveOn(session, turn, limit, live);
|
|
75
|
+
if (turn.status === 'error') result = { verdict: 'error' };
|
|
76
|
+
else {
|
|
77
|
+
this.#stage(session, turn, 'checking', 'Checking with the tests');
|
|
78
|
+
result = await runCheck({ dir: workdir(session), project: this.projectOf(session), worktree: session.worktree });
|
|
79
|
+
if (!live()) return;
|
|
80
|
+
a.checks.push({ turnId: turn.id, ...result, output: undefined });
|
|
81
|
+
this.#present(session, turn, result);
|
|
82
|
+
}
|
|
83
|
+
this.router.signal(session.id, { check: result.verdict, repaired: a.repairs > 0 || undefined });
|
|
84
|
+
|
|
85
|
+
const failing = result.verdict === 'fail' || result.verdict === 'error';
|
|
86
|
+
// Repairs and hand-overs are for the task as you sent it: once you've followed up yourself,
|
|
87
|
+
// wisp still checks and shows the result, but leaves the next move to you.
|
|
88
|
+
const yours = session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
|
|
89
|
+
if (result.verdict === 'fail' && a.repairs < s.repairs && !yours) {
|
|
90
|
+
a.repairs++;
|
|
91
|
+
this.#stage(session, turn, 'repairing', `Fixing ${plural(result.newFailures.length || result.failed || 1, 'failing test')}`);
|
|
92
|
+
this.runner.reply(session, repairPrompt(result, a.repairs, s.repairs), [], { by: 'wisp' });
|
|
93
|
+
return; // the repair turn comes back through wants()/run()
|
|
94
|
+
}
|
|
95
|
+
if (failing && s.escalate && this.#canEscalate(session)) {
|
|
96
|
+
// A step up: a stronger tier than this run's (or the strongest there is, if it was already top).
|
|
97
|
+
const tier = tierOf(session.route?.key || '');
|
|
98
|
+
const pick = this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: 'best', important: true, minTier: Math.min(3, tier + 1), exclude: [session.route?.key, ...(a.tried || [])].filter(Boolean), needDelegate: !!session.subagents?.length });
|
|
99
|
+
if (!pick || tierOf(pick.key) < tier) this.#note(session, turn, 'check', 'No stronger harness · model to hand it to');
|
|
100
|
+
else {
|
|
101
|
+
this.#stage(session, turn, 'escalating', `Handing it to ${pick.label}`);
|
|
102
|
+
const sib = await this.launchSibling(session, pick, { reason: result.verdict === 'error' ? 'error' : 'tests' });
|
|
103
|
+
if (!live()) return;
|
|
104
|
+
a.escalatedTo = sib.id;
|
|
105
|
+
a.reason = result.verdict === 'error' ? 'error' : 'tests';
|
|
106
|
+
this.#note(session, turn, 'check', `wisp handed this task to ${pick.label}: ${result.verdict === 'error' ? 'the run ended with an error' : 'the tests still fail'}`);
|
|
107
|
+
return this.#settle(session, { quiet: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.#settle(session);
|
|
111
|
+
if (a.strategy === 'best2') await this.#judgeGroup(session);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.warn(`wisp: auto pipeline: ${err.stack || err.message}`);
|
|
114
|
+
if (live()) this.#settle(session);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The harness was at a usage or rate limit: the run doesn't count against it, it cools down until
|
|
119
|
+
// the limit resets, and the task moves to the best pick that can run now.
|
|
120
|
+
async #moveOn(session, turn, limit, live) {
|
|
121
|
+
const a = session.auto;
|
|
122
|
+
const why = errorText(turn).split('\n').find((l) => l.trim())?.trim() || 'at its limit';
|
|
123
|
+
const cooled = this.router.cool(session.route?.key || `${session.agent}:${session.model || ''}`, { ...limit, why });
|
|
124
|
+
this.router.void(session.id);
|
|
125
|
+
const name = cooled.endsWith(':*') ? session.route?.label?.split(' · ')[0] || session.agent : session.route?.label || session.agent;
|
|
126
|
+
const tried = [...(a.tried || []), session.route?.key].filter(Boolean);
|
|
127
|
+
const pick = tried.length < MAX_MOVES ? this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: session.route?.goal || 'balanced', exclude: tried, needDelegate: !!session.subagents?.length }) : null;
|
|
128
|
+
if (!pick) {
|
|
129
|
+
this.#note(session, turn, 'check', `${name} is at its limit, and nothing else Auto may use can run right now`);
|
|
130
|
+
return this.#settle(session);
|
|
131
|
+
}
|
|
132
|
+
this.#stage(session, turn, 'moving', `${name} is at its limit · moving to ${pick.label}`);
|
|
133
|
+
const sib = await this.launchSibling(session, pick, { reason: 'limit', tried });
|
|
134
|
+
if (!live()) return;
|
|
135
|
+
a.escalatedTo = sib.id;
|
|
136
|
+
a.reason = 'limit';
|
|
137
|
+
return this.#settle(session, { quiet: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Escalate once per task, and only while the user hasn't stepped in with follow-ups.
|
|
141
|
+
#canEscalate(session) {
|
|
142
|
+
const a = session.auto;
|
|
143
|
+
// A fresh worktree from the same base, or the second run would build on the first one's mess.
|
|
144
|
+
return a.strategy === 'single' && !a.escalatedTo && !!session.worktree && !session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#stage(session, turn, stage, label) {
|
|
148
|
+
Object.assign(session.auto, { busy: true, stage, label });
|
|
149
|
+
this.#note(session, turn, 'check', `${label}…`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#note(session, turn, kind, label) {
|
|
153
|
+
this.runner.note(session, turn, { kind, label });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#settle(session, { quiet = false } = {}) {
|
|
157
|
+
this.tokens.delete(session.id);
|
|
158
|
+
Object.assign(session.auto, { busy: false, stage: 'done', label: '' });
|
|
159
|
+
this.runner.settle(session, { quiet });
|
|
160
|
+
this.onSettled?.(session);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// wisp's tests block goes right under the hero; a "done" that wisp's run contradicts is called out.
|
|
164
|
+
#present(session, turn, r) {
|
|
165
|
+
const blocks = turn.presentation?.blocks;
|
|
166
|
+
if (!blocks) return;
|
|
167
|
+
const label = `${r.command ? `\`${r.command}\`` : 'the tests'}`;
|
|
168
|
+
if (r.verdict === 'skip') {
|
|
169
|
+
this.#note(session, turn, 'check', `No tests to run: ${r.reason}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const failures = (r.newFailures.length ? r.newFailures : r.verdict === 'fail' ? ['(see output)'] : []).slice(0, 8).map((name) => ({ name, message: '' }));
|
|
173
|
+
const block = { type: 'tests', by: 'wisp', command: r.command, passed: r.passed ?? undefined, failed: r.verdict === 'pass' ? 0 : r.newFailures.length || r.failed, skipped: r.skipped ?? undefined, durationMs: r.durationMs, failures, note: r.note || undefined };
|
|
174
|
+
if (r.verdict === 'pass' && block.passed == null) block.passed = 1;
|
|
175
|
+
const at = Math.max(0, blocks.findIndex((b) => b.type === 'hero') + 1);
|
|
176
|
+
blocks.splice(at, 0, block);
|
|
177
|
+
const hero = blocks.find((b) => b.type === 'hero');
|
|
178
|
+
const claimed = blocks.some((b) => b.type === 'tests' && b.by !== 'wisp' && !(Number(b.failed) > 0)) || hero?.status === 'done';
|
|
179
|
+
if (r.verdict === 'fail') {
|
|
180
|
+
if (hero && hero.status === 'done') (hero.agentStatus = hero.status), (hero.status = 'partial');
|
|
181
|
+
if (claimed) blocks.splice(at + 1, 0, { type: 'callout', tone: 'danger', by: 'wisp', title: "wisp's test run disagrees", text: `The agent reported this as done, but ${label} has ${plural(r.newFailures.length || r.failed, 'failing test')} that passed before the change.` });
|
|
182
|
+
}
|
|
183
|
+
this.#note(session, turn, 'check', r.verdict === 'pass' ? `Tests pass: ${label}${r.note ? ` (${r.note})` : ''}` : r.verdict === 'fail' ? `Tests fail: ${plural(r.newFailures.length || r.failed, 'new failure')} in ${label}` : `Tests inconclusive: ${r.note || label}`);
|
|
184
|
+
this.store.save(session);
|
|
185
|
+
this.broadcast('turn', { sessionId: session.id, turnId: turn.id });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Best-of-2: once both runs have settled, mark the better one.
|
|
189
|
+
async #judgeGroup(session) {
|
|
190
|
+
const group = this.store.all().filter((x) => x.groupId === session.groupId && x.auto?.strategy === 'best2');
|
|
191
|
+
if (group.length < 2 || group.some((x) => x.auto.busy || x.state === 'running') || group.some((x) => x.auto.pick)) return;
|
|
192
|
+
group.forEach((x) => (x.auto.pick = { pending: true }));
|
|
193
|
+
const candidates = group.slice(0, 2).map((x) => ({ id: x.id, label: x.route?.label || x.agent, check: x.auto.checks.at(-1), diff: diffOf(workdir(x), x.worktree?.head || 'HEAD') }));
|
|
194
|
+
const j = await judge({ prompt: group[0].turns[0].prompt, candidates, agents: this.agents() });
|
|
195
|
+
for (const x of group) {
|
|
196
|
+
x.auto.pick = { winner: x.id === j.winner, why: j.why, by: j.by };
|
|
197
|
+
this.store.save(x);
|
|
198
|
+
this.broadcast('summary', this.summarize(x));
|
|
199
|
+
}
|
|
200
|
+
const w = group.find((x) => x.id === j.winner);
|
|
201
|
+
if (w) this.#note(w, w.turns.at(-1), 'check', `wisp's pick of the two runs: ${j.why}`);
|
|
202
|
+
for (const x of group) this.router.signal(x.id, { picked: x.id === j.winner });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function repairPrompt(r, round, max) {
|
|
207
|
+
const list = r.newFailures.length ? r.newFailures.slice(0, 20).map((t) => `- ${t}`).join('\n') : '';
|
|
208
|
+
return [
|
|
209
|
+
`wisp ran \`${r.command}\` on your change and it fails${list ? ':' : '.'}`,
|
|
210
|
+
list,
|
|
211
|
+
r.output ? `Output (tail):\n\`\`\`\n${r.output.slice(-3500)}\n\`\`\`` : '',
|
|
212
|
+
'These tests passed before your change. Fix the code so they pass again: do not delete, skip or weaken tests. Run the tests yourself, then finish with an updated presentation.',
|
|
213
|
+
max > 1 ? `(Repair round ${round} of ${max}.)` : '',
|
|
214
|
+
]
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.join('\n\n');
|
|
217
|
+
}
|
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)));
|