@chatpanel/events 0.92.1 → 0.95.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/team-run.js CHANGED
@@ -256,7 +256,7 @@ export async function runTeam({
256
256
  const running = new Set();
257
257
  const isDone = (tid) => carried.has(tid) || tasksOut.some((x) => x.id === tid);
258
258
  // A sub-task with no holder cannot run; it is recorded `unassigned` at the end.
259
- const ready = () => tasks.filter((x) => x.role && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
259
+ const ready = () => tasks.filter((x) => x.role && x.kind !== 'merge' && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
260
260
  let subtasks = tasks.filter((x) => x.parent).length;
261
261
 
262
262
  /**
@@ -378,7 +378,11 @@ export async function runTeam({
378
378
  // say it needs among what the role holds. A model attempt that ends with zero calls
379
379
  // while holding one of these is nudged once, then may finish.
380
380
  const grantsHeld = role?.grants || [];
381
- const mustUse = role?.mode === 'model' ? [...new Set([...grantsNeededFor(task.prompt, { held: grantsHeld }), ...(task.grants || []).filter((g) => grantsHeld.includes(g) || (g.startsWith('mcp:') && grantsHeld.includes('mcp')))])] : [];
381
+ // THE MERGE IS A TASK LIKE THE OTHERS (its row, thread, transcript and work log), with
382
+ // three differences: it reads the whole board, it answers in prose rather than findings,
383
+ // and it is never nudged — "verify a figure with a tool" is its instruction, not a need.
384
+ const isMerge = task.kind === 'merge';
385
+ const mustUse = role?.mode === 'model' && !isMerge ? [...new Set([...grantsNeededFor(task.prompt, { held: grantsHeld }), ...(task.grants || []).filter((g) => grantsHeld.includes(g) || (g.startsWith('mcp:') && grantsHeld.includes('mcp')))])] : [];
382
386
  let nudged = false;
383
387
  try {
384
388
  if (!role) throw new Error(`no role "${task.role}" in the team`);
@@ -392,12 +396,12 @@ export async function runTeam({
392
396
  if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
393
397
  // What this member reads: the threads of the tasks it depends on, answered asks (its
394
398
  // own — a resumed task finds the person's answer here), settled discussions.
395
- const prior = boardText(board, { taskIds: task.dependsOn?.length ? task.dependsOn : null, role: role.id });
396
- const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
399
+ const prior = boardText(board, { taskIds: !isMerge && task.dependsOn?.length ? task.dependsOn : null, role: role.id });
400
+ const prompt = [task.prompt, prior, isMerge ? '' : findingsInstruction()].filter(Boolean).join('\n\n');
397
401
  // A host may build a toolset asynchronously (connecting MCP servers takes time).
398
402
  // The board tool rides on top of whatever the role was granted.
399
403
  const boardTool = boardToolProvider({
400
- board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
404
+ board, role: role.id, taskId: task.id, taskIds: !isMerge && task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
401
405
  onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
402
406
  onRequest: (req) => onRequest(task, role, req),
403
407
  waitFor: askMs > 0 ? async (threadId, ms, sig) => {
@@ -513,7 +517,8 @@ export async function runTeam({
513
517
  } finally {
514
518
  unsubscribe?.();
515
519
  }
516
- const findings = status === 'ok' ? parseFindings(text, { role: role?.id, taskId: task.id }) : [];
520
+ // The merge's answer is the proposal, not a finding of its own (it would double every claim).
521
+ const findings = status === 'ok' && !isMerge ? parseFindings(text, { role: role?.id, taskId: task.id }) : [];
517
522
  if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role?.id, finding: f }); }
518
523
  const thread = board.threadForTask(task.id);
519
524
  // The thread says how the task ended. A failure is posted in it as well — a person reading
@@ -532,7 +537,7 @@ export async function runTeam({
532
537
  say('task.scored', {
533
538
  agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), engine: routed?.engine || null, scm: scm || undefined, outcome: status === 'ok' ? 'task.done' : 'task.failed',
534
539
  size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
535
- roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
540
+ roleKind: isMerge ? 'orchestrator' : 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
536
541
  refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
537
542
  ...(task.parent ? { parent: task.parent, requestedBy: task.requestedBy || null } : {}),
538
543
  });
@@ -551,7 +556,7 @@ export async function runTeam({
551
556
  // Over budget with work left: ask the person ONCE for more, on the board, before stopping.
552
557
  if (overBudget && !budgetAsked && !stopped()) {
553
558
  budgetAsked = true;
554
- const left = tasks.filter((x) => x.role && !tasksOut.some((y) => y.id === x.id)).length;
559
+ const left = tasks.filter((x) => x.role && x.kind !== 'merge' && !tasksOut.some((y) => y.id === x.id)).length;
555
560
  const spent = budget.snapshot().spent;
556
561
  const what = left ? `${left} task${left === 1 ? '' : 's'} and the merge left` : 'only the merge left';
557
562
  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 ${what}. Raise it by half, or stop here with what it has?`, options: ['Raise by half', 'Stop here'] });
@@ -560,7 +565,7 @@ export async function runTeam({
560
565
  }
561
566
  // Every planned task gets a row — what never ran is recorded as skipped, not forgotten;
562
567
  // a sub-task nobody took is `unassigned`, which is its own kind of undone.
563
- 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: task.parent && !task.role ? 'unassigned' : 'skipped', text: '', findings: [], ...(task.parent ? { parent: task.parent } : {}) });
568
+ for (const task of tasks) if (task.kind !== 'merge' && !tasksOut.some((x) => x.id === task.id)) tasksOut.push({ id: task.id, role: task.role, title: task.title, status: task.parent && !task.role ? 'unassigned' : 'skipped', text: '', findings: [], ...(task.parent ? { parent: task.parent } : {}) });
564
569
  if (stopped()) return finish('stopped');
565
570
  if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
566
571
  // Over budget is a STOP only when it left work undone; a budget spent on the last task
@@ -574,46 +579,32 @@ export async function runTeam({
574
579
  if (!okTasks.length) return finish('failed', { proposal: null });
575
580
  if (t.merge === 'judge') {
576
581
  const judge = roleOf(t.judge) || strongestRole(t);
577
- const m = modelFor(judge);
578
- if (m?.model && budget.canAfford({ tokens: 0 })) {
579
- const prompt = [
580
- `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.`,
581
- `Request: ${String(request || '').trim()}`,
582
- boardText(board),
583
- ].join('\n\n');
584
- // The judge works with its own grants (it may verify a figure) and the board — and it
585
- // is a run member like the others: a model that is not there rotates.
586
- say('task.started', { taskId: 'merge', role: judge.id, title: `merge (${judge.name || judge.id})` });
587
- const judgeTools = withBoardTool(withRunCache(await toolsFor(judge), runCache, { role: judge.id }), boardToolProvider({ board, role: judge.id, taskId: 'merge', taskIds: null }));
588
- let res = null;
589
- const excl = new Set();
590
- let judgeErr = '';
591
- let judgeModel = null; // the appointment that answered (or the last one tried)
592
- let judgeRoute = null;
593
- for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
594
- const mm = attempt === 1 ? (runExclude.has(m?.model) ? modelFor(judge, excluding(excl)) : m) : modelFor(judge, excluding(excl));
595
- if (!mm?.model) break;
596
- if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
597
- say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
598
- judgeModel = mm; judgeRoute = routeOf(mm, judge, { attempt, exclude: excl });
599
- say('task.routed', { taskId: 'merge', role: judge.id, attempt, ...judgeRoute });
600
- 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 }) });
601
- if (res?.usage) budget.charge(res.usage);
602
- if (res?.ok && String(res.text || '').trim()) break;
603
- const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
604
- if (stopped() || !isModelUnavailable(err)) break;
605
- excl.add(mm.model); runExclude.add(mm.model); judgeErr = err;
606
- }
607
- const judged = res?.ok && String(res.text || '').trim();
608
- 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 });
609
- const judgeScm = normalizeScm(res?.scm);
610
- if (judgeScm) say('task.scm', { taskId: 'merge', role: judge.id, ...judgeScm });
611
- say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: judgeModel?.model || m?.model, engine: judgeRoute?.engine || null, scm: judgeScm || undefined, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
612
- say('run.usage', { usage: budget.snapshot() });
613
- proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
614
- } else {
615
- proposal = mergeCheap(t, board.all(), tasksOut);
582
+ // The judge's task IS the merge (team-plan.js fixedPlan) — and it is a TASK: a row on the
583
+ // record, a thread, a transcript, a work log, a scorecard entry with real evidence. Before
584
+ // this it was a bare model call whose task.started/task.done named a task the record did
585
+ // not have, so the fold dropped them and the writer of the final answer showed nowhere.
586
+ // A resumed run that died mid-merge finds the task on its plan and continues it.
587
+ let mergeTask = tasks.find((x) => x.kind === 'merge');
588
+ if (!mergeTask) {
589
+ mergeTask = {
590
+ id: 'merge', kind: 'merge', role: judge.id, title: `merge (${judge.name || judge.id})`,
591
+ prompt: [
592
+ `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.`,
593
+ `Request: ${String(request || '').trim()}`,
594
+ ].join('\n\n'),
595
+ dependsOn: tasks.filter((x) => x.role && x.kind !== 'merge').map((x) => x.id),
596
+ };
597
+ tasks.push(mergeTask);
598
+ say('task.added', { taskId: mergeTask.id, kind: 'merge', role: mergeTask.role, title: mergeTask.title, dependsOn: mergeTask.dependsOn });
599
+ board.openThread({ taskId: mergeTask.id, kind: 'task', title: mergeTask.title, by: RUNNER, holder: judge.id });
616
600
  }
601
+ const done = tasksOut.find((x) => x.id === mergeTask.id && x.status === 'ok');
602
+ const row = done || (modelFor(judge)?.model && budget.canAfford({ tokens: 0 }) ? await runTask(mergeTask) : null);
603
+ // The merge is a task: stopped or waiting on a person mid-way, the run is too — and it
604
+ // resumes from the merge's transcript, like any other.
605
+ if (stopped()) return finish('stopped');
606
+ if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
607
+ proposal = row?.status === 'ok' && String(row.text || '').trim() ? { kind: 'answer', text: String(row.text), by: judge.id, taskId: mergeTask.id } : mergeCheap(t, board.all(), tasksOut);
617
608
  } else if (t.merge === 'converge') {
618
609
  const drafts = okTasks.map((x) => ({ claims: toBriefClaims(x.findings) }));
619
610
  const { agreed, disputed } = converge(drafts, { minAgree: Math.min(2, drafts.length) });
package/team-tool.js CHANGED
@@ -1,4 +1,6 @@
1
- // The `team` tool — a team invoked from any chat: run, dry_run, save.
1
+ // The `team` tool — a team invoked from any chat: run, dry_run, save — and `project`, a goal
2
+ // handed to the executive (project-run.js): jobs, recruiting, rounds run as teams, a review,
3
+ // follow-ups, done-when, every step on the project record a person reads on the board.
2
4
  //
3
5
  // The recipe tool's twin, on purpose: one registered tool whose description is the
4
6
  // catalogue, `dry_run` showing what a person would approve, `save` proposing a team from the
@@ -48,13 +50,18 @@ export function teamToolSpec(teams) {
48
50
  + 'Actions: {"action":"run","name":"<team>","request":"<what to do>"} runs one (streams; may take a while); '
49
51
  + '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
50
52
  + '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
53
+ + '{"action":"project","goal":"<what done looks like>","title":"<short name>","doneWhen":"<a check the result can be held to>","budget":{"tokens":200000,"ms":3600000}} hands a GOAL to the executive: it posts jobs, recruits agents from the pool for each, runs them as teams in rounds, reviews the results against done-when, posts follow-ups, asks the user before spending or changing scope, and closes when done-when holds (may take a long while; use it for a goal with several parts, not a question). '
51
54
  + '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}}. '
52
55
  + 'grants: none | data | web | history | mcp | mcp:<server> | shell | fs:write | scm:read | scm:push | scm:pr. A role may say "agent":"<id>" instead of a prompt to stand for an agent from the pool. merge: judge | converge | concat | first. A budget is required. '
53
56
  + '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.',
54
57
  parameters: {
55
58
  type: 'object',
56
59
  properties: {
57
- action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
60
+ action: { type: 'string', enum: ['run', 'dry_run', 'save', 'project'] },
61
+ goal: { type: 'string', description: 'For project: the goal, in the user\'s words — what done looks like.' },
62
+ title: { type: 'string', description: 'For project: a short name.' },
63
+ doneWhen: { type: 'string', description: 'For project: the check the result is held to.' },
64
+ budget: { type: 'object', description: 'For project: {"tokens","ms","usd"} for the whole project.', additionalProperties: true },
58
65
  name: { type: 'string', description: 'Team name, for run / dry_run.' },
59
66
  request: { type: 'string', description: 'What the team should do, for run / dry_run.' },
60
67
  team: { type: 'object', description: 'The team to save, for save.', additionalProperties: true },
@@ -82,13 +89,16 @@ const json = (v) => JSON.stringify(v);
82
89
  * @param appoint `(role) => { model, mode } | null` for the dry run
83
90
  * @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
84
91
  * @param saveTeam `async (team) => void`
92
+ * @param runProject `async ({ goal, title, doneWhen, budget, toolset }) => project result` — the
93
+ * host's executive loop (project-run.js runProject with its own deps);
94
+ * absent, the action is refused
85
95
  */
86
96
  /**
87
97
  * `resolve` is the host's `(team) => team` that fills roles standing for agents from the
88
98
  * pool (agent.js resolveTeam) — applied before a dry run and before a run, never to what is
89
99
  * saved: the stored team keeps its references, the run gets the cards as they are now.
90
100
  */
91
- export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null } = {}) {
101
+ export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null, runProject = null } = {}) {
92
102
  const byName = new Map(usable(teams).map((t) => [t.name, t]));
93
103
  // A team whose roles stand for agents is filled from the pool on the way to a run; a
94
104
  // resolver that throws (an agent missing from the pool) is the tool's error, not a crash.
@@ -103,6 +113,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
103
113
  specs: [teamToolSpec(teams)],
104
114
  system: [
105
115
  byName.size ? 'Saved agent teams exist (see the `team` tool). When a request is broad enough that several roles would do it better — research plus writing, several sources to reconcile — run the matching team rather than doing it all in one turn.' : '',
116
+ runProject ? 'A GOAL with several parts (a project, not a question) goes to the executive: call the `team` tool with {"action":"project","goal":…,"doneWhen":…} and report what it produced.' : '',
106
117
  (confirmSave && saveTeam) ? 'When the user asks to create, make, set up or save a team (of agents / roles), do not run one: call the `team` tool with {"action":"save","team":{…}} — pick roles, prompts, grants and a budget from what they said, and ask only for what you cannot infer. The user approves it on a card.' : '',
107
118
  ].filter(Boolean).join(' '),
108
119
  bind(toolset) { bound = toolset; },
@@ -127,6 +138,26 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
127
138
  return json({ saved: stored.name, roles: stored.roles.map((r) => r.id), hint: `Run it with {"action":"run","name":"${stored.name}","request":"…"} or by typing /${stored.name}.` });
128
139
  }
129
140
 
141
+ if (action === 'project') {
142
+ const goal = String(input?.goal || '').trim();
143
+ if (!goal) return json({ error: 'project needs a goal — what does done look like?' });
144
+ if (typeof runProject !== 'function') return json({ error: 'This surface cannot run a project. Run a team instead, or describe the jobs.' });
145
+ // One project per turn, like one run per team: a loop that asks the person and gets
146
+ // "stop" is not started again with a rephrased goal.
147
+ if (ran.has('project')) { const p = ran.get('project'); return json({ error: `A project already ran in this turn (${p.projectId}, ${p.status}). Report its result and ask the user how to proceed.`, ...p }); }
148
+ let result;
149
+ try {
150
+ result = await runProject({ goal, title: String(input?.title || '').trim(), doneWhen: String(input?.doneWhen || '').trim(), budget: input?.budget && typeof input.budget === 'object' ? input.budget : null, toolset: bound });
151
+ } catch (e) { return json({ error: e?.message || String(e) }); }
152
+ const out = { projectId: result.projectId, status: result.status, rounds: result.rounds, jobs: result.jobs, runs: result.runs, report: result.report, spend: result.spend, why: result.why || undefined };
153
+ ran.set('project', out);
154
+ const hint = result.status === 'done' ? 'Done-when holds and the project is closed. Present the report as the answer; the jobs and their runs are on the board.'
155
+ : result.status === 'over-budget' ? 'The project stopped at its budget; the report so far stands. Say so and ask whether to raise it.'
156
+ : result.status === 'failed' ? `The project FAILED — ${result.why || 'see the jobs'}. Do not start it again this turn; tell the user which job failed and why.`
157
+ : `The project is ${result.status}${result.why ? ` — ${result.why}` : ''}. Present the report so far and say what is left; do not start it again this turn.`;
158
+ return json({ ...out, hint });
159
+ }
160
+
130
161
  const team = byName.get(String(input?.name || ''));
131
162
  if (!team) return json({ error: `No team named "${input?.name}".`, available: [...byName.keys()] });
132
163
  const request = String(input?.request || '').trim();
@@ -159,7 +190,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
159
190
  : 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;
160
191
  return json({ name: team.name, runId: result.runId, status: result.status, proposal: result.proposal, tasks, findings, usage: result.usage, hint });
161
192
  }
