@chatpanel/events 0.83.1 → 0.84.1
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 +2 -0
- package/package.json +5 -1
- package/team-record.js +102 -0
- package/team-run.js +82 -13
- package/team-task.js +111 -0
- package/team-trail.js +6 -2
package/index.js
CHANGED
|
@@ -197,6 +197,8 @@ export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHE
|
|
|
197
197
|
export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
|
|
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
|
+
export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
|
|
201
|
+
export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
|
|
200
202
|
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
201
203
|
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
202
204
|
export { teamLine, teamLanes } from './team-trail.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.1",
|
|
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",
|
|
@@ -92,6 +92,8 @@
|
|
|
92
92
|
"./team-board.js": "./team-board.js",
|
|
93
93
|
"./board-tool.js": "./board-tool.js",
|
|
94
94
|
"./team-cache.js": "./team-cache.js",
|
|
95
|
+
"./team-task.js": "./team-task.js",
|
|
96
|
+
"./team-record.js": "./team-record.js",
|
|
95
97
|
"./team-plan.js": "./team-plan.js",
|
|
96
98
|
"./team-run.js": "./team-run.js",
|
|
97
99
|
"./team-tool.js": "./team-tool.js",
|
|
@@ -209,6 +211,8 @@
|
|
|
209
211
|
"team-board.js",
|
|
210
212
|
"board-tool.js",
|
|
211
213
|
"team-cache.js",
|
|
214
|
+
"team-task.js",
|
|
215
|
+
"team-record.js",
|
|
212
216
|
"team-plan.js",
|
|
213
217
|
"team-run.js",
|
|
214
218
|
"team-tool.js",
|
package/team-record.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The run record — a team run folded from its events, the same way on the gateway's store
|
|
2
|
+
// and in either client. One fold, so what the desktop shows, what the extension shows and
|
|
3
|
+
// what the store holds never disagree; and enough on the record to RESUME the run from
|
|
4
|
+
// anywhere: the plan, every task's status and transcript, the board, the spend.
|
|
5
|
+
//
|
|
6
|
+
// The gateway vendors this file (its store used to carry a mirror of it, which is the copy
|
|
7
|
+
// that drifts). `checkpointFrom(run)` is what a client hands `resumeTeam` after reading a
|
|
8
|
+
// run the process that started it no longer runs.
|
|
9
|
+
|
|
10
|
+
import { foldBoard, emptyBoardState } from './team-board.js';
|
|
11
|
+
|
|
12
|
+
export const LIVE_RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting']);
|
|
13
|
+
export const RESUMABLE_RUN_STATUSES = Object.freeze(['waiting', 'stopped', 'failed', 'partial', 'over-budget', 'answered']);
|
|
14
|
+
const TASK_TEXT_MAX = 20_000;
|
|
15
|
+
|
|
16
|
+
export function emptyRun({ id, client = '', now = Date.now() } = {}) {
|
|
17
|
+
return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const taskOf = (run, id) => run.tasks.find((x) => x.id === id);
|
|
21
|
+
|
|
22
|
+
/** Fold one event (`{ type, at, payload }`, or a flat `{ type, at, ...payload }`) into the record. */
|
|
23
|
+
export function foldRun(run, ev) {
|
|
24
|
+
const type = String(ev?.type || '');
|
|
25
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
|
|
26
|
+
const at = Number(ev?.at) || Date.now();
|
|
27
|
+
run.lastEventAt = at;
|
|
28
|
+
switch (type) {
|
|
29
|
+
case 'run.started':
|
|
30
|
+
case 'run.resumed':
|
|
31
|
+
run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
|
|
32
|
+
run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || at;
|
|
33
|
+
if (type === 'run.resumed') { run.endedAt = null; run.checkpoint = null; run.resumedAt = at; }
|
|
34
|
+
break;
|
|
35
|
+
case 'plan.ready':
|
|
36
|
+
run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
|
|
37
|
+
// A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
|
|
38
|
+
run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : 'pending', findings: taskOf(run, t.id)?.findings || 0 }));
|
|
39
|
+
run.status = 'running';
|
|
40
|
+
break;
|
|
41
|
+
case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
|
|
42
|
+
case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
|
|
43
|
+
case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
|
|
44
|
+
case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
|
|
45
|
+
case 'task.tool': { const t = taskOf(run, p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
|
|
46
|
+
case 'task.delta': { const t = taskOf(run, p.taskId); if (t) t.text = String(p.text || '').slice(0, TASK_TEXT_MAX); break; }
|
|
47
|
+
case 'task.finding':
|
|
48
|
+
if (p.finding && p.finding.text) { run.board.push({ ...p.finding, at }); const t = taskOf(run, p.taskId); if (t) t.findings += 1; }
|
|
49
|
+
break;
|
|
50
|
+
case 'task.waiting': { const t = taskOf(run, p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
|
|
51
|
+
case 'task.done':
|
|
52
|
+
case 'task.failed': { const t = taskOf(run, p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; } break; }
|
|
53
|
+
case 'run.merging': run.status = 'merging'; break;
|
|
54
|
+
case 'run.waiting': run.status = 'running'; break;
|
|
55
|
+
case 'run.usage': run.usage = p.usage || run.usage; break;
|
|
56
|
+
case 'run.done':
|
|
57
|
+
run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = at;
|
|
58
|
+
if (p.checkpoint) run.checkpoint = p.checkpoint;
|
|
59
|
+
break;
|
|
60
|
+
case 'run.stop-requested': run.stopRequested = at; break;
|
|
61
|
+
case 'board.thread': case 'board.post': case 'board.decision': case 'board.thread-status':
|
|
62
|
+
run.threads = foldBoard(run.threads || emptyBoardState(), ev);
|
|
63
|
+
if (type === 'board.thread-status' && p.status !== 'waiting' && run.status === 'waiting' && !(run.threads.threads || []).some((t) => t.kind === 'ask' && t.status === 'waiting')) run.status = 'answered';
|
|
64
|
+
break;
|
|
65
|
+
default: break;
|
|
66
|
+
}
|
|
67
|
+
return run;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fold a whole event list into a fresh record. */
|
|
71
|
+
export function runFromEvents(id, events, opts = {}) {
|
|
72
|
+
const run = emptyRun({ id, ...opts });
|
|
73
|
+
for (const ev of events || []) foldRun(run, ev);
|
|
74
|
+
return run;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What `resumeTeam` needs, from the record alone — the runner's own checkpoint when the run
|
|
79
|
+
* ended with one, else one built from the folded tasks: whatever the process that ran it
|
|
80
|
+
* managed to write before it went. A task recorded `running` (its process died) resumes
|
|
81
|
+
* from its transcript like a waiting one.
|
|
82
|
+
*/
|
|
83
|
+
export function checkpointFrom(run) {
|
|
84
|
+
if (!run?.plan?.tasks?.length) return null;
|
|
85
|
+
if (run.checkpoint?.plan?.tasks?.length) return { ...run.checkpoint, board: run.threads && run.threads.threads?.length >= (run.checkpoint.board?.threads?.length || 0) ? run.threads : run.checkpoint.board };
|
|
86
|
+
return {
|
|
87
|
+
runId: run.id,
|
|
88
|
+
startedAt: run.startedAt || run.createdAt,
|
|
89
|
+
plan: run.plan,
|
|
90
|
+
tasks: run.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: t.status === 'running' || t.status === 'pending' ? 'stopped' : t.status, text: t.text || '', error: t.error || null, transcript: t.transcript || [], attempts: t.attempts || [], usage: null })),
|
|
91
|
+
board: run.threads || emptyBoardState(),
|
|
92
|
+
budget: { cap: run.usage?.cap || run.budget || {}, spent: run.usage?.spent || {} },
|
|
93
|
+
budgetAsked: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Can this record be picked up again? Not one that completed, not one still being run by a live client. */
|
|
98
|
+
export function isResumable(run) {
|
|
99
|
+
if (!run?.plan?.tasks?.length) return false;
|
|
100
|
+
if (RESUMABLE_RUN_STATUSES.includes(run.status)) return true;
|
|
101
|
+
return !!run.stale && LIVE_RUN_STATUSES.includes(run.status); // its client went away mid-run
|
|
102
|
+
}
|
package/team-run.js
CHANGED
|
@@ -22,6 +22,7 @@ import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
|
|
|
22
22
|
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
|
|
23
23
|
import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
24
24
|
import { createRunCache, withRunCache } from './team-cache.js';
|
|
25
|
+
import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote } from './team-task.js';
|
|
25
26
|
import { converge } from './promotion.js';
|
|
26
27
|
|
|
27
28
|
export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
|
|
@@ -96,6 +97,9 @@ export async function runTeam({
|
|
|
96
97
|
answers = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS,
|
|
97
98
|
// A checkpoint from a run that ended `waiting` — see resumeTeam.
|
|
98
99
|
resume = null,
|
|
100
|
+
// The host's control channel (team-task.js createControl): a person hands a task to another
|
|
101
|
+
// model from the board, on either client; the runner continues the task's transcript there.
|
|
102
|
+
control = null,
|
|
99
103
|
} = {}) {
|
|
100
104
|
if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
|
|
101
105
|
const t = normalizeTeam(team); // throws on a team without a budget — O1
|
|
@@ -111,9 +115,12 @@ export async function runTeam({
|
|
|
111
115
|
const box = answers || createAnswerBox();
|
|
112
116
|
const askMs = answers ? askTimeoutMs : 0;
|
|
113
117
|
const startedAt = resume?.startedAt || now();
|
|
114
|
-
// Tasks a checkpoint already finished are carried over, not re-run.
|
|
118
|
+
// Tasks a checkpoint already finished are carried over, not re-run. Tasks it left
|
|
119
|
+
// interrupted — waiting, stopped, failed, or running when the process died — are resumed
|
|
120
|
+
// from their transcripts, not started again.
|
|
115
121
|
const tasksOut = (resume?.tasks || []).filter((x) => x.status === 'ok').map((x) => ({ ...x }));
|
|
116
122
|
const carried = new Set(tasksOut.map((x) => x.id));
|
|
123
|
+
const interrupted = new Map((resume?.tasks || []).filter((x) => x.status !== 'ok' && Array.isArray(x.transcript) && x.transcript.length).map((x) => [x.id, x]));
|
|
117
124
|
const roleOf = (rid) => t.roles.find((r) => r.id === rid);
|
|
118
125
|
// `exclude` holds what failed as unavailable this run; a re-appointment skips it. A role's
|
|
119
126
|
// pinned model is tried first and, when it is the one that failed, the roster steps in.
|
|
@@ -153,7 +160,9 @@ export async function runTeam({
|
|
|
153
160
|
const usage = budget.snapshot();
|
|
154
161
|
const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, lookups: { distinct: runCache.size, shared: runCache.shared }, startedAt, endedAt: now(), ...extra };
|
|
155
162
|
// What a resume needs, on the record: the plan, what finished, the board, the spend.
|
|
156
|
-
|
|
163
|
+
// A run that did not complete can be resumed from its record — the plan, every task's
|
|
164
|
+
// transcript and status, the board, the spend — by any client, any time later.
|
|
165
|
+
if (status !== 'completed') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut.map((x) => ({ ...x, findings: undefined })), board: board.state(), budget: { cap: budget.cap, spent: usage.spent }, budgetAsked: !!extra.budgetAsked };
|
|
157
166
|
say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id), waitingTaskIds: tasksOut.filter((x) => x.status === 'waiting').map((x) => x.id), ...(out.checkpoint ? { checkpoint: out.checkpoint } : {}) });
|
|
158
167
|
return out;
|
|
159
168
|
};
|
|
@@ -179,16 +188,33 @@ export async function runTeam({
|
|
|
179
188
|
if (stopped() || overBudget || waitingOnPerson) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
|
|
180
189
|
const role = roleOf(task.role);
|
|
181
190
|
const t0 = now();
|
|
182
|
-
|
|
191
|
+
const was = interrupted.get(task.id) || null;
|
|
192
|
+
say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
|
|
183
193
|
let text = '';
|
|
184
194
|
let usage = null;
|
|
185
195
|
let status = 'ok';
|
|
186
196
|
let error = null;
|
|
197
|
+
// THE TASK'S TRANSCRIPT — its conversation so far, apart from any one model. An attempt
|
|
198
|
+
// continues it; a hand-off continues it on another model; a resume continues it from
|
|
199
|
+
// the record. Only a task that has never been attempted starts from the bare prompt.
|
|
200
|
+
let transcript = was ? [...was.transcript] : [];
|
|
201
|
+
const attempts = was?.attempts ? [...was.attempts] : [];
|
|
187
202
|
// The task's own abort: an ask nobody answered in time stops THIS member's turn (the
|
|
188
|
-
// run then checkpoints), without stopping the run's other members.
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
// run then checkpoints), without stopping the run's other members. A person's hand-off
|
|
204
|
+
// aborts it too, and names where the task continues.
|
|
205
|
+
let taskAc = new AbortController();
|
|
206
|
+
const onRunAbort = () => taskAc.abort();
|
|
207
|
+
signal?.addEventListener?.('abort', onRunAbort, { once: true });
|
|
191
208
|
let askedAndWaiting = null;
|
|
209
|
+
let handoffTo = control?._pendingFor?.(task.id) || null;
|
|
210
|
+
const unsubscribe = control?._subscribe?.((req) => {
|
|
211
|
+
if (req.type !== 'handoff' || req.taskId !== task.id) return false;
|
|
212
|
+
handoffTo = req; taskAc.abort(); return true;
|
|
213
|
+
}) || null;
|
|
214
|
+
const recordSteps = (before, after) => {
|
|
215
|
+
const added = newSteps(before, after);
|
|
216
|
+
if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map((m) => clipTranscriptOne(m)) });
|
|
217
|
+
};
|
|
192
218
|
try {
|
|
193
219
|
if (role.mode === 'recipe') {
|
|
194
220
|
if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
|
|
@@ -219,20 +245,53 @@ export async function runTeam({
|
|
|
219
245
|
// models. Anything else (a refusal, a timeout, a bad request) fails the task.
|
|
220
246
|
const exclude = new Set();
|
|
221
247
|
let lastErr = '';
|
|
248
|
+
// What the next attempt is told, when it continues rather than starts.
|
|
249
|
+
let note = was ? continuationNote(was.status === 'waiting' ? { kind: 'answer', answer: 'see the board' } : { kind: 'resume', reason: was.error || was.status }) : null;
|
|
250
|
+
let lastModel = was?.attempts?.at?.(-1)?.model || null;
|
|
222
251
|
for (let attempt = 1; ; attempt++) {
|
|
223
|
-
|
|
252
|
+
let m;
|
|
253
|
+
if (handoffTo) {
|
|
254
|
+
// A person's hand-off names the model; the task continues there whatever the
|
|
255
|
+
// roster would have chosen. Said on the board, so everyone knows who has it.
|
|
256
|
+
m = { model: handoffTo.model, mode: role.mode };
|
|
257
|
+
const th = board.threadForTask(task.id);
|
|
258
|
+
if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'decision', text: `Handed off from ${lastModel || 'the roster'} to ${handoffTo.model} by ${handoffTo.by}${handoffTo.reason ? ` — ${handoffTo.reason}` : ''}.` });
|
|
259
|
+
say('task.handoff', { taskId: task.id, role: role.id, from: lastModel, to: handoffTo.model, by: handoffTo.by, reason: handoffTo.reason || '' });
|
|
260
|
+
note = continuationNote({ kind: 'handoff', from: lastModel, to: handoffTo.model, reason: `handed off by ${handoffTo.by}` });
|
|
261
|
+
handoffTo = null;
|
|
262
|
+
taskAc = new AbortController(); signal?.addEventListener?.('abort', onRunAbort, { once: true });
|
|
263
|
+
} else {
|
|
264
|
+
m = modelFor(role, excluding(exclude));
|
|
265
|
+
}
|
|
224
266
|
if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
|
|
225
|
-
if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
|
|
267
|
+
if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
|
|
226
268
|
// Who is doing this task, for a ledger that shows the lanes — said per attempt.
|
|
227
269
|
say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
|
|
270
|
+
attempts.push({ model: m.model, at: now(), continued: !!note });
|
|
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)] }); } };
|
|
228
278
|
const res = await callModel({
|
|
229
279
|
runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
|
|
230
|
-
system: role.prompt, prompt, tools, signal: taskAc.signal,
|
|
280
|
+
system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
|
|
231
281
|
onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
|
|
282
|
+
onStep,
|
|
232
283
|
});
|
|
233
284
|
usage = res?.usage || null;
|
|
234
285
|
if (usage) budget.charge(usage);
|
|
286
|
+
// Whatever the attempt did is the task's now — on the record, before any verdict.
|
|
287
|
+
// What the host already reported step by step is not reported again.
|
|
288
|
+
transcript = mergeTranscript(sent, res);
|
|
289
|
+
if (transcript.length < live.length) transcript = live;
|
|
290
|
+
recordSteps(live, transcript);
|
|
291
|
+
lastModel = m.model;
|
|
292
|
+
attempts[attempts.length - 1].status = res?.ok ? (String(res?.text || '').trim() ? 'ok' : 'empty') : 'error';
|
|
235
293
|
if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; break; }
|
|
294
|
+
if (handoffTo) { note = null; continue; } // the person moved it: continue the transcript there
|
|
236
295
|
if (res?.aborted || taskAc.signal.aborted) { status = 'stopped'; break; }
|
|
237
296
|
// A turn that ended with nothing to say — an agent that exited, a stream that
|
|
238
297
|
// died after its tool calls — is not a done task: three members "completed" empty
|
|
@@ -249,19 +308,26 @@ export async function runTeam({
|
|
|
249
308
|
if (mine.length) { text = mine.map((p) => p.text).join('\n\n'); say('task.note', { taskId: task.id, role: role.id, text: 'answered from its board posts' }); break; }
|
|
250
309
|
}
|
|
251
310
|
const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
|
|
311
|
+
attempts[attempts.length - 1].error = err;
|
|
252
312
|
if (attempt >= MAX_APPOINTMENTS || stopped() || !isModelUnavailable(err)) throw new Error(err);
|
|
253
313
|
exclude.add(m.model); runExclude.add(m.model); lastErr = err;
|
|
314
|
+
// The next model CONTINUES this transcript; it does not start over.
|
|
315
|
+
note = continuationNote({ kind: 'handoff', from: m.model, reason: err });
|
|
316
|
+
const th = board.threadForTask(task.id);
|
|
317
|
+
if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'note', text: `${m.model} stopped (${err.slice(0, 160)}); the task continues on the next model with its work so far.` });
|
|
254
318
|
}
|
|
255
319
|
}
|
|
256
320
|
} catch (e) {
|
|
257
321
|
if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; }
|
|
258
322
|
else { status = overBudget ? 'over-budget' : 'failed'; error = String(e?.message || e); }
|
|
323
|
+
} finally {
|
|
324
|
+
unsubscribe?.();
|
|
259
325
|
}
|
|
260
326
|
const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
|
|
261
327
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
262
328
|
const thread = board.threadForTask(task.id);
|
|
263
329
|
if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
|
|
264
|
-
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
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 } : {}) };
|
|
265
331
|
tasksOut.push(row);
|
|
266
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 } : {}) });
|
|
267
333
|
// The spend so far, after every task — a ledger reads it live instead of at the end.
|
|
@@ -344,11 +410,14 @@ export async function runTeam({
|
|
|
344
410
|
return finish(failed ? 'partial' : 'completed', { proposal });
|
|
345
411
|
}
|
|
346
412
|
|
|
347
|
-
/**
|
|
413
|
+
/**
|
|
414
|
+
* Continue a run from its checkpoint — one that waited on a person, was stopped, failed, or
|
|
415
|
+
* whose process died. Finished tasks are carried; interrupted ones continue from their
|
|
416
|
+
* transcripts on whatever model the roster gives them now (or a hand-off names); the board
|
|
417
|
+
* and the spend carry over. Any client, any time later: the checkpoint is on the record.
|
|
418
|
+
*/
|
|
348
419
|
export function resumeTeam({ checkpoint, ...deps } = {}) {
|
|
349
420
|
if (!checkpoint?.plan?.tasks) throw new TeamRunError('BAD_RESUME', 'a checkpoint with a plan is required');
|
|
350
|
-
// The waiting task runs again; its ask thread now holds the answer, and boardText gives it
|
|
351
|
-
// to the member. Tasks recorded `waiting`/`skipped` are dropped from the carried list.
|
|
352
421
|
return runTeam({ ...deps, resume: checkpoint });
|
|
353
422
|
}
|
|
354
423
|
|
package/team-task.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A task is a conversation, and a model call is one attempt at continuing it.
|
|
2
|
+
//
|
|
3
|
+
// A member's task keeps its TRANSCRIPT — the wire messages so far: the request, what the
|
|
4
|
+
// model said, the tools it called and what came back — apart from whichever model or CLI
|
|
5
|
+
// agent happens to be answering. When that model fails, is handed off, or the process
|
|
6
|
+
// itself dies and the run is resumed a month later from the store, the next model does not
|
|
7
|
+
// start over: it is given the transcript and told to continue. The same thing a chat window
|
|
8
|
+
// does when a person switches models mid-conversation; here it is what makes a run survive
|
|
9
|
+
// a process, a model, or an API going away.
|
|
10
|
+
//
|
|
11
|
+
// Every attempt, hand-off and continuation is said on the board (a runner post in the
|
|
12
|
+
// task's thread) and in the run's events, so the record — on the gateway, read by either
|
|
13
|
+
// client — is enough to pick the task up from anywhere.
|
|
14
|
+
|
|
15
|
+
export const STEP_MAX_CHARS = 4000; // one persisted message's content
|
|
16
|
+
export const TASK_TRANSCRIPT_MAX_CHARS = 60_000; // a task's whole persisted transcript
|
|
17
|
+
|
|
18
|
+
const clipStr = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n…[${s.length - n} more chars omitted from the record]` : s);
|
|
19
|
+
|
|
20
|
+
/** A wire message trimmed for the record: tool results and long answers are clipped. */
|
|
21
|
+
export function clipMessage(m) {
|
|
22
|
+
if (!m || typeof m !== 'object') return m;
|
|
23
|
+
const out = { role: m.role };
|
|
24
|
+
if (typeof m.content === 'string') out.content = clipStr(m.content, STEP_MAX_CHARS);
|
|
25
|
+
else if (m.content != null) out.content = m.content;
|
|
26
|
+
if (m.tool_calls) out.tool_calls = m.tool_calls.map((c) => ({ id: c.id, type: c.type || 'function', function: { name: c.function?.name, arguments: clipStr(String(c.function?.arguments ?? ''), STEP_MAX_CHARS) } }));
|
|
27
|
+
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
|
|
28
|
+
if (m.name) out.name = m.name;
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The record's copy of a transcript: clipped per message and bounded as a whole (oldest tool traffic goes first). */
|
|
33
|
+
export function clipTranscript(messages) {
|
|
34
|
+
const list = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role && m.role !== 'system').map(clipMessage);
|
|
35
|
+
let size = list.reduce((n, m) => n + JSON.stringify(m).length, 0);
|
|
36
|
+
// Drop the oldest tool exchanges (assistant tool_calls + tool results) until it fits,
|
|
37
|
+
// never the first user message (the task) and never the last message.
|
|
38
|
+
for (let i = 1; size > TASK_TRANSCRIPT_MAX_CHARS && i < list.length - 1; ) {
|
|
39
|
+
const m = list[i];
|
|
40
|
+
if (m.role === 'tool' || (m.role === 'assistant' && m.tool_calls)) { size -= JSON.stringify(m).length; list.splice(i, 1); } else i += 1;
|
|
41
|
+
}
|
|
42
|
+
return list;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The messages beyond what the record already holds — what one attempt added. */
|
|
46
|
+
export function newSteps(prev, next) {
|
|
47
|
+
const a = Array.isArray(prev) ? prev.length : 0;
|
|
48
|
+
return (Array.isArray(next) ? next : []).slice(a);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the next model is told when it continues another's work. `kind`:
|
|
53
|
+
* handoff — the previous attempt (another model / agent) stopped: an error, a person's choice
|
|
54
|
+
* resume — the run itself was stopped or died and is being resumed from the record
|
|
55
|
+
* answer — the task waited on a person and the answer is now on the board
|
|
56
|
+
*/
|
|
57
|
+
export function continuationNote({ kind = 'handoff', from = '', to = '', reason = '', answer = '' } = {}) {
|
|
58
|
+
const who = from ? ` by ${from}` : '';
|
|
59
|
+
if (kind === 'answer') return `The user has answered your question on the board (see the board, or: ${answer}). Continue the task from where you left off — do not repeat work already done above — and finish with your findings.`;
|
|
60
|
+
if (kind === 'resume') return `This task was interrupted (${reason || 'the run was stopped'}) and is being resumed${to ? ` by ${to}` : ''}. Everything above is the work done so far — read it, do not redo it. Continue from where it stopped and finish with your findings.`;
|
|
61
|
+
return `You are continuing this task. A previous attempt${who} stopped (${reason || 'it did not finish'}). Everything above is its work so far — read it, do not redo lookups already made. Continue from where it stopped and finish with your findings.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The messages to send for an attempt: the task's transcript plus a continuation note when
|
|
66
|
+
* there is one; the bare task when there is not. `system` travels separately (the host puts
|
|
67
|
+
* it first) so a transcript never carries a role prompt that a later role might not share.
|
|
68
|
+
*/
|
|
69
|
+
export function messagesFor(task, { prompt, note = null } = {}) {
|
|
70
|
+
const transcript = Array.isArray(task?.transcript) ? task.transcript : [];
|
|
71
|
+
if (!transcript.length) return [{ role: 'user', content: String(prompt || '') }];
|
|
72
|
+
const last = transcript[transcript.length - 1];
|
|
73
|
+
// A transcript that ends in an unanswered tool call cannot be continued as-is: close it.
|
|
74
|
+
const closed = last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length
|
|
75
|
+
? [...transcript, ...last.tool_calls.map((c) => ({ role: 'tool', tool_call_id: c.id, content: 'This call was not answered: the attempt stopped here.' }))]
|
|
76
|
+
: transcript;
|
|
77
|
+
return note ? [...closed, { role: 'user', content: note }] : closed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fold what a host returned into the task's transcript. A host that hands back its wire
|
|
82
|
+
* transcript (`res.transcript`) is taken as is (minus system); one that only returns text
|
|
83
|
+
* gets the messages it was sent plus one assistant message — still a continuation.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeTranscript(sent, res) {
|
|
86
|
+
const returned = Array.isArray(res?.transcript) ? res.transcript.filter((m) => m && m.role !== 'system') : null;
|
|
87
|
+
if (returned && returned.length >= sent.length) return returned;
|
|
88
|
+
const text = String(res?.text || '');
|
|
89
|
+
return text ? [...sent, { role: 'assistant', content: text }] : sent;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The control channel a host holds on a running team: a person's hand-off of a task to a
|
|
94
|
+
* named model (from the board, either client), or a stop of one task. The runner subscribes.
|
|
95
|
+
*/
|
|
96
|
+
export function createControl() {
|
|
97
|
+
const listeners = new Set();
|
|
98
|
+
const pending = new Map(); // taskId -> { model, by } asked before the task subscribed
|
|
99
|
+
const api = {
|
|
100
|
+
handoff(taskId, model, by = 'person', reason = '') {
|
|
101
|
+
const req = { type: 'handoff', taskId, model, by, reason };
|
|
102
|
+
let taken = false;
|
|
103
|
+
for (const fn of listeners) { if (fn(req) === true) taken = true; }
|
|
104
|
+
if (!taken) pending.set(taskId, req);
|
|
105
|
+
return taken;
|
|
106
|
+
},
|
|
107
|
+
_subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
108
|
+
_pendingFor(taskId) { const r = pending.get(taskId); if (r) pending.delete(taskId); return r || null; },
|
|
109
|
+
};
|
|
110
|
+
return api;
|
|
111
|
+
}
|
package/team-trail.js
CHANGED
|
@@ -9,12 +9,15 @@ export function teamLine(ev) {
|
|
|
9
9
|
switch (ev.type) {
|
|
10
10
|
case 'run.started': return { type: 'status', text: `team ${ev.team}: ${(ev.roles || []).join(', ')}` };
|
|
11
11
|
case 'plan.ready': return { type: 'status', text: `plan: ${(ev.tasks || []).length} task${(ev.tasks || []).length === 1 ? '' : 's'} (${ev.by})` };
|
|
12
|
-
case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}` };
|
|
12
|
+
case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}${ev.resumed ? ` (resumed, ${ev.steps} steps so far)` : ''}` };
|
|
13
13
|
case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
|
|
14
14
|
case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
|
|
15
15
|
case 'run.resumed': return { type: 'status', text: `team ${ev.team} resumed${(ev.carried || []).length ? ` (${ev.carried.length} task${ev.carried.length === 1 ? '' : 's'} carried over)` : ''}` };
|
|
16
16
|
case 'board.post': return ev.post && ev.post.kind !== 'finding' ? { type: 'status', text: `${ev.post.by} ${ev.post.replyTo ? 'replied' : 'posted'} (${ev.post.kind}): ${String(ev.post.text || '').slice(0, 120)}` } : null;
|
|
17
17
|
case 'board.decision': return { type: 'status', text: `${ev.by || 'someone'} ${ev.status} a post` };
|
|
18
|
+
case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
|
|
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
|
+
case 'task.step': return null;
|
|
18
21
|
case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
|
|
19
22
|
case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
|
|
20
23
|
case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
|
|
@@ -35,8 +38,9 @@ export function teamLanes(prev, ev) {
|
|
|
35
38
|
case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
|
|
36
39
|
case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
|
|
37
40
|
case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
|
|
38
|
-
case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
|
|
39
41
|
case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
|
|
42
|
+
case 'task.handoff': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.to, handoffs: (lanes.tasks[ev.taskId].handoffs || 0) + 1 }; break;
|
|
43
|
+
case 'task.step': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], steps: (lanes.tasks[ev.taskId].steps || 0) + (ev.steps || []).length }; break;
|
|
40
44
|
case 'task.tool': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], tools: (lanes.tasks[ev.taskId].tools || 0) + 1, lastTool: ev.text ? `${ev.name} ${ev.text}` : ev.name }; break;
|
|
41
45
|
case 'run.usage': lanes.usage = ev.usage; break;
|
|
42
46
|
case 'task.waiting': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: 'waiting', waitingOn: ev.threadId }; lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;
|