@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/agent.js +7 -0
- package/client-prefs.js +1 -1
- package/index.js +15 -1
- package/package.json +8 -2
- package/project-run.js +444 -0
- package/team-record.js +18 -2
- package/team-run.js +39 -48
- package/team-tool.js +35 -4
- package/team-trail.js +10 -0
- package/tool-hints.js +7 -1
- package/tool-loop-guard.js +184 -0
- package/tool-traits.js +27 -3
- package/turn-loop.js +441 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
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)
|
|
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`;
|
|
154
|
-
// `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp"
|
|
155
|
-
|
|
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
|
+
}
|