162
- return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
193
+ return json({ error: `Unknown action "${action}". Use run, dry_run, save or project.` });
163
194
  },
164
195
  };
165
196
  }
package/team-trail.js CHANGED
@@ -9,6 +9,7 @@ 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.added': return { type: 'status', text: `${ev.kind === 'merge' ? 'the merge' : ev.title || ev.taskId} is ${role}'s${(ev.dependsOn || []).length ? ` — after ${ev.dependsOn.join(', ')}` : ''}` };
12
13
  case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}${ev.resumed ? ` (resumed, ${ev.steps} steps so far)` : ''}` };
13
14
  case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
14
15
  case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
@@ -30,6 +31,14 @@ export function teamLine(ev) {
30
31
  case 'task.failed': return { type: 'error', text: `${role} ${ev.status || 'failed'}${ev.error ? ` — ${ev.error}` : ''}` };
31
32
  case 'run.merging': return { type: 'status', text: `merging (${ev.policy})` };
32
33
  case 'run.done': return { type: 'status', text: `team ${ev.status}${ev.usage?.spent?.tokens ? ` · ${ev.usage.spent.tokens} tokens` : ''}` };
34
+ // A PROJECT (project-run.js): the executive's steps, the jobs, the report — the same
35
+ // strip a run's lines go to, so a person sees the loop move without opening the board.
36
+ case 'project.thinking': return { type: 'status', text: `executive is ${ev.what === 'project_review' ? 'reviewing the round' : 'planning the jobs'}${ev.model ? ` (${ev.model})` : ''}` };
37
+ case 'project.status': return { type: 'status', text: `project ${ev.status}${ev.by ? ` (${ev.by})` : ''}` };
38
+ case 'project.decision': return ev.kind === 'answer' || ev.kind === 'status' ? null : { type: 'status', text: `${ev.by || 'executive'} · ${ev.kind}: ${String(ev.text || '').split('\n')[0].slice(0, 140)}` };
39
+ case 'job.posted': return { type: 'status', text: `job posted: ${ev.job?.title || ev.job?.id || '?'}${ev.job?.needs?.skills?.length ? ` (${ev.job.needs.skills.join(', ')})` : ''}` };
40
+ case 'job.updated': return ev.job?.status && ['recruited', 'done', 'failed'].includes(ev.job.status) ? { type: ev.job.status === 'failed' ? 'error' : 'status', text: `job ${ev.job.id} ${ev.job.status}${ev.job.recruited?.agentId ? ` → ${ev.job.recruited.agentId}` : ''}${ev.job.status === 'failed' && ev.job.result?.text ? ` — ${String(ev.job.result.text).slice(0, 120)}` : ''}` } : null;
41
+ case 'project.report': return { type: 'status', text: `report written (${ev.by || 'executive'})` };
33
42
  default: return null;
34
43
  }
