openzoo 0.48.94 → 0.48.97
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/lib/grokui.mjs +35 -23
- package/lib/livestatus.js +137 -0
- package/lib/podagent.mjs +106 -43
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
|
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
|
|
15
|
-
import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
15
|
+
import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS } from './livestatus.js';
|
|
16
16
|
import { creditBalance } from './info.js';
|
|
17
17
|
import {
|
|
18
18
|
SUBSCRIPTIONS_PAGE,
|
|
@@ -672,7 +672,10 @@ function setRunTurnForTest(fn) {
|
|
|
672
672
|
runTurnOverride = typeof fn === 'function' ? fn : null;
|
|
673
673
|
}
|
|
674
674
|
function kickTurn(threadId, userText, onEvent, images) {
|
|
675
|
-
|
|
675
|
+
// Default to emitToThread so a spawned/pinged kid streams when someone has
|
|
676
|
+
// that thread open. emitToThread is a no-op if nobody is watching.
|
|
677
|
+
const emit = onEvent === undefined ? (ev) => emitToThread(threadId, ev) : onEvent;
|
|
678
|
+
return (runTurnOverride || runTurn)(threadId, userText, emit, images);
|
|
676
679
|
}
|
|
677
680
|
function pingWakeText(extra) {
|
|
678
681
|
const msg = String(extra || '').trim();
|
|
@@ -1235,7 +1238,7 @@ async function handleSlash(task, t) {
|
|
|
1235
1238
|
}
|
|
1236
1239
|
const crew = subtreeOf(t.id);
|
|
1237
1240
|
if (!crew.length) return 'You have no subagents to send to.';
|
|
1238
|
-
for (const x of crew)
|
|
1241
|
+
for (const x of crew) kickTurn(x.id, arg).catch(() => {});
|
|
1239
1242
|
return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
|
|
1240
1243
|
}
|
|
1241
1244
|
|
|
@@ -1309,7 +1312,7 @@ setInterval(() => {
|
|
|
1309
1312
|
if (c.nextAt > now) continue;
|
|
1310
1313
|
c.nextAt = now + c.everyMin * 60000;
|
|
1311
1314
|
saveThreads();
|
|
1312
|
-
|
|
1315
|
+
kickTurn(t.id, c.text).catch(() => {});
|
|
1313
1316
|
}
|
|
1314
1317
|
}
|
|
1315
1318
|
}, 15000).unref();
|
|
@@ -1810,7 +1813,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1810
1813
|
}
|
|
1811
1814
|
// Every thread now exists, so spawnPosition sees the COMPLETE cohort.
|
|
1812
1815
|
for (const { t: sub, task, fresh } of made) {
|
|
1813
|
-
if (fresh)
|
|
1816
|
+
if (fresh) kickTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
|
|
1814
1817
|
else wakeOnPing(sub);
|
|
1815
1818
|
}
|
|
1816
1819
|
const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
|
|
@@ -1846,7 +1849,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1846
1849
|
}
|
|
1847
1850
|
const sub = newThread(name, originId);
|
|
1848
1851
|
// The child gets the ORIGINATING brief plus its own job — see spawnBrief.
|
|
1849
|
-
|
|
1852
|
+
kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
|
|
1850
1853
|
return `Spawned ${name} — working on it.`;
|
|
1851
1854
|
}
|
|
1852
1855
|
// SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
|
|
@@ -1875,7 +1878,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1875
1878
|
const msg = sendM[2].trim();
|
|
1876
1879
|
const target = findByName(name);
|
|
1877
1880
|
if (target) {
|
|
1878
|
-
|
|
1881
|
+
kickTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
|
|
1879
1882
|
return `Messaged ${name}.`;
|
|
1880
1883
|
}
|
|
1881
1884
|
// The SAME storm guard SPAWN uses — promoting a SEND must not be a way
|
|
@@ -1887,7 +1890,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1887
1890
|
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
|
|
1888
1891
|
}
|
|
1889
1892
|
const sub = newThread(name, originId);
|
|
1890
|
-
|
|
1893
|
+
kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
|
|
1891
1894
|
return `${name} did not exist — spawned it with that message as its task.`;
|
|
1892
1895
|
}
|
|
1893
1896
|
// PING had the same anchor bug — no `m`, so a PING after any preamble (or
|
|
@@ -2131,7 +2134,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
2131
2134
|
const root = rootOf(t).rootId;
|
|
2132
2135
|
const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
|
|
2133
2136
|
for (const x of crew) {
|
|
2134
|
-
|
|
2137
|
+
kickTurn(x.id, `[${t.name} finished] ${peek}\n`
|
|
2135
2138
|
+ `(${t.todos.length - left}/${t.todos.length} of its goals done. `
|
|
2136
2139
|
+ `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
|
|
2137
2140
|
.catch(() => {});
|
|
@@ -2299,11 +2302,11 @@ async function mcpDirective(url, tool, args) {
|
|
|
2299
2302
|
|
|
2300
2303
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2301
2304
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2302
|
-
// detail} while paying / waiting / walking tools, {type:'delta',name,
|
|
2303
|
-
// delta} per streamed token
|
|
2304
|
-
// reply (or directive ack) is
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2305
|
+
// detail} while paying / waiting / racing / walking tools, {type:'delta',name,
|
|
2306
|
+
// color,delta} per streamed token (replace:true swaps the bubble once),
|
|
2307
|
+
// {type:'final',name,color,text} once its full reply (or directive ack) is
|
|
2308
|
+
// settled. Background turns go through kickTurn → emitToThread, which is a
|
|
2309
|
+
// no-op if nobody has the thread open.
|
|
2307
2310
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
2308
2311
|
const t = threads.get(threadId);
|
|
2309
2312
|
if (!t) return;
|
|
@@ -2324,7 +2327,9 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2324
2327
|
t.status = 'thinking';
|
|
2325
2328
|
t.thinkingAt = Date.now();
|
|
2326
2329
|
t.lastDeltaAt = Date.now();
|
|
2327
|
-
t.
|
|
2330
|
+
const raceN = Math.min(Number(t.race) || 0, 4);
|
|
2331
|
+
const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
|
|
2332
|
+
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2328
2333
|
let chained = false;
|
|
2329
2334
|
let parked = false;
|
|
2330
2335
|
try {
|
|
@@ -2385,7 +2390,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2385
2390
|
}
|
|
2386
2391
|
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
2387
2392
|
let reply = '';
|
|
2388
|
-
paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
|
|
2393
|
+
paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
|
|
2389
2394
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
2390
2395
|
// t.messages, so it can't accumulate across a chained auto run or get bound
|
|
2391
2396
|
// into the thread's context.
|
|
@@ -2410,7 +2415,11 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2410
2415
|
// `attempt` exists because a retry must be allowed to land somewhere else:
|
|
2411
2416
|
// see the empty-completion loop below.
|
|
2412
2417
|
const ask = async (attempt = 0) => {
|
|
2413
|
-
const emit = (delta) => paint({
|
|
2418
|
+
const emit = (delta, meta) => paint({
|
|
2419
|
+
type: 'delta', name: t.name, color: t.color, delta,
|
|
2420
|
+
...(meta?.replace ? { replace: true } : {}),
|
|
2421
|
+
...(meta?.model ? { model: meta.model } : {}),
|
|
2422
|
+
});
|
|
2414
2423
|
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2415
2424
|
// Retrieval breadth scales with the PROJECT's corpus, not this thread's —
|
|
2416
2425
|
// the holobrain is shared at the root, so that is the pool being searched.
|
|
@@ -2421,10 +2430,10 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2421
2430
|
// need = how many must come BACK before judging. need 1 is a plain
|
|
2422
2431
|
// first-past-the-post race; need N waits for all of them. The point of
|
|
2423
2432
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
2424
|
-
// setting the latency.
|
|
2433
|
+
// setting the latency. Collection is first-X-back (non-empty);
|
|
2434
|
+
// classify runs only on those X.
|
|
2425
2435
|
const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
|
|
2426
|
-
|
|
2427
|
-
return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
|
|
2436
|
+
return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus)).trim();
|
|
2428
2437
|
}
|
|
2429
2438
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2430
2439
|
const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
|
|
@@ -4493,7 +4502,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4493
4502
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
4494
4503
|
if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
|
|
4495
4504
|
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
4496
|
-
else if (ev.type === 'delta') {
|
|
4505
|
+
else if (ev.type === 'delta') {
|
|
4506
|
+
streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
|
|
4507
|
+
paintStream();
|
|
4508
|
+
}
|
|
4497
4509
|
else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
|
|
4498
4510
|
};
|
|
4499
4511
|
es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
|
|
@@ -5172,7 +5184,7 @@ const server = http.createServer((req, res) => {
|
|
|
5172
5184
|
saveThreads();
|
|
5173
5185
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
5174
5186
|
res.end('{"ok":true}');
|
|
5175
|
-
|
|
5187
|
+
kickTurn(t.id, '(you denied running that command)').catch(() => {});
|
|
5176
5188
|
return;
|
|
5177
5189
|
}
|
|
5178
5190
|
entry.runStatus = 'running';
|
|
@@ -5183,7 +5195,7 @@ const server = http.createServer((req, res) => {
|
|
|
5183
5195
|
entry.runStatus = 'done';
|
|
5184
5196
|
entry.runOutput = output;
|
|
5185
5197
|
saveThreads();
|
|
5186
|
-
|
|
5198
|
+
kickTurn(t.id, `(command output)\n${output}`).catch(() => {});
|
|
5187
5199
|
});
|
|
5188
5200
|
return;
|
|
5189
5201
|
}
|
package/lib/livestatus.js
CHANGED
|
@@ -28,6 +28,143 @@ export function formatPayStatus(attempt = 0) {
|
|
|
28
28
|
return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** First-X-back race: how many of the K we asked for have actually landed. */
|
|
32
|
+
export function formatRaceStatus(back, need) {
|
|
33
|
+
const b = Math.max(0, Number(back) || 0);
|
|
34
|
+
const n = Math.max(1, Number(need) || 1);
|
|
35
|
+
return `racing ${b}/${n} back…`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Race-level failure when no countable answer exists. Never a single model name. */
|
|
39
|
+
export const RACE_EVERY_FAILED = '(race: every model failed — no reply)';
|
|
40
|
+
|
|
41
|
+
const RACE_HTTP_NOTE = /^\((?:upstream error|request failed|payment failed|rate limited|stream timed out|stream stalled)/i;
|
|
42
|
+
const RACE_MODEL_FAILED = /^\([^)]+ (?:failed:|returned nothing)/i;
|
|
43
|
+
const RACE_FETCH_FAILED = /^(?:typeerror:\s*)?fetch failed$/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Real answers count toward X.
|
|
47
|
+
* Empty, HTTP/pay/timeout notes, TypeError `fetch failed`, `(model failed: …)`,
|
|
48
|
+
* and any arrival with `.error` do not — those racers are abandoned.
|
|
49
|
+
* Accepts a string or `{ text, error }`.
|
|
50
|
+
*/
|
|
51
|
+
export function isRaceCountable(textOrArrival) {
|
|
52
|
+
const arrival = textOrArrival && typeof textOrArrival === 'object' && !Array.isArray(textOrArrival)
|
|
53
|
+
? textOrArrival
|
|
54
|
+
: { text: textOrArrival };
|
|
55
|
+
if (arrival.error) return false;
|
|
56
|
+
const s = String(arrival.text || '').trim();
|
|
57
|
+
if (!s) return false;
|
|
58
|
+
if (RACE_FETCH_FAILED.test(s)) return false;
|
|
59
|
+
if (RACE_HTTP_NOTE.test(s)) return false;
|
|
60
|
+
if (RACE_MODEL_FAILED.test(s)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fallback when X never fills: one race-level error, not `(one-model failed: …)`.
|
|
66
|
+
* Failed arrivals are abandoned, not shipped as the assistant message.
|
|
67
|
+
*/
|
|
68
|
+
export function raceLastShip(arrivals) {
|
|
69
|
+
const list = Array.isArray(arrivals) ? arrivals : [];
|
|
70
|
+
const last = [...list].reverse().find((a) => isRaceCountable(a));
|
|
71
|
+
if (last) return { ...last, text: String(last.text) };
|
|
72
|
+
return { model: '', text: RACE_EVERY_FAILED, error: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Default bar a classified race answer must clear (0–10). Overridable. */
|
|
76
|
+
export const RACE_MIN_SCORE = Number(process.env.OZ_RACE_MIN_SCORE || 6);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a cheap classify reply into a 0–10 score.
|
|
80
|
+
* Prefers `SCORE 7` / `SCORE: 7`; falls back to a lone 0–10.
|
|
81
|
+
* Unparseable → 0 (does not clear the bar).
|
|
82
|
+
*/
|
|
83
|
+
export function parseClassifyScore(text) {
|
|
84
|
+
const s = String(text || '');
|
|
85
|
+
const tagged = /SCORE\s*[:=]?\s*(-?\d+(?:\.\d+)?)/i.exec(s);
|
|
86
|
+
const lone = tagged || /\b(10|[0-9])(?:\s*\/\s*10)?\b/.exec(s);
|
|
87
|
+
if (!lone) return 0;
|
|
88
|
+
const n = Number(lone[1]);
|
|
89
|
+
if (!Number.isFinite(n)) return 0;
|
|
90
|
+
return Math.max(0, Math.min(10, n));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pick a winner among the first-X-back candidates after they have been scored.
|
|
95
|
+
* Passing = score >= minScore. Highest score wins; a tie is returned as
|
|
96
|
+
* `reason: 'tie'` so the caller can pairwise-break it. If nobody clears the
|
|
97
|
+
* bar, the last of the X is accepted — never blank.
|
|
98
|
+
*/
|
|
99
|
+
export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
|
|
100
|
+
const list = Array.isArray(cands) ? cands.filter(Boolean) : [];
|
|
101
|
+
if (!list.length) return { winner: null, reason: 'empty', tied: [] };
|
|
102
|
+
const passing = list.filter((c) => (Number(c.score) || 0) >= minScore);
|
|
103
|
+
if (!passing.length) {
|
|
104
|
+
return { winner: list[list.length - 1], reason: 'fallback-last', tied: [] };
|
|
105
|
+
}
|
|
106
|
+
let max = -Infinity;
|
|
107
|
+
for (const c of passing) {
|
|
108
|
+
const sc = Number(c.score) || 0;
|
|
109
|
+
if (sc > max) max = sc;
|
|
110
|
+
}
|
|
111
|
+
const tied = passing.filter((c) => (Number(c.score) || 0) === max);
|
|
112
|
+
if (tied.length === 1) return { winner: tied[0], reason: 'score', tied };
|
|
113
|
+
return { winner: null, reason: 'tie', tied };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Live race bubble: stream the fastest still-alive entrant, swap once if the
|
|
118
|
+
* winner is someone else. `onDelta(text, { replace, model })`.
|
|
119
|
+
*/
|
|
120
|
+
export function createRaceFeed(onDelta, onStatus, need) {
|
|
121
|
+
let live = null;
|
|
122
|
+
let settled = false;
|
|
123
|
+
let back = 0;
|
|
124
|
+
const buf = new Map();
|
|
125
|
+
const dead = new Set();
|
|
126
|
+
const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
|
|
127
|
+
return {
|
|
128
|
+
start() { paintStatus(); },
|
|
129
|
+
liveModel() { return live; },
|
|
130
|
+
onToken(model, chunk) {
|
|
131
|
+
if (settled || chunk == null || chunk === '') return;
|
|
132
|
+
buf.set(model, (buf.get(model) || '') + chunk);
|
|
133
|
+
if (!live) {
|
|
134
|
+
live = model;
|
|
135
|
+
onDelta?.(chunk, { model });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (live === model) onDelta?.(chunk, { model });
|
|
139
|
+
},
|
|
140
|
+
onFail(model) {
|
|
141
|
+
dead.add(model);
|
|
142
|
+
if (settled || live !== model) return;
|
|
143
|
+
const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
|
|
144
|
+
if (next) {
|
|
145
|
+
live = next[0];
|
|
146
|
+
onDelta?.(next[1], { replace: true, model: live });
|
|
147
|
+
} else {
|
|
148
|
+
live = null;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
onBack() {
|
|
152
|
+
back += 1;
|
|
153
|
+
paintStatus();
|
|
154
|
+
},
|
|
155
|
+
settle(winner) {
|
|
156
|
+
settled = true;
|
|
157
|
+
const text = String(winner?.text || '').trim()
|
|
158
|
+
? winner.text
|
|
159
|
+
: RACE_EVERY_FAILED;
|
|
160
|
+
// Live stream already showing this answer — keep going, do not re-dump.
|
|
161
|
+
if (winner?.model && live === winner.model && !winner.error) return;
|
|
162
|
+
live = winner?.model || live;
|
|
163
|
+
onDelta?.(text, { replace: true, model: winner?.model });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
31
168
|
export function peekDirectiveStatus(reply, runCmd) {
|
|
32
169
|
if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
|
|
33
170
|
const raw = String(reply || '');
|
package/lib/podagent.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { appendFileSync } from 'node:fs';
|
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
25
|
import {
|
|
26
26
|
formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
|
|
27
|
+
createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
|
|
28
|
+
isRaceCountable, raceLastShip,
|
|
27
29
|
} from './livestatus.js';
|
|
28
30
|
|
|
29
31
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
@@ -630,26 +632,66 @@ export async function tierModels(tier, n = 1, random = false) {
|
|
|
630
632
|
* count toward K — otherwise the fastest model to FAIL would decide the race,
|
|
631
633
|
* the exact bug this exists to fix.
|
|
632
634
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
635
|
+
* Live tokens: the fastest still-alive entrant is forwarded into onDelta as
|
|
636
|
+
* they arrive (not swallowed). When a winner is picked, if it is that live
|
|
637
|
+
* stream the bubble keeps going; if it is someone else the bubble is replaced
|
|
638
|
+
* once (`onDelta(text, { replace: true })`), not left on mute dots.
|
|
639
|
+
*
|
|
640
|
+
* After the first X land, a cheap classify call scores each of those X
|
|
641
|
+
* (correctness, completeness, actually did the asked thing — a RUN:/DONE:
|
|
642
|
+
* directive is success, not a flaw). Highest score that clears the bar wins;
|
|
643
|
+
* a tie is pairwise-broken. If nobody clears, the last of the X is shipped
|
|
644
|
+
* anyway. If X never fills (every entrant empty/5xx/fetch-failed), ship a
|
|
645
|
+
* race-level error — never a single model's `(name failed: fetch failed)` as
|
|
646
|
+
* if it won. Never blank, never hang.
|
|
636
647
|
*
|
|
637
648
|
* Every entrant is paid for, including the abandoned one — this trades money
|
|
638
649
|
* for latency and quality, which is why it is opt-in and capped.
|
|
650
|
+
*
|
|
651
|
+
* `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
|
|
639
652
|
*/
|
|
640
|
-
export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens) {
|
|
653
|
+
export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens, onStatus, hooks = {}) {
|
|
654
|
+
const stream = hooks.stream || brainStream;
|
|
655
|
+
const classify = hooks.classify || classifyRaceAnswer;
|
|
656
|
+
const pairwise = hooks.pairwise || pairwiseTied;
|
|
657
|
+
const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
|
|
641
658
|
const list = (models || []).filter(Boolean).slice(0, RACE_MAX);
|
|
642
|
-
if (list.length < 2) return
|
|
659
|
+
if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
|
|
643
660
|
const want = Math.max(1, Math.min(Number(need) || 1, list.length));
|
|
644
661
|
|
|
662
|
+
const feed = createRaceFeed(onDelta, onStatus, want);
|
|
663
|
+
feed.start();
|
|
664
|
+
|
|
645
665
|
const done = [];
|
|
666
|
+
const arrivals = [];
|
|
646
667
|
let finished = 0;
|
|
647
668
|
let release;
|
|
648
669
|
const enough = new Promise((r) => { release = r; });
|
|
649
670
|
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
.
|
|
671
|
+
const ship = (cand) => {
|
|
672
|
+
const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
|
|
673
|
+
feed.settle(out);
|
|
674
|
+
return out.text;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Do not pass onStatus into each entrant — their "waiting on model…" would
|
|
678
|
+
// clobber the race line. Race owns the status until a winner ships.
|
|
679
|
+
const attempts = list.map((m) => stream(messages, (chunk) => feed.onToken(m, chunk), contextId, m, maxTokens)
|
|
680
|
+
.then((text) => {
|
|
681
|
+
const raw = text == null ? '' : String(text);
|
|
682
|
+
const arrival = { model: m, text: raw };
|
|
683
|
+
arrivals.push(arrival);
|
|
684
|
+
if (isRaceCountable(arrival)) {
|
|
685
|
+
done.push(arrival);
|
|
686
|
+
feed.onBack();
|
|
687
|
+
} else {
|
|
688
|
+
feed.onFail(m);
|
|
689
|
+
}
|
|
690
|
+
})
|
|
691
|
+
.catch((e) => {
|
|
692
|
+
arrivals.push({ model: m, text: '', error: e?.message || 'error' });
|
|
693
|
+
feed.onFail(m);
|
|
694
|
+
})
|
|
653
695
|
.finally(() => {
|
|
654
696
|
finished += 1;
|
|
655
697
|
// Either we have what we asked for, or everyone is done and no more is
|
|
@@ -662,58 +704,79 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
|
|
|
662
704
|
for (const p of attempts) p.catch(() => {});
|
|
663
705
|
|
|
664
706
|
await enough;
|
|
665
|
-
// Completion order, so this really is the first
|
|
666
|
-
// launched.
|
|
707
|
+
// Completion order, so this really is the first X back — not the first X
|
|
708
|
+
// launched. A slow 3rd never enters this set. Empty/5xx/fetch-failed stay
|
|
709
|
+
// in arrivals only so an all-fail race can ship one race-level error.
|
|
667
710
|
const cands = done.slice(0, want);
|
|
668
|
-
if (!cands.length) return
|
|
669
|
-
//
|
|
670
|
-
|
|
711
|
+
if (!cands.length) return ship(raceLastShip(arrivals));
|
|
712
|
+
// One real answer — nothing to compare. Ship it; do not spend a classify
|
|
713
|
+
// call to rubber-stamp the only candidate.
|
|
714
|
+
if (cands.length === 1) return ship(cands[0]);
|
|
715
|
+
|
|
716
|
+
onStatus?.('judging…');
|
|
717
|
+
const scored = await Promise.all(cands.map(async (c) => {
|
|
718
|
+
let score = 0;
|
|
719
|
+
try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
|
|
720
|
+
return { ...c, score };
|
|
721
|
+
}));
|
|
722
|
+
|
|
723
|
+
let picked = pickRaceWinner(scored, minScore);
|
|
724
|
+
if (picked.reason === 'tie' && picked.tied.length > 1) {
|
|
725
|
+
let broken = null;
|
|
726
|
+
try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
|
|
727
|
+
const usable = broken && String(broken.text || '').trim();
|
|
728
|
+
// Malformed verdict / all equally bad → last finished of the tie, not empty.
|
|
729
|
+
picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
|
|
730
|
+
}
|
|
731
|
+
return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function raceQuestion(messages) {
|
|
735
|
+
const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
|
|
736
|
+
return typeof asked === 'string' ? asked : '(see candidates)';
|
|
737
|
+
}
|
|
671
738
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
739
|
+
/**
|
|
740
|
+
* Cheap structured score of ONE finished answer vs the question.
|
|
741
|
+
* This is grokui's own classify call — not OpenRouter's async log-tag
|
|
742
|
+
* Classifiers beta, which does not pick winners.
|
|
743
|
+
*/
|
|
744
|
+
async function classifyRaceAnswer(messages, cand) {
|
|
745
|
+
const prompt = 'Score this answer to one question from 0 to 10.\n\n'
|
|
746
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
747
|
+
+ 'ANSWER:\n' + String(cand?.text || '').slice(0, 6000) + '\n\n'
|
|
748
|
+
+ 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
749
|
+
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
750
|
+
+ 'Ignore length and confidence of tone.\n'
|
|
751
|
+
+ 'Reply with exactly: SCORE <n>';
|
|
752
|
+
const verdict = await brainStream(
|
|
753
|
+
[{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
|
|
754
|
+
);
|
|
755
|
+
return parseClassifyScore(verdict);
|
|
675
756
|
}
|
|
676
757
|
|
|
677
758
|
/**
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
* BLIND, as A/B/C/D. A judge told "this one is Claude and this one is a 4B
|
|
681
|
-
* llama" is being handed the answer and will take it, which would turn the
|
|
682
|
-
* whole thing into an expensive way to re-pick the tier's first entry.
|
|
683
|
-
*
|
|
684
|
-
* Cheap on purpose: reading finished replies and comparing them against a
|
|
685
|
-
* question is a far easier task than answering it, and paying frontier prices
|
|
686
|
-
* to referee frontier models would roughly double the cost of the expensive
|
|
687
|
-
* tier for no measured gain.
|
|
759
|
+
* Pairwise break among same-score passers. Blind A/B/C so the model names
|
|
760
|
+
* cannot leak the answer. Last of the tied set if the call dies.
|
|
688
761
|
*/
|
|
689
|
-
async function
|
|
690
|
-
const letters =
|
|
691
|
-
// The question, not the transcript: the judge needs to know what was ASKED,
|
|
692
|
-
// and a full history would cost more to judge than the turn cost to answer.
|
|
693
|
-
const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
|
|
694
|
-
const question = typeof asked === 'string' ? asked : '(see candidates)';
|
|
762
|
+
async function pairwiseTied(messages, tied) {
|
|
763
|
+
const letters = tied.map((_, i) => String.fromCharCode(65 + i));
|
|
695
764
|
const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
|
|
696
|
-
+ 'QUESTION:\n' + String(
|
|
697
|
-
+
|
|
765
|
+
+ 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
|
|
766
|
+
+ tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + String(c.text || '').slice(0, 6000)).join('\n\n')
|
|
698
767
|
+ '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
|
|
699
768
|
+ '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
|
|
700
769
|
+ 'Ignore length and confidence of tone.\n'
|
|
701
770
|
+ 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
|
|
702
771
|
try {
|
|
703
772
|
const verdict = await brainStream([{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 8);
|
|
704
|
-
// First in-range letter anywhere in the reply. A judge that ignores "one
|
|
705
|
-
// letter and nothing else" and writes "The best is B." still counts, which
|
|
706
|
-
// is most of them.
|
|
707
773
|
const hit = String(verdict).toUpperCase().split('').find((ch) => {
|
|
708
774
|
const n = ch.charCodeAt(0) - 65;
|
|
709
|
-
return n >= 0 && n <
|
|
775
|
+
return n >= 0 && n < tied.length;
|
|
710
776
|
});
|
|
711
|
-
if (hit) return
|
|
777
|
+
if (hit) return tied[hit.charCodeAt(0) - 65];
|
|
712
778
|
} catch { /* fall through */ }
|
|
713
|
-
|
|
714
|
-
// first finisher degrades this to "fastest wins" — worse than judged, far
|
|
715
|
-
// better than empty.
|
|
716
|
-
return cands[0];
|
|
779
|
+
return tied[tied.length - 1];
|
|
717
780
|
}
|
|
718
781
|
|
|
719
782
|
const RACE_MAX = Number(process.env.OZ_RACE_MAX || 4);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.97",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|