@staix/agent-hub 0.12.17 → 0.12.19

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/src/hub/report.ts CHANGED
@@ -207,3 +207,112 @@ export function formatReport(r: Report): string[] {
207
207
  lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
208
208
  return lines;
209
209
  }
210
+
211
+ const usageCounters = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens"] as const;
212
+ const usageCounts = ["inputRecords", "outputRecords", "cacheReadRecords", "cacheWriteRecords", "totalRecords"] as const;
213
+ type TaskUsage = Omit<UsageCoverage, typeof usageCounters[number]> & Record<typeof usageCounters[number], number | null>;
214
+ export interface TaskPeerReport { tokens: number | null; usage: TaskUsage }
215
+ export interface TaskTotals { turns: number; peers: Record<string, TaskPeerReport> }
216
+ export interface TaskSummary extends TaskTotals { class: string | null; outcome: string | null; wallMs: number | null; pii?: true }
217
+ export interface TaskReport {
218
+ from?: string;
219
+ to?: string;
220
+ tasks: Record<string, TaskSummary>;
221
+ classes: Record<string, TaskTotals>;
222
+ totals: { tokens: number; usageRecords: number };
223
+ unattributed: TaskTotals & { tokens: number; usageRecords: number; tokenShare: number | null; usageShare: number | null };
224
+ beforeAttribution: TaskTotals & { tokens: number; usageRecords: number };
225
+ }
226
+
227
+ const taskTotals = (): TaskTotals => ({ turns: 0, peers: {} });
228
+ const taskPeer = (group: TaskTotals, id: string): TaskPeerReport => group.peers[id] ??= {
229
+ tokens: null, usage: { ...emptyUsage(), inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, totalTokens: null },
230
+ };
231
+
232
+ function addTaskUsage(peer: TaskPeerReport, e: Extract<StampedEvent, { type: "usage" }>): void {
233
+ const known = emptyUsage();
234
+ recordUsage(known, e);
235
+ const u = peer.usage;
236
+ u.records += known.records; u.withUsage += known.withUsage; u.withoutUsage += known.withoutUsage;
237
+ for (let i = 0; i < usageCounters.length; i++) {
238
+ const field = usageCounters[i]!, count = usageCounts[i]!;
239
+ if (known[count]) u[field] = (u[field] ?? 0) + known[field];
240
+ u[count] += known[count];
241
+ }
242
+ }
243
+
244
+ /** Task identities and history come only from telemetry. Historical usage is never reassigned. */
245
+ export function summarizeByTask(events: StampedEvent[]): TaskReport {
246
+ const r: TaskReport = { tasks: {}, classes: {}, totals: { tokens: 0, usageRecords: 0 },
247
+ unattributed: { ...taskTotals(), tokens: 0, usageRecords: 0, tokenShare: null, usageShare: null },
248
+ beforeAttribution: { ...taskTotals(), tokens: 0, usageRecords: 0 } };
249
+ const task = (id: number) => r.tasks[String(id)] ??= { ...taskTotals(), class: null, outcome: null, wallMs: null };
250
+ const starts = new Map<number, number>(), approvals = new Map<number, number>();
251
+ const seenUsage = new Set<string>();
252
+ // Resolve latest class/outcome before rolling usage up: usage may precede the task's history in a slice.
253
+ for (const e of events) {
254
+ r.from ??= e.at; r.to = e.at;
255
+ if (e.type !== "task") continue;
256
+ const t = task(e.id);
257
+ t.class = e.class; t.outcome = e.state;
258
+ if (e.pii) t.pii = true;
259
+ const at = Date.parse(e.at);
260
+ if (!Number.isFinite(at)) continue;
261
+ if (e.state === "in_progress" && !starts.has(e.id)) starts.set(e.id, at);
262
+ if (e.state === "approved" && !approvals.has(e.id)) approvals.set(e.id, at);
263
+ }
264
+ for (const [id, t] of Object.entries(r.tasks)) {
265
+ const start = starts.get(Number(id)), end = approvals.get(Number(id));
266
+ if (t.outcome === "approved" && start !== undefined && end !== undefined && end >= start) t.wallMs = end - start;
267
+ r.classes[t.class ?? "unknown"] ??= taskTotals();
268
+ }
269
+ for (const e of events) {
270
+ if (e.type !== "tokens" && e.type !== "usage" && e.type !== "turn_end") continue;
271
+ if (e.type === "usage") {
272
+ const key = `${e.peer}\0${e.source}\0${e.id}`;
273
+ if (seenUsage.has(key)) continue;
274
+ seenUsage.add(key);
275
+ r.totals.usageRecords++;
276
+ } else if (e.type === "tokens") r.totals.tokens += e.n;
277
+ let groups: TaskTotals[];
278
+ if (e.attribution === undefined) {
279
+ groups = [r.beforeAttribution];
280
+ if (e.type === "tokens") r.beforeAttribution.tokens += e.n;
281
+ if (e.type === "usage") r.beforeAttribution.usageRecords++;
282
+ } else if (e.attribution === "unattributed" || e.task === undefined) {
283
+ groups = [r.unattributed];
284
+ if (e.type === "tokens") r.unattributed.tokens += e.n;
285
+ if (e.type === "usage") r.unattributed.usageRecords++;
286
+ } else {
287
+ const t = task(e.task);
288
+ if (e.pii) t.pii = true;
289
+ groups = [t, r.classes[t.class ?? "unknown"] ??= taskTotals()];
290
+ }
291
+ for (const group of groups) {
292
+ const p = taskPeer(group, e.peer);
293
+ if (e.type === "turn_end") group.turns++;
294
+ else if (e.type === "tokens") p.tokens = (p.tokens ?? 0) + e.n;
295
+ else addTaskUsage(p, e);
296
+ }
297
+ }
298
+ r.unattributed.tokenShare = r.totals.tokens ? r.unattributed.tokens / r.totals.tokens : null;
299
+ r.unattributed.usageShare = r.totals.usageRecords ? r.unattributed.usageRecords / r.totals.usageRecords : null;
300
+ return r;
301
+ }
302
+
303
+ export function formatTaskReport(r: TaskReport): string[] {
304
+ const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
305
+ const peers = (group: TaskTotals) => Object.entries(group.peers).sort(([a], [b]) => a.localeCompare(b)).map(([id, p]) => {
306
+ const u = p.usage;
307
+ const counts = usageCounters.map((field, i) => `${field} ${u[field] ?? "unknown"} (${u[usageCounts[i]!]}/${u.records} known)`).join(", ");
308
+ return ` peer ${id}: tokens ${p.tokens ?? "unknown"}; ${counts}`;
309
+ });
310
+ for (const [id, t] of Object.entries(r.tasks).sort(([a], [b]) => Number(a) - Number(b))) {
311
+ lines.push(`task #${id}${t.pii ? " [pii]" : ""}: class ${t.class ?? "unknown"}, outcome ${t.outcome ?? "unknown"}, turns ${t.turns}, wall ${t.wallMs === null ? "unknown" : `${t.wallMs} ms`}`, ...peers(t));
312
+ }
313
+ for (const [name, group] of Object.entries(r.classes).sort(([a], [b]) => a.localeCompare(b))) lines.push(`class ${name}: turns ${group.turns}`, ...peers(group));
314
+ const share = (value: number | null) => value === null ? "unknown" : `${(value * 100).toFixed(1)}%`;
315
+ lines.push(`unattributed: tokens ${r.unattributed.tokens}/${r.totals.tokens} (${share(r.unattributed.tokenShare)}), usage records ${r.unattributed.usageRecords}/${r.totals.usageRecords} (${share(r.unattributed.usageShare)}), turns ${r.unattributed.turns}`, ...peers(r.unattributed));
316
+ lines.push(`before attribution: tokens ${r.beforeAttribution.tokens}, usage records ${r.beforeAttribution.usageRecords}, turns ${r.beforeAttribution.turns}`, ...peers(r.beforeAttribution));
317
+ return lines;
318
+ }