@staix/agent-hub 0.12.2 → 0.12.4
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 +16 -0
- package/docs/cooperbench.md +109 -0
- package/docs/events.md +14 -3
- package/docs/operations.md +158 -5
- package/docs/smoke.md +16 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +66 -0
- package/docs/verification/2026-10-02-0.12.3.md +44 -0
- package/docs/verification/2026-10-02-0.12.4.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23 -8
- package/src/adapters/claude-channel.ts +19 -5
- package/src/adapters/codex-appserver.ts +38 -0
- package/src/adapters/local-worker.ts +109 -16
- package/src/adapters/pi.ts +106 -21
- package/src/cli/facts-hook.ts +40 -0
- package/src/cli/launch.ts +45 -4
- package/src/cli/main.ts +44 -2
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +92 -6
- package/src/hub/cohorts.ts +308 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +425 -21
- package/src/hub/events.ts +12 -1
- package/src/hub/execution-budget.ts +138 -0
- package/src/hub/facts.ts +754 -0
- package/src/hub/report.ts +88 -1
- package/src/hub/routing.ts +85 -0
- package/src/hub/tasks.ts +402 -19
- package/src/hub/usage.ts +55 -0
- package/src/local/sandbox.ts +6 -2
- package/src/local/tools.ts +7 -2
- package/src/models/relay.ts +12 -4
- package/src/omniroute/client.ts +14 -3
- package/src/omniroute/usage.ts +38 -0
- package/src/pi/extension.ts +44 -8
- package/templates/AGENTS.block.md +1 -1
package/src/hub/report.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface Report {
|
|
|
4
4
|
from?: string;
|
|
5
5
|
to?: string;
|
|
6
6
|
peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
|
|
7
|
+
usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
|
|
7
8
|
messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
|
|
8
9
|
overlaps: { warnings: number; pairs: number };
|
|
9
10
|
/** Files one agent changed after another owner's open task had changed them (issue #32). */
|
|
@@ -12,12 +13,82 @@ export interface Report {
|
|
|
12
13
|
quota: { readings: number; hard: number };
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface UsageCoverage {
|
|
17
|
+
records: number;
|
|
18
|
+
withUsage: number;
|
|
19
|
+
withoutUsage: number;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
inputRecords: number;
|
|
22
|
+
outputTokens: number;
|
|
23
|
+
outputRecords: number;
|
|
24
|
+
cacheReadTokens: number;
|
|
25
|
+
cacheReadRecords: number;
|
|
26
|
+
cacheWriteTokens: number;
|
|
27
|
+
cacheWriteRecords: number;
|
|
28
|
+
totalTokens: number;
|
|
29
|
+
totalRecords: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type UsageTotals = Omit<UsageCoverage, "records" | "withUsage" | "withoutUsage">;
|
|
33
|
+
|
|
34
|
+
const emptyUsage = (): UsageCoverage => ({ records: 0, withUsage: 0, withoutUsage: 0, inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 });
|
|
35
|
+
|
|
36
|
+
function recordUsage(coverage: UsageCoverage, e: Extract<StampedEvent, { type: "usage" }>): void {
|
|
37
|
+
coverage.records++;
|
|
38
|
+
const fields = [e.inputTokens, e.outputTokens, e.cacheReadTokens, e.cacheWriteTokens, e.totalTokens];
|
|
39
|
+
if (fields.some((n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0)) coverage.withUsage++;
|
|
40
|
+
else coverage.withoutUsage++;
|
|
41
|
+
const add = (field: keyof UsageTotals, n: number | undefined, countField: keyof UsageTotals) => {
|
|
42
|
+
if (typeof n === "number" && Number.isSafeInteger(n) && n >= 0) {
|
|
43
|
+
coverage[field] += n;
|
|
44
|
+
coverage[countField]++;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
add("inputTokens", e.inputTokens, "inputRecords");
|
|
48
|
+
add("outputTokens", e.outputTokens, "outputRecords");
|
|
49
|
+
add("cacheReadTokens", e.cacheReadTokens, "cacheReadRecords");
|
|
50
|
+
add("cacheWriteTokens", e.cacheWriteTokens, "cacheWriteRecords");
|
|
51
|
+
add("totalTokens", e.totalTokens, "totalRecords");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function finalizeUsage(report: Report): void {
|
|
55
|
+
for (const id of Object.keys(report.peers)) {
|
|
56
|
+
if (!report.usage.peers[id]) {
|
|
57
|
+
report.usage.peers[id] = emptyUsage();
|
|
58
|
+
report.usage.unknownPeers.push(id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const p of Object.values(report.usage.peers)) {
|
|
62
|
+
const out = report.usage.totals;
|
|
63
|
+
for (const field of ["inputTokens", "inputRecords", "outputTokens", "outputRecords", "cacheReadTokens", "cacheReadRecords", "cacheWriteTokens", "cacheWriteRecords", "totalTokens", "totalRecords"] as const) out[field] += p[field];
|
|
64
|
+
if (p.withoutUsage || p.inputRecords < p.withUsage || p.outputRecords < p.withUsage || p.cacheReadRecords < p.withUsage || p.cacheWriteRecords < p.withUsage || p.totalRecords < p.withUsage) report.usage.completeCoverage = false;
|
|
65
|
+
}
|
|
66
|
+
if (report.usage.unknownPeers.length) report.usage.completeCoverage = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatUsage(usage: Report["usage"]): string[] {
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
for (const [id, u] of Object.entries(usage.peers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
72
|
+
const coverage = `${u.withUsage}/${u.records} records with usage`;
|
|
73
|
+
const value = (name: string, count: number, records: number) => records ? `${name} ${count} (${records} known)` : `${name} unknown (0 known)`;
|
|
74
|
+
const known = [value("input", u.inputTokens, u.inputRecords), value("output", u.outputTokens, u.outputRecords), value("cache read", u.cacheReadTokens, u.cacheReadRecords), value("cache write", u.cacheWriteTokens, u.cacheWriteRecords), value("reported total", u.totalTokens, u.totalRecords)].join(", ");
|
|
75
|
+
const incomplete = u.withoutUsage || u.inputRecords < u.withUsage || u.outputRecords < u.withUsage || u.cacheReadRecords < u.withUsage || u.cacheWriteRecords < u.withUsage || u.totalRecords < u.withUsage;
|
|
76
|
+
lines.push(`usage ${id}: ${u.records ? coverage : "no usage records"}; ${known}; coverage ${incomplete ? "incomplete" : u.records ? "complete for recorded calls" : "unknown"}`);
|
|
77
|
+
}
|
|
78
|
+
const total = usage.totals.totalRecords ? `${usage.totals.totalTokens} reported tokens (${usage.totals.totalRecords} known records)` : "reported token total unknown (0 known records)";
|
|
79
|
+
const coverage = usage.completeCoverage ? "recorded-call coverage complete" : `incomplete or unknown peer coverage${usage.unknownPeers.length ? ` (${usage.unknownPeers.join(", ")})` : ""}`;
|
|
80
|
+
lines.push(`usage team totals: ${total}; ${coverage}; estimated price unknown; measured spend unknown`);
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
|
|
15
84
|
/** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
|
|
16
85
|
export function summarize(events: StampedEvent[]): Report {
|
|
17
|
-
const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
86
|
+
const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
18
87
|
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
88
|
+
const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
|
|
19
89
|
const pairs = new Set<string>();
|
|
20
90
|
const taskMessages = new Map<string, number>();
|
|
91
|
+
const seenUsage = new Set<string>();
|
|
21
92
|
for (const e of events) {
|
|
22
93
|
r.from ??= e.at;
|
|
23
94
|
r.to = e.at;
|
|
@@ -31,13 +102,26 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
31
102
|
case "undeliverable":
|
|
32
103
|
r.messages[e.type]++;
|
|
33
104
|
break;
|
|
105
|
+
case "stale": // published, then dropped before delivery (issue #106)
|
|
106
|
+
r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
|
|
107
|
+
break;
|
|
34
108
|
case "turn_end":
|
|
35
109
|
peer(e.peer).turns++;
|
|
36
110
|
peer(e.peer).busyMinutes += e.ms / 60_000;
|
|
37
111
|
break;
|
|
112
|
+
case "state":
|
|
113
|
+
peer(e.peer);
|
|
114
|
+
break;
|
|
38
115
|
case "tokens":
|
|
39
116
|
peer(e.peer).tokens += e.n;
|
|
40
117
|
break;
|
|
118
|
+
case "usage": {
|
|
119
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
120
|
+
if (seenUsage.has(key)) break;
|
|
121
|
+
seenUsage.add(key);
|
|
122
|
+
recordUsage(usagePeer(e.peer), e);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
41
125
|
case "task":
|
|
42
126
|
r.tasks[e.event] = (r.tasks[e.event] ?? 0) + 1;
|
|
43
127
|
break;
|
|
@@ -55,6 +139,8 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
55
139
|
}
|
|
56
140
|
}
|
|
57
141
|
r.overlaps.pairs = pairs.size;
|
|
142
|
+
finalizeUsage(r);
|
|
143
|
+
// Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
|
|
58
144
|
r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
|
|
59
145
|
for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
|
|
60
146
|
return r;
|
|
@@ -63,6 +149,7 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
63
149
|
export function formatReport(r: Report): string[] {
|
|
64
150
|
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
65
151
|
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
|
|
152
|
+
if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
|
|
66
153
|
const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
|
|
67
154
|
lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
|
|
68
155
|
lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
|
package/src/hub/routing.ts
CHANGED
|
@@ -248,3 +248,88 @@ export function assign(
|
|
|
248
248
|
if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
249
249
|
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), trace };
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One recorded task of a peer in a class, as stage proxies (issue #109): `orient` from being handed the task to its
|
|
254
|
+
* accept (queueing included), `work` from the accept to its done. Unknown stages stay undefined, never zero.
|
|
255
|
+
*/
|
|
256
|
+
export interface SplitObservation {
|
|
257
|
+
outcome: "approved" | "failed";
|
|
258
|
+
orient?: number;
|
|
259
|
+
work?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Everything the shadow split prediction reads; `Tasks` builds it once and `route explain` shows the same. */
|
|
263
|
+
export interface SplitInput {
|
|
264
|
+
/** The pair: the peer routing would pick for the task, and the owner of the open task it overlaps. */
|
|
265
|
+
peers: [PeerId, PeerId];
|
|
266
|
+
/** Each peer's observations in this class, from this hub run only: one version, one hook profile. */
|
|
267
|
+
observations: Record<PeerId, SplitObservation[]>;
|
|
268
|
+
/** Work units of the routed task and of the one it overlaps; undefined is unknown. */
|
|
269
|
+
units: [number | undefined, number | undefined];
|
|
270
|
+
/** Other open work each peer has: a busy owner does not start from orientation plus two whole units. */
|
|
271
|
+
backlog: Record<PeerId, number>;
|
|
272
|
+
available: Record<PeerId, boolean>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface SplitPrediction {
|
|
276
|
+
verdict: "split" | "single" | "unknown";
|
|
277
|
+
trace: string[];
|
|
278
|
+
/** The peer that would finish both units soonest on its own. */
|
|
279
|
+
single?: PeerId;
|
|
280
|
+
/** Predicted seconds: both peers one unit each, in parallel (the later of the two), and the best peer alone. */
|
|
281
|
+
splitS?: number;
|
|
282
|
+
singleS?: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Observations a peer needs, and the share of failures a history may hold, before a prediction is made. */
|
|
286
|
+
export const SPLIT_MIN = 5;
|
|
287
|
+
const SPLIT_FAILURE_SHARE = 0.3;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Shadow split prediction (issue #109): never changes assignment. For two similar units of overlapping work, a split
|
|
291
|
+
* (each peer one unit, in parallel) finishes when the later of `o + u` does; the best single peer takes `o + 2u`. With
|
|
292
|
+
* a faster and a slower peer that is `o_s + u_s < o_f + 2u_f`. It is a model assumption (equal units, measured
|
|
293
|
+
* orientation, free coordination), not a bound, so anything that breaks it makes the prediction unknown: unequal or
|
|
294
|
+
* unknown units, a busy or unavailable peer, too few or failure-heavy records, or work times too spread out to call
|
|
295
|
+
* comparable. A difference under a tenth of the single time is inconclusive.
|
|
296
|
+
*/
|
|
297
|
+
export function predictSplit(input: SplitInput): SplitPrediction {
|
|
298
|
+
const trace: string[] = ["shadow split prediction (it never changes assignment):"];
|
|
299
|
+
const unknown = (why: string): SplitPrediction => ({ verdict: "unknown", trace: [...trace, ` unknown: ${why}`] });
|
|
300
|
+
const [a, b] = input.peers;
|
|
301
|
+
if (a === b) return unknown("the routed task and the one it overlaps have the same owner");
|
|
302
|
+
const [ua, ub] = input.units;
|
|
303
|
+
if (ua === undefined || ub === undefined || ua !== ub) return unknown(`work units ${ua ?? "?"} and ${ub ?? "?"}: the rule needs two equal, known units`);
|
|
304
|
+
for (const p of [a, b]) {
|
|
305
|
+
if (!input.available[p]) return unknown(`${p} is not available`);
|
|
306
|
+
if ((input.backlog[p] ?? 0) > 0) return unknown(`${p} has ${input.backlog[p]} other open task(s): it would not start from orientation plus two units`);
|
|
307
|
+
}
|
|
308
|
+
const median = (xs: number[]) => {
|
|
309
|
+
const v = [...xs].sort((x, y) => x - y);
|
|
310
|
+
return v.length % 2 ? v[(v.length - 1) / 2]! : (v[v.length / 2 - 1]! + v[v.length / 2]!) / 2;
|
|
311
|
+
};
|
|
312
|
+
const stats: Record<PeerId, { o: number; u: number }> = {};
|
|
313
|
+
for (const p of [a, b]) {
|
|
314
|
+
const all = input.observations[p] ?? [];
|
|
315
|
+
const failed = all.filter((o) => o.outcome !== "approved").length;
|
|
316
|
+
const ok = all.filter((o): o is Required<SplitObservation> => o.outcome === "approved" && o.orient !== undefined && o.work !== undefined);
|
|
317
|
+
if (ok.length < SPLIT_MIN) return unknown(`${p} has ${ok.length} measured task(s) in this hub run; ${SPLIT_MIN} are needed`);
|
|
318
|
+
if (failed / all.length > SPLIT_FAILURE_SHARE) return unknown(`${failed} of ${all.length} of ${p}'s recorded tasks failed: its successes alone would understate its time`);
|
|
319
|
+
const works = ok.map((o) => o.work).sort((x, y) => x - y);
|
|
320
|
+
const u = median(works);
|
|
321
|
+
const iqr = works[Math.floor((works.length * 3) / 4)]! - works[Math.floor(works.length / 4)]!;
|
|
322
|
+
if (u <= 0 || iqr / u > 1) return unknown(`${p}'s work times spread too widely (IQR ${Math.round(iqr / 1000)} s against a median of ${Math.round(u / 1000)} s) to call its tasks comparable units`);
|
|
323
|
+
stats[p] = { o: median(ok.map((o) => o.orient)), u };
|
|
324
|
+
trace.push(` ${p}: orientation ${Math.round(stats[p]!.o / 1000)} s, one unit ${Math.round(u / 1000)} s (median of ${ok.length})`);
|
|
325
|
+
}
|
|
326
|
+
const single = stats[a]!.o + 2 * stats[a]!.u <= stats[b]!.o + 2 * stats[b]!.u ? a : b;
|
|
327
|
+
const s = (ms: number) => Math.round(ms / 1000);
|
|
328
|
+
const splitS = s(Math.max(stats[a]!.o + stats[a]!.u, stats[b]!.o + stats[b]!.u));
|
|
329
|
+
const singleS = s(stats[single]!.o + 2 * stats[single]!.u);
|
|
330
|
+
trace.push(` split: ${splitS} s (${a} ${s(stats[a]!.o + stats[a]!.u)} s, ${b} ${s(stats[b]!.o + stats[b]!.u)} s for one unit each); ${single} alone: ${singleS} s for two`);
|
|
331
|
+
if (Math.abs(splitS - singleS) < singleS / 10) return { ...unknown("the difference is under a tenth of the single time: inconclusive"), single, splitS, singleS };
|
|
332
|
+
const verdict = splitS < singleS ? "split" : "single";
|
|
333
|
+
trace.push(verdict === "split" ? " a split would finish sooner" : ` ${single} alone would finish sooner`);
|
|
334
|
+
return { verdict, trace, single, splitS, singleS };
|
|
335
|
+
}
|