@chatpanel/events 0.81.2 → 0.82.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/team-cache.js +63 -0
- package/team-plan.js +5 -1
- package/team-run.js +27 -6
- package/team-tool.js +2 -1
package/index.js
CHANGED
|
@@ -196,6 +196,7 @@ export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows,
|
|
|
196
196
|
export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
|
|
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
|
+
export { createRunCache, withRunCache } from './team-cache.js';
|
|
199
200
|
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
200
201
|
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
201
202
|
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.82.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",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"./tags.js": "./tags.js",
|
|
92
92
|
"./team-board.js": "./team-board.js",
|
|
93
93
|
"./board-tool.js": "./board-tool.js",
|
|
94
|
+
"./team-cache.js": "./team-cache.js",
|
|
94
95
|
"./team-plan.js": "./team-plan.js",
|
|
95
96
|
"./team-run.js": "./team-run.js",
|
|
96
97
|
"./team-tool.js": "./team-tool.js",
|
|
@@ -207,6 +208,7 @@
|
|
|
207
208
|
"tags.js",
|
|
208
209
|
"team-board.js",
|
|
209
210
|
"board-tool.js",
|
|
211
|
+
"team-cache.js",
|
|
210
212
|
"team-plan.js",
|
|
211
213
|
"team-run.js",
|
|
212
214
|
"team-tool.js",
|
package/team-cache.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// One lookup per run — a run-scoped cache over read-only tools.
|
|
2
|
+
//
|
|
3
|
+
// Three members of a travel team searched "Snoqualmie Valley School District calendar" five
|
|
4
|
+
// times between them. Each member's own turn already dedupes its own repeats; the RUN did
|
|
5
|
+
// not, and members in one wave cannot read each other's findings yet. So: a read-only call
|
|
6
|
+
// with the same name and arguments, from any member, runs once per run; the rest get the
|
|
7
|
+
// first answer back, marked as shared. Only tools that declare themselves read-only (MCP
|
|
8
|
+
// annotations, the shared retrieval tools) are cached; a dispatcher's paging (`get_result`)
|
|
9
|
+
// and the board are per-member and never are.
|
|
10
|
+
|
|
11
|
+
const NEVER = new Set(['get_result', 'board', 'team', 'recipe']);
|
|
12
|
+
const READ_ONLY_NAME_RE = /^(find|web_search|history_search|history_get_source|history_list|meeting_live_transcript|read|fetch|get_|list_|search)/i;
|
|
13
|
+
|
|
14
|
+
function stableKey(name, input) {
|
|
15
|
+
const walk = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v).sort().reduce((o, k) => { o[k] = walk(v[k]); return o; }, {}) : Array.isArray(v) ? v.map(walk) : v);
|
|
16
|
+
try { return `${name} ${JSON.stringify(walk(input ?? {}))}`; } catch { return `${name} ?`; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createRunCache() {
|
|
20
|
+
const hits = new Map(); // key -> { result, by, at }
|
|
21
|
+
let shared = 0;
|
|
22
|
+
return {
|
|
23
|
+
get: (k) => hits.get(k) || null,
|
|
24
|
+
set: (k, v) => { hits.set(k, v); },
|
|
25
|
+
delete: (k) => { hits.delete(k); },
|
|
26
|
+
countShared: () => { shared += 1; },
|
|
27
|
+
get size() { return hits.size; },
|
|
28
|
+
get shared() { return shared; },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isReadOnly(toolset, name) {
|
|
33
|
+
if (NEVER.has(name)) return false;
|
|
34
|
+
const spec = (toolset.specs || []).find((s) => s.name === name);
|
|
35
|
+
if (spec?.annotations?.readOnlyHint === true) return true;
|
|
36
|
+
if (spec?.annotations && spec.annotations.readOnlyHint === false) return false;
|
|
37
|
+
const t = toolset.traits instanceof Map ? toolset.traits.get(name) : null;
|
|
38
|
+
if (t && typeof t.readOnly === 'boolean') return t.readOnly;
|
|
39
|
+
return READ_ONLY_NAME_RE.test(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The toolset with its read-only calls served from the run's cache. */
|
|
43
|
+
export function withRunCache(toolset, cache, { role = '' } = {}) {
|
|
44
|
+
if (!toolset || !cache) return toolset;
|
|
45
|
+
return {
|
|
46
|
+
...toolset,
|
|
47
|
+
execute: async (name, input, ...rest) => {
|
|
48
|
+
if (!isReadOnly(toolset, name)) return toolset.execute(name, input, ...rest);
|
|
49
|
+
const key = stableKey(name, input);
|
|
50
|
+
const hit = cache.get(key);
|
|
51
|
+
if (hit) {
|
|
52
|
+
// Members in one wave ask at the same moment: the second waits on the first's call.
|
|
53
|
+
cache.countShared();
|
|
54
|
+
const result = await hit.promise;
|
|
55
|
+
const note = `[shared: ${hit.by || 'another member'} already ran this in this run]\n`;
|
|
56
|
+
return typeof result === 'string' ? note + result : result;
|
|
57
|
+
}
|
|
58
|
+
const promise = Promise.resolve().then(() => toolset.execute(name, input, ...rest));
|
|
59
|
+
cache.set(key, { promise, by: role, at: Date.now() });
|
|
60
|
+
try { return await promise; } catch (e) { cache.delete(key); throw e; }
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/team-plan.js
CHANGED
|
@@ -52,7 +52,11 @@ export function plannerPrompt(team, request) {
|
|
|
52
52
|
/** One task per role, in declaration order, with the roles' own dependencies. */
|
|
53
53
|
export function fixedPlan(team, request) {
|
|
54
54
|
const req = String(request || '').trim();
|
|
55
|
-
|
|
55
|
+
// The judge's work IS the merge: it reads the whole board and writes the answer. Giving it
|
|
56
|
+
// a task as well made a planner plan twice — once blind, in the first wave, and once at
|
|
57
|
+
// the end — and the first, done with no findings to read, was pure duplicate research.
|
|
58
|
+
const judge = team.merge === 'judge' ? team.judge : null;
|
|
59
|
+
return (team.roles || []).filter((r) => r.id !== judge || (team.roles || []).length === 1).map((r) => ({
|
|
56
60
|
id: `t_${r.id}`,
|
|
57
61
|
role: r.id,
|
|
58
62
|
title: r.name || r.id,
|
package/team-run.js
CHANGED
|
@@ -21,6 +21,7 @@ import { createBudget } from './budget.js';
|
|
|
21
21
|
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
|
+
import { createRunCache, withRunCache } from './team-cache.js';
|
|
24
25
|
import { converge } from './promotion.js';
|
|
25
26
|
|
|
26
27
|
export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
|
|
@@ -106,6 +107,7 @@ export async function runTeam({
|
|
|
106
107
|
const board = createBoard({ now, state: resume?.board || null, onEvent: (type, ev) => say(type, ev) });
|
|
107
108
|
// No answer box from the host means nobody can answer: asks are off, a member that asks is
|
|
108
109
|
// told to proceed on its own assumption at once, and the budget stop is final.
|
|
110
|
+
const runCache = createRunCache(); // one lookup per run, across members
|
|
109
111
|
const box = answers || createAnswerBox();
|
|
110
112
|
const askMs = answers ? askTimeoutMs : 0;
|
|
111
113
|
const startedAt = resume?.startedAt || now();
|
|
@@ -144,7 +146,7 @@ export async function runTeam({
|
|
|
144
146
|
|
|
145
147
|
const finish = (status, extra = {}) => {
|
|
146
148
|
const usage = budget.snapshot();
|
|
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 };
|
|
149
|
+
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 };
|
|
148
150
|
// What a resume needs, on the record: the plan, what finished, the board, the spend.
|
|
149
151
|
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
152
|
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 } : {}) });
|
|
@@ -206,7 +208,7 @@ export async function runTeam({
|
|
|
206
208
|
return a;
|
|
207
209
|
} : null,
|
|
208
210
|
});
|
|
209
|
-
const tools = withBoardTool(await toolsFor(role), boardTool);
|
|
211
|
+
const tools = withBoardTool(withRunCache(await toolsFor(role), runCache, { role: role.id }), boardTool);
|
|
210
212
|
// A model that is not there — not deployed, no key, gone — is not the task failing:
|
|
211
213
|
// the next model on the roster is appointed and the task tried again, up to three
|
|
212
214
|
// models. Anything else (a refusal, a timeout, a bad request) fails the task.
|
|
@@ -278,13 +280,32 @@ export async function runTeam({
|
|
|
278
280
|
const m = modelFor(judge);
|
|
279
281
|
if (m?.model && budget.canAfford({ tokens: 0 })) {
|
|
280
282
|
const prompt = [
|
|
281
|
-
`You are the ${judge.name || judge.id} of team "${t.name}".
|
|
283
|
+
`You are the ${judge.name || judge.id} of team "${t.name}". The members' work is on the board below (and in the board tool). Write the team's FINAL ANSWER to the request: complete, well organised, only what the findings support, with the refs they came from. Say plainly what was not found or assumed. Flag anything the members disagreed on. Do not research from scratch — verify a figure with a tool only where the board is silent or contradictory.`,
|
|
282
284
|
`Request: ${String(request || '').trim()}`,
|
|
283
285
|
boardText(board),
|
|
284
286
|
].join('\n\n');
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
287
|
+
// The judge works with its own grants (it may verify a figure) and the board — and it
|
|
288
|
+
// is a run member like the others: a model that is not there rotates.
|
|
289
|
+
say('task.started', { taskId: 'merge', role: judge.id, title: `merge (${judge.name || judge.id})` });
|
|
290
|
+
const judgeTools = withBoardTool(withRunCache(await toolsFor(judge), runCache, { role: judge.id }), boardToolProvider({ board, role: judge.id, taskId: 'merge', taskIds: null }));
|
|
291
|
+
let res = null;
|
|
292
|
+
const excl = new Set();
|
|
293
|
+
for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
|
|
294
|
+
const mm = attempt === 1 ? m : modelFor(judge, excl);
|
|
295
|
+
if (!mm?.model) break;
|
|
296
|
+
if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl] });
|
|
297
|
+
say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
|
|
298
|
+
res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: mm.model, mode: 'model', system: judge.prompt, prompt, tools: judgeTools, signal, onDelta: (delta, full) => say('task.delta', { taskId: 'merge', role: judge.id, delta, text: full }) });
|
|
299
|
+
if (res?.usage) budget.charge(res.usage);
|
|
300
|
+
if (res?.ok && String(res.text || '').trim()) break;
|
|
301
|
+
const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
|
|
302
|
+
if (stopped() || !isModelUnavailable(err)) break;
|
|
303
|
+
excl.add(mm.model);
|
|
304
|
+
}
|
|
305
|
+
const judged = res?.ok && String(res.text || '').trim();
|
|
306
|
+
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 });
|
|
307
|
+
say('run.usage', { usage: budget.snapshot() });
|
|
308
|
+
proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
288
309
|
} else {
|
|
289
310
|
proposal = mergeCheap(t, board.all(), tasksOut);
|
|
290
311
|
}
|
package/team-tool.js
CHANGED
|
@@ -36,7 +36,8 @@ export function teamToolSpec(teams) {
|
|
|
36
36
|
+ '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
|
|
37
37
|
+ '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
|
|
38
38
|
+ 'A team: {"name":"research" (a short identifier: letters, digits, - _; used as /research),"description":"…","roles":[{"id":"researcher","prompt":"…","prefer":"balanced","grants":["data","web"]},{"id":"writer","prompt":"…","prefer":"strong","grants":["none"]}],"merge":"judge","judge":"writer","budget":{"tokens":40000,"ms":300000}}. '
|
|
39
|
-
+ 'grants: none | data | web | history | mcp | mcp:<server>. merge: judge | converge | concat | first. A budget is required.'
|
|
39
|
+
+ 'grants: none | data | web | history | mcp | mcp:<server>. merge: judge | converge | concat | first. A budget is required. '
|
|
40
|
+
+ 'Order the work with "dependsOn": a role that builds on another\'s findings (a budget checker on a researcher) lists it, so it runs after and reads the board instead of searching again. The judge does not need a task of its own - the merge is its work.',
|
|
40
41
|
parameters: {
|
|
41
42
|
type: 'object',
|
|
42
43
|
properties: {
|