@chatpanel/events 0.73.0 → 0.75.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/budget.js +110 -0
- package/client-prefs.js +1 -0
- package/index.js +10 -0
- package/mcp-dispatch.js +52 -0
- package/package.json +33 -17
- package/recipe-tool.js +161 -0
- package/team-board.js +123 -0
- package/team-plan.js +121 -0
- package/team-run.js +209 -0
- package/team-tool.js +124 -0
- package/team.js +123 -0
package/budget.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A budget — the number a run may not exceed, and the record of what it spent.
|
|
2
|
+
//
|
|
3
|
+
// There was no spend cap anywhere. A live monitor is declared class C and starts model turns
|
|
4
|
+
// for the length of a meeting; a spoken "keep an eye on X" arms that with nothing bounding
|
|
5
|
+
// it; `jobs.js` caps a per-day job COUNT, not spend. The class declarations on every intent
|
|
6
|
+
// and rule were made for exactly this, and nothing read them.
|
|
7
|
+
//
|
|
8
|
+
// A budget is a VALUE: declared on the thing that spends (a team, a schedule, a monitor),
|
|
9
|
+
// charged by whatever runs it, and carried on the run record so the ledger can say what a
|
|
10
|
+
// run cost in the same units it was capped in. Four dimensions, because the expensive thing
|
|
11
|
+
// differs by executor: tokens and calls for a model, wall time for an agent that thinks for
|
|
12
|
+
// minutes, cost when the gateway can report it. Any dimension may be absent; an absent one is
|
|
13
|
+
// not enforced. A budget with NO dimension is not a budget — `validateBudget` refuses it, and
|
|
14
|
+
// a team without one does not run (F8, O1).
|
|
15
|
+
//
|
|
16
|
+
// Pure. `now` is injected so a wall-time cap is testable.
|
|
17
|
+
|
|
18
|
+
export const BUDGET_DIMENSIONS = Object.freeze(['tokens', 'calls', 'ms', 'usd']);
|
|
19
|
+
|
|
20
|
+
export class BudgetError extends Error {
|
|
21
|
+
constructor(code, message) { super(message); this.name = 'BudgetError'; this.code = code; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `{ ok, errors }` — a budget must cap at least one thing, and every cap must be a positive number. */
|
|
25
|
+
export function validateBudget(b) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
if (!b || typeof b !== 'object') return { ok: false, errors: ['budget must be an object'] };
|
|
28
|
+
let any = false;
|
|
29
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
30
|
+
if (b[k] === undefined || b[k] === null) continue;
|
|
31
|
+
const n = Number(b[k]);
|
|
32
|
+
if (!Number.isFinite(n) || n <= 0) errors.push(`${k}: a positive number`);
|
|
33
|
+
else any = true;
|
|
34
|
+
}
|
|
35
|
+
for (const k of Object.keys(b)) if (!BUDGET_DIMENSIONS.includes(k)) errors.push(`${k}: not a budget dimension (${BUDGET_DIMENSIONS.join(', ')})`);
|
|
36
|
+
if (!any) errors.push('a budget must cap at least one of tokens, calls, ms, usd');
|
|
37
|
+
return { ok: errors.length === 0, errors };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Only the declared dimensions, as numbers. */
|
|
41
|
+
export function normalizeBudget(b) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
44
|
+
const n = Number(b?.[k]);
|
|
45
|
+
if (Number.isFinite(n) && n > 0) out[k] = n;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The usage a model call reports, in the shapes the providers use, as one record:
|
|
52
|
+
* `{ tokens, calls, usd }`. `ms` is measured by the budget itself.
|
|
53
|
+
*/
|
|
54
|
+
export function usageOf(u = {}) {
|
|
55
|
+
const tokens = Number(u.tokens ?? u.total_tokens ?? ((Number(u.input_tokens ?? u.prompt_tokens) || 0) + (Number(u.output_tokens ?? u.completion_tokens) || 0)));
|
|
56
|
+
return { tokens: Number.isFinite(tokens) ? tokens : 0, calls: Number(u.calls ?? 1) || 0, usd: Number(u.usd ?? u.cost) || 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A live budget for one run.
|
|
61
|
+
*
|
|
62
|
+
* charge(usage) add a model call's usage; returns what is left
|
|
63
|
+
* canAfford(estimate) false when an estimated call would cross a cap — ask BEFORE calling
|
|
64
|
+
* exhausted() the dimension that ran out, or null
|
|
65
|
+
* snapshot() { cap, spent, remaining, exhausted } for the run record and the meter
|
|
66
|
+
*/
|
|
67
|
+
export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
68
|
+
const v = validateBudget(declared);
|
|
69
|
+
if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
|
|
70
|
+
const cap = normalizeBudget(declared);
|
|
71
|
+
const startedAt = now();
|
|
72
|
+
const spent = { tokens: 0, calls: 0, usd: 0 };
|
|
73
|
+
const elapsed = () => now() - startedAt;
|
|
74
|
+
const remaining = () => {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
77
|
+
if (cap[k] === undefined) continue;
|
|
78
|
+
out[k] = Math.max(0, cap[k] - (k === 'ms' ? elapsed() : spent[k]));
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
const exhausted = () => {
|
|
83
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
84
|
+
if (cap[k] === undefined) continue;
|
|
85
|
+
if ((k === 'ms' ? elapsed() : spent[k]) >= cap[k]) return k;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
cap,
|
|
91
|
+
charge(usage) {
|
|
92
|
+
const u = usageOf(usage);
|
|
93
|
+
spent.tokens += u.tokens; spent.calls += u.calls; spent.usd += u.usd;
|
|
94
|
+
return remaining();
|
|
95
|
+
},
|
|
96
|
+
canAfford(estimate = {}) {
|
|
97
|
+
if (exhausted()) return false;
|
|
98
|
+
const e = usageOf({ calls: 1, ...estimate });
|
|
99
|
+
for (const k of ['tokens', 'calls', 'usd']) {
|
|
100
|
+
if (cap[k] !== undefined && spent[k] + e[k] > cap[k]) return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
remaining,
|
|
105
|
+
exhausted,
|
|
106
|
+
snapshot() {
|
|
107
|
+
return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/client-prefs.js
CHANGED
|
@@ -22,6 +22,7 @@ export const PREF_SECTIONS = Object.freeze([
|
|
|
22
22
|
{ id: 'skills', label: 'Skills', path: ['skills'], kind: 'array' },
|
|
23
23
|
{ id: 'skillDirs', label: 'Skill folders', path: ['ui', 'skillDirs'], kind: 'array' },
|
|
24
24
|
{ id: 'recipes', label: 'Recipes', path: ['recipes'], kind: 'array' },
|
|
25
|
+
{ id: 'teams', label: 'Agent teams', path: ['teams'], kind: 'array' },
|
|
25
26
|
{ id: 'webSearch', label: 'Web search', path: ['ui', 'webSearch'], kind: 'object' },
|
|
26
27
|
{ id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
|
|
27
28
|
{ id: 'suggestions', label: 'Smart suggestions', path: ['ui', 'suggestions'], kind: 'object' },
|
package/index.js
CHANGED
|
@@ -188,6 +188,16 @@ export { FIND_TOOL_NAME, FIND_DESCRIPTION, FIND_RESIDENT, findDispatchProvider }
|
|
|
188
188
|
export { WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_SYSTEM, WEB_SEARCH_SPEC, searchResultsToText, webSearchToolProvider } from './web-search-tool.js';
|
|
189
189
|
export { compressToolSpec, compressToolSpecs, compressionStats, trimDescription, COMPRESSION_MODES, DEFAULT_COMPRESSION } from './tool-schema.js';
|
|
190
190
|
export { validateRecipe, expandRecipe, recipeParams, mapInput, dryRunRecipe, runPlan, runRecipe, RecipeError, RECIPE_MODES } from './recipe.js';
|
|
191
|
+
export { recipeToolProvider, recipeToolSpec, describeRecipeForApproval, RECIPE_TOOL_NAME } from './recipe-tool.js';
|
|
192
|
+
// Agent teams (F8): a team is data, a run is a turn of turns under a budget, members talk
|
|
193
|
+
// through a typed board, nothing lands without a person.
|
|
194
|
+
export { createBudget, validateBudget, normalizeBudget, usageOf, BudgetError, BUDGET_DIMENSIONS } from './budget.js';
|
|
195
|
+
export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE } from './team.js';
|
|
196
|
+
export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
|
|
197
|
+
export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS } from './team-board.js';
|
|
198
|
+
export { runTeam, dryRunTeam, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
199
|
+
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
200
|
+
export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
|
|
191
201
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
|
192
202
|
export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
|
|
193
203
|
export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
|
package/mcp-dispatch.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// PROGRESSIVE DISCLOSURE for MCP servers — one registered tool instead of dozens.
|
|
2
|
+
//
|
|
3
|
+
// MCP is the largest resident cost by far: every connected server contributes a full JSON
|
|
4
|
+
// schema per tool plus an inventory block, and the shared MCP rulebook (~600 tokens of
|
|
5
|
+
// citation policy, argument-forming rules and fallback etiquette) is added once on top.
|
|
6
|
+
// On a setup with a few servers that is thousands of tokens on every turn — including
|
|
7
|
+
// turns that never touch a server.
|
|
8
|
+
//
|
|
9
|
+
// The existing defence was a relevance cap that DROPS tools beyond it. That is a real
|
|
10
|
+
// loss of capability, silently: a tool the model needed but that ranked low simply was
|
|
11
|
+
// not there. A dispatcher keeps every tool reachable and pays only for the menu, so the
|
|
12
|
+
// cap stops being a capability decision and becomes a menu-length decision.
|
|
13
|
+
//
|
|
14
|
+
// PRIVACY: these tools call third parties, so the provider stays flagged `remote`. The
|
|
15
|
+
// harness uses that flag to keep PII off remote tools under "redact remote" — a
|
|
16
|
+
// dispatcher that dropped it would quietly convert redacted tools into unredacted ones.
|
|
17
|
+
// That is the one property here worth a test of its own.
|
|
18
|
+
|
|
19
|
+
import { makeDispatchProvider } from './tool-dispatch.js';
|
|
20
|
+
|
|
21
|
+
// Deliberately NOT `mcp_*`. buildToolset adds the ~600-token shared MCP rulebook whenever
|
|
22
|
+
// a spec name matches /^mcp[_-]/, so a dispatcher called `mcp_call` would collapse the
|
|
23
|
+
// per-server schemas and then re-admit the rulebook it was meant to defer. The rulebook
|
|
24
|
+
// travels with `describe` instead, and remoteness is carried by the provider's `remote`
|
|
25
|
+
// flag rather than inferred from the name — which is where it should have come from
|
|
26
|
+
// anyway.
|
|
27
|
+
export const MCP_TOOL_NAME = 'mcp';
|
|
28
|
+
|
|
29
|
+
const DESCRIPTION =
|
|
30
|
+
'Call a tool on a connected MCP server (the user\'s own integrations). Pass an `action` '
|
|
31
|
+
+ 'and put that action\'s own arguments inside `args`, e.g. '
|
|
32
|
+
+ '{"action":"mcp_jira__search","args":{"query":"ATLAS-1"}}. Unsure of an action\'s '
|
|
33
|
+
+ 'arguments? {"action":"describe","args":{"tool":"<action>"}} returns its full schema '
|
|
34
|
+
+ 'and how to use that server. Match the request\'s domain to the server\'s domain, and '
|
|
35
|
+
+ 'do not call these when the page or provided context already answers the question.';
|
|
36
|
+
|
|
37
|
+
export function mcpDispatchProvider(inner, { all = null, rank = undefined } = {}) {
|
|
38
|
+
return makeDispatchProvider({
|
|
39
|
+
all,
|
|
40
|
+
rank,
|
|
41
|
+
name: MCP_TOOL_NAME,
|
|
42
|
+
description: DESCRIPTION,
|
|
43
|
+
// Same lesson as the data and page groups: name the capability, not just the tool.
|
|
44
|
+
resident:
|
|
45
|
+
"You HAVE access to the user's connected MCP servers — their own integrations — "
|
|
46
|
+
+ 'through the `mcp` tool. When a request matches a connected server\'s domain, call '
|
|
47
|
+
+ 'it rather than saying the integration is unavailable.',
|
|
48
|
+
inner,
|
|
49
|
+
// Load-bearing for redaction, not bookkeeping. See the note above.
|
|
50
|
+
remote: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
9
|
"./adapters.js": "./adapters.js",
|
|
10
|
+
"./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
|
|
10
11
|
"./attribution.js": "./attribution.js",
|
|
11
12
|
"./backup-envelope.js": "./backup-envelope.js",
|
|
13
|
+
"./budget.js": "./budget.js",
|
|
12
14
|
"./capability.js": "./capability.js",
|
|
13
15
|
"./citations.js": "./citations.js",
|
|
16
|
+
"./client-prefs.js": "./client-prefs.js",
|
|
17
|
+
"./context-attachments.js": "./context-attachments.js",
|
|
14
18
|
"./cowriter-router.js": "./cowriter-router.js",
|
|
15
|
-
"./cowriter.js": "./cowriter.js",
|
|
16
19
|
"./cowriter-writer.js": "./cowriter-writer.js",
|
|
20
|
+
"./cowriter.js": "./cowriter.js",
|
|
17
21
|
"./curate.js": "./curate.js",
|
|
18
22
|
"./distance.js": "./distance.js",
|
|
19
23
|
"./entitlement.js": "./entitlement.js",
|
|
20
24
|
"./entity.js": "./entity.js",
|
|
21
25
|
"./event.js": "./event.js",
|
|
22
26
|
"./extraction.js": "./extraction.js",
|
|
27
|
+
"./find-tool.js": "./find-tool.js",
|
|
23
28
|
"./flowchart.js": "./flowchart.js",
|
|
24
29
|
"./harness.js": "./harness.js",
|
|
25
30
|
"./invariants.js": "./invariants.js",
|
|
@@ -31,9 +36,13 @@
|
|
|
31
36
|
"./manifest.js": "./manifest.js",
|
|
32
37
|
"./markdown-authoring.js": "./markdown-authoring.js",
|
|
33
38
|
"./markdown-render.js": "./markdown-render.js",
|
|
39
|
+
"./mcp-client.js": "./mcp-client.js",
|
|
40
|
+
"./mcp-dispatch.js": "./mcp-dispatch.js",
|
|
34
41
|
"./mcp-errors.js": "./mcp-errors.js",
|
|
42
|
+
"./mcp-manager.js": "./mcp-manager.js",
|
|
35
43
|
"./media-transcript.js": "./media-transcript.js",
|
|
36
44
|
"./meeting-analyzers.js": "./meeting-analyzers.js",
|
|
45
|
+
"./meeting-insights.js": "./meeting-insights.js",
|
|
37
46
|
"./meeting-shape.js": "./meeting-shape.js",
|
|
38
47
|
"./meeting-text.js": "./meeting-text.js",
|
|
39
48
|
"./memory.js": "./memory.js",
|
|
@@ -52,7 +61,9 @@
|
|
|
52
61
|
"./promotion.js": "./promotion.js",
|
|
53
62
|
"./queue.js": "./queue.js",
|
|
54
63
|
"./reach.js": "./reach.js",
|
|
64
|
+
"./recipe-tool.js": "./recipe-tool.js",
|
|
55
65
|
"./recipe.js": "./recipe.js",
|
|
66
|
+
"./record-list.js": "./record-list.js",
|
|
56
67
|
"./redaction-tokens.js": "./redaction-tokens.js",
|
|
57
68
|
"./ref.js": "./ref.js",
|
|
58
69
|
"./registry.js": "./registry.js",
|
|
@@ -78,38 +89,35 @@
|
|
|
78
89
|
"./sync-plan.js": "./sync-plan.js",
|
|
79
90
|
"./synthesis.js": "./synthesis.js",
|
|
80
91
|
"./tags.js": "./tags.js",
|
|
92
|
+
"./team-board.js": "./team-board.js",
|
|
93
|
+
"./team-plan.js": "./team-plan.js",
|
|
94
|
+
"./team-run.js": "./team-run.js",
|
|
95
|
+
"./team-tool.js": "./team-tool.js",
|
|
96
|
+
"./team.js": "./team.js",
|
|
81
97
|
"./text-search.js": "./text-search.js",
|
|
82
98
|
"./theme.js": "./theme.js",
|
|
83
99
|
"./titles.js": "./titles.js",
|
|
84
100
|
"./tool-discovery.js": "./tool-discovery.js",
|
|
101
|
+
"./tool-dispatch.js": "./tool-dispatch.js",
|
|
85
102
|
"./tool-groups.js": "./tool-groups.js",
|
|
103
|
+
"./tool-hints.js": "./tool-hints.js",
|
|
86
104
|
"./tool-need.js": "./tool-need.js",
|
|
87
105
|
"./tool-result.js": "./tool-result.js",
|
|
88
106
|
"./tool-round.js": "./tool-round.js",
|
|
89
107
|
"./tool-schema.js": "./tool-schema.js",
|
|
90
108
|
"./tool-traits.js": "./tool-traits.js",
|
|
109
|
+
"./toolset.js": "./toolset.js",
|
|
91
110
|
"./trajectory.js": "./trajectory.js",
|
|
92
111
|
"./upcast.js": "./upcast.js",
|
|
93
112
|
"./vault.js": "./vault.js",
|
|
94
113
|
"./view.js": "./view.js",
|
|
95
114
|
"./voice-intents.js": "./voice-intents.js",
|
|
96
115
|
"./voice-speaker.js": "./voice-speaker.js",
|
|
116
|
+
"./weather-tool.js": "./weather-tool.js",
|
|
97
117
|
"./weather.js": "./weather.js",
|
|
98
|
-
"./web-search.js": "./web-search.js",
|
|
99
|
-
"./widget.js": "./widget.js",
|
|
100
|
-
"./toolset.js": "./toolset.js",
|
|
101
|
-
"./tool-dispatch.js": "./tool-dispatch.js",
|
|
102
|
-
"./find-tool.js": "./find-tool.js",
|
|
103
118
|
"./web-search-tool.js": "./web-search-tool.js",
|
|
104
|
-
"./
|
|
105
|
-
"./
|
|
106
|
-
"./client-prefs.js": "./client-prefs.js",
|
|
107
|
-
"./tool-hints.js": "./tool-hints.js",
|
|
108
|
-
"./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
|
|
109
|
-
"./mcp-client.js": "./mcp-client.js",
|
|
110
|
-
"./mcp-manager.js": "./mcp-manager.js",
|
|
111
|
-
"./weather-tool.js": "./weather-tool.js",
|
|
112
|
-
"./context-attachments.js": "./context-attachments.js"
|
|
119
|
+
"./web-search.js": "./web-search.js",
|
|
120
|
+
"./widget.js": "./widget.js"
|
|
113
121
|
},
|
|
114
122
|
"files": [
|
|
115
123
|
"LICENSE",
|
|
@@ -117,13 +125,14 @@
|
|
|
117
125
|
"adapters.js",
|
|
118
126
|
"adaptive-tool-policy.js",
|
|
119
127
|
"backup-envelope.js",
|
|
128
|
+
"budget.js",
|
|
120
129
|
"capability.js",
|
|
121
130
|
"citations.js",
|
|
122
131
|
"client-prefs.js",
|
|
123
132
|
"context-attachments.js",
|
|
124
133
|
"cowriter-router.js",
|
|
125
|
-
"cowriter.js",
|
|
126
134
|
"cowriter-writer.js",
|
|
135
|
+
"cowriter.js",
|
|
127
136
|
"curate.js",
|
|
128
137
|
"distance.js",
|
|
129
138
|
"entitlement.js",
|
|
@@ -144,6 +153,7 @@
|
|
|
144
153
|
"markdown-authoring.js",
|
|
145
154
|
"markdown-render.js",
|
|
146
155
|
"mcp-client.js",
|
|
156
|
+
"mcp-dispatch.js",
|
|
147
157
|
"mcp-errors.js",
|
|
148
158
|
"mcp-manager.js",
|
|
149
159
|
"media-transcript.js",
|
|
@@ -166,6 +176,7 @@
|
|
|
166
176
|
"promotion.js",
|
|
167
177
|
"queue.js",
|
|
168
178
|
"reach.js",
|
|
179
|
+
"recipe-tool.js",
|
|
169
180
|
"recipe.js",
|
|
170
181
|
"record-list.js",
|
|
171
182
|
"redaction-tokens.js",
|
|
@@ -192,6 +203,11 @@
|
|
|
192
203
|
"sync-plan.js",
|
|
193
204
|
"synthesis.js",
|
|
194
205
|
"tags.js",
|
|
206
|
+
"team-board.js",
|
|
207
|
+
"team-plan.js",
|
|
208
|
+
"team-run.js",
|
|
209
|
+
"team-tool.js",
|
|
210
|
+
"team.js",
|
|
195
211
|
"text-search.js",
|
|
196
212
|
"theme.js",
|
|
197
213
|
"titles.js",
|
package/recipe-tool.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The `recipe` tool — a workflow the model did once, kept as data, and run by name.
|
|
2
|
+
//
|
|
3
|
+
// The engine is @chatpanel/events recipe.js: a recipe is `call` / `parallel` / `batch` /
|
|
4
|
+
// `pipeline` with `{ "$param": "x" }` slots, expanded, dry-run and run with no model in
|
|
5
|
+
// the loop. What is here is the CONVERSATIONAL half the spine's amendment A2 asked for —
|
|
6
|
+
// authored in chat, approved before it exists, declarative, never agent-written code:
|
|
7
|
+
//
|
|
8
|
+
// save the model proposes a recipe from what it just did ("you fetched two issues
|
|
9
|
+
// and compared them — keep that as `triage_pair`?"). The dry run is what the
|
|
10
|
+
// PERSON sees on the card: every step, every slot, every warning. Approval
|
|
11
|
+
// stores it; nothing runs.
|
|
12
|
+
// dry_run the same report, on demand, for a saved recipe with real parameters.
|
|
13
|
+
// run expand with the given parameters and execute through the turn's OWN toolset —
|
|
14
|
+
// so the destructive gate, redaction, the loop guard and the shield all apply
|
|
15
|
+
// to every step exactly as if the model had called it. A recipe composes calls;
|
|
16
|
+
// it is never a way around them.
|
|
17
|
+
//
|
|
18
|
+
// One registered tool, not three: a spec per verb is per-turn token cost on every armed
|
|
19
|
+
// turn, and the description already lists the saved recipes by name and parameters, which
|
|
20
|
+
// is the whole catalogue. Bound LATE to the toolset it lives in (`bind`), because the
|
|
21
|
+
// toolset that runs its steps is the one it is a member of.
|
|
22
|
+
//
|
|
23
|
+
// Shared: the extension and the desktop arm the same tool over the same `recipes` prefs
|
|
24
|
+
// section, so a recipe approved in one client runs in the other. The approval card and
|
|
25
|
+
// the store are the host's (injected); everything the model sees is here.
|
|
26
|
+
|
|
27
|
+
import { validateRecipe, expandRecipe, dryRunRecipe, runPlan, recipeParams, RECIPE_MODES } from './recipe.js';
|
|
28
|
+
|
|
29
|
+
export const RECIPE_TOOL_NAME = 'recipe';
|
|
30
|
+
|
|
31
|
+
const RECIPE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
32
|
+
|
|
33
|
+
function catalogue(recipes) {
|
|
34
|
+
const list = (recipes || []).filter((r) => r && r.enabled !== false && r.name);
|
|
35
|
+
if (!list.length) return 'No recipes saved yet.';
|
|
36
|
+
return `Saved recipes: ${list.map((r) => {
|
|
37
|
+
const ps = recipeParams(r).map((p) => (p.required ? p.name : `${p.name}?`));
|
|
38
|
+
return `${r.name}(${ps.join(', ')})${r.description ? ` — ${r.description}` : ''}`;
|
|
39
|
+
}).join('; ')}.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One spec. The shape a model needs to `save` is spelled out once, compactly; the
|
|
44
|
+
* engine's validator names anything it got wrong.
|
|
45
|
+
*/
|
|
46
|
+
export function recipeToolSpec(recipes) {
|
|
47
|
+
return {
|
|
48
|
+
name: RECIPE_TOOL_NAME,
|
|
49
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
50
|
+
description:
|
|
51
|
+
`Saved, repeatable tool workflows — run by name with no re-planning. ${catalogue(recipes)} `
|
|
52
|
+
+ 'Actions: {"action":"run","name":"<recipe>","params":{…}} runs one; '
|
|
53
|
+
+ '{"action":"dry_run","name":"<recipe>","params":{…}} shows what it would do without running; '
|
|
54
|
+
+ '{"action":"save","recipe":{…}} proposes a NEW one after you have done a multi-step task the '
|
|
55
|
+
+ 'user may repeat — the user approves it on a card. A recipe: {"name":"open_bug","description":"…",'
|
|
56
|
+
+ '"mode":"call","tool":"<tool name, e.g. mcp_github__create_issue>","arguments":{"title":{"$param":"title"},"labels":["bug"]}}. '
|
|
57
|
+
+ `Modes: ${RECIPE_MODES.join(' | ')} — parallel takes "calls":[{tool,arguments}], batch takes "tool"+"items":[{arguments}], `
|
|
58
|
+
+ 'pipeline takes "steps":[{tool,arguments,inputMapping:{arg:"$json.path"|"$text"}}]. '
|
|
59
|
+
+ 'Use {"$param":"x"} for anything the user will supply each time; a "default" makes it optional.',
|
|
60
|
+
parameters: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
|
|
64
|
+
name: { type: 'string', description: 'Recipe name, for run / dry_run.' },
|
|
65
|
+
params: { type: 'object', description: 'Parameter values, for run / dry_run.', additionalProperties: true },
|
|
66
|
+
recipe: { type: 'object', description: 'The recipe to save, for save.', additionalProperties: true },
|
|
67
|
+
},
|
|
68
|
+
required: ['action'],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The card a person approves: what it does, step by step, and what the dry run flagged. */
|
|
74
|
+
export function describeRecipeForApproval(recipe, report) {
|
|
75
|
+
const lines = [`${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`, `Mode: ${recipe.mode}`];
|
|
76
|
+
const ps = recipeParams(recipe);
|
|
77
|
+
if (ps.length) lines.push(`Parameters: ${ps.map((p) => (p.required ? p.name : `${p.name} (optional)`)).join(', ')}`);
|
|
78
|
+
const calls = report?.calls || [];
|
|
79
|
+
calls.forEach((c, i) => {
|
|
80
|
+
const args = JSON.stringify(c.arguments);
|
|
81
|
+
lines.push(`${calls.length > 1 ? `${i + 1}. ` : ''}${c.tool}${args && args !== '{}' ? ` ${args.length > 140 ? `${args.slice(0, 137)}…` : args}` : ''}${c.mappedLater?.length ? ` (+ ${c.mappedLater.join(', ')} from the previous step)` : ''}`);
|
|
82
|
+
});
|
|
83
|
+
for (const w of report?.warnings || []) lines.push(`⚠ ${w.message}`);
|
|
84
|
+
lines.push('Saved recipes run with no further planning; destructive steps still ask each time.');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const json = (v) => JSON.stringify(v);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param recipes the saved list (settings.recipes)
|
|
92
|
+
* @param confirmSave async (detail, recipe) => 'allow' | 'deny' — the surface's card. Absent on a
|
|
93
|
+
* surface with no window: `save` is then refused, `run` still works.
|
|
94
|
+
* @param saveRecipe async (recipe) => void — persist an approved one
|
|
95
|
+
*/
|
|
96
|
+
export function recipeToolProvider({ recipes = [], confirmSave = null, saveRecipe = null } = {}) {
|
|
97
|
+
let bound = null; // { execute, specs, traits, hiddenVia }
|
|
98
|
+
const byName = new Map((recipes || []).filter((r) => r?.name && r.enabled !== false).map((r) => [r.name, r]));
|
|
99
|
+
|
|
100
|
+
// Steps name REAL tools; a tool that lives behind a dispatcher (`mcp_gh__get_issue`
|
|
101
|
+
// behind `mcp`) is reached through it, so every guard that keys on the action fires.
|
|
102
|
+
const execute = (tool, args, meta) => {
|
|
103
|
+
if (!bound) return json({ error: 'The recipe tool is not bound to a toolset yet.' });
|
|
104
|
+
const via = bound.hiddenVia?.get(tool);
|
|
105
|
+
return via ? bound.execute(via, { action: tool, args }, meta) : bound.execute(tool, args, meta);
|
|
106
|
+
};
|
|
107
|
+
const specsForDryRun = () => (bound ? [...(bound.specs || []), ...(bound.reach || [])].filter((s) => s?.name !== RECIPE_TOOL_NAME) : null);
|
|
108
|
+
const traitsOf = (tool) => bound?.traits?.get(tool) || null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: 'recipe',
|
|
112
|
+
specs: [recipeToolSpec(recipes)],
|
|
113
|
+
system: byName.size ? 'Saved recipes exist (see the `recipe` tool). When the user asks for one by name or describes what one does, run it rather than re-planning the steps.' : '',
|
|
114
|
+
bind(toolset) {
|
|
115
|
+
bound = { execute: toolset.execute.bind(toolset), specs: toolset.specs, reach: toolset.reach, traits: toolset.traits, hiddenVia: toolset.hiddenVia };
|
|
116
|
+
},
|
|
117
|
+
async execute(name, input) {
|
|
118
|
+
if (name !== RECIPE_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
|
|
119
|
+
const action = String(input?.action || '');
|
|
120
|
+
|
|
121
|
+
if (action === 'save') {
|
|
122
|
+
const recipe = input?.recipe;
|
|
123
|
+
const v = validateRecipe(recipe);
|
|
124
|
+
if (!v.ok) return json({ error: 'The recipe is not valid.', problems: v.errors });
|
|
125
|
+
if (!RECIPE_NAME_RE.test(recipe.name)) return json({ error: 'name must be a short identifier: letters, digits, _ or -.' });
|
|
126
|
+
if (byName.has(recipe.name)) return json({ error: `A recipe named "${recipe.name}" already exists. Pick another name.` });
|
|
127
|
+
if (!confirmSave || !saveRecipe) return json({ error: 'Saving a recipe needs the user\'s approval, which this surface cannot ask for. Describe the recipe to the user and suggest saving it from the side panel.' });
|
|
128
|
+
const report = dryRunRecipe(recipe, {}, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
|
|
129
|
+
// Missing parameters are the POINT of a template; only structural problems block.
|
|
130
|
+
const blocking = (report.warnings || []).filter((w) => w.code === 'unknown_tool');
|
|
131
|
+
if (blocking.length) return json({ error: 'The recipe names tools that are not available in this conversation.', problems: blocking.map((w) => w.message) });
|
|
132
|
+
const detail = describeRecipeForApproval(recipe, report);
|
|
133
|
+
const decision = await confirmSave(detail, recipe);
|
|
134
|
+
if (decision !== 'allow') return json({ error: `The user did not save "${recipe.name}". Do not propose it again this turn.`, declined: true });
|
|
135
|
+
const stored = { ...recipe, enabled: true, createdAt: Date.now() };
|
|
136
|
+
await saveRecipe(stored);
|
|
137
|
+
byName.set(stored.name, stored);
|
|
138
|
+
return json({ saved: stored.name, params: recipeParams(stored).map((p) => p.name), hint: `Run it later with {"action":"run","name":"${stored.name}","params":{…}} or by typing /${stored.name}.` });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const recipe = byName.get(String(input?.name || ''));
|
|
142
|
+
if (!recipe) return json({ error: `No recipe named "${input?.name}".`, available: [...byName.keys()] });
|
|
143
|
+
const params = input?.params && typeof input.params === 'object' ? input.params : {};
|
|
144
|
+
|
|
145
|
+
if (action === 'dry_run') {
|
|
146
|
+
const report = dryRunRecipe(recipe, params, { specs: specsForDryRun(), traitsOf: bound ? (t) => traitsOf(t) || undefined : null });
|
|
147
|
+
return json({ name: recipe.name, ok: report.ok, missing: report.missing, calls: report.calls.map((c) => ({ tool: c.tool, arguments: c.arguments, known: c.known, destructive: c.traits?.destructive === true, mappedLater: c.mappedLater })), warnings: report.warnings });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (action === 'run') {
|
|
151
|
+
const ex = expandRecipe(recipe, params);
|
|
152
|
+
if (!ex.plan) return json({ error: 'The saved recipe is not valid.', problems: ex.errors });
|
|
153
|
+
if (!ex.ok) return json({ error: `Missing parameter(s): ${ex.missing.join(', ')}.`, params: recipeParams(recipe) });
|
|
154
|
+
const result = await runPlan(ex.plan, { execute, traitsOf: (t) => traitsOf(t) || undefined });
|
|
155
|
+
return json({ name: recipe.name, ...result });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
package/team-board.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The board — what a team's members say to each other, as typed findings, not talk.
|
|
2
|
+
//
|
|
3
|
+
// Members do not read each other's transcripts. A task ends with FINDINGS: claims with the
|
|
4
|
+
// refs they came from (the brief shape, I-K1), drafts, links, questions. A later task reads
|
|
5
|
+
// the board, sized like a shielded tool result so a long-running team does not feed a
|
|
6
|
+
// later member forty thousand characters of earlier members. The board is the run's record:
|
|
7
|
+
// durable, attributable per role, and — after a run — the thing that can become draft
|
|
8
|
+
// briefs and be promoted on convergence (W7), rather than evaporating with the run.
|
|
9
|
+
//
|
|
10
|
+
// Findings are parsed GENEROUSLY from a model's answer through the structured layer, and a
|
|
11
|
+
// task whose answer cannot be read as findings is not lost: its whole answer becomes one
|
|
12
|
+
// `draft` finding. A member that only wrote prose still contributed.
|
|
13
|
+
|
|
14
|
+
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
15
|
+
|
|
16
|
+
export const FINDING_KINDS = Object.freeze(['claim', 'draft', 'link', 'question', 'answer']);
|
|
17
|
+
export const MAX_FINDINGS_PER_TASK = 40;
|
|
18
|
+
export const BOARD_TEXT_MAX = 12_000;
|
|
19
|
+
|
|
20
|
+
export const FINDINGS_SCHEMA = defineSchema({
|
|
21
|
+
name: 'findings',
|
|
22
|
+
purpose: 'what this task established, each item on its own with where it came from',
|
|
23
|
+
fields: {
|
|
24
|
+
findings: {
|
|
25
|
+
type: 'object[]', required: true, maxItems: MAX_FINDINGS_PER_TASK,
|
|
26
|
+
describe: 'one entry per fact, draft, link or open question — never a paragraph of several',
|
|
27
|
+
fields: {
|
|
28
|
+
kind: { type: 'enum', values: FINDING_KINDS, default: 'claim' },
|
|
29
|
+
text: { type: 'string', required: true, max: 2000 },
|
|
30
|
+
refs: { type: 'string[]', maxItems: 8, describe: 'record ids or URLs this rests on, when any' },
|
|
31
|
+
confidence: { type: 'number', describe: '0–1, how sure' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
nothing: { findings: [] },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The instruction appended to every task so the answer can be read as findings. */
|
|
39
|
+
export function findingsInstruction() {
|
|
40
|
+
return `When you are done, end your answer with your findings in this shape:\n${describeSchema(FINDINGS_SCHEMA)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read a task's answer into findings. The JSON block, when present; otherwise the whole
|
|
45
|
+
* answer as one draft — a member that only wrote prose still contributed.
|
|
46
|
+
*/
|
|
47
|
+
export function parseFindings(text, { role, taskId } = {}) {
|
|
48
|
+
const raw = String(text || '').trim();
|
|
49
|
+
if (!raw) return [];
|
|
50
|
+
const got = coerce(raw, FINDINGS_SCHEMA);
|
|
51
|
+
const list = Array.isArray(got?.value?.findings) ? got.value.findings.filter((f) => f && String(f.text || '').trim()) : [];
|
|
52
|
+
const stamp = (f, i) => ({
|
|
53
|
+
id: `${taskId || 't'}:${i + 1}`,
|
|
54
|
+
kind: FINDING_KINDS.includes(f.kind) ? f.kind : 'claim',
|
|
55
|
+
text: String(f.text).trim().slice(0, 2000),
|
|
56
|
+
refs: Array.isArray(f.refs) ? f.refs.map(String).filter(Boolean).slice(0, 8) : [],
|
|
57
|
+
// Absent or zero reads as "not stated": a model that gives no number is not 0% sure.
|
|
58
|
+
confidence: Number.isFinite(Number(f.confidence)) && Number(f.confidence) > 0 ? Math.min(1, Number(f.confidence)) : null,
|
|
59
|
+
role: role || null,
|
|
60
|
+
taskId: taskId || null,
|
|
61
|
+
});
|
|
62
|
+
if (list.length) return list.slice(0, MAX_FINDINGS_PER_TASK).map(stamp);
|
|
63
|
+
// No JSON block: the prose is the finding. Strip a fenced JSON tail that failed to parse.
|
|
64
|
+
const prose = raw.replace(/```json[\s\S]*$/i, '').trim() || raw;
|
|
65
|
+
return [stamp({ kind: 'draft', text: prose.slice(0, 2000), refs: [] }, 0)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createBoard({ now = () => Date.now() } = {}) {
|
|
69
|
+
const findings = [];
|
|
70
|
+
const listeners = new Set();
|
|
71
|
+
return {
|
|
72
|
+
add(list) {
|
|
73
|
+
const at = now();
|
|
74
|
+
for (const f of Array.isArray(list) ? list : [list]) {
|
|
75
|
+
if (!f || !f.text) continue;
|
|
76
|
+
const entry = { ...f, at };
|
|
77
|
+
findings.push(entry);
|
|
78
|
+
for (const l of listeners) l(entry);
|
|
79
|
+
}
|
|
80
|
+
return findings.length;
|
|
81
|
+
},
|
|
82
|
+
all: () => [...findings],
|
|
83
|
+
byTask: (taskId) => findings.filter((f) => f.taskId === taskId),
|
|
84
|
+
byRole: (role) => findings.filter((f) => f.role === role),
|
|
85
|
+
onFinding(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
86
|
+
get size() { return findings.length; },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a later task READS: the findings of the tasks it depends on (or everything so far),
|
|
92
|
+
* sized. Newest are kept whole; the oldest are what get cut, and the cut is stated.
|
|
93
|
+
*/
|
|
94
|
+
export function boardText(findings, { taskIds = null, max = BOARD_TEXT_MAX } = {}) {
|
|
95
|
+
const list = (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId));
|
|
96
|
+
if (!list.length) return '';
|
|
97
|
+
const lines = list.map((f) => `- [${f.kind}${f.role ? ` · ${f.role}` : ''}${f.confidence != null ? ` · ${Math.round(f.confidence * 100)}%` : ''}] ${f.text}${f.refs?.length ? ` (refs: ${f.refs.join(', ')})` : ''}`);
|
|
98
|
+
let out = lines.join('\n');
|
|
99
|
+
if (out.length <= max) return `Findings so far:\n${out}`;
|
|
100
|
+
let kept = [];
|
|
101
|
+
let size = 0;
|
|
102
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
103
|
+
if (size + lines[i].length + 1 > max) break;
|
|
104
|
+
kept.unshift(lines[i]);
|
|
105
|
+
size += lines[i].length + 1;
|
|
106
|
+
}
|
|
107
|
+
return `Findings so far (${lines.length - kept.length} earlier ones omitted for length):\n${kept.join('\n')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Findings → the claim shape a draft brief takes (W7): text + refs as `{ kind, id }`. */
|
|
111
|
+
export function toBriefClaims(findings) {
|
|
112
|
+
return (findings || [])
|
|
113
|
+
.filter((f) => f.kind === 'claim' && f.text)
|
|
114
|
+
.map((f) => ({
|
|
115
|
+
text: f.text,
|
|
116
|
+
refs: (f.refs || []).map((r) => {
|
|
117
|
+
const m = /^([a-z][a-z0-9_-]*):(?!\/\/)(.+)$/.exec(String(r));
|
|
118
|
+
return m ? { kind: m[1], id: m[2] } : { kind: 'url', id: String(r) };
|
|
119
|
+
}),
|
|
120
|
+
by: f.role || 'team',
|
|
121
|
+
confidence: f.confidence,
|
|
122
|
+
}));
|
|
123
|
+
}
|
package/team-plan.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Planning a run: which tasks, for which roles, in what order.
|
|
2
|
+
//
|
|
3
|
+
// Two ways. `fixed` — one task per role, each given the request and its own prompt, run in
|
|
4
|
+
// declaration order with the dependencies the roles declare; no model is asked how to
|
|
5
|
+
// split the work, so the plan is free and reproducible. `planner` — the strongest appointed
|
|
6
|
+
// role is asked, through the shared structured layer (never a hand-typed JSON prompt), to
|
|
7
|
+
// break the request into tasks and assign each to a role it may use. A planner that returns
|
|
8
|
+
// nothing readable falls back to the fixed plan, which is why a run can always start.
|
|
9
|
+
//
|
|
10
|
+
// A task's `dependsOn` is what the fan-out turns into barriers: a task with no unmet
|
|
11
|
+
// dependency runs alongside the others (the round's pool), a dependent one waits and reads
|
|
12
|
+
// the board. That is the whole scheduling model — the same one a tool round uses.
|
|
13
|
+
|
|
14
|
+
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
15
|
+
|
|
16
|
+
export const MAX_TASKS = 12;
|
|
17
|
+
|
|
18
|
+
export const TEAM_PLAN_SCHEMA = defineSchema({
|
|
19
|
+
name: 'team_plan',
|
|
20
|
+
purpose: 'the tasks a team will run for a request, each assigned to one role',
|
|
21
|
+
fields: {
|
|
22
|
+
tasks: {
|
|
23
|
+
type: 'object[]', required: true, maxItems: MAX_TASKS,
|
|
24
|
+
describe: 'concrete, independent where possible; a task that needs another\'s result names it in dependsOn',
|
|
25
|
+
fields: {
|
|
26
|
+
id: { type: 'string', required: true, max: 32, describe: 'a short id like t1' },
|
|
27
|
+
role: { type: 'string', required: true, max: 32, describe: 'the id of the role that runs it' },
|
|
28
|
+
title: { type: 'string', required: true, max: 80 },
|
|
29
|
+
prompt: { type: 'string', required: true, max: 1200, describe: 'the focused instruction for this task' },
|
|
30
|
+
dependsOn: { type: 'string[]', maxItems: 6, describe: 'task ids whose findings this one needs' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
nothing: { tasks: [] },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The planner's instruction — the request, the roles it may use, the shape it must answer in. */
|
|
38
|
+
export function plannerPrompt(team, request) {
|
|
39
|
+
const roles = (team.roles || []).map((r) => `- ${r.id}: ${r.name || r.id} — ${r.prompt.slice(0, 200)}${r.grants?.length ? ` (tools: ${r.grants.join(', ')})` : ''}`).join('\n');
|
|
40
|
+
return [
|
|
41
|
+
`You are the planner of a team named "${team.name}". Break the request into 2–${Math.min(MAX_TASKS, Math.max(2, (team.roles || []).length * 2))} tasks and assign each to ONE of these roles by id:`,
|
|
42
|
+
roles,
|
|
43
|
+
'',
|
|
44
|
+
`Request: ${String(request || '').trim()}`,
|
|
45
|
+
'',
|
|
46
|
+
'Prefer tasks that can run at the same time; use dependsOn only when a task truly needs another\'s findings. Do not assign a role a task it has no tools for.',
|
|
47
|
+
'',
|
|
48
|
+
describeSchema(TEAM_PLAN_SCHEMA),
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One task per role, in declaration order, with the roles' own dependencies. */
|
|
53
|
+
export function fixedPlan(team, request) {
|
|
54
|
+
const req = String(request || '').trim();
|
|
55
|
+
return (team.roles || []).map((r) => ({
|
|
56
|
+
id: `t_${r.id}`,
|
|
57
|
+
role: r.id,
|
|
58
|
+
title: r.name || r.id,
|
|
59
|
+
prompt: r.prompt ? `${r.prompt}\n\nRequest: ${req}` : req,
|
|
60
|
+
dependsOn: (r.dependsOn || []).map((d) => `t_${d}`),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a planner's answer into tasks, dropping what cannot run: an unknown role, a
|
|
66
|
+
* dependency on nothing, a cycle. Returns [] when nothing survives, so the caller falls back.
|
|
67
|
+
*/
|
|
68
|
+
export function parsePlan(text, team) {
|
|
69
|
+
const got = coerce(text, TEAM_PLAN_SCHEMA);
|
|
70
|
+
const roles = new Set((team.roles || []).map((r) => r.id));
|
|
71
|
+
const raw = Array.isArray(got?.value?.tasks) ? got.value.tasks : [];
|
|
72
|
+
const tasks = raw
|
|
73
|
+
.filter((t) => t && roles.has(String(t.role || '')) && String(t.prompt || '').trim())
|
|
74
|
+
.map((t, i) => ({
|
|
75
|
+
id: String(t.id || `t${i + 1}`).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32) || `t${i + 1}`,
|
|
76
|
+
role: String(t.role),
|
|
77
|
+
title: String(t.title || t.prompt).slice(0, 80),
|
|
78
|
+
prompt: String(t.prompt).trim(),
|
|
79
|
+
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(String) : [],
|
|
80
|
+
}))
|
|
81
|
+
.slice(0, MAX_TASKS);
|
|
82
|
+
const ids = new Set(tasks.map((t) => t.id));
|
|
83
|
+
for (const t of tasks) t.dependsOn = t.dependsOn.filter((d) => ids.has(d) && d !== t.id);
|
|
84
|
+
return breakCycles(tasks);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A task whose dependencies cannot all be satisfied loses them, rather than never running. */
|
|
88
|
+
export function breakCycles(tasks) {
|
|
89
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
90
|
+
const state = new Map(); // id -> 'visiting' | 'done'
|
|
91
|
+
const visit = (t, stack) => {
|
|
92
|
+
if (state.get(t.id) === 'done') return;
|
|
93
|
+
if (state.get(t.id) === 'visiting') return;
|
|
94
|
+
state.set(t.id, 'visiting');
|
|
95
|
+
t.dependsOn = t.dependsOn.filter((d) => {
|
|
96
|
+
const dep = byId.get(d);
|
|
97
|
+
if (!dep) return false;
|
|
98
|
+
if (state.get(d) === 'visiting' || stack.has(d)) return false; // a cycle: drop the edge
|
|
99
|
+
visit(dep, new Set([...stack, t.id]));
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
state.set(t.id, 'done');
|
|
103
|
+
};
|
|
104
|
+
for (const t of tasks) visit(t, new Set());
|
|
105
|
+
return tasks;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Tasks in waves: everything runnable now, then what those unblock — the barrier order. */
|
|
109
|
+
export function waves(tasks) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const done = new Set();
|
|
112
|
+
let rest = [...tasks];
|
|
113
|
+
while (rest.length) {
|
|
114
|
+
const ready = rest.filter((t) => (t.dependsOn || []).every((d) => done.has(d)));
|
|
115
|
+
if (!ready.length) { out.push(rest); break; } // cannot happen after breakCycles; never loop forever
|
|
116
|
+
out.push(ready);
|
|
117
|
+
for (const t of ready) done.add(t.id);
|
|
118
|
+
rest = rest.filter((t) => !done.has(t.id));
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
package/team-run.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// A run — a turn of turns: plan, fan out, merge, propose. One runner for every client.
|
|
2
|
+
//
|
|
3
|
+
// The runner never speaks to a model and never runs a tool. `callModel` is the host's own
|
|
4
|
+
// model turn (the extension's streamChat, the desktop's gateway call — redaction on the way
|
|
5
|
+
// out, restore on the way back, the tool loop with its round, shield and gate inside it);
|
|
6
|
+
// `toolsFor(role)` is the host's own toolset narrowed to the role's grants. A team therefore
|
|
7
|
+
// cannot reach what a single turn cannot, and every guard a client earned stays in force.
|
|
8
|
+
//
|
|
9
|
+
// Tasks run in WAVES (team-plan.js): everything with its dependencies met runs together
|
|
10
|
+
// through the same pool a tool round uses; a dependent task waits and reads the board. A
|
|
11
|
+
// task ends with findings; the merge turns the board into ONE proposal — agreed claims
|
|
12
|
+
// (converge, W7), a judged answer, or the members' work side by side — and the proposal is
|
|
13
|
+
// what a person sees. Nothing here lands anywhere.
|
|
14
|
+
//
|
|
15
|
+
// Budget first. `createBudget` refuses a team without one; before every model call the run
|
|
16
|
+
// asks `canAfford`, and a run that cannot afford its next call stops with what it has and
|
|
17
|
+
// says so (`status: 'over-budget'`). Stop is one signal, fanned out.
|
|
18
|
+
|
|
19
|
+
import { normalizeTeam } from './team.js';
|
|
20
|
+
import { createBudget } from './budget.js';
|
|
21
|
+
import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
|
|
22
|
+
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims } from './team-board.js';
|
|
23
|
+
import { converge } from './promotion.js';
|
|
24
|
+
|
|
25
|
+
export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
|
|
26
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
27
|
+
|
|
28
|
+
export class TeamRunError extends Error {
|
|
29
|
+
constructor(code, message) { super(message); this.name = 'TeamRunError'; this.code = code; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function pool(items, limit, fn) {
|
|
33
|
+
const out = new Array(items.length);
|
|
34
|
+
let next = 0;
|
|
35
|
+
const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); } };
|
|
36
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) || 1 }, worker));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TIER = { cheap: 0, balanced: 1, strong: 2 };
|
|
41
|
+
const strongestRole = (team) => [...team.roles].sort((a, b) => (TIER[b.prefer] ?? 1) - (TIER[a.prefer] ?? 1))[0];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a person sees before approving a run: every role with the model it would get, its
|
|
45
|
+
* grants and mode; the plan when it is fixed (a planner plans at run time); the budget.
|
|
46
|
+
*/
|
|
47
|
+
export function dryRunTeam(team, request, { appoint = null } = {}) {
|
|
48
|
+
const t = normalizeTeam(team);
|
|
49
|
+
const roles = t.roles.map((r) => {
|
|
50
|
+
const a = appoint ? appoint(r) : null;
|
|
51
|
+
return { id: r.id, name: r.name, mode: r.mode, prefer: r.prefer, model: a?.model || r.model || null, appointed: !!(a?.model || r.model), grants: r.grants, ...(r.recipe ? { recipe: r.recipe } : {}) };
|
|
52
|
+
});
|
|
53
|
+
const missing = roles.filter((r) => r.mode !== 'recipe' && !r.appointed).map((r) => r.id);
|
|
54
|
+
return {
|
|
55
|
+
name: t.name, description: t.description, plan: t.plan, merge: t.merge, judge: t.judge, budget: t.budget, roles,
|
|
56
|
+
tasks: t.plan === 'fixed' ? fixedPlan(t, request) : null,
|
|
57
|
+
ok: missing.length === 0,
|
|
58
|
+
missing,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param callModel `async ({ runId, taskId, role, model, mode, system, prompt, tools, signal, onDelta }) =>
|
|
64
|
+
* { ok, text, usage?, error?, aborted? }` — the host's model turn
|
|
65
|
+
* @param toolsFor `(role) => toolset | undefined` — narrowed to the role's grants by the host
|
|
66
|
+
* @param appoint `(role) => { model, mode } | null` — the host's roster through cowriter-router
|
|
67
|
+
* @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
|
|
68
|
+
* @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
|
|
69
|
+
* task.done · task.failed · run.merging · run.done; the host forwards them to
|
|
70
|
+
* its UI and to the gateway's run store
|
|
71
|
+
*/
|
|
72
|
+
export async function runTeam({
|
|
73
|
+
team, request, callModel, toolsFor = () => undefined, appoint = null, runRecipe = null,
|
|
74
|
+
now = () => Date.now(), newId = () => `run_${Math.random().toString(36).slice(2, 10)}`,
|
|
75
|
+
emit = () => {}, signal = null, maxConcurrency = DEFAULT_CONCURRENCY, runId = null,
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
|
|
78
|
+
const t = normalizeTeam(team); // throws on a team without a budget — O1
|
|
79
|
+
const id = runId || newId();
|
|
80
|
+
const budget = createBudget(t.budget, { now });
|
|
81
|
+
const board = createBoard({ now });
|
|
82
|
+
const startedAt = now();
|
|
83
|
+
const tasksOut = [];
|
|
84
|
+
const say = (type, payload = {}) => emit(type, { runId: id, at: now(), ...payload });
|
|
85
|
+
const roleOf = (rid) => t.roles.find((r) => r.id === rid);
|
|
86
|
+
const modelFor = (r) => (appoint ? appoint(r) : null) || (r.model ? { model: r.model, mode: r.mode } : null);
|
|
87
|
+
const stopped = () => !!signal?.aborted;
|
|
88
|
+
|
|
89
|
+
say('run.started', { team: t.name, request: String(request || ''), budget: t.budget, roles: t.roles.map((r) => r.id) });
|
|
90
|
+
|
|
91
|
+
// ── plan ──────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
let tasks;
|
|
93
|
+
let planBy = 'fixed';
|
|
94
|
+
if (t.plan === 'planner') {
|
|
95
|
+
const planner = strongestRole(t);
|
|
96
|
+
const m = modelFor(planner);
|
|
97
|
+
if (m && budget.canAfford({ tokens: 0 })) {
|
|
98
|
+
const res = await callModel({ runId: id, taskId: 'plan', role: planner.id, model: m.model, mode: 'model', system: '', prompt: plannerPrompt(t, request), tools: undefined, signal });
|
|
99
|
+
if (res?.usage) budget.charge(res.usage);
|
|
100
|
+
const parsed = res?.ok ? parsePlan(res.text, t) : [];
|
|
101
|
+
if (parsed.length) { tasks = parsed; planBy = 'planner'; }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!tasks) tasks = fixedPlan(t, request);
|
|
105
|
+
say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn })) });
|
|
106
|
+
|
|
107
|
+
const finish = (status, extra = {}) => {
|
|
108
|
+
const usage = budget.snapshot();
|
|
109
|
+
const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), usage, startedAt, endedAt: now(), ...extra };
|
|
110
|
+
say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id) });
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ── fan out, in waves ─────────────────────────────────────────────────────────────────
|
|
115
|
+
let overBudget = false;
|
|
116
|
+
for (const wave of waves(tasks)) {
|
|
117
|
+
if (stopped() || overBudget) break;
|
|
118
|
+
await pool(wave, maxConcurrency, async (task) => {
|
|
119
|
+
if (stopped() || overBudget) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
|
|
120
|
+
const role = roleOf(task.role);
|
|
121
|
+
const t0 = now();
|
|
122
|
+
say('task.started', { taskId: task.id, role: task.role, title: task.title });
|
|
123
|
+
let text = '';
|
|
124
|
+
let usage = null;
|
|
125
|
+
let status = 'ok';
|
|
126
|
+
let error = null;
|
|
127
|
+
try {
|
|
128
|
+
if (role.mode === 'recipe') {
|
|
129
|
+
if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
|
|
130
|
+
const r = await runRecipe(role.recipe, { request: String(request || ''), task: task.prompt });
|
|
131
|
+
text = typeof r === 'string' ? r : JSON.stringify(r);
|
|
132
|
+
} else {
|
|
133
|
+
const m = modelFor(role);
|
|
134
|
+
if (!m?.model) throw new Error(`no model for role "${role.id}"`);
|
|
135
|
+
// A call's tokens are unknown until it returns; what can be asked beforehand is
|
|
136
|
+
// whether the budget is already exhausted and whether one more call is allowed.
|
|
137
|
+
if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
|
|
138
|
+
const prior = boardText(board.all(), { taskIds: task.dependsOn?.length ? task.dependsOn : null });
|
|
139
|
+
const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
|
|
140
|
+
const res = await callModel({
|
|
141
|
+
runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
|
|
142
|
+
system: role.prompt, prompt, tools: toolsFor(role), signal,
|
|
143
|
+
onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
|
|
144
|
+
});
|
|
145
|
+
usage = res?.usage || null;
|
|
146
|
+
if (usage) budget.charge(usage);
|
|
147
|
+
if (res?.aborted) { status = 'stopped'; }
|
|
148
|
+
else if (!res?.ok) throw new Error(res?.error || 'the model did not answer');
|
|
149
|
+
text = String(res?.text || '');
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
status = overBudget ? 'over-budget' : 'failed';
|
|
153
|
+
error = String(e?.message || e);
|
|
154
|
+
}
|
|
155
|
+
const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
|
|
156
|
+
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
157
|
+
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings };
|
|
158
|
+
tasksOut.push(row);
|
|
159
|
+
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length });
|
|
160
|
+
if (budget.exhausted()) overBudget = true;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Every planned task gets a row — what never ran is recorded as skipped, not forgotten.
|
|
164
|
+
for (const task of tasks) if (!tasksOut.some((x) => x.id === task.id)) tasksOut.push({ id: task.id, role: task.role, title: task.title, status: 'skipped', text: '', findings: [] });
|
|
165
|
+
if (stopped()) return finish('stopped');
|
|
166
|
+
if (overBudget) return finish('over-budget', { proposal: mergeCheap(t, board.all(), tasksOut) });
|
|
167
|
+
|
|
168
|
+
// ── merge ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
say('run.merging', { policy: t.merge });
|
|
170
|
+
const okTasks = tasksOut.filter((x) => x.status === 'ok');
|
|
171
|
+
let proposal;
|
|
172
|
+
if (!okTasks.length) return finish('failed', { proposal: null });
|
|
173
|
+
if (t.merge === 'judge') {
|
|
174
|
+
const judge = roleOf(t.judge) || strongestRole(t);
|
|
175
|
+
const m = modelFor(judge);
|
|
176
|
+
if (m?.model && budget.canAfford({ tokens: 0 })) {
|
|
177
|
+
const prompt = [
|
|
178
|
+
`You are the ${judge.name || judge.id} of team "${t.name}". Review the team's findings for the request below and write the final answer — accurate, concise, and only what the findings support. Flag anything the members disagreed on.`,
|
|
179
|
+
`Request: ${String(request || '').trim()}`,
|
|
180
|
+
boardText(board.all()),
|
|
181
|
+
].join('\n\n');
|
|
182
|
+
const res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: m.model, mode: 'model', system: judge.prompt, prompt, tools: undefined, signal });
|
|
183
|
+
if (res?.usage) budget.charge(res.usage);
|
|
184
|
+
proposal = res?.ok ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
185
|
+
} else {
|
|
186
|
+
proposal = mergeCheap(t, board.all(), tasksOut);
|
|
187
|
+
}
|
|
188
|
+
} else if (t.merge === 'converge') {
|
|
189
|
+
const drafts = okTasks.map((x) => ({ claims: toBriefClaims(x.findings) }));
|
|
190
|
+
const { agreed, disputed } = converge(drafts, { minAgree: Math.min(2, drafts.length) });
|
|
191
|
+
proposal = { kind: 'claims', agreed, disputed, by: 'converge' };
|
|
192
|
+
} else if (t.merge === 'first') {
|
|
193
|
+
proposal = { kind: 'answer', text: okTasks[0].text, by: okTasks[0].role };
|
|
194
|
+
} else {
|
|
195
|
+
proposal = mergeCheap(t, board.all(), tasksOut);
|
|
196
|
+
}
|
|
197
|
+
const failed = tasksOut.some((x) => x.status !== 'ok');
|
|
198
|
+
return finish(failed ? 'partial' : 'completed', { proposal });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** No model: the members' work side by side, findings first — always available. */
|
|
202
|
+
function mergeCheap(team, findings, tasks) {
|
|
203
|
+
const sections = tasks.filter((x) => x.status === 'ok').map((x) => {
|
|
204
|
+
const role = team.roles.find((r) => r.id === x.role);
|
|
205
|
+
const own = (x.findings || []).map((f) => `- ${f.text}${f.refs?.length ? ` (${f.refs.join(', ')})` : ''}`).join('\n');
|
|
206
|
+
return `### ${role?.name || x.role}\n${own || x.text}`;
|
|
207
|
+
});
|
|
208
|
+
return { kind: 'answer', text: sections.join('\n\n'), by: 'concat' };
|
|
209
|
+
}
|
package/team-tool.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The `team` tool — a team invoked from any chat: run, dry_run, save.
|
|
2
|
+
//
|
|
3
|
+
// The recipe tool's twin, on purpose: one registered tool whose description is the
|
|
4
|
+
// catalogue, `dry_run` showing what a person would approve, `save` proposing a team from the
|
|
5
|
+
// conversation and storing it only on the card's Allow, `run` executing through the host's
|
|
6
|
+
// own runner. A team run is a tool call inside an ordinary turn, so it streams, can be
|
|
7
|
+
// stopped, and is queued and steered like anything else the model does.
|
|
8
|
+
//
|
|
9
|
+
// Everything the model sees is here; the card, the store and the runner's `callModel` are
|
|
10
|
+
// the host's, injected. Bound late (`bind`) to the toolset it lives in: a team's members
|
|
11
|
+
// receive the host's toolset narrowed to their grants, and the host builds that from the
|
|
12
|
+
// same providers this tool is a member of.
|
|
13
|
+
|
|
14
|
+
import { validateTeam, normalizeTeam, describeRole, TEAM_NAME_RE } from './team.js';
|
|
15
|
+
import { dryRunTeam } from './team-run.js';
|
|
16
|
+
|
|
17
|
+
export const TEAM_TOOL_NAME = 'team';
|
|
18
|
+
|
|
19
|
+
function catalogue(teams) {
|
|
20
|
+
const list = (teams || []).filter((t) => t && t.enabled !== false && t.name);
|
|
21
|
+
if (!list.length) return 'No teams saved yet.';
|
|
22
|
+
return `Saved teams: ${list.map((t) => `${t.name} (${(t.roles || []).map((r) => r.id).join(', ')})${t.description ? ` — ${t.description}` : ''}`).join('; ')}.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function teamToolSpec(teams) {
|
|
26
|
+
return {
|
|
27
|
+
name: TEAM_TOOL_NAME,
|
|
28
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
29
|
+
description:
|
|
30
|
+
`Saved agent teams — several roles working a request in parallel, merged into one answer. ${catalogue(teams)} `
|
|
31
|
+
+ 'Actions: {"action":"run","name":"<team>","request":"<what to do>"} runs one (streams; may take a while); '
|
|
32
|
+
+ '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
|
|
33
|
+
+ '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
|
|
34
|
+
+ 'A team: {"name":"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}}. '
|
|
35
|
+
+ 'grants: none | data | web | history | mcp | mcp:<server>. merge: judge | converge | concat | first. A budget is required.',
|
|
36
|
+
parameters: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
|
|
40
|
+
name: { type: 'string', description: 'Team name, for run / dry_run.' },
|
|
41
|
+
request: { type: 'string', description: 'What the team should do, for run / dry_run.' },
|
|
42
|
+
team: { type: 'object', description: 'The team to save, for save.', additionalProperties: true },
|
|
43
|
+
},
|
|
44
|
+
required: ['action'],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The card a person approves a new team on. */
|
|
50
|
+
export function describeTeamForApproval(team, dry) {
|
|
51
|
+
const lines = [`${team.name}${team.description ? ` — ${team.description}` : ''}`, `Plan: ${team.plan || 'fixed'} · merge: ${team.merge || 'concat'}${team.judge ? ` (judge: ${team.judge})` : ''}`];
|
|
52
|
+
for (const r of dry?.roles || team.roles || []) lines.push(`• ${describeRole({ ...r, model: r.model || undefined })}${r.appointed === false ? ' — NO MODEL AVAILABLE' : ''}`);
|
|
53
|
+
const b = team.budget || {};
|
|
54
|
+
lines.push(`Budget: ${Object.entries(b).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
|
|
55
|
+
lines.push('Runs go through your own models and tools; a team may not act on a page. Nothing a team produces lands without you.');
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const json = (v) => JSON.stringify(v);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param teams the saved list (the `teams` prefs section)
|
|
63
|
+
* @param run `async ({ team, request, onEvent }) => runResult` — the host's runTeam binding
|
|
64
|
+
* @param appoint `(role) => { model, mode } | null` for the dry run
|
|
65
|
+
* @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
|
|
66
|
+
* @param saveTeam `async (team) => void`
|
|
67
|
+
*/
|
|
68
|
+
export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null } = {}) {
|
|
69
|
+
const byName = new Map((teams || []).filter((t) => t?.name && t.enabled !== false).map((t) => [t.name, t]));
|
|
70
|
+
let bound = null;
|
|
71
|
+
return {
|
|
72
|
+
id: 'team',
|
|
73
|
+
specs: [teamToolSpec(teams)],
|
|
74
|
+
system: 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.' : '',
|
|
75
|
+
bind(toolset) { bound = toolset; },
|
|
76
|
+
async execute(name, input) {
|
|
77
|
+
if (name !== TEAM_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
|
|
78
|
+
const action = String(input?.action || '');
|
|
79
|
+
|
|
80
|
+
if (action === 'save') {
|
|
81
|
+
const team = input?.team;
|
|
82
|
+
const v = validateTeam(team);
|
|
83
|
+
if (!v.ok) return json({ error: 'The team is not valid.', problems: v.errors, hint: 'A team needs a name, roles with prompts and grants, and a budget.' });
|
|
84
|
+
if (!TEAM_NAME_RE.test(team.name)) return json({ error: 'name must be a short identifier.' });
|
|
85
|
+
if (byName.has(team.name)) return json({ error: `A team named "${team.name}" already exists. Pick another name.` });
|
|
86
|
+
if (!confirmSave || !saveTeam) return json({ error: 'Saving a team needs the user\'s approval, which this surface cannot ask for. Describe the team and suggest saving it from the side panel or the desktop.' });
|
|
87
|
+
const norm = normalizeTeam(team);
|
|
88
|
+
const dry = dryRunTeam(norm, '', { appoint });
|
|
89
|
+
const decision = await confirmSave(describeTeamForApproval(norm, dry), norm);
|
|
90
|
+
if (decision !== 'allow') return json({ error: `The user did not save "${norm.name}". Do not propose it again this turn.`, declined: true });
|
|
91
|
+
const stored = { ...norm, createdAt: Date.now() };
|
|
92
|
+
await saveTeam(stored);
|
|
93
|
+
byName.set(stored.name, stored);
|
|
94
|
+
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}.` });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const team = byName.get(String(input?.name || ''));
|
|
98
|
+
if (!team) return json({ error: `No team named "${input?.name}".`, available: [...byName.keys()] });
|
|
99
|
+
const request = String(input?.request || '').trim();
|
|
100
|
+
|
|
101
|
+
if (action === 'dry_run') {
|
|
102
|
+
const dry = dryRunTeam(team, request, { appoint });
|
|
103
|
+
return json({ name: team.name, ok: dry.ok, missing: dry.missing, roles: dry.roles, plan: dry.plan, tasks: dry.tasks, merge: dry.merge, budget: dry.budget });
|
|
104
|
+
}
|
|
105
|
+
if (action === 'run') {
|
|
106
|
+
if (!request) return json({ error: 'run needs a request — what should the team do?' });
|
|
107
|
+
if (typeof run !== 'function') return json({ error: 'This surface cannot run a team.' });
|
|
108
|
+
const dry = dryRunTeam(team, request, { appoint });
|
|
109
|
+
if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
|
|
110
|
+
const result = await run({ team, request, toolset: bound });
|
|
111
|
+
const findings = (result.board || []).map((f) => ({ role: f.role, kind: f.kind, text: f.text, refs: f.refs }));
|
|
112
|
+
return json({
|
|
113
|
+
name: team.name, runId: result.runId, status: result.status,
|
|
114
|
+
proposal: result.proposal,
|
|
115
|
+
tasks: (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined })),
|
|
116
|
+
findings,
|
|
117
|
+
usage: result.usage,
|
|
118
|
+
hint: result.status === 'over-budget' ? 'The team stopped at its budget; the proposal is what it had. Say so.' : undefined,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
package/team.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A team, as data — roles with grants, a merge policy, a budget. Nothing runs here.
|
|
2
|
+
//
|
|
3
|
+
// The Notes co-writer swarm was one team, hard-wired: a planner, four roles appointed per
|
|
4
|
+
// model, a shared board. The desktop was about to copy it, and every client would then hold
|
|
5
|
+
// its own answer to "what is a researcher allowed to touch". So a team is declared once, in
|
|
6
|
+
// this shape, and shared through the client-prefs document like a skill or a recipe: defined
|
|
7
|
+
// in one client, invokable in the other at its next open.
|
|
8
|
+
//
|
|
9
|
+
// Two invariants are enforced here rather than trusted:
|
|
10
|
+
// • A role's GRANTS name tool groups, never tools — and `page` is not grantable. A tab is
|
|
11
|
+
// one person's; a team member acting on it is the one thing every guard was written to
|
|
12
|
+
// stop. `none` is a legitimate grant: a writer needs no tools.
|
|
13
|
+
// • A team has a BUDGET, or it is not a team (F8 O1). `validateTeam` refuses one without.
|
|
14
|
+
//
|
|
15
|
+
// Trust is derived, never declared (the skill-manifest rule): a stored `builtin` cannot
|
|
16
|
+
// survive an `origin`, and a team a client stores as trusted is stored as nothing of the kind.
|
|
17
|
+
|
|
18
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
19
|
+
|
|
20
|
+
export const TEAM_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
21
|
+
export const ROLE_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
|
|
22
|
+
export const ROLE_MODES = Object.freeze(['model', 'subagent', 'recipe']);
|
|
23
|
+
export const ROLE_PREFERS = Object.freeze(['cheap', 'balanced', 'strong']);
|
|
24
|
+
export const MERGE_POLICIES = Object.freeze(['judge', 'converge', 'concat', 'first']);
|
|
25
|
+
export const PLAN_MODES = Object.freeze(['fixed', 'planner']);
|
|
26
|
+
/** The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them. */
|
|
27
|
+
export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history']);
|
|
28
|
+
export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64})$/;
|
|
29
|
+
export const MAX_ROLES = 8;
|
|
30
|
+
|
|
31
|
+
export class TeamError extends Error {
|
|
32
|
+
constructor(code, message) { super(message); this.name = 'TeamError'; this.code = code; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
36
|
+
|
|
37
|
+
/** A role's grants, normalized: `none` alone means no tools; duplicates and `page` are dropped. */
|
|
38
|
+
export function normalizeGrants(grants) {
|
|
39
|
+
const list = (Array.isArray(grants) ? grants : typeof grants === 'string' ? [grants] : []).map((g) => String(g || '').trim()).filter(Boolean);
|
|
40
|
+
const ok = [...new Set(list.filter((g) => GRANT_RE.test(g)))];
|
|
41
|
+
if (!ok.length || ok.includes('none')) return ['none'];
|
|
42
|
+
return ok;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateTeam(team) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
if (!isRecord(team)) return { ok: false, errors: ['team must be an object'] };
|
|
48
|
+
if (!TEAM_NAME_RE.test(String(team.name || ''))) errors.push('name: a short identifier (letters, digits, _ -)');
|
|
49
|
+
if (!Array.isArray(team.roles) || !team.roles.length) errors.push('roles: a non-empty array');
|
|
50
|
+
else {
|
|
51
|
+
if (team.roles.length > MAX_ROLES) errors.push(`roles: at most ${MAX_ROLES}`);
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
team.roles.forEach((r, i) => {
|
|
54
|
+
const w = `roles[${i}]`;
|
|
55
|
+
if (!isRecord(r)) { errors.push(`${w}: must be an object`); return; }
|
|
56
|
+
if (!ROLE_ID_RE.test(String(r.id || ''))) errors.push(`${w}.id: a short identifier`);
|
|
57
|
+
else if (seen.has(r.id)) errors.push(`${w}.id: duplicate "${r.id}"`);
|
|
58
|
+
seen.add(r.id);
|
|
59
|
+
if (r.mode !== undefined && !ROLE_MODES.includes(r.mode)) errors.push(`${w}.mode: one of ${ROLE_MODES.join(', ')}`);
|
|
60
|
+
if (r.prefer !== undefined && !ROLE_PREFERS.includes(r.prefer)) errors.push(`${w}.prefer: one of ${ROLE_PREFERS.join(', ')}`);
|
|
61
|
+
if ((r.mode || 'model') === 'recipe' && !r.recipe) errors.push(`${w}.recipe: a recipe name is required in recipe mode`);
|
|
62
|
+
if ((r.mode || 'model') !== 'recipe' && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
|
|
63
|
+
const bad = (Array.isArray(r.grants) ? r.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
64
|
+
if (bad.length) errors.push(`${w}.grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; a team may not act on it)' : ''}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (team.merge !== undefined && !MERGE_POLICIES.includes(team.merge)) errors.push(`merge: one of ${MERGE_POLICIES.join(', ')}`);
|
|
68
|
+
if (team.plan !== undefined && !PLAN_MODES.includes(team.plan)) errors.push(`plan: one of ${PLAN_MODES.join(', ')}`);
|
|
69
|
+
if (team.judge !== undefined && team.judge !== null && !(Array.isArray(team.roles) && team.roles.some((r) => r?.id === team.judge))) errors.push('judge: must name one of the roles');
|
|
70
|
+
const b = validateBudget(team.budget);
|
|
71
|
+
if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
|
|
72
|
+
return { ok: errors.length === 0, errors };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The stored form. Defaults filled, grants normalized, trust derived: `builtin` only when the
|
|
77
|
+
* host says so, never from the record.
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeTeam(team, { builtin = false } = {}) {
|
|
80
|
+
const v = validateTeam(team);
|
|
81
|
+
if (!v.ok) throw new TeamError('INVALID', v.errors.join('; '));
|
|
82
|
+
return {
|
|
83
|
+
name: String(team.name),
|
|
84
|
+
description: String(team.description || '').trim().slice(0, 300),
|
|
85
|
+
plan: PLAN_MODES.includes(team.plan) ? team.plan : 'fixed',
|
|
86
|
+
merge: MERGE_POLICIES.includes(team.merge) ? team.merge : (team.judge ? 'judge' : 'concat'),
|
|
87
|
+
judge: team.judge || null,
|
|
88
|
+
roles: team.roles.map((r) => ({
|
|
89
|
+
id: String(r.id),
|
|
90
|
+
name: String(r.name || r.id).slice(0, 60),
|
|
91
|
+
mode: ROLE_MODES.includes(r.mode) ? r.mode : 'model',
|
|
92
|
+
prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : 'balanced',
|
|
93
|
+
...(r.model ? { model: String(r.model) } : {}),
|
|
94
|
+
prompt: String(r.prompt || '').trim().slice(0, 4000),
|
|
95
|
+
grants: normalizeGrants(r.grants),
|
|
96
|
+
...(r.recipe ? { recipe: String(r.recipe) } : {}),
|
|
97
|
+
...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String).filter((d) => d !== r.id) } : {}),
|
|
98
|
+
})),
|
|
99
|
+
budget: normalizeBudget(team.budget),
|
|
100
|
+
enabled: team.enabled !== false,
|
|
101
|
+
...(team.origin && isRecord(team.origin) ? { origin: { ...team.origin } } : {}),
|
|
102
|
+
...(builtin ? { builtin: true } : {}),
|
|
103
|
+
...(team.createdAt ? { createdAt: team.createdAt } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function defineTeam(team) { return Object.freeze(normalizeTeam(team)); }
|
|
108
|
+
|
|
109
|
+
/** Which of a client's tool groups a role may hold — `(groupId, serverId?) => boolean`. */
|
|
110
|
+
export function grantAllows(grants, groupId, serverId = '') {
|
|
111
|
+
const g = normalizeGrants(grants);
|
|
112
|
+
if (g.includes('none')) return false;
|
|
113
|
+
if (groupId === 'page') return false;
|
|
114
|
+
if (groupId === 'mcp') return g.includes('mcp') || (!!serverId && g.includes(`mcp:${serverId}`));
|
|
115
|
+
return g.includes(groupId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One line a person reads per role: name · tier/model · grants · mode. */
|
|
119
|
+
export function describeRole(r) {
|
|
120
|
+
const who = r.model || r.prefer || 'balanced';
|
|
121
|
+
const grants = (r.grants || ['none']).join(', ');
|
|
122
|
+
return `${r.name || r.id} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
|
|
123
|
+
}
|