@chatpanel/events 0.80.1 → 0.81.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/board-tool.js +144 -0
- package/budget.js +7 -1
- package/index.js +3 -2
- package/package.json +3 -1
- package/team-board.js +214 -29
- package/team-run.js +102 -26
- package/team-tool.js +7 -4
- package/team-trail.js +8 -0
package/board-tool.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The `board` tool — how a member operates on the board.
|
|
2
|
+
//
|
|
3
|
+
// Every role gets it, whatever its grants: it reaches nothing outside the run. Four actions:
|
|
4
|
+
// `read` the threads this member may see, `post` a note or draft in a thread, `reply` to a
|
|
5
|
+
// post (agree, dispute with a ref, extend), and `ask` — open an ask thread and WAIT for a
|
|
6
|
+
// person to answer it, from either client. Members do not chat freely: a post is typed, a
|
|
7
|
+
// reply hangs off a post, and an ask pauses the member's own task, never the run.
|
|
8
|
+
//
|
|
9
|
+
// Bound per task: the member's role and task are fixed at bind time, so a member cannot post
|
|
10
|
+
// as someone else, and its ask lands in its own task's context.
|
|
11
|
+
|
|
12
|
+
import { boardText, POST_KINDS, ASK_TYPES } from './team-board.js';
|
|
13
|
+
|
|
14
|
+
export const BOARD_TOOL_NAME = 'board';
|
|
15
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
|
|
16
|
+
|
|
17
|
+
export function boardToolSpec() {
|
|
18
|
+
return {
|
|
19
|
+
name: BOARD_TOOL_NAME,
|
|
20
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
21
|
+
description:
|
|
22
|
+
'The team board — where the other members\' work is, and where you speak to them and to the user. '
|
|
23
|
+
+ 'Actions: {"action":"read"} the threads you may see; '
|
|
24
|
+
+ '{"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task\'s thread; '
|
|
25
|
+
+ '{"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member\'s post — agree, dispute with a ref, extend; '
|
|
26
|
+
+ '{"action":"ask","type":"info|budget|permission|direction","text":"what you need and why","options":["…"]} asks the USER and waits for the answer (minutes). '
|
|
27
|
+
+ 'Ask only when you are stuck — a fact you could not find, a choice only the user can make. Otherwise decide, say what you assumed, and go on.',
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
action: { type: 'string', enum: ['read', 'post', 'reply', 'ask'] },
|
|
32
|
+
kind: { type: 'string', enum: POST_KINDS.filter((k) => k !== 'finding' && k !== 'answer' && k !== 'decision') },
|
|
33
|
+
text: { type: 'string' },
|
|
34
|
+
refs: { type: 'array', items: { type: 'string' } },
|
|
35
|
+
postId: { type: 'string', description: 'For reply: the post to reply to.' },
|
|
36
|
+
threadId: { type: 'string', description: 'For post: another thread you may see (default: your task\'s).' },
|
|
37
|
+
type: { type: 'string', enum: [...ASK_TYPES], description: 'For ask.' },
|
|
38
|
+
options: { type: 'array', items: { type: 'string' }, description: 'For ask: choices to offer the user.' },
|
|
39
|
+
},
|
|
40
|
+
required: ['action'],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const json = (v) => JSON.stringify(v);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param board the run's live board (team-board.js createBoard)
|
|
49
|
+
* @param role the member's role id
|
|
50
|
+
* @param taskId the member's task
|
|
51
|
+
* @param taskIds the tasks this member may read (its dependencies, or null for all)
|
|
52
|
+
* @param waitFor `async (threadId, timeoutMs, signal) => { text, by } | null` — the runner's answer box
|
|
53
|
+
* @param onAsk `(thread, post) => void` — the runner marks the task waiting
|
|
54
|
+
*/
|
|
55
|
+
export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
|
|
56
|
+
const ownThread = () => board.threadForTask(taskId) || board.openThread({ taskId, kind: 'task', title: taskId, by: 'runner' });
|
|
57
|
+
return {
|
|
58
|
+
id: 'board',
|
|
59
|
+
specs: [boardToolSpec()],
|
|
60
|
+
system: 'Other members\' work is on the board (the `board` tool): read it before repeating a lookup, reply where you disagree, and ask the user only when you are stuck.',
|
|
61
|
+
async execute(name, input) {
|
|
62
|
+
if (name !== BOARD_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
|
|
63
|
+
const action = String(input?.action || '');
|
|
64
|
+
if (action === 'read') {
|
|
65
|
+
const text = boardText(board, { taskIds, role });
|
|
66
|
+
const threads = board.threads().filter((t) => t.kind !== 'proposal').map((t) => ({ id: t.id, kind: t.kind, title: t.title, status: t.status, by: t.by, posts: t.posts }));
|
|
67
|
+
return json({ board: text || 'Nothing on the board yet.', threads, postIds: board.posts().filter((p) => p.kind !== 'answer').map((p) => ({ id: p.id, threadId: p.threadId, by: p.by, kind: p.kind, head: p.text.slice(0, 80) })) });
|
|
68
|
+
}
|
|
69
|
+
const text = String(input?.text || '').trim();
|
|
70
|
+
if (action === 'post' || action === 'reply') {
|
|
71
|
+
if (!text) return json({ error: `${action} needs text.` });
|
|
72
|
+
let threadId = ownThread().id;
|
|
73
|
+
let replyTo = null;
|
|
74
|
+
if (action === 'reply') {
|
|
75
|
+
const target = board.postById(String(input?.postId || ''));
|
|
76
|
+
if (!target) return json({ error: `No post ${input?.postId}. Read the board for post ids.` });
|
|
77
|
+
threadId = target.threadId; replyTo = target.id;
|
|
78
|
+
} else if (input?.threadId && board.thread(String(input.threadId))) {
|
|
79
|
+
threadId = String(input.threadId);
|
|
80
|
+
}
|
|
81
|
+
const post = board.post({ threadId, by: role, kind: input?.kind || 'note', text, refs: input?.refs, replyTo });
|
|
82
|
+
return json({ posted: post.id, threadId, replyTo });
|
|
83
|
+
}
|
|
84
|
+
if (action === 'ask') {
|
|
85
|
+
if (!text) return json({ error: 'ask needs text — what you need and why.' });
|
|
86
|
+
if (typeof waitFor !== 'function') return json({ error: 'This run cannot wait for the user. Decide on your best assumption and say what you assumed.' });
|
|
87
|
+
const { thread, post } = board.ask({ taskId, by: role, type: input?.type, text, options: input?.options, timeoutMs: askTimeoutMs });
|
|
88
|
+
onAsk?.(thread, post);
|
|
89
|
+
const answer = await waitFor(thread.id, askTimeoutMs, signal);
|
|
90
|
+
if (!answer) return json({ answered: false, threadId: thread.id, hint: 'No answer arrived in time. Proceed on your best assumption, say what you assumed, and note that the user did not answer.' });
|
|
91
|
+
return json({ answered: true, threadId: thread.id, answer: answer.text, by: answer.by });
|
|
92
|
+
}
|
|
93
|
+
return json({ error: `Unknown action "${action}". Use read, post, reply or ask.` });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The runner's answer box: `wait(threadId, ms, signal)` resolves when `answer(threadId, …)`
|
|
100
|
+
* is called — by the host, from its own UI or from the run store's tail when the OTHER
|
|
101
|
+
* client answered. An answer that arrives before anyone waits is kept.
|
|
102
|
+
*/
|
|
103
|
+
export function createAnswerBox() {
|
|
104
|
+
const waiting = new Map(); // threadId -> resolve
|
|
105
|
+
const early = new Map(); // threadId -> answer
|
|
106
|
+
return {
|
|
107
|
+
wait(threadId, timeoutMs, signal = null) {
|
|
108
|
+
if (early.has(threadId)) { const a = early.get(threadId); early.delete(threadId); return Promise.resolve(a); }
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
let timer = null;
|
|
111
|
+
const done = (v) => { clearTimeout(timer); waiting.delete(threadId); signal?.removeEventListener?.('abort', onAbort); resolve(v); };
|
|
112
|
+
const onAbort = () => done(null);
|
|
113
|
+
waiting.set(threadId, done);
|
|
114
|
+
if (timeoutMs > 0) timer = setTimeout(() => done(null), timeoutMs);
|
|
115
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
116
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
answer(threadId, answer) {
|
|
120
|
+
const a = { text: String(answer?.text ?? answer ?? ''), by: answer?.by || 'person', id: answer?.id || null };
|
|
121
|
+
const w = waiting.get(threadId);
|
|
122
|
+
if (w) w(a); else early.set(threadId, a);
|
|
123
|
+
return !!w;
|
|
124
|
+
},
|
|
125
|
+
get pending() { return [...waiting.keys()]; },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The host's toolset with the board tool added — first, so it is read first. */
|
|
130
|
+
export function withBoardTool(toolset, provider) {
|
|
131
|
+
if (!toolset) {
|
|
132
|
+
return {
|
|
133
|
+
specs: [...provider.specs], system: provider.system,
|
|
134
|
+
execute: (name, input) => provider.execute(name, input),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const own = new Set(provider.specs.map((s) => s.name));
|
|
138
|
+
return {
|
|
139
|
+
...toolset,
|
|
140
|
+
specs: [...provider.specs, ...(toolset.specs || []).filter((s) => !own.has(s.name))],
|
|
141
|
+
system: [provider.system, toolset.system].filter(Boolean).join('\n\n'),
|
|
142
|
+
execute: (name, input, ...rest) => (own.has(name) ? provider.execute(name, input) : toolset.execute(name, input, ...rest)),
|
|
143
|
+
};
|
|
144
|
+
}
|
package/budget.js
CHANGED
|
@@ -67,7 +67,7 @@ export function usageOf(u = {}) {
|
|
|
67
67
|
export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
68
68
|
const v = validateBudget(declared);
|
|
69
69
|
if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
|
|
70
|
-
const cap = normalizeBudget(declared);
|
|
70
|
+
const cap = { ...normalizeBudget(declared) };
|
|
71
71
|
const startedAt = now();
|
|
72
72
|
const spent = { tokens: 0, calls: 0, usd: 0 };
|
|
73
73
|
const elapsed = () => now() - startedAt;
|
|
@@ -103,6 +103,12 @@ export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
|
103
103
|
},
|
|
104
104
|
remaining,
|
|
105
105
|
exhausted,
|
|
106
|
+
/** A person raised the cap mid-run (a budget ask answered "allow"): by a factor, once. */
|
|
107
|
+
raise(factor = 1.5) {
|
|
108
|
+
const f = Math.max(1, Number(factor) || 1);
|
|
109
|
+
for (const k of Object.keys(cap)) if (cap[k] !== undefined) cap[k] = Math.ceil(cap[k] * f);
|
|
110
|
+
return { ...cap };
|
|
111
|
+
},
|
|
106
112
|
snapshot() {
|
|
107
113
|
return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
|
|
108
114
|
},
|
package/index.js
CHANGED
|
@@ -194,8 +194,9 @@ export { recipeToolProvider, recipeToolSpec, describeRecipeForApproval, RECIPE_T
|
|
|
194
194
|
export { createBudget, validateBudget, normalizeBudget, usageOf, BudgetError, BUDGET_DIMENSIONS } from './budget.js';
|
|
195
195
|
export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE, STARTER_TEAMS, starterTeams, blankTeam, teamFromForm, slugTeamName } from './team.js';
|
|
196
196
|
export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
|
|
197
|
-
export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS } from './team-board.js';
|
|
198
|
-
export {
|
|
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
|
+
export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
199
|
+
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
199
200
|
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
200
201
|
export { teamLine, teamLanes } from './team-trail.js';
|
|
201
202
|
export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.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",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"./synthesis.js": "./synthesis.js",
|
|
91
91
|
"./tags.js": "./tags.js",
|
|
92
92
|
"./team-board.js": "./team-board.js",
|
|
93
|
+
"./board-tool.js": "./board-tool.js",
|
|
93
94
|
"./team-plan.js": "./team-plan.js",
|
|
94
95
|
"./team-run.js": "./team-run.js",
|
|
95
96
|
"./team-tool.js": "./team-tool.js",
|
|
@@ -205,6 +206,7 @@
|
|
|
205
206
|
"synthesis.js",
|
|
206
207
|
"tags.js",
|
|
207
208
|
"team-board.js",
|
|
209
|
+
"board-tool.js",
|
|
208
210
|
"team-plan.js",
|
|
209
211
|
"team-run.js",
|
|
210
212
|
"team-tool.js",
|
package/team-board.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// The board —
|
|
1
|
+
// The board — where a team's members meet: a message board, not a log.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// One board per run. A THREAD per task (and one per ask, discussion or proposal); POSTS in
|
|
4
|
+
// threads — a member's findings, its notes, a question, a person's answer, a draft, a
|
|
5
|
+
// decision — and REPLIES hanging off posts. Members do not read each other's transcripts:
|
|
6
|
+
// a task ends with FINDINGS (claims with the refs they came from, I-K1), those become posts
|
|
7
|
+
// in its thread, and a later task reads the threads it depends on, threaded and sized like a
|
|
8
|
+
// shielded tool result. A member that disagrees replies where it disagrees; a member that
|
|
9
|
+
// is stuck ASKS, and its task waits for a person — on either client — to answer. A person
|
|
10
|
+
// posting is a member posting, and a person's decision on any post wins.
|
|
11
|
+
//
|
|
12
|
+
// The board is the run's record: durable (every change is an event the run store folds with
|
|
13
|
+
// `foldBoard`), attributable per member, and — after a run — what can become draft briefs
|
|
14
|
+
// and be promoted on convergence (W7), rather than evaporating with the run.
|
|
9
15
|
//
|
|
10
16
|
// Findings are parsed GENEROUSLY from a model's answer through the structured layer, and a
|
|
11
17
|
// task whose answer cannot be read as findings is not lost: its whole answer becomes one
|
|
@@ -65,46 +71,225 @@ export function parseFindings(text, { role, taskId } = {}) {
|
|
|
65
71
|
return [stamp({ kind: 'draft', text: prose.slice(0, 2000), refs: [] }, 0)];
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
// ── threads, posts, asks ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
|
|
77
|
+
export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'approved', 'rejected']);
|
|
78
|
+
export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
|
|
79
|
+
export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
|
|
80
|
+
export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
|
|
81
|
+
export const RUNNER = 'runner';
|
|
82
|
+
export const PERSON = 'person';
|
|
83
|
+
export const MAX_POST_TEXT = 4000;
|
|
84
|
+
|
|
85
|
+
const clip = (t, n) => String(t || '').trim().slice(0, n);
|
|
86
|
+
const refsOf = (r) => (Array.isArray(r) ? r.map(String).filter(Boolean).slice(0, 8) : []);
|
|
87
|
+
|
|
88
|
+
/** The empty folded state — what a run record holds, what `foldBoard` grows. */
|
|
89
|
+
export function emptyBoardState() { return { threads: [], posts: [] }; }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fold one board event into a state (pure; the gateway's run store and both clients use it).
|
|
93
|
+
* Ids dedupe: an answer posted through the gateway and echoed by the running client's runner
|
|
94
|
+
* arrives twice with one id and lands once.
|
|
95
|
+
*/
|
|
96
|
+
export function foldBoard(state, ev) {
|
|
97
|
+
const s = state && Array.isArray(state.threads) && Array.isArray(state.posts) ? state : emptyBoardState();
|
|
98
|
+
const type = String(ev?.type || '');
|
|
99
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : ev || {};
|
|
100
|
+
if (type === 'board.thread' && p.thread?.id) {
|
|
101
|
+
if (!s.threads.some((t) => t.id === p.thread.id)) s.threads.push({ ...p.thread });
|
|
102
|
+
} else if (type === 'board.post' && p.post?.id) {
|
|
103
|
+
if (!s.posts.some((x) => x.id === p.post.id)) s.posts.push({ ...p.post });
|
|
104
|
+
const t = s.threads.find((x) => x.id === p.post.threadId);
|
|
105
|
+
if (t) { t.lastAt = p.post.at; t.lastBy = p.post.by; t.posts = (t.posts || 0) + 1; }
|
|
106
|
+
} else if (type === 'board.decision' && p.postId) {
|
|
107
|
+
const x = s.posts.find((q) => q.id === p.postId);
|
|
108
|
+
if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
|
|
109
|
+
} else if (type === 'board.thread-status' && p.threadId) {
|
|
110
|
+
const t = s.threads.find((x) => x.id === p.threadId);
|
|
111
|
+
if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; }
|
|
112
|
+
}
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The live board a run works on. `state` seeds it (a resume); `onEvent` receives every
|
|
118
|
+
* change as the event the run store folds. The legacy findings API (`add`, `all`, `byTask`)
|
|
119
|
+
* stays: a finding is a post of kind `finding` in its task's thread.
|
|
120
|
+
*/
|
|
121
|
+
export function createBoard({ now = () => Date.now(), newId = null, state = null, onEvent = null } = {}) {
|
|
122
|
+
const st = state && Array.isArray(state.threads) ? { threads: state.threads.map((t) => ({ ...t })), posts: (state.posts || []).map((x) => ({ ...x })) } : emptyBoardState();
|
|
70
123
|
const listeners = new Set();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
124
|
+
let seq = st.posts.length + st.threads.length;
|
|
125
|
+
const mk = (prefix) => (newId ? newId(prefix) : `${prefix}_${(++seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`);
|
|
126
|
+
const say = (type, payload) => { const ev = { type, at: now(), ...payload }; if (onEvent) onEvent(type, ev); return ev; };
|
|
127
|
+
const threadOf = (id) => st.threads.find((t) => t.id === id);
|
|
128
|
+
const postOf = (id) => st.posts.find((x) => x.id === id);
|
|
129
|
+
const threadForTask = (taskId) => st.threads.find((t) => t.kind === 'task' && t.taskId === taskId);
|
|
130
|
+
|
|
131
|
+
const api = {
|
|
132
|
+
/** Open a thread. A task's thread is opened once; asking for it again returns it. */
|
|
133
|
+
openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null } = {}) {
|
|
134
|
+
if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
|
|
135
|
+
const thread = { id: id || mk('th'), kind: THREAD_KINDS.includes(kind) ? kind : 'discussion', taskId, title: clip(title, 200), by, status: THREAD_STATUSES.includes(status) ? status : 'open', at: now(), posts: 0, ...(ask ? { ask } : {}) };
|
|
136
|
+
st.threads.push(thread);
|
|
137
|
+
say('board.thread', { thread: { ...thread } });
|
|
138
|
+
return thread;
|
|
139
|
+
},
|
|
140
|
+
/** A post in a thread; `replyTo` makes it a reply. */
|
|
141
|
+
post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null } = {}) {
|
|
142
|
+
const t = threadOf(threadId);
|
|
143
|
+
if (!t) throw new Error(`no thread ${threadId}`);
|
|
144
|
+
if (id && postOf(id)) return postOf(id);
|
|
145
|
+
const post = {
|
|
146
|
+
id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
|
|
147
|
+
text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
|
|
148
|
+
...(finding ? { finding } : {}), ...(ask ? { ask } : {}),
|
|
149
|
+
};
|
|
150
|
+
st.posts.push(post);
|
|
151
|
+
t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
|
|
152
|
+
say('board.post', { post: { ...post } });
|
|
153
|
+
for (const l of listeners) l(post);
|
|
154
|
+
return post;
|
|
155
|
+
},
|
|
156
|
+
/** A reply hangs off a post and lives in that post's thread. */
|
|
157
|
+
reply({ postId, ...args }) {
|
|
158
|
+
const target = postOf(postId || args.replyTo);
|
|
159
|
+
if (!target) throw new Error(`no post ${postId}`);
|
|
160
|
+
return api.post({ ...args, threadId: target.threadId, replyTo: target.id });
|
|
161
|
+
},
|
|
162
|
+
/** A person's (or the judge's) decision on a post. */
|
|
163
|
+
decide(postId, status, by = PERSON) {
|
|
164
|
+
const x = postOf(postId);
|
|
165
|
+
if (!x || !['approved', 'rejected', 'proposed', 'open'].includes(status)) return null;
|
|
166
|
+
x.status = status; x.decidedBy = by; x.decidedAt = now();
|
|
167
|
+
say('board.decision', { postId, status, by, at: x.decidedAt });
|
|
168
|
+
return x;
|
|
169
|
+
},
|
|
170
|
+
setThreadStatus(threadId, status, extra = {}) {
|
|
171
|
+
const t = threadOf(threadId);
|
|
172
|
+
if (!t || !THREAD_STATUSES.includes(status)) return null;
|
|
173
|
+
t.status = status; if (status !== 'waiting') t.waitingOn = null; Object.assign(t, extra);
|
|
174
|
+
say('board.thread-status', { threadId, status, ...extra });
|
|
175
|
+
return t;
|
|
176
|
+
},
|
|
177
|
+
/**
|
|
178
|
+
* A member is stuck: open an ask thread (status `waiting`) with the question as its
|
|
179
|
+
* first post. The runner waits on it; a person answers from either client.
|
|
180
|
+
*/
|
|
181
|
+
ask({ taskId = null, by, type = 'info', text, options = [], timeoutMs = 0, title = '' } = {}) {
|
|
182
|
+
const ask = { type: ASK_TYPES.includes(type) ? type : 'info', options: (Array.isArray(options) ? options : []).map(String).slice(0, 6), timeoutMs };
|
|
183
|
+
const thread = api.openThread({ taskId, kind: 'ask', title: title || clip(text, 120), by, status: 'waiting', ask });
|
|
184
|
+
thread.waitingOn = PERSON;
|
|
185
|
+
const post = api.post({ threadId: thread.id, by, kind: 'question', text, ask });
|
|
186
|
+
return { thread, post };
|
|
187
|
+
},
|
|
188
|
+
/** The answer to an ask — from a person, on any client. Resolves the thread. */
|
|
189
|
+
answer(threadId, { id = null, text, by = PERSON } = {}) {
|
|
190
|
+
const t = threadOf(threadId);
|
|
191
|
+
if (!t) return null;
|
|
192
|
+
const post = api.post({ id, threadId, by, kind: 'answer', text });
|
|
193
|
+
api.setThreadStatus(threadId, 'resolved', { answeredAt: post.at });
|
|
194
|
+
return post;
|
|
195
|
+
},
|
|
196
|
+
thread: threadOf,
|
|
197
|
+
threadForTask,
|
|
198
|
+
threads: () => st.threads.map((t) => ({ ...t })),
|
|
199
|
+
posts: (threadId = null) => st.posts.filter((x) => !threadId || x.threadId === threadId).map((x) => ({ ...x })),
|
|
200
|
+
postById: postOf,
|
|
201
|
+
/** Every ask still waiting — what a client pins at the top. */
|
|
202
|
+
waiting: () => st.threads.filter((t) => t.kind === 'ask' && t.status === 'waiting').map((t) => ({ ...t })),
|
|
203
|
+
/** The whole board, for the run record and for a resume. */
|
|
204
|
+
state: () => ({ threads: st.threads.map((t) => ({ ...t })), posts: st.posts.map((x) => ({ ...x })) }),
|
|
205
|
+
|
|
206
|
+
// ── findings, as before: a finding is a post in its task's thread ──
|
|
207
|
+
add(list, { by = null } = {}) {
|
|
208
|
+
let n = 0;
|
|
74
209
|
for (const f of Array.isArray(list) ? list : [list]) {
|
|
75
210
|
if (!f || !f.text) continue;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
211
|
+
const t = f.taskId ? (threadForTask(f.taskId) || api.openThread({ taskId: f.taskId, kind: 'task', title: f.taskId })) : (st.threads.find((x) => x.kind === 'discussion' && x.title === 'findings') || api.openThread({ kind: 'discussion', title: 'findings' }));
|
|
212
|
+
api.post({ id: f.id, threadId: t.id, by: by || f.role || RUNNER, kind: 'finding', text: f.text, refs: f.refs, finding: { kind: f.kind || 'claim', confidence: f.confidence ?? null } });
|
|
213
|
+
n += 1;
|
|
79
214
|
}
|
|
80
|
-
return
|
|
215
|
+
return n;
|
|
81
216
|
},
|
|
82
|
-
all: () =>
|
|
83
|
-
byTask: (taskId) =>
|
|
84
|
-
byRole: (role) =>
|
|
85
|
-
onFinding(fn) { listeners.add(
|
|
86
|
-
get size() { return
|
|
217
|
+
all: () => st.posts.filter((x) => x.kind === 'finding').map(asFinding(st)),
|
|
218
|
+
byTask: (taskId) => api.all().filter((f) => f.taskId === taskId),
|
|
219
|
+
byRole: (role) => api.all().filter((f) => f.role === role),
|
|
220
|
+
onFinding(fn) { const l = (p) => { if (p.kind === 'finding') fn(asFinding(st)(p)); }; listeners.add(l); return () => listeners.delete(l); },
|
|
221
|
+
get size() { return st.posts.filter((x) => x.kind === 'finding').length; },
|
|
87
222
|
};
|
|
223
|
+
return api;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A finding post in the legacy finding shape (what merge, briefs and lanes read). */
|
|
227
|
+
function asFinding(st) {
|
|
228
|
+
return (p) => {
|
|
229
|
+
const t = st.threads.find((x) => x.id === p.threadId);
|
|
230
|
+
return { id: p.id, kind: p.finding?.kind || 'claim', text: p.text, refs: p.refs || [], confidence: p.finding?.confidence ?? null, role: p.by, taskId: t?.taskId || null, at: p.at, status: p.status };
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Findings from a folded state (a run record read from the store). */
|
|
235
|
+
export function findingsOf(state) {
|
|
236
|
+
const st = state && Array.isArray(state.posts) ? state : emptyBoardState();
|
|
237
|
+
return st.posts.filter((x) => x.kind === 'finding').map(asFinding(st));
|
|
88
238
|
}
|
|
89
239
|
|
|
90
240
|
/**
|
|
91
241
|
* What a later task READS: the findings of the tasks it depends on (or everything so far),
|
|
92
242
|
* sized. Newest are kept whole; the oldest are what get cut, and the cut is stated.
|
|
93
243
|
*/
|
|
94
|
-
export function boardText(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const lines =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
244
|
+
export function boardText(source, { taskIds = null, max = BOARD_TEXT_MAX, role = null } = {}) {
|
|
245
|
+
// A board (threads + posts) reads threaded; a bare findings array reads as before.
|
|
246
|
+
const state = source && typeof source.state === 'function' ? source.state() : (source && Array.isArray(source.posts) ? source : null);
|
|
247
|
+
const lines = state ? threadedLines(state, { taskIds, role }) : findingLines(source, taskIds);
|
|
248
|
+
if (!lines.length) return '';
|
|
249
|
+
const head = state ? 'The board so far' : 'Findings so far';
|
|
250
|
+
const out = lines.join('\n');
|
|
251
|
+
if (out.length <= max) return `${head}:\n${out}`;
|
|
252
|
+
const kept = [];
|
|
101
253
|
let size = 0;
|
|
102
254
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
103
255
|
if (size + lines[i].length + 1 > max) break;
|
|
104
256
|
kept.unshift(lines[i]);
|
|
105
257
|
size += lines[i].length + 1;
|
|
106
258
|
}
|
|
107
|
-
return
|
|
259
|
+
return `${head} (${lines.length - kept.length} earlier lines omitted for length):\n${kept.join('\n')}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function findingLines(findings, taskIds) {
|
|
263
|
+
return (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId))
|
|
264
|
+
.map((f) => `- [${f.kind}${f.role ? ` · ${f.role}` : ''}${f.confidence != null ? ` · ${Math.round(f.confidence * 100)}%` : ''}] ${f.text}${f.refs?.length ? ` (refs: ${f.refs.join(', ')})` : ''}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What a member READS: the task threads it depends on (or every task thread), the answered
|
|
269
|
+
* asks and settled discussions — never rejected posts — threaded, a reply under its post.
|
|
270
|
+
* A person's decision is marked so a member treats it as settled.
|
|
271
|
+
*/
|
|
272
|
+
function threadedLines(state, { taskIds, role }) {
|
|
273
|
+
const out = [];
|
|
274
|
+
const posts = state.posts;
|
|
275
|
+
const threads = state.threads.filter((t) => {
|
|
276
|
+
if (t.kind === 'task') return !taskIds || taskIds.includes(t.taskId);
|
|
277
|
+
if (t.kind === 'ask') return t.status === 'resolved' && (!role || t.by === role || t.by === RUNNER);
|
|
278
|
+
if (t.kind === 'discussion') return true;
|
|
279
|
+
return false; // a proposal is the run's output, not a member's input
|
|
280
|
+
});
|
|
281
|
+
for (const t of threads) {
|
|
282
|
+
const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
|
|
283
|
+
if (!own.length) continue;
|
|
284
|
+
out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
|
|
285
|
+
const line = (x, depth) => {
|
|
286
|
+
const tag = [x.kind === 'finding' ? (x.finding?.kind || 'claim') : x.kind, x.by, x.finding?.confidence != null ? `${Math.round(x.finding.confidence * 100)}%` : null, x.status === 'approved' ? 'APPROVED' : x.status === 'proposed' ? 'proposed' : null, x.kind === 'decision' || x.by === PERSON ? 'SETTLED' : null].filter(Boolean).join(' · ');
|
|
287
|
+
out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
|
|
288
|
+
for (const r of own.filter((y) => y.replyTo === x.id)) line(r, depth + 1);
|
|
289
|
+
};
|
|
290
|
+
for (const x of own.filter((y) => !y.replyTo)) line(x, 0);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
108
293
|
}
|
|
109
294
|
|
|
110
295
|
/** Findings → the claim shape a draft brief takes (W7): text + refs as `{ kind, id }`. */
|
package/team-run.js
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
import { normalizeTeam } from './team.js';
|
|
20
20
|
import { createBudget } from './budget.js';
|
|
21
21
|
import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
|
|
22
|
-
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims } from './team-board.js';
|
|
22
|
+
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
|
|
23
|
+
import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
23
24
|
import { converge } from './promotion.js';
|
|
24
25
|
|
|
25
|
-
export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
|
|
26
|
+
export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
|
|
26
27
|
const DEFAULT_CONCURRENCY = 3;
|
|
27
28
|
|
|
28
29
|
export class TeamRunError extends Error {
|
|
@@ -88,15 +89,29 @@ export async function runTeam({
|
|
|
88
89
|
team, request, callModel, toolsFor = () => undefined, appoint = null, runRecipe = null,
|
|
89
90
|
now = () => Date.now(), newId = () => `run_${Math.random().toString(36).slice(2, 10)}`,
|
|
90
91
|
emit = () => {}, signal = null, maxConcurrency = DEFAULT_CONCURRENCY, runId = null,
|
|
92
|
+
// Asks: the box a person's answers arrive in (the host feeds it from its UI and from the
|
|
93
|
+
// run store's tail), and how long a member waits before the run checkpoints. 0 = a member
|
|
94
|
+
// that asks is told to proceed on its own assumption at once.
|
|
95
|
+
answers = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS,
|
|
96
|
+
// A checkpoint from a run that ended `waiting` — see resumeTeam.
|
|
97
|
+
resume = null,
|
|
91
98
|
} = {}) {
|
|
92
99
|
if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
|
|
93
100
|
const t = normalizeTeam(team); // throws on a team without a budget — O1
|
|
94
|
-
const id = runId || newId();
|
|
95
|
-
const budget = createBudget(t.budget, { now });
|
|
96
|
-
|
|
97
|
-
const startedAt = now();
|
|
98
|
-
const tasksOut = [];
|
|
101
|
+
const id = runId || resume?.runId || newId();
|
|
102
|
+
const budget = createBudget(resume?.budget?.cap || t.budget, { now });
|
|
103
|
+
if (resume?.budget?.spent) budget.charge({ tokens: resume.budget.spent.tokens, calls: resume.budget.spent.calls, usd: resume.budget.spent.usd });
|
|
99
104
|
const say = (type, payload = {}) => emit(type, { runId: id, at: now(), ...payload });
|
|
105
|
+
// Every change to the board is an event the run store folds — the other client reads it live.
|
|
106
|
+
const board = createBoard({ now, state: resume?.board || null, onEvent: (type, ev) => say(type, ev) });
|
|
107
|
+
// No answer box from the host means nobody can answer: asks are off, a member that asks is
|
|
108
|
+
// told to proceed on its own assumption at once, and the budget stop is final.
|
|
109
|
+
const box = answers || createAnswerBox();
|
|
110
|
+
const askMs = answers ? askTimeoutMs : 0;
|
|
111
|
+
const startedAt = resume?.startedAt || now();
|
|
112
|
+
// Tasks a checkpoint already finished are carried over, not re-run.
|
|
113
|
+
const tasksOut = (resume?.tasks || []).filter((x) => x.status === 'ok').map((x) => ({ ...x }));
|
|
114
|
+
const carried = new Set(tasksOut.map((x) => x.id));
|
|
100
115
|
const roleOf = (rid) => t.roles.find((r) => r.id === rid);
|
|
101
116
|
// `exclude` holds what failed as unavailable this run; a re-appointment skips it. A role's
|
|
102
117
|
// pinned model is tried first and, when it is the one that failed, the roster steps in.
|
|
@@ -107,12 +122,12 @@ export async function runTeam({
|
|
|
107
122
|
const MAX_APPOINTMENTS = 3;
|
|
108
123
|
const stopped = () => !!signal?.aborted;
|
|
109
124
|
|
|
110
|
-
say('run.started', { team: t.name, request: String(request || ''), budget:
|
|
125
|
+
say(resume ? 'run.resumed' : 'run.started', { team: t.name, request: String(request || ''), budget: budget.cap, roles: t.roles.map((r) => r.id), ...(resume ? { carried: [...carried] } : {}) });
|
|
111
126
|
|
|
112
127
|
// ── plan ──────────────────────────────────────────────────────────────────────────────
|
|
113
|
-
let tasks;
|
|
114
|
-
let planBy = 'fixed';
|
|
115
|
-
if (t.plan === 'planner') {
|
|
128
|
+
let tasks = resume?.plan?.tasks?.length ? resume.plan.tasks : null;
|
|
129
|
+
let planBy = resume?.plan?.by || 'fixed';
|
|
130
|
+
if (!tasks && t.plan === 'planner') {
|
|
116
131
|
const planner = strongestRole(t);
|
|
117
132
|
const m = modelFor(planner);
|
|
118
133
|
if (m && budget.canAfford({ tokens: 0 })) {
|
|
@@ -124,20 +139,37 @@ export async function runTeam({
|
|
|
124
139
|
}
|
|
125
140
|
if (!tasks) tasks = fixedPlan(t, request);
|
|
126
141
|
say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn })) });
|
|
142
|
+
// A thread per task, before anything runs: a member's findings and replies have a home.
|
|
143
|
+
for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: RUNNER });
|
|
127
144
|
|
|
128
145
|
const finish = (status, extra = {}) => {
|
|
129
146
|
const usage = budget.snapshot();
|
|
130
|
-
const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), usage, startedAt, endedAt: now(), ...extra };
|
|
131
|
-
|
|
147
|
+
const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, startedAt, endedAt: now(), ...extra };
|
|
148
|
+
// What a resume needs, on the record: the plan, what finished, the board, the spend.
|
|
149
|
+
if (status === 'waiting') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.state(), budget: { cap: budget.cap, spent: usage.spent } };
|
|
150
|
+
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 } : {}) });
|
|
132
151
|
return out;
|
|
133
152
|
};
|
|
134
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The runner asks the person (a budget, a direction). Same thread shape as a member's ask;
|
|
156
|
+
* answered from either client; null when nobody answered in time or asks are off.
|
|
157
|
+
*/
|
|
158
|
+
const askPerson = async ({ type, text, options, taskId = null }) => {
|
|
159
|
+
if (askMs <= 0) return null;
|
|
160
|
+
const { thread } = board.ask({ taskId, by: RUNNER, type, text, options, timeoutMs: askMs });
|
|
161
|
+
say('run.waiting', { threadId: thread.id, askType: type, text, options });
|
|
162
|
+
return box.wait(thread.id, askMs, signal);
|
|
163
|
+
};
|
|
164
|
+
let waitingOnPerson = false; // a task that timed out on its ask — the run checkpoints
|
|
165
|
+
|
|
135
166
|
// ── fan out, in waves ─────────────────────────────────────────────────────────────────
|
|
136
167
|
let overBudget = false;
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
let budgetAsked = !!resume?.budgetAsked;
|
|
169
|
+
for (const wave of waves(tasks.filter((x) => !carried.has(x.id)))) {
|
|
170
|
+
if (stopped() || overBudget || waitingOnPerson) break;
|
|
139
171
|
await pool(wave, maxConcurrency, async (task) => {
|
|
140
|
-
if (stopped() || overBudget) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
|
|
172
|
+
if (stopped() || overBudget || waitingOnPerson) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
|
|
141
173
|
const role = roleOf(task.role);
|
|
142
174
|
const t0 = now();
|
|
143
175
|
say('task.started', { taskId: task.id, role: task.role, title: task.title });
|
|
@@ -145,6 +177,11 @@ export async function runTeam({
|
|
|
145
177
|
let usage = null;
|
|
146
178
|
let status = 'ok';
|
|
147
179
|
let error = null;
|
|
180
|
+
// The task's own abort: an ask nobody answered in time stops THIS member's turn (the
|
|
181
|
+
// run then checkpoints), without stopping the run's other members.
|
|
182
|
+
const taskAc = new AbortController();
|
|
183
|
+
signal?.addEventListener?.('abort', () => taskAc.abort(), { once: true });
|
|
184
|
+
let askedAndWaiting = null;
|
|
148
185
|
try {
|
|
149
186
|
if (role.mode === 'recipe') {
|
|
150
187
|
if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
|
|
@@ -154,10 +191,22 @@ export async function runTeam({
|
|
|
154
191
|
// A call's tokens are unknown until it returns; what can be asked beforehand is
|
|
155
192
|
// whether the budget is already exhausted and whether one more call is allowed.
|
|
156
193
|
if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
|
|
157
|
-
|
|
194
|
+
// What this member reads: the threads of the tasks it depends on, answered asks (its
|
|
195
|
+
// own — a resumed task finds the person's answer here), settled discussions.
|
|
196
|
+
const prior = boardText(board, { taskIds: task.dependsOn?.length ? task.dependsOn : null, role: role.id });
|
|
158
197
|
const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
|
|
159
198
|
// A host may build a toolset asynchronously (connecting MCP servers takes time).
|
|
160
|
-
|
|
199
|
+
// The board tool rides on top of whatever the role was granted.
|
|
200
|
+
const boardTool = boardToolProvider({
|
|
201
|
+
board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
|
|
202
|
+
onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
|
|
203
|
+
waitFor: askMs > 0 ? async (threadId, ms, sig) => {
|
|
204
|
+
const a = await box.wait(threadId, ms, sig);
|
|
205
|
+
if (!a && !stopped()) { askedAndWaiting = threadId; taskAc.abort(); }
|
|
206
|
+
return a;
|
|
207
|
+
} : null,
|
|
208
|
+
});
|
|
209
|
+
const tools = withBoardTool(await toolsFor(role), boardTool);
|
|
161
210
|
// A model that is not there — not deployed, no key, gone — is not the task failing:
|
|
162
211
|
// the next model on the roster is appointed and the task tried again, up to three
|
|
163
212
|
// models. Anything else (a refusal, a timeout, a bad request) fails the task.
|
|
@@ -168,12 +217,13 @@ export async function runTeam({
|
|
|
168
217
|
if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude] });
|
|
169
218
|
const res = await callModel({
|
|
170
219
|
runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
|
|
171
|
-
system: role.prompt, prompt, tools, signal,
|
|
220
|
+
system: role.prompt, prompt, tools, signal: taskAc.signal,
|
|
172
221
|
onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
|
|
173
222
|
});
|
|
174
223
|
usage = res?.usage || null;
|
|
175
224
|
if (usage) budget.charge(usage);
|
|
176
|
-
if (
|
|
225
|
+
if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; break; }
|
|
226
|
+
if (res?.aborted || taskAc.signal.aborted) { status = 'stopped'; break; }
|
|
177
227
|
// A turn that ended with nothing to say — an agent that exited, a stream that
|
|
178
228
|
// died after its tool calls — is not a done task: three members "completed" empty
|
|
179
229
|
// once, the run merged nothing, and the caller ran the team again. It is treated
|
|
@@ -185,21 +235,34 @@ export async function runTeam({
|
|
|
185
235
|
}
|
|
186
236
|
}
|
|
187
237
|
} catch (e) {
|
|
188
|
-
status =
|
|
189
|
-
error = String(e?.message || e);
|
|
238
|
+
if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; }
|
|
239
|
+
else { status = overBudget ? 'over-budget' : 'failed'; error = String(e?.message || e); }
|
|
190
240
|
}
|
|
191
241
|
const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
|
|
192
242
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
193
|
-
const
|
|
243
|
+
const thread = board.threadForTask(task.id);
|
|
244
|
+
if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
|
|
245
|
+
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
194
246
|
tasksOut.push(row);
|
|
195
|
-
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length });
|
|
247
|
+
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
|
196
248
|
if (budget.exhausted()) overBudget = true;
|
|
197
249
|
});
|
|
250
|
+
// Over budget with work left: ask the person ONCE for more, on the board, before stopping.
|
|
251
|
+
if (overBudget && !budgetAsked && !stopped()) {
|
|
252
|
+
budgetAsked = true;
|
|
253
|
+
const left = tasks.filter((x) => !tasksOut.some((y) => y.id === x.id)).length;
|
|
254
|
+
const spent = budget.snapshot().spent;
|
|
255
|
+
const a = await askPerson({ type: 'budget', text: `The team has used its budget (${Object.entries(spent).filter(([k]) => budget.cap[k] !== undefined).map(([k, v]) => `${k} ${v} of ${budget.cap[k]}`).join(', ')}) with ${left} task${left === 1 ? '' : 's'} left. Raise it by half, or stop here with what it has?`, options: ['Raise by half', 'Stop here'] });
|
|
256
|
+
if (a && /raise|allow|yes|more|continue/i.test(a.text)) { budget.raise(1.5); overBudget = false; }
|
|
257
|
+
}
|
|
198
258
|
}
|
|
199
259
|
// Every planned task gets a row — what never ran is recorded as skipped, not forgotten.
|
|
200
260
|
for (const task of tasks) if (!tasksOut.some((x) => x.id === task.id)) tasksOut.push({ id: task.id, role: task.role, title: task.title, status: 'skipped', text: '', findings: [] });
|
|
201
261
|
if (stopped()) return finish('stopped');
|
|
202
|
-
if (
|
|
262
|
+
if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
|
|
263
|
+
// Over budget is a STOP only when it left work undone; a budget spent on the last task
|
|
264
|
+
// is a run that finished, and the merge below falls back to the cheap one if it must.
|
|
265
|
+
if (overBudget && tasksOut.some((x) => x.status === 'skipped' || x.status === 'over-budget')) return finish('over-budget', { proposal: mergeCheap(t, board.all(), tasksOut) });
|
|
203
266
|
|
|
204
267
|
// ── merge ─────────────────────────────────────────────────────────────────────────────
|
|
205
268
|
say('run.merging', { policy: t.merge });
|
|
@@ -213,7 +276,7 @@ export async function runTeam({
|
|
|
213
276
|
const prompt = [
|
|
214
277
|
`You are the ${judge.name || judge.id} of team "${t.name}". Review the team's findings for the request below and write the final answer — accurate, concise, and only what the findings support. Flag anything the members disagreed on.`,
|
|
215
278
|
`Request: ${String(request || '').trim()}`,
|
|
216
|
-
boardText(board
|
|
279
|
+
boardText(board),
|
|
217
280
|
].join('\n\n');
|
|
218
281
|
const res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: m.model, mode: 'model', system: judge.prompt, prompt, tools: undefined, signal });
|
|
219
282
|
if (res?.usage) budget.charge(res.usage);
|
|
@@ -230,10 +293,23 @@ export async function runTeam({
|
|
|
230
293
|
} else {
|
|
231
294
|
proposal = mergeCheap(t, board.all(), tasksOut);
|
|
232
295
|
}
|
|
296
|
+
// The merge is a proposal thread: a draft the person approves, rejects or replies to.
|
|
297
|
+
if (proposal?.text || proposal?.agreed) {
|
|
298
|
+
const th = board.openThread({ kind: 'proposal', title: `Proposal — ${t.merge}`, by: proposal.by || RUNNER });
|
|
299
|
+
board.post({ threadId: th.id, by: proposal.by || RUNNER, kind: 'draft', status: 'proposed', text: proposal.text || (proposal.agreed || []).map((c) => c.text).join('\n'), refs: [] });
|
|
300
|
+
}
|
|
233
301
|
const failed = tasksOut.some((x) => x.status !== 'ok');
|
|
234
302
|
return finish(failed ? 'partial' : 'completed', { proposal });
|
|
235
303
|
}
|
|
236
304
|
|
|
305
|
+
/** Continue a run that ended `waiting` from its checkpoint — the answered ask is on the board. */
|
|
306
|
+
export function resumeTeam({ checkpoint, ...deps } = {}) {
|
|
307
|
+
if (!checkpoint?.plan?.tasks) throw new TeamRunError('BAD_RESUME', 'a checkpoint with a plan is required');
|
|
308
|
+
// The waiting task runs again; its ask thread now holds the answer, and boardText gives it
|
|
309
|
+
// to the member. Tasks recorded `waiting`/`skipped` are dropped from the carried list.
|
|
310
|
+
return runTeam({ ...deps, resume: checkpoint });
|
|
311
|
+
}
|
|
312
|
+
|
|
237
313
|
/** No model: the members' work side by side, findings first — always available. */
|
|
238
314
|
function mergeCheap(team, findings, tasks) {
|
|
239
315
|
const sections = tasks.filter((x) => x.status === 'ok').map((x) => {
|
package/team-tool.js
CHANGED
|
@@ -116,9 +116,12 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
116
116
|
if (action === 'run') {
|
|
117
117
|
if (!request) return json({ error: 'run needs a request — what should the team do?' });
|
|
118
118
|
if (typeof run !== 'function') return json({ error: 'This surface cannot run a team.' });
|
|
119
|
-
|
|
119
|
+
// ONE run per team per turn — keyed by the team, not the request: a model that
|
|
120
|
+
// re-runs after a partial result rephrases the request each time, and that was a
|
|
121
|
+
// second circle. Its partial result stands; the person decides what happens next.
|
|
122
|
+
const key = team.name;
|
|
120
123
|
const prior = ran.get(key);
|
|
121
|
-
if (prior) return json({ error: `The "${team.name}" team already ran
|
|
124
|
+
if (prior) return json({ error: `The "${team.name}" team already ran in this turn (run ${prior.runId}, ${prior.status}). Do not run it again, even with a different request: report what it produced — ${prior.summary} — with its proposal, and ask the user how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks, proposal: prior.proposal });
|
|
122
125
|
const dry = dryRunTeam(team, request, { appoint });
|
|
123
126
|
if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
|
|
124
127
|
const result = await run({ team, request, toolset: bound });
|
|
@@ -126,10 +129,10 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
126
129
|
const tasks = (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined }));
|
|
127
130
|
const failed = tasks.filter((x) => x.status !== 'ok');
|
|
128
131
|
const summary = failed.length ? failed.map((x) => `${x.role} ${x.status}${x.error ? ` (${x.error})` : ''}`).join('; ') : `${findings.length} findings`;
|
|
129
|
-
ran.set(key, { runId: result.runId, status: result.status, summary, tasks });
|
|
132
|
+
ran.set(key, { runId: result.runId, status: result.status, summary, tasks, proposal: result.proposal });
|
|
130
133
|
const hint = result.status === 'over-budget' ? 'The team stopped at its budget; the proposal is what it had. Say so.'
|
|
131
134
|
: result.status === 'failed' ? `The run FAILED — ${summary}. Do not run the team again this turn. Tell the user exactly which role failed and why, and ask whether to retry, change the team\'s models in Settings → Teams, or answer without the team.`
|
|
132
|
-
: failed.length ? `Some roles did not finish — ${summary}.
|
|
135
|
+
: failed.length ? `Some roles did not finish — ${summary}. Present the proposal as the team's answer and say which role did not finish; do NOT run the team again this turn.` : undefined;
|
|
133
136
|
return json({ name: team.name, runId: result.runId, status: result.status, proposal: result.proposal, tasks, findings, usage: result.usage, hint });
|
|
134
137
|
}
|
|
135
138
|
return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
|
package/team-trail.js
CHANGED
|
@@ -10,6 +10,11 @@ export function teamLine(ev) {
|
|
|
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
12
|
case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}` };
|
|
13
|
+
case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
|
|
14
|
+
case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
|
|
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
|
+
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
|
+
case 'board.decision': return { type: 'status', text: `${ev.by || 'someone'} ${ev.status} a post` };
|
|
13
18
|
case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable)` };
|
|
14
19
|
case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
|
|
15
20
|
case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
|
|
@@ -30,7 +35,10 @@ export function teamLanes(prev, ev) {
|
|
|
30
35
|
case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
|
|
31
36
|
case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
|
|
32
37
|
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.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;
|
|
33
39
|
case 'task.done': case 'task.failed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: ev.status || 'ok', ms: ev.ms }; break;
|
|
40
|
+
case 'board.thread-status': if (ev.status !== 'waiting' && lanes.waiting) lanes.waiting = lanes.waiting.filter((x) => x !== ev.threadId); break;
|
|
41
|
+
case 'run.waiting': lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;
|
|
34
42
|
case 'run.done': lanes.status = ev.status; lanes.usage = ev.usage; break;
|
|
35
43
|
default: break;
|
|
36
44
|
}
|