35
44
  }
@@ -40,6 +49,7 @@ export function teamLanes(prev, ev) {
40
49
  switch (ev.type) {
41
50
  case 'run.started': lanes.team = ev.team; lanes.roles = ev.roles; break;
42
51
  case 'plan.ready': for (const t of ev.tasks || []) lanes.tasks[t.id] = { id: t.id, role: t.role, title: t.title, status: 'pending', findings: 0 }; break;
52
+ case 'task.added': lanes.tasks[ev.taskId] = { id: ev.taskId, role: ev.role, title: ev.title, status: 'pending', findings: 0, ...(ev.kind ? { kind: ev.kind } : {}) }; break;
43
53
  case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
44
54
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
45
55
  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;
package/tool-hints.js CHANGED
@@ -24,7 +24,13 @@ export function sourceCitationSystem({ compact = false } = {}) {
24
24
 
25
25
  export function toolStatus(result) {
26
26
  const o = resultObject(result);
27
- if (!o) return '';
27
+ if (!o) {
28
+ // A plain-text result (a search's prose, a relayed agent's line): an error when it says
29
+ // so the way the shared tools do — `error: …`, `web_search failed: …` — else fine.
30
+ const s = typeof result === 'string' ? result : (result && typeof result === 'object' && typeof result.text === 'string' ? result.text : '');
31
+ if (!s.trim()) return '';
32
+ return /^(error:|\w+ failed\b)/i.test(s) ? `error: ${s.slice(0, 80)}` : 'ok';
33
+ }
28
34
  if (o.error) {
29
35
  const detail = errorDetail(o);
30
36
  if (o.blocked) return `blocked: ${detail}`.slice(0, 90);
@@ -0,0 +1,184 @@
1
+ // The loop guard — what stops a model that keeps asking the same thing.
2
+ //
3
+ // Lived inside the extension's providers.js for a year, which meant the desktop could not
4
+ // use it and wrote a smaller one (a `seen` map and a repeat count) that answered the same
5
+ // model behaviour differently: the extension blocked a repeated write BEFORE running it,
6
+ // the desktop ran it once and replayed it; the extension noticed a whole round repeating,
7
+ // the desktop only a single call. Same tools, same model, two outcomes. Now one guard,
8
+ // with everything either client had learned:
9
+ //
10
+ // • a call repeated past `maxIdenticalCalls` is not executed — a READ is answered from
11
+ // its first result (a pure read asked twice has one answer, and refusing it is how a
12
+ // small model concludes the tool is broken and invents an answer); a WRITE is refused
13
+ // with a result that says why, because replaying a click would be a lie about
14
+ // something that changed the world;
15
+ // • observation tools never count — read → act → read again with the same empty input
16
+ // is correct, not a loop;
17
+ // • a discrete-input tool that SUCCEEDED (a keystroke, a click) is progress and clears
18
+ // its own count — pressing Enter twice is normal; failing to press it twice is not;
19
+ // • a ROUND that repeats the previous round byte for byte, or in which every call was
20
+ // blocked, counts toward `stalled` — and a stalled turn is offered no more tools, so
21
+ // the model has to answer with what it has;
22
+ // • `repeats` counts every replay and refusal across the turn, so a loop can tell when
23
+ // the model has been told enough times (the desktop's rule: three, then a closing
24
+ // request with no tools).
25
+ //
26
+ // Class R: no I/O, no clock. Names are read through `effectiveToolName` so a dispatched
27
+ // action is judged on what it is, not on the dispatcher's name.
28
+
29
+ import { effectiveToolName } from './tool-traits.js';
30
+ import { resultText } from './adaptive-tool-policy.js';
31
+
32
+ export const DEFAULT_MAX_IDENTICAL_CALLS = 3;
33
+ export const DEFAULT_MAX_STALLED_ROUNDS = 2;
34
+ export const DEFAULT_MAX_REPEATS = 3;
35
+
36
+ // Observation/read tools are MEANT to be repeated with the SAME (empty) input.
37
+ export const OBSERVATION_TOOLS = new Set(['inspect_page', 'read_canvas', 'screenshot', 'marked_screenshot']);
38
+
39
+ // Tools whose whole job is ONE discrete physical input. A SUCCESSFUL application counts as
40
+ // progress and clears the repeat count; a failing one (unknown key, nothing at point) does
41
+ // not, so a genuinely stuck call still trips the guard.
42
+ export const INPUT_PROGRESS_TOOLS = new Set([
43
+ 'press_key', 'type_text', 'click_at', 'move_mouse', 'click_mark', 'draw_path', 'input_sequence',
44
+ 'click_element', 'click_by_text',
45
+ ]);
46
+
47
+ /** A tool whose repetition signals a LOOP (search/query/fetch), not one meant to repeat. */
48
+ export function isLoopableTool(name) {
49
+ return !OBSERVATION_TOOLS.has(name) && !INPUT_PROGRESS_TOOLS.has(name) && name !== 'scroll';
50
+ }
51
+
52
+ function stableStringify(value) {
53
+ if (value == null || typeof value !== 'object') return JSON.stringify(value);
54
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
55
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
56
+ }
57
+
58
+ /** The identity of a call: its name and its arguments with keys in a stable order. */
59
+ export function stableToolCallKey(name, input) {
60
+ return `${String(name || '')}\n${stableStringify(input ?? {})}`;
61
+ }
62
+
63
+ /** The identity of a ROUND: its loopable calls, sorted — so a re-read or a scroll never makes two rounds look alike. */
64
+ export function roundSignature(calls) {
65
+ return (Array.isArray(calls) ? calls : [])
66
+ .filter((c) => isLoopableTool(effectiveToolName(c?.name, c?.input)))
67
+ .map((c) => stableToolCallKey(c.name, c.input))
68
+ .sort()
69
+ .join('|');
70
+ }
71
+
72
+ /** The result a refused repeat receives — machine-readable, with the way out spelled out. */
73
+ export function blockedToolResult(name, message, extra = {}) {
74
+ return JSON.stringify({
75
+ ok: false,
76
+ blocked: true,
77
+ error: 'tool_loop_blocked',
78
+ tool: name || 'tool',
79
+ message,
80
+ retry_hint: 'Answer using the already available conversation context and tool results. Do not call more tools unless the user asks you to continue.',
81
+ ...extra,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Did a repeatable tool actually do something? For `scroll`, "more page below"; for a
87
+ * discrete input, `ok: true`. Anything else is not progress — the step cap is the backstop.
88
+ */
89
+ export function toolMadeProgress(name, result, input = null) {
90
+ // Through the dispatcher too: `page {action:'scroll'}` is a scroll. Judged by the bare
91
+ // name, four scrolls through `page` looked like a stuck loop and were blocked.
92
+ const eff = effectiveToolName(name, input);
93
+ if (eff === 'scroll') {
94
+ try { return JSON.parse(resultText(result))?.atBottom === false; } catch { return false; }
95
+ }
96
+ if (INPUT_PROGRESS_TOOLS.has(eff)) {
97
+ try { return JSON.parse(resultText(result))?.ok === true; } catch { return false; }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * @param maxIdenticalCalls how many times the SAME call runs before it is replayed or refused
104
+ * @param maxStalledRounds how many no-progress rounds in a row before `stalled`
105
+ * @param maxRepeats how many replays/refusals in a turn before `looping`
106
+ */
107
+ export function createToolLoopGuard({
108
+ maxIdenticalCalls = DEFAULT_MAX_IDENTICAL_CALLS,
109
+ maxStalledRounds = DEFAULT_MAX_STALLED_ROUNDS,
110
+ maxRepeats = DEFAULT_MAX_REPEATS,
111
+ } = {}) {
112
+ const counts = new Map();
113
+ const lastResult = new Map(); // key → what that identical call returned the first time
114
+ let stalledRounds = 0;
115
+ let lastSignature = null;
116
+ let repeats = 0;
117
+
118
+ return {
119
+ // No nuclear per-turn kill switch — one looping tool must not disable the rest. The
120
+ // round cap is the overall backstop.
121
+ get disabled() { return false; },
122
+ get stalled() { return stalledRounds >= maxStalledRounds; },
123
+ /** Replays and refusals so far this turn. */
124
+ get repeats() { return repeats; },
125
+ /** The model has been answered "you already asked that" enough times to stop asking. */
126
+ get looping() { return repeats >= maxRepeats; },
127
+
128
+ /**
129
+ * After each round, note progress. No progress = every call was blocked, OR the round's
130
+ * loopable call-set is byte-identical to the previous round's (a loop even before the
131
+ * per-tool threshold trips). An exact-repeat round is definitive — two strikes at once.
132
+ */
133
+ noteRound(blockedCount, total, signature = '') {
134
+ const allBlocked = total > 0 && blockedCount >= total;
135
+ const repeatRound = !!signature && signature === lastSignature;
136
+ lastSignature = signature;
137
+ if (repeatRound) stalledRounds += 2;
138
+ else if (allBlocked) stalledRounds += 1;
139
+ else stalledRounds = 0;
140
+ },
141
+
142
+ /** Clear a call's repeat count when it actually made progress. */
143
+ reset(key) { if (key) counts.delete(key); },
144
+
145
+ /** Remember what a READ returned, so a repeat can be answered instead of refused. */
146
+ remember(key, name, input, result, { readOnly = false } = {}) {
147
+ if (!key || !result || !readOnly) return;
148
+ lastResult.set(key, result);
149
+ },
150
+
151
+ /**
152
+ * Should this call run? `{ blocked, replayed, count, key, result }` — `result` is what to
153
+ * answer with when it should not.
154
+ */
155
+ check(name, input) {
156
+ if (OBSERVATION_TOOLS.has(effectiveToolName(name, input))) return { blocked: false };
157
+ const key = stableToolCallKey(name, input);
158
+ const count = (counts.get(key) || 0) + 1;
159
+ counts.set(key, count);
160
+ if (count > maxIdenticalCalls && lastResult.has(key)) {
161
+ // Serve the answer it already earned — and say so, because a model that repeats
162
+ // itself is usually waiting for a value that will not change. Still counted, so a
163
+ // genuinely stuck loop stays visible in the log.
164
+ repeats += 1;
165
+ const prior = lastResult.get(key);
166
+ const note = '[This exact call was already made this turn; the result is unchanged. Answer from what you have.]';
167
+ const result = typeof prior === 'string' ? `${prior}\n\n${note}` : (prior && typeof prior === 'object' && typeof prior.text === 'string' ? { ...prior, text: `${prior.text}\n\n${note}` } : prior);
168
+ return { blocked: false, replayed: true, count, key, result };
169
+ }
170
+ if (count > maxIdenticalCalls) {
171
+ repeats += 1;
172
+ return {
173
+ blocked: true, count, key,
174
+ result: blockedToolResult(
175
+ name,
176
+ `Skipped a repeated identical ${name || 'tool'} call (${count}× with the same input). Vary the input or try a different action — your other tools still work.`,
177
+ { repeated: true, identicalCallCount: count, maxIdenticalCalls },
178
+ ),
179
+ };
180
+ }
181
+ return { blocked: false, count, key };
182
+ },
183
+ };
184
+ }
package/tool-traits.js CHANGED
@@ -150,9 +150,33 @@ export function withDestructiveGate(toolset, { confirm = null, only = () => true
150
150
  };
151
151
  }
152
152
 
153
- // A dispatcher carries the real action in `input.action`; the gate must see through it or
154
- // `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp".
155
- function defaultEffectiveName(name, input) {
153
+ // A dispatcher carries the real action in `input.action`; every name-based policy must
154
+ // see through it or `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp" — and
155
+ // `page {action:'screenshot'}` taken four times looks like a stuck loop instead of a look.
156
+ // One definition: the extension, the desktop and the gate each had their own copy.
157
+ export function effectiveToolName(name, input) {
156
158
  const action = input && typeof input === 'object' ? input.action : null;
157
159
  return typeof action === 'string' && action ? action : name;
158
160
  }
161
+ const defaultEffectiveName = effectiveToolName;
162
+
163
+ // Local tools whose reads may overlap in one round: they touch the user's own data or the
164
+ // network, never the one tab a page tool is driving. Everything not remote and not here
165
+ // runs one at a time, whatever its name says — a wrong "parallel" races the world, a wrong
166
+ // "serial" only costs latency. The `find` dispatcher is here as a whole: everything behind
167
+ // it is a read of the user's data or the web (its writes are separate tools by design).
168
+ //
169
+ // ONE list. The extension and the desktop each kept their own and they drifted within
170
+ // weeks — the desktop serialised `recall` and `skill_open` that the extension overlapped.
171
+ export const PARALLEL_LOCAL_RE = /^(find$|history_|web_search$|weather$|get_result$|skill_open$|skill_file$|recall$|memory_recall$|meeting_live_transcript$)/;
172
+
173
+ /**
174
+ * May this call share a batch with its neighbours? Read-only by its traits, not pinned
175
+ * serial by the toolset, and either remote (its own server) or on the local overlap list.
176
+ */
177
+ export function parallelEligible(tools, call, traits) {
178
+ if (!traits?.readOnly) return false;
179
+ if (tools?.serialTools?.has(call.name)) return false;
180
+ const eff = effectiveToolName(call.name, call.input);
181
+ return !!tools?.remoteTools?.has(call.name) || PARALLEL_LOCAL_RE.test(eff) || PARALLEL_LOCAL_RE.test(call.name);
182
+ }