@chatpanel/events 0.84.0 → 0.85.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/index.js +1 -0
- package/package.json +3 -1
- package/scorecard.js +191 -0
- package/team-run.js +24 -2
- package/team-trail.js +1 -0
package/index.js
CHANGED
|
@@ -198,6 +198,7 @@ export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClai
|
|
|
198
198
|
export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
199
199
|
export { createRunCache, withRunCache } from './team-cache.js';
|
|
200
200
|
export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
|
|
201
|
+
export { canonical, sha256, makeEntry, verifyChain, attest, verifyAttested, summarize, fit, SCORECARD_ENTRY_KINDS, ROLE_KINDS, SCORECARD_VERSION } from './scorecard.js';
|
|
201
202
|
export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
|
|
202
203
|
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
203
204
|
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"./team-cache.js": "./team-cache.js",
|
|
95
95
|
"./team-task.js": "./team-task.js",
|
|
96
96
|
"./team-record.js": "./team-record.js",
|
|
97
|
+
"./scorecard.js": "./scorecard.js",
|
|
97
98
|
"./team-plan.js": "./team-plan.js",
|
|
98
99
|
"./team-run.js": "./team-run.js",
|
|
99
100
|
"./team-tool.js": "./team-tool.js",
|
|
@@ -213,6 +214,7 @@
|
|
|
213
214
|
"team-cache.js",
|
|
214
215
|
"team-task.js",
|
|
215
216
|
"team-record.js",
|
|
217
|
+
"scorecard.js",
|
|
216
218
|
"team-plan.js",
|
|
217
219
|
"team-run.js",
|
|
218
220
|
"team-tool.js",
|
package/scorecard.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// An agent's scorecard — an immutable record of what it actually did, and matching on it.
|
|
2
|
+
//
|
|
3
|
+
// A scorecard is a CHAIN of entries, one per fact, append-only: a task it finished (how big,
|
|
4
|
+
// with which tools, alongside whom, in which role), a rating a job gave it, an agent it
|
|
5
|
+
// created, an interaction. Every entry carries the hash of the one before and its own hash,
|
|
6
|
+
// so an edit anywhere breaks every link after it; the gateway's store adds its own mark
|
|
7
|
+
// (an HMAC over the hash with a key only the store holds) so an entry a client — or an
|
|
8
|
+
// agent — wrote for itself shows as unattested. The facts are produced by the runner and
|
|
9
|
+
// attested by the store, never written by the agent: the only way to a better scorecard is
|
|
10
|
+
// the work.
|
|
11
|
+
//
|
|
12
|
+
// `summarize` turns the chain into the card a recruiter reads; `fit` scores an agent type
|
|
13
|
+
// against a job's needs with that record — the same function the evaluator starts from, so
|
|
14
|
+
// an application's fit has reasons a person can read and overrule.
|
|
15
|
+
//
|
|
16
|
+
// Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
|
|
17
|
+
|
|
18
|
+
export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
|
|
19
|
+
export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
|
|
20
|
+
export const SCORECARD_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
const enc = new TextEncoder();
|
|
23
|
+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
24
|
+
|
|
25
|
+
/** Canonical JSON: keys sorted at every level, so the same fact hashes the same everywhere. */
|
|
26
|
+
export function canonical(v) {
|
|
27
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
28
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
29
|
+
return `{${Object.keys(v).sort().map((k) => (v[k] === undefined ? null : `${JSON.stringify(k)}:${canonical(v[k])}`)).filter(Boolean).join(',')}}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** SHA-256 over text, as hex; `subtle` is injectable (a runtime without it passes its own). */
|
|
33
|
+
export async function sha256(text, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
34
|
+
if (!subtle) throw new Error('scorecard: no crypto.subtle — pass one');
|
|
35
|
+
return hex(await subtle.digest('SHA-256', enc.encode(String(text))));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The fields a hash covers — everything but the hash and the store's mark. */
|
|
39
|
+
function hashable(e) {
|
|
40
|
+
const { hash: _h, sig: _s, ...rest } = e;
|
|
41
|
+
return rest;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A new entry chained onto `prev` (the last entry, or null for the first). Pure apart from
|
|
46
|
+
* the digest: the caller (the store) decides whether it is attested.
|
|
47
|
+
*/
|
|
48
|
+
export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
|
|
49
|
+
if (!fact || typeof fact !== 'object') throw new Error('scorecard: an entry needs a fact');
|
|
50
|
+
if (!SCORECARD_ENTRY_KINDS.includes(fact.kind)) throw new Error(`scorecard: kind must be one of ${SCORECARD_ENTRY_KINDS.join(', ')}`);
|
|
51
|
+
if (!fact.agentId) throw new Error('scorecard: agentId required');
|
|
52
|
+
const e = {
|
|
53
|
+
v: SCORECARD_VERSION,
|
|
54
|
+
seq: prev ? prev.seq + 1 : 0,
|
|
55
|
+
agentId: String(fact.agentId),
|
|
56
|
+
kind: fact.kind,
|
|
57
|
+
at: Number(fact.at) || now(),
|
|
58
|
+
...(fact.projectId ? { projectId: String(fact.projectId) } : {}),
|
|
59
|
+
...(fact.jobId ? { jobId: String(fact.jobId) } : {}),
|
|
60
|
+
...(fact.runId ? { runId: String(fact.runId) } : {}),
|
|
61
|
+
...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
|
|
62
|
+
...(fact.model ? { model: String(fact.model) } : {}),
|
|
63
|
+
...(fact.size ? { size: sizeOf(fact.size) } : {}),
|
|
64
|
+
...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
|
|
65
|
+
...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
|
|
66
|
+
...(Array.isArray(fact.with) && fact.with.length ? { with: [...new Set(fact.with.map(String))].sort() } : {}),
|
|
67
|
+
...(Array.isArray(fact.created) && fact.created.length ? { created: [...new Set(fact.created.map(String))] } : {}),
|
|
68
|
+
...(fact.rating ? { rating: { by: String(fact.rating.by || 'person'), score: clamp01(fact.rating.score), ...(fact.rating.note ? { note: String(fact.rating.note).slice(0, 500) } : {}), ...(fact.rating.about != null ? { about: Number(fact.rating.about) } : {}) } } : {}),
|
|
69
|
+
...(Array.isArray(fact.refs) && fact.refs.length ? { refs: fact.refs.map(String).slice(0, 12) } : {}),
|
|
70
|
+
...(fact.error ? { error: String(fact.error).slice(0, 300) } : {}),
|
|
71
|
+
prev: prev ? prev.hash : null,
|
|
72
|
+
};
|
|
73
|
+
e.hash = await sha256(canonical(hashable(e)), { subtle });
|
|
74
|
+
return e;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
78
|
+
const sizeOf = (s) => ({ ms: Math.max(0, Math.round(Number(s.ms) || 0)), steps: Math.max(0, Math.round(Number(s.steps) || 0)), tools: Math.max(0, Math.round(Number(s.tools) || 0)), findings: Math.max(0, Math.round(Number(s.findings) || 0)), tokens: Math.max(0, Math.round(Number(s.tokens) || 0)) });
|
|
79
|
+
|
|
80
|
+
/** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
|
|
81
|
+
export async function verifyChain(entries, { subtle } = {}) {
|
|
82
|
+
let prev = null;
|
|
83
|
+
for (const e of entries || []) {
|
|
84
|
+
if (!e || typeof e !== 'object') return { ok: false, at: prev ? prev.seq + 1 : 0, why: 'not an entry' };
|
|
85
|
+
if ((prev ? prev.seq + 1 : 0) !== e.seq) return { ok: false, at: e.seq, why: 'seq' };
|
|
86
|
+
if ((prev ? prev.hash : null) !== e.prev) return { ok: false, at: e.seq, why: 'prev' };
|
|
87
|
+
const h = await sha256(canonical(hashable(e)), { subtle });
|
|
88
|
+
if (h !== e.hash) return { ok: false, at: e.seq, why: 'hash' };
|
|
89
|
+
prev = e;
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, at: null, length: (entries || []).length };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The store's mark. `key` is raw bytes only the store holds; an HMAC-SHA-256 over the hash.
|
|
96
|
+
* Anyone can re-hash the chain; only the store can mark it, so an entry written elsewhere
|
|
97
|
+
* is honest about being unattested. Injectable `subtle` again.
|
|
98
|
+
*/
|
|
99
|
+
export async function attest(entry, key, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
100
|
+
const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
101
|
+
return { ...entry, sig: hex(await subtle.sign('HMAC', k, enc.encode(entry.hash))) };
|
|
102
|
+
}
|
|
103
|
+
export async function verifyAttested(entries, key, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
104
|
+
const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const e of entries || []) {
|
|
107
|
+
const sig = e?.sig ? new Uint8Array(e.sig.match(/../g).map((x) => parseInt(x, 16))) : null;
|
|
108
|
+
out.push(!!sig && await subtle.verify('HMAC', k, sig, enc.encode(e.hash)));
|
|
109
|
+
}
|
|
110
|
+
return { ok: out.every(Boolean), attested: out.filter(Boolean).length, of: out.length };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The card a recruiter reads. */
|
|
114
|
+
export function summarize(entries, { recent = 5 } = {}) {
|
|
115
|
+
const list = (entries || []).filter((e) => e && e.kind);
|
|
116
|
+
const done = list.filter((e) => e.kind === 'task.done');
|
|
117
|
+
const failed = list.filter((e) => e.kind === 'task.failed');
|
|
118
|
+
const sum = (k) => done.reduce((n, e) => n + (e.size?.[k] || 0), 0);
|
|
119
|
+
const largest = done.reduce((m, e) => Math.max(m, e.size?.steps || 0), 0);
|
|
120
|
+
const tools = new Set(); const withAgents = new Set(); const created = new Set();
|
|
121
|
+
const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
|
|
122
|
+
const models = new Map();
|
|
123
|
+
for (const e of list) {
|
|
124
|
+
for (const t of e.tools || []) tools.add(t);
|
|
125
|
+
for (const a of e.with || []) withAgents.add(a);
|
|
126
|
+
for (const a of e.created || []) created.add(a);
|
|
127
|
+
if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
|
|
128
|
+
if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
|
|
131
|
+
const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
|
|
132
|
+
const recentRatings = ratings.slice(-recent);
|
|
133
|
+
const refs = [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent);
|
|
134
|
+
return {
|
|
135
|
+
agentId: list[0]?.agentId || null,
|
|
136
|
+
entries: list.length,
|
|
137
|
+
jobsDone: done.length,
|
|
138
|
+
jobsFailed: failed.length,
|
|
139
|
+
size: { ms: sum('ms'), steps: sum('steps'), tools: sum('tools'), findings: sum('findings'), tokens: sum('tokens'), largestSteps: largest },
|
|
140
|
+
tools: [...tools].sort(),
|
|
141
|
+
workedWith: [...withAgents].sort(),
|
|
142
|
+
created: [...created],
|
|
143
|
+
roles,
|
|
144
|
+
models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
|
|
145
|
+
rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
|
|
146
|
+
refs,
|
|
147
|
+
since: list[0]?.at || null,
|
|
148
|
+
last: list.at(-1)?.at || null,
|
|
149
|
+
head: list.at(-1)?.hash || null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* How well an agent TYPE fits a job, with that type's record. `job.needs` is
|
|
155
|
+
* `{ skills[], tools[], grants[] }`; `type` carries `skills[]`, `tools[]`, `grants[]`; the
|
|
156
|
+
* summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
|
|
157
|
+
* without the tools cannot do the job), track record second, size third.
|
|
158
|
+
*/
|
|
159
|
+
export function fit(job, type, summary = null) {
|
|
160
|
+
const needs = job?.needs || {};
|
|
161
|
+
const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
|
|
162
|
+
const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
|
|
163
|
+
const reasons = [];
|
|
164
|
+
const coverage = (want, has, label) => {
|
|
165
|
+
const w = (want || []).map((x) => String(x).toLowerCase());
|
|
166
|
+
if (!w.length) return 1;
|
|
167
|
+
const hit = w.filter((x) => has.has(x));
|
|
168
|
+
if (hit.length < w.length) reasons.push(`missing ${label}: ${w.filter((x) => !has.has(x)).join(', ')}`);
|
|
169
|
+
return hit.length / w.length;
|
|
170
|
+
};
|
|
171
|
+
const cSkills = coverage(needs.skills, skills, 'skills');
|
|
172
|
+
const cTools = coverage(needs.tools, tools, 'tools');
|
|
173
|
+
const cGrants = coverage(needs.grants, grants, 'grants');
|
|
174
|
+
const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
|
|
175
|
+
if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
|
|
176
|
+
let record = 0.5; // a fresh type is neither trusted nor distrusted
|
|
177
|
+
if (summary && summary.entries) {
|
|
178
|
+
const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
|
|
179
|
+
const rated = summary.rating.avg == null ? 0.5 : summary.rating.avg;
|
|
180
|
+
record = doneRate * 0.5 + rated * 0.5;
|
|
181
|
+
reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? `, rated ${Math.round(summary.rating.avg * 100)}%` : ''}`);
|
|
182
|
+
if (summary.roles.orchestrator + summary.roles.manager + summary.roles['manager-of-managers'] > 0) reasons.push(`has led: ${summary.roles.orchestrator} as orchestrator, ${summary.roles.manager} as manager`);
|
|
183
|
+
} else {
|
|
184
|
+
reasons.push('no record yet');
|
|
185
|
+
}
|
|
186
|
+
const wantSize = Number(job?.size?.steps) || 0;
|
|
187
|
+
const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
|
|
188
|
+
if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
|
|
189
|
+
const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
|
|
190
|
+
return { score, reasons, parts: { needs: needScore, record, size: sizeScore } };
|
|
191
|
+
}
|
package/team-run.js
CHANGED
|
@@ -269,17 +269,25 @@ export async function runTeam({
|
|
|
269
269
|
say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
|
|
270
270
|
attempts.push({ model: m.model, at: now(), continued: !!note });
|
|
271
271
|
const sent = messagesFor({ transcript }, { prompt, note });
|
|
272
|
+
// The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
|
|
273
|
+
// moment it exists (a tool call, its result) puts it on the record then, so a
|
|
274
|
+
// process that dies mid-attempt leaves the work so far behind it, not nothing.
|
|
275
|
+
let live = sent;
|
|
276
|
+
if (sent.length > transcript.length) recordSteps(transcript, sent);
|
|
277
|
+
const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [clipTranscriptOne(message)] }); } };
|
|
272
278
|
const res = await callModel({
|
|
273
279
|
runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
|
|
274
280
|
system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
|
|
275
281
|
onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
|
|
282
|
+
onStep,
|
|
276
283
|
});
|
|
277
284
|
usage = res?.usage || null;
|
|
278
285
|
if (usage) budget.charge(usage);
|
|
279
286
|
// Whatever the attempt did is the task's now — on the record, before any verdict.
|
|
280
|
-
|
|
287
|
+
// What the host already reported step by step is not reported again.
|
|
281
288
|
transcript = mergeTranscript(sent, res);
|
|
282
|
-
|
|
289
|
+
if (transcript.length < live.length) transcript = live;
|
|
290
|
+
recordSteps(live, transcript);
|
|
283
291
|
lastModel = m.model;
|
|
284
292
|
attempts[attempts.length - 1].status = res?.ok ? (String(res?.text || '').trim() ? 'ok' : 'empty') : 'error';
|
|
285
293
|
if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; break; }
|
|
@@ -322,6 +330,16 @@ export async function runTeam({
|
|
|
322
330
|
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
323
331
|
tasksOut.push(row);
|
|
324
332
|
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
|
333
|
+
// The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
|
|
334
|
+
// in which role — produced here, attested by the store, never written by the agent.
|
|
335
|
+
if (status === 'ok' || status === 'failed') {
|
|
336
|
+
say('task.scored', {
|
|
337
|
+
agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), outcome: status === 'ok' ? 'task.done' : 'task.failed',
|
|
338
|
+
size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
|
|
339
|
+
roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
340
|
+
refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
325
343
|
// The spend so far, after every task — a ledger reads it live instead of at the end.
|
|
326
344
|
say('run.usage', { usage: budget.snapshot() });
|
|
327
345
|
if (budget.exhausted()) overBudget = true;
|
|
@@ -379,6 +397,7 @@ export async function runTeam({
|
|
|
379
397
|
}
|
|
380
398
|
const judged = res?.ok && String(res.text || '').trim();
|
|
381
399
|
say(judged ? 'task.done' : 'task.failed', { taskId: 'merge', role: judge.id, status: judged ? 'ok' : 'failed', error: judged ? null : (res?.error || 'the judge did not answer'), findings: 0 });
|
|
400
|
+
say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: res ? (excl.size ? [...excl].at(-1) : m?.model) : m?.model, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
|
|
382
401
|
say('run.usage', { usage: budget.snapshot() });
|
|
383
402
|
proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
384
403
|
} else {
|
|
@@ -413,6 +432,9 @@ export function resumeTeam({ checkpoint, ...deps } = {}) {
|
|
|
413
432
|
return runTeam({ ...deps, resume: checkpoint });
|
|
414
433
|
}
|
|
415
434
|
|
|
435
|
+
const lastModelOf = (attempts) => (attempts || []).at(-1)?.model || null;
|
|
436
|
+
const toolNamesOf = (transcript) => [...new Set((transcript || []).flatMap((m) => (m.role === 'assistant' && Array.isArray(m.tool_calls) ? m.tool_calls.map((c) => c.function?.name).filter(Boolean) : [])))];
|
|
437
|
+
|
|
416
438
|
/** No model: the members' work side by side, findings first — always available. */
|
|
417
439
|
function mergeCheap(team, findings, tasks) {
|
|
418
440
|
const sections = tasks.filter((x) => x.status === 'ok').map((x) => {
|
package/team-trail.js
CHANGED
|
@@ -18,6 +18,7 @@ export function teamLine(ev) {
|
|
|
18
18
|
case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
|
|
19
19
|
case 'task.handoff': return { type: 'status', text: `${role} handed off ${ev.from ? `from ${ev.from} ` : ''}to ${ev.to} by ${ev.by || 'person'}${ev.reason ? ` — ${ev.reason}` : ''}` };
|
|
20
20
|
case 'task.step': return null;
|
|
21
|
+
case 'task.scored': return null;
|
|
21
22
|
case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
|
|
22
23
|
case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
|
|
23
24
|
case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
|