@chatpanel/events 0.90.0 → 0.91.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/board-tool.js +26 -4
- package/index.js +3 -2
- package/package.json +3 -1
- package/recruit.js +35 -2
- package/team-board.js +12 -7
- package/team-plan.js +8 -1
- package/team-record.js +25 -3
- package/team-run.js +158 -20
- package/team-subtask.js +192 -0
- package/team-task.js +2 -0
- package/team-worklog.js +2 -2
- package/tool-need.js +28 -0
package/board-tool.js
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
// person to answer it, from either client. Members do not chat freely: a post is typed, a
|
|
7
7
|
// reply hangs off a post, and an ask pauses the member's own task, never the run.
|
|
8
8
|
//
|
|
9
|
+
// A fifth, `request`: a member hands a PIECE of its task to someone else — the runner turns
|
|
10
|
+
// it into a sub-task with its own thread, offers it to the members that fit, posts it on the
|
|
11
|
+
// job board when none does, and (by default) waits for it and returns its findings here.
|
|
12
|
+
//
|
|
9
13
|
// Bound per task: the member's role and task are fixed at bind time, so a member cannot post
|
|
10
14
|
// as someone else, and its ask lands in its own task's context.
|
|
11
15
|
|
|
12
16
|
import { boardText, POST_KINDS, ASK_TYPES } from './team-board.js';
|
|
17
|
+
import { normalizeRequest } from './team-subtask.js';
|
|
13
18
|
|
|
14
19
|
export const BOARD_TOOL_NAME = 'board';
|
|
15
20
|
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -24,11 +29,12 @@ export function boardToolSpec() {
|
|
|
24
29
|
+ '{"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task\'s thread; '
|
|
25
30
|
+ '{"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member\'s post — agree, dispute with a ref, extend; '
|
|
26
31
|
+ '{"action":"ask","type":"info|budget|permission|direction","text":"what you need and why","options":["…"]} asks the USER and waits for the answer (minutes). '
|
|
27
|
-
+ '
|
|
32
|
+
+ '{"action":"request","title":"check the valuation","brief":"what to do and what done looks like","skills":["finance"],"grants":["web"],"wait":true} hands a piece of your task to another member (or, if none fits, to the job board): a sub-task with its own thread; with wait (default) you get its findings back here. '
|
|
33
|
+
+ 'Ask only when you are stuck — a fact you could not find, a choice only the user can make. Otherwise decide, say what you assumed, and go on. Request only work you should not do yourself — another skill, a tool you lack, or a second pair of eyes.',
|
|
28
34
|
parameters: {
|
|
29
35
|
type: 'object',
|
|
30
36
|
properties: {
|
|
31
|
-
action: { type: 'string', enum: ['read', 'post', 'reply', 'ask'] },
|
|
37
|
+
action: { type: 'string', enum: ['read', 'post', 'reply', 'ask', 'request'] },
|
|
32
38
|
kind: { type: 'string', enum: POST_KINDS.filter((k) => k !== 'finding' && k !== 'answer' && k !== 'decision') },
|
|
33
39
|
text: { type: 'string' },
|
|
34
40
|
refs: { type: 'array', items: { type: 'string' } },
|
|
@@ -36,6 +42,11 @@ export function boardToolSpec() {
|
|
|
36
42
|
threadId: { type: 'string', description: 'For post: another thread you may see (default: your task\'s).' },
|
|
37
43
|
type: { type: 'string', enum: [...ASK_TYPES], description: 'For ask.' },
|
|
38
44
|
options: { type: 'array', items: { type: 'string' }, description: 'For ask: choices to offer the user.' },
|
|
45
|
+
title: { type: 'string', description: 'For request: the sub-task in a few words.' },
|
|
46
|
+
brief: { type: 'string', description: 'For request: what to do and what done looks like.' },
|
|
47
|
+
skills: { type: 'array', items: { type: 'string' }, description: 'For request: skills it needs.' },
|
|
48
|
+
grants: { type: 'array', items: { type: 'string' }, description: 'For request: tools it needs — data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr.' },
|
|
49
|
+
wait: { type: 'boolean', description: 'For request: wait for it and get its findings back (default true); false queues it after your task.' },
|
|
39
50
|
},
|
|
40
51
|
required: ['action'],
|
|
41
52
|
},
|
|
@@ -51,8 +62,10 @@ const json = (v) => JSON.stringify(v);
|
|
|
51
62
|
* @param taskIds the tasks this member may read (its dependencies, or null for all)
|
|
52
63
|
* @param waitFor `async (threadId, timeoutMs, signal) => { text, by } | null` — the runner's answer box
|
|
53
64
|
* @param onAsk `(thread, post) => void` — the runner marks the task waiting
|
|
65
|
+
* @param onRequest `async ({ title, brief, needs, wait }) => result` — the runner turns a request
|
|
66
|
+
* into a sub-task (team-subtask.js); absent, a member is told to do it itself
|
|
54
67
|
*/
|
|
55
|
-
export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
|
|
68
|
+
export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, onRequest = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
|
|
56
69
|
const ownThread = () => board.threadForTask(taskId) || board.openThread({ taskId, kind: 'task', title: taskId, by: 'runner' });
|
|
57
70
|
return {
|
|
58
71
|
id: 'board',
|
|
@@ -90,7 +103,16 @@ export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor
|
|
|
90
103
|
if (!answer) return json({ answered: false, threadId: thread.id, hint: 'No answer arrived in time. Proceed on your best assumption, say what you assumed, and note that the user did not answer.' });
|
|
91
104
|
return json({ answered: true, threadId: thread.id, answer: answer.text, by: answer.by });
|
|
92
105
|
}
|
|
93
|
-
|
|
106
|
+
if (action === 'request') {
|
|
107
|
+
if (typeof onRequest !== 'function') return json({ error: 'This run cannot delegate. Do the work yourself with the tools you have, and say what you could not do.' });
|
|
108
|
+
const r = normalizeRequest(input);
|
|
109
|
+
if (!r.ok) return json({ error: r.error });
|
|
110
|
+
// The request is a post in the member's own thread — the record of who asked for what.
|
|
111
|
+
const post = board.post({ threadId: ownThread().id, by: role, kind: 'request', text: `${r.request.title}${r.request.brief && r.request.brief !== r.request.title ? ` — ${r.request.brief}` : ''}`, refs: [] });
|
|
112
|
+
const result = await onRequest({ ...r.request, postId: post.id });
|
|
113
|
+
return json(result || { error: 'the request was not taken' });
|
|
114
|
+
}
|
|
115
|
+
return json({ error: `Unknown action "${action}". Use read, post, reply, ask or request.` });
|
|
94
116
|
},
|
|
95
117
|
};
|
|
96
118
|
}
|
package/index.js
CHANGED
|
@@ -70,7 +70,7 @@ export {
|
|
|
70
70
|
WEATHER_HOST, WEATHER_TIMEOUT_MS, WeatherError,
|
|
71
71
|
} from './weather.js';
|
|
72
72
|
export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
|
|
73
|
-
export { toolNeedFor } from './tool-need.js';
|
|
73
|
+
export { toolNeedFor, grantsNeededFor } from './tool-need.js';
|
|
74
74
|
export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
|
|
75
75
|
export { validateView, validateViewInvocation, viewResult } from './view.js';
|
|
76
76
|
export { validateWidget, validateWidgetMessage, effectiveGrants, widgetIcon, WIDGET_SURFACES } from './widget.js';
|
|
@@ -207,13 +207,14 @@ export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, norm
|
|
|
207
207
|
export { AGENT_ID_RE, APPLIES_TO, EGRESS_CLASSES, ASSISTANT_ID, AgentError, validateAgent, normalizeAgent, defineAgent, assistantAgent, engineOf, describeAgent, slugAgentId, resolveTeam, STARTER_AGENTS, starterAgents, blankAgent, agentFromForm, poolFor } from './agent.js';
|
|
208
208
|
export { LEDGER_VERSION, LEDGER_ENTRY_KINDS, DECLINE_REASONS, WITHDRAW_AFTER, ledgerKey, normalizeCall, makeLedgerEntry, summarizeEngine } from './model-ledger.js';
|
|
209
209
|
// Recruiting (F8 §12.2.4, pillars §13.4): the pool applies at once; an (agent, engine) pair is recruited; the evaluator is one optional structured call.
|
|
210
|
-
export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
|
|
210
|
+
export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, recruitForRun, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
|
|
211
211
|
export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.js';
|
|
212
212
|
export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connectionFor, branchFor, worktreeDirFor, credentialEnv, describeConnection, blankConnection, connectionFromForm } from './scm-connection.js';
|
|
213
213
|
export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
|
|
214
214
|
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
215
215
|
export { teamToolProvider, teamToolSpec, teamToolTimeoutMs, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
216
216
|
export { workLogFor, workLogText, workLogEvidence, describeCall, WORKLOG_KINDS } from './team-worklog.js';
|
|
217
|
+
export { normalizeRequest, subtaskFromRequest, takeUp, takeUpLine, holdsGrants, jobFromSubtask, extendDependents, taskTree, threadRows, MAX_SUBTASKS, MAX_DEPTH, MIN_TAKEUP_FIT } from './team-subtask.js';
|
|
217
218
|
export { teamLine, teamLanes } from './team-trail.js';
|
|
218
219
|
export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
|
|
219
220
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.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",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"./team-cache.js": "./team-cache.js",
|
|
100
100
|
"./team-task.js": "./team-task.js",
|
|
101
101
|
"./team-record.js": "./team-record.js",
|
|
102
|
+
"./team-subtask.js": "./team-subtask.js",
|
|
102
103
|
"./scorecard.js": "./scorecard.js",
|
|
103
104
|
"./project.js": "./project.js",
|
|
104
105
|
"./job.js": "./job.js",
|
|
@@ -231,6 +232,7 @@
|
|
|
231
232
|
"team-cache.js",
|
|
232
233
|
"team-task.js",
|
|
233
234
|
"team-record.js",
|
|
235
|
+
"team-subtask.js",
|
|
234
236
|
"scorecard.js",
|
|
235
237
|
"project.js",
|
|
236
238
|
"job.js",
|
package/recruit.js
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
import { fit, engineKey, normalizeEngine } from './scorecard.js';
|
|
31
31
|
import { cardOverride, DEFAULT_MIN_CALLS } from './model-ledger.js';
|
|
32
32
|
import { normalizeEngineSpec, engineKeyOf, describeEngine } from './engine.js';
|
|
33
|
-
import { engineOf, agentFromForm } from './agent.js';
|
|
34
|
-
import { applyAll } from './job.js';
|
|
33
|
+
import { engineOf, agentFromForm, normalizeAgent, resolveTeam } from './agent.js';
|
|
34
|
+
import { applyAll, jobToRole } from './job.js';
|
|
35
35
|
import { WORK_GRANTS } from './team.js';
|
|
36
36
|
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
37
37
|
import { normalizeBudget } from './budget.js';
|
|
@@ -422,3 +422,36 @@ export async function recruitJob(job, pool, { summaries = {}, rows = [], reach =
|
|
|
422
422
|
const decision = decide(job, apps, { evaluation, minFit });
|
|
423
423
|
return { applications: apps, evaluation, decision, events: recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record }), prompt };
|
|
424
424
|
}
|
|
425
|
+
|
|
426
|
+
// ── A run's job board (§15.2): the host's `recruit` hook, shared ─────────────────────────
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* What a host hands the runner as `recruit`: one pass over the pool for a sub-task's job,
|
|
430
|
+
* the pick made into a ROLE the run can appoint (job.js `jobToRole`, resolved through the
|
|
431
|
+
* pool so it carries the agent's prompt, grants, skills and a model target — agent.js
|
|
432
|
+
* `resolveTeam`). Returns `{ role, agentId, engine, why, fit, applications, events }` or,
|
|
433
|
+
* when no one is recruited, `{ why, proposal, applications, events }` with `proposal` the
|
|
434
|
+
* agent card a person may approve (the runner posts it). `create` is that card once
|
|
435
|
+
* approved — the host has persisted it to its pool before calling — and it applies with
|
|
436
|
+
* the rest. `events` are the project-record events of the pass (recruit.js `recruitEvents`)
|
|
437
|
+
* for a host that has a project to land them on; the run's own record has its own.
|
|
438
|
+
*/
|
|
439
|
+
export async function recruitForRun(job, pool, { rows = [], summaries = {}, reach = 'any', chatModel = null, targetFor = null, ask = null, minFit = MIN_FIT, create = null, now = Date.now() } = {}) {
|
|
440
|
+
const list = (Array.isArray(pool) ? pool : []).filter((a) => a && a.id);
|
|
441
|
+
if (create && isRecord(create)) {
|
|
442
|
+
try { const made = normalizeAgent(create); if (!list.some((a) => a.id === made.id)) list.push(made); } catch { /* an unusable card applies as nothing */ }
|
|
443
|
+
}
|
|
444
|
+
const pass = await recruitJob(job, list, { summaries, rows, reach, chatModel, ask, minFit, now });
|
|
445
|
+
const d = pass.decision;
|
|
446
|
+
if (d.kind !== 'recruit') {
|
|
447
|
+
const card = proposalToAgent(d.proposal || proposalFromNeeds(job), job, { by: d.by || 'evaluator' });
|
|
448
|
+
return { why: d.why, proposal: card?.ok === false ? null : (card?.agent || card), applications: pass.applications, events: pass.events };
|
|
449
|
+
}
|
|
450
|
+
const agent = list.find((a) => a.id === d.agentId);
|
|
451
|
+
const base = jobToRole(job, agent);
|
|
452
|
+
let role = { ...base, agent: agent.id, engine: d.engine };
|
|
453
|
+
try {
|
|
454
|
+
role = resolveTeam({ name: 'recruit', roles: [role], budget: { tokens: 1 } }, list, { chatModel, targetFor }).roles[0];
|
|
455
|
+
} catch { /* unresolved: the runner's appointer decides from the engine */ }
|
|
456
|
+
return { role, agentId: d.agentId, engine: d.engine, why: d.why, fit: d.fit ?? null, applications: pass.applications, events: pass.events };
|
|
457
|
+
}
|
package/team-board.js
CHANGED
|
@@ -77,7 +77,9 @@ export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposa
|
|
|
77
77
|
// `failed`: the task behind the thread ended without an answer (every model on the roster
|
|
78
78
|
// tried, or a hard error) — not `resolved`, which read as "done" on the board.
|
|
79
79
|
export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'failed', 'approved', 'rejected']);
|
|
80
|
-
|
|
80
|
+
// `request`: a member asks for a piece of its task to be done by someone else — the runner
|
|
81
|
+
// turns it into a sub-task with its own thread (team-subtask.js).
|
|
82
|
+
export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision', 'request']);
|
|
81
83
|
export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
|
|
82
84
|
export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
|
|
83
85
|
export const RUNNER = 'runner';
|
|
@@ -110,7 +112,7 @@ export function foldBoard(state, ev) {
|
|
|
110
112
|
if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
|
|
111
113
|
} else if (type === 'board.thread-status' && p.threadId) {
|
|
112
114
|
const t = s.threads.find((x) => x.id === p.threadId);
|
|
113
|
-
if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; }
|
|
115
|
+
if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; if (p.holder) t.holder = p.holder; }
|
|
114
116
|
}
|
|
115
117
|
return s;
|
|
116
118
|
}
|
|
@@ -132,22 +134,25 @@ export function createBoard({ now = () => Date.now(), newId = null, state = null
|
|
|
132
134
|
|
|
133
135
|
const api = {
|
|
134
136
|
/** Open a thread. A task's thread is opened once; asking for it again returns it. */
|
|
135
|
-
openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null } = {}) {
|
|
137
|
+
openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null, parent = null, holder = null } = {}) {
|
|
136
138
|
if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
|
|
137
|
-
|
|
139
|
+
// `parent`: the task this one was requested from (a sub-task's thread hangs under its
|
|
140
|
+
// parent's on the board); `holder`: the member that took it.
|
|
141
|
+
const thread = { id: id || mk('th'), kind: THREAD_KINDS.includes(kind) ? kind : 'discussion', taskId, title: clip(title, 200), by, status: THREAD_STATUSES.includes(status) ? status : 'open', at: now(), posts: 0, ...(ask ? { ask } : {}), ...(parent ? { parent } : {}), ...(holder ? { holder } : {}) };
|
|
138
142
|
st.threads.push(thread);
|
|
139
143
|
say('board.thread', { thread: { ...thread } });
|
|
140
144
|
return thread;
|
|
141
145
|
},
|
|
142
146
|
/** A post in a thread; `replyTo` makes it a reply. */
|
|
143
|
-
|
|
147
|
+
/** `proposal` rides on a draft a person decides on — `{ kind: 'agent', agent, jobId }` (§15.2): the card the host creates when the post is approved. */
|
|
148
|
+
post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null, proposal = null } = {}) {
|
|
144
149
|
const t = threadOf(threadId);
|
|
145
150
|
if (!t) throw new Error(`no thread ${threadId}`);
|
|
146
151
|
if (id && postOf(id)) return postOf(id);
|
|
147
152
|
const post = {
|
|
148
153
|
id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
|
|
149
154
|
text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
|
|
150
|
-
...(finding ? { finding } : {}), ...(ask ? { ask } : {}),
|
|
155
|
+
...(finding ? { finding } : {}), ...(ask ? { ask } : {}), ...(proposal && typeof proposal === 'object' ? { proposal } : {}),
|
|
151
156
|
};
|
|
152
157
|
st.posts.push(post);
|
|
153
158
|
t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
|
|
@@ -283,7 +288,7 @@ function threadedLines(state, { taskIds, role }) {
|
|
|
283
288
|
for (const t of threads) {
|
|
284
289
|
const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
|
|
285
290
|
if (!own.length) continue;
|
|
286
|
-
out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
|
|
291
|
+
out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.holder ? ` (held by ${t.holder})` : ''}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
|
|
287
292
|
const line = (x, depth) => {
|
|
288
293
|
const tag = [x.kind === 'finding' ? (x.finding?.kind || 'claim') : x.kind, x.by, x.finding?.confidence != null ? `${Math.round(x.finding.confidence * 100)}%` : null, x.status === 'approved' ? 'APPROVED' : x.status === 'proposed' ? 'proposed' : null, x.kind === 'decision' || x.by === PERSON ? 'SETTLED' : null].filter(Boolean).join(' · ');
|
|
289
294
|
out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
|
package/team-plan.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// the board. That is the whole scheduling model — the same one a tool round uses.
|
|
13
13
|
|
|
14
14
|
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
15
|
+
import { GRANT_RE } from './team.js';
|
|
15
16
|
|
|
16
17
|
export const MAX_TASKS = 12;
|
|
17
18
|
|
|
@@ -28,6 +29,11 @@ export const TEAM_PLAN_SCHEMA = defineSchema({
|
|
|
28
29
|
title: { type: 'string', required: true, max: 80 },
|
|
29
30
|
prompt: { type: 'string', required: true, max: 1200, describe: 'the focused instruction for this task' },
|
|
30
31
|
dependsOn: { type: 'string[]', maxItems: 6, describe: 'task ids whose findings this one needs' },
|
|
32
|
+
// The planner's TOOL PROPOSAL (§15.2): which of the role's tools this task will need,
|
|
33
|
+
// and why — posted on the board as a proposal a person reads; a task that then ends
|
|
34
|
+
// without touching a tool it was said to need is nudged once before it may finish.
|
|
35
|
+
grants: { type: 'string[]', maxItems: 6, describe: 'the tools this task needs, from the role\'s own: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr — or none' },
|
|
36
|
+
why: { type: 'string', max: 160, describe: 'why those tools, in a few words' },
|
|
31
37
|
},
|
|
32
38
|
},
|
|
33
39
|
},
|
|
@@ -43,7 +49,7 @@ export function plannerPrompt(team, request) {
|
|
|
43
49
|
'',
|
|
44
50
|
`Request: ${String(request || '').trim()}`,
|
|
45
51
|
'',
|
|
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.',
|
|
52
|
+
'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. For each task, say which of the role\'s tools it will need (grants) and why — do not miss a tool that would help.',
|
|
47
53
|
'',
|
|
48
54
|
describeSchema(TEAM_PLAN_SCHEMA),
|
|
49
55
|
].join('\n');
|
|
@@ -81,6 +87,7 @@ export function parsePlan(text, team) {
|
|
|
81
87
|
title: String(t.title || t.prompt).slice(0, 80),
|
|
82
88
|
prompt: String(t.prompt).trim(),
|
|
83
89
|
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(String) : [],
|
|
90
|
+
...(Array.isArray(t.grants) && t.grants.length ? { grants: [...new Set(t.grants.map((g) => String(g).trim().toLowerCase()).filter((g) => GRANT_RE.test(g) && g !== 'none'))].slice(0, 6), ...(t.why ? { why: String(t.why).slice(0, 160) } : {}) } : {}),
|
|
84
91
|
}))
|
|
85
92
|
.slice(0, MAX_TASKS);
|
|
86
93
|
const ids = new Set(tasks.map((t) => t.id));
|
package/team-record.js
CHANGED
|
@@ -14,7 +14,7 @@ export const RESUMABLE_RUN_STATUSES = Object.freeze(['waiting', 'stopped', 'fail
|
|
|
14
14
|
const TASK_TEXT_MAX = 20_000;
|
|
15
15
|
|
|
16
16
|
export function emptyRun({ id, client = '', now = Date.now() } = {}) {
|
|
17
|
-
return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
|
|
17
|
+
return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], jobs: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const taskOf = (run, id) => run.tasks.find((x) => x.id === id);
|
|
@@ -35,9 +35,24 @@ export function foldRun(run, ev) {
|
|
|
35
35
|
case 'plan.ready':
|
|
36
36
|
run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
|
|
37
37
|
// A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
|
|
38
|
-
run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : 'pending', findings: taskOf(run, t.id)?.findings || 0 }));
|
|
38
|
+
run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : (t.parent && !t.role ? 'unassigned' : 'pending'), findings: taskOf(run, t.id)?.findings || 0, ...(t.parent ? { parent: t.parent, requestedBy: t.requestedBy || null } : {}), ...(t.grants ? { grants: t.grants, why: t.why || '' } : {}) }));
|
|
39
39
|
run.status = 'running';
|
|
40
40
|
break;
|
|
41
|
+
// A SUB-TASK (§15.2): requested by a member mid-run, it joins the plan under its parent;
|
|
42
|
+
// taken by a member, recruited from the pool, created on a person's approval — or not.
|
|
43
|
+
case 'task.requested':
|
|
44
|
+
if (p.taskId && !taskOf(run, p.taskId)) {
|
|
45
|
+
const task = { id: p.taskId, role: null, title: p.title || p.taskId, prompt: p.prompt || '', dependsOn: Array.isArray(p.dependsOn) ? p.dependsOn : [], parent: p.parent || null, requestedBy: p.by || null, needs: p.needs || null, depth: p.depth || 1, wait: p.wait !== false };
|
|
46
|
+
if (run.plan) run.plan.tasks = [...(run.plan.tasks || []), task];
|
|
47
|
+
run.tasks.push({ id: task.id, role: null, title: task.title, status: 'requested', findings: 0, parent: task.parent, requestedBy: task.requestedBy, needs: task.needs, requestedAt: at });
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case 'task.taken': { const t = taskOf(run, p.taskId); if (t) { t.role = p.role; t.status = 'pending'; t.takenBy = { by: p.by || 'fit', role: p.role, fit: p.fit ?? null, reasons: p.reasons || [], agentId: p.agentId || null, engine: p.engine || null, why: p.why || '', at }; } const pt = run.plan?.tasks?.find((x) => x.id === p.taskId); if (pt) pt.role = p.role; break; }
|
|
51
|
+
case 'task.posted': { const t = taskOf(run, p.taskId); if (t) t.job = p.job || null; if (p.job && !run.jobs.some((j) => j.id === p.job.id)) run.jobs.push({ ...p.job, taskId: p.taskId, at }); break; }
|
|
52
|
+
case 'task.proposed': { const t = taskOf(run, p.taskId); if (t) t.proposal = { agent: p.agent || null, threadId: p.threadId || null, postId: p.postId || null, why: p.why || '', at }; break; }
|
|
53
|
+
case 'task.unassigned': { const t = taskOf(run, p.taskId); if (t) { t.status = 'unassigned'; t.error = p.why || null; t.endedAt = at; } const j = run.jobs.find((x) => x.taskId === p.taskId); if (j) j.status = 'failed'; break; }
|
|
54
|
+
case 'task.nudged': { const t = taskOf(run, p.taskId); if (t) t.nudged = [...(t.nudged || []), { grants: p.grants || [], at }]; break; }
|
|
55
|
+
case 'run.role-added': if (p.role?.id && !run.roles.includes(p.role.id)) { run.roles.push(p.role.id); run.recruited = [...(run.recruited || []), { ...p.role, jobId: p.jobId || null, at }]; const j = run.jobs.find((x) => x.id === p.jobId); if (j) { j.status = 'recruited'; j.recruited = { agentId: p.role.agent || p.role.id, engine: p.role.engine || null, at }; } } break;
|
|
41
56
|
case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
|
|
42
57
|
case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
|
|
43
58
|
case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
|
|
@@ -49,7 +64,14 @@ export function foldRun(run, ev) {
|
|
|
49
64
|
break;
|
|
50
65
|
case 'task.waiting': { const t = taskOf(run, p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
|
|
51
66
|
case 'task.done':
|
|
52
|
-
case 'task.failed': {
|
|
67
|
+
case 'task.failed': {
|
|
68
|
+
const t = taskOf(run, p.taskId);
|
|
69
|
+
if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; }
|
|
70
|
+
// A job posting's outcome is the sub-task's (§15.2.5): taken by, finished how.
|
|
71
|
+
const j = run.jobs.find((x) => x.taskId === p.taskId);
|
|
72
|
+
if (j) { j.status = type === 'task.done' ? 'done' : 'failed'; j.endedAt = at; }
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
53
75
|
case 'run.merging': run.status = 'merging'; break;
|
|
54
76
|
case 'run.waiting': run.status = 'running'; break;
|
|
55
77
|
case 'run.usage': run.usage = p.usage || run.usage; break;
|
package/team-run.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Tasks run in WAVES (team-plan.js): everything with its dependencies met runs together
|
|
10
10
|
// through the same pool a tool round uses; a dependent task waits and reads the board. A
|
|
11
|
+
// member may REQUEST a piece of its task be done by someone else (board tool `request`,
|
|
12
|
+
// team-subtask.js): the sub-task joins the plan with its own thread under the parent's, is
|
|
13
|
+
// offered to the members that fit, then to the pool through the host's `recruit`, then
|
|
14
|
+
// proposed as a new agent to the person — so the plan is a tree the board draws as one. A
|
|
11
15
|
// task ends with findings; the merge turns the board into ONE proposal — agreed claims
|
|
12
16
|
// (converge, W7), a judged answer, or the members' work side by side — and the proposal is
|
|
13
17
|
// what a person sees. Nothing here lands anywhere.
|
|
@@ -19,8 +23,11 @@
|
|
|
19
23
|
import { normalizeTeam } from './team.js';
|
|
20
24
|
import { normalizeEngine, normalizeScm } from './scorecard.js';
|
|
21
25
|
import { createBudget } from './budget.js';
|
|
22
|
-
import { fixedPlan, plannerPrompt, parsePlan
|
|
23
|
-
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
|
|
26
|
+
import { fixedPlan, plannerPrompt, parsePlan } from './team-plan.js';
|
|
27
|
+
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER, PERSON } from './team-board.js';
|
|
28
|
+
import { subtaskFromRequest, takeUp, takeUpLine, jobFromSubtask, extendDependents, MAX_SUBTASKS, MAX_DEPTH } from './team-subtask.js';
|
|
29
|
+
import { proposalToAgent, proposalFromNeeds } from './recruit.js';
|
|
30
|
+
import { grantsNeededFor } from './tool-need.js';
|
|
24
31
|
import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
25
32
|
import { createRunCache, withRunCache } from './team-cache.js';
|
|
26
33
|
import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote, isThought } from './team-task.js';
|
|
@@ -94,6 +101,14 @@ export function dryRunTeam(team, request, { appoint = null } = {}) {
|
|
|
94
101
|
* `reasons` and `alternatives` are why this one and who else could have —
|
|
95
102
|
* said as `task.routed`, which every run records from here on (pillars §13)
|
|
96
103
|
* @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
|
|
104
|
+
* @param recruit `async (job, { runId, requestedBy, create? }) => { role, agentId?, engine?, why?, fit? }
|
|
105
|
+
* | { proposal? , why? } | null` — the host's job board (recruit.js over its
|
|
106
|
+
* pool): a sub-task nobody in the run fits is posted as `job`; the host
|
|
107
|
+
* answers with the role to add (job.js jobToRole, resolved to a model), or
|
|
108
|
+
* nothing, optionally with the agent it would propose. With `create` (the
|
|
109
|
+
* card a person approved on the board) the host adds it to the pool first.
|
|
110
|
+
* Absent, a sub-task nobody fits is recorded unassigned.
|
|
111
|
+
* @param projectId the project a job posting belongs to, when the run has one (else the run id)
|
|
97
112
|
* @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
|
|
98
113
|
* task.done · task.failed · run.merging · run.done; the host forwards them to
|
|
99
114
|
* its UI and to the gateway's run store
|
|
@@ -111,6 +126,7 @@ export async function runTeam({
|
|
|
111
126
|
// The host's control channel (team-task.js createControl): a person hands a task to another
|
|
112
127
|
// model from the board, on either client; the runner continues the task's transcript there.
|
|
113
128
|
control = null,
|
|
129
|
+
recruit = null, projectId = null,
|
|
114
130
|
} = {}) {
|
|
115
131
|
if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
|
|
116
132
|
const t = normalizeTeam(team); // throws on a team without a budget — O1
|
|
@@ -178,9 +194,16 @@ export async function runTeam({
|
|
|
178
194
|
}
|
|
179
195
|
}
|
|
180
196
|
if (!tasks) tasks = fixedPlan(t, request);
|
|
181
|
-
say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn })) });
|
|
197
|
+
say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn, ...(x.parent ? { parent: x.parent, requestedBy: x.requestedBy || null } : {}), ...(x.grants ? { grants: x.grants, why: x.why || '' } : {}) })) });
|
|
182
198
|
// A thread per task, before anything runs: a member's findings and replies have a home.
|
|
183
|
-
for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: RUNNER });
|
|
199
|
+
for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: task.requestedBy || RUNNER, ...(task.parent ? { parent: task.parent } : {}), ...(task.role && task.parent ? { holder: task.role } : {}) });
|
|
200
|
+
// The planner's TOOL PROPOSAL (§15.2): which tools each task will need and why, as a
|
|
201
|
+
// proposal thread a person reads — and what the nudge below holds the member to.
|
|
202
|
+
if (!resume && tasks.some((x) => x.grants?.length)) {
|
|
203
|
+
const th = board.openThread({ kind: 'proposal', title: 'Tools per task', by: t.roles.find((r) => r.id === (tasks[0]?.role))?.id || RUNNER });
|
|
204
|
+
const lines = tasks.filter((x) => x.grants?.length).map((x) => { const held = t.roles.find((r) => r.id === x.role)?.grants || []; const missing = x.grants.filter((g) => !held.includes(g) && !(g.startsWith('mcp:') && held.includes('mcp'))); return `${x.role} (${x.id}): ${x.grants.join(', ')}${x.why ? ` — ${x.why}` : ''}${missing.length ? ` (not granted: ${missing.join(', ')})` : ''}`; });
|
|
205
|
+
board.post({ threadId: th.id, by: RUNNER, kind: 'draft', status: 'proposed', text: lines.join('\n') });
|
|
206
|
+
}
|
|
184
207
|
|
|
185
208
|
const finish = (status, extra = {}) => {
|
|
186
209
|
const usage = budget.snapshot();
|
|
@@ -205,17 +228,99 @@ export async function runTeam({
|
|
|
205
228
|
};
|
|
206
229
|
let waitingOnPerson = false; // a task that timed out on its ask — the run checkpoints
|
|
207
230
|
|
|
208
|
-
// ── fan out
|
|
231
|
+
// ── fan out: everything ready runs together; a sub-task requested mid-run joins the plan ──
|
|
209
232
|
let overBudget = false;
|
|
210
233
|
let budgetAsked = !!resume?.budgetAsked;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
234
|
+
const running = new Set();
|
|
235
|
+
const isDone = (tid) => carried.has(tid) || tasksOut.some((x) => x.id === tid);
|
|
236
|
+
// A sub-task with no holder cannot run; it is recorded `unassigned` at the end.
|
|
237
|
+
const ready = () => tasks.filter((x) => x.role && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
|
|
238
|
+
let subtasks = tasks.filter((x) => x.parent).length;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A member's REQUEST (board tool `request`): a sub-task with its own thread, offered to the
|
|
242
|
+
* run's members first, then to the pool through the host's `recruit`, then proposed as a
|
|
243
|
+
* new agent to the person. With `wait` the requester gets the findings back in its turn;
|
|
244
|
+
* without, the sub-task runs after it and whoever depended on the requester reads it too.
|
|
245
|
+
*/
|
|
246
|
+
const onRequest = async (parentTask, role, req) => {
|
|
247
|
+
if (subtasks >= MAX_SUBTASKS) return { error: `This run has reached its limit of ${MAX_SUBTASKS} sub-tasks. Do this yourself, or say what you could not do.` };
|
|
248
|
+
if ((Number(parentTask.depth) || 0) >= MAX_DEPTH) return { error: 'A sub-task cannot delegate further. Do this yourself, or say what you could not do.' };
|
|
249
|
+
if (stopped() || overBudget) return { error: 'The run is stopping; do what you can and finish.' };
|
|
250
|
+
subtasks += 1;
|
|
251
|
+
const sub = subtaskFromRequest(req, parentTask, { id: `${parentTask.id}-s${subtasks}`, by: role.id, now: now() });
|
|
252
|
+
tasks.push(sub);
|
|
253
|
+
say('task.requested', { taskId: sub.id, parent: parentTask.id, by: role.id, title: sub.title, prompt: sub.prompt, needs: sub.needs, dependsOn: sub.dependsOn, depth: sub.depth, wait: sub.wait, postId: req.postId || null });
|
|
254
|
+
const thread = board.openThread({ taskId: sub.id, kind: 'task', title: sub.title, by: role.id, parent: parentTask.id });
|
|
255
|
+
// 1. The run's own members, by fit on skills and grants.
|
|
256
|
+
let pick = takeUp(sub, t.roles, { exclude: [role.id] });
|
|
257
|
+
let takenBy = null;
|
|
258
|
+
if (pick.roleId) {
|
|
259
|
+
takenBy = { by: 'fit', roleId: pick.roleId, fit: pick.fit, reasons: pick.reasons };
|
|
260
|
+
} else if (typeof recruit === 'function') {
|
|
261
|
+
// 2. The job board: the pool applies, the host recruits an (agent, engine) pair.
|
|
262
|
+
const job = jobFromSubtask(sub, { runId: id, projectId: projectId || null, by: role.id });
|
|
263
|
+
say('task.posted', { taskId: sub.id, job, why: pick.why });
|
|
264
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Posted on the job board — ${pick.why}. Needs: ${describeNeeds(sub.needs)}.` });
|
|
265
|
+
let r = null;
|
|
266
|
+
try { r = await recruit(job, { runId: id, requestedBy: role.id }); } catch (e) { board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Recruiting failed: ${String(e?.message || e).slice(0, 200)}` }); }
|
|
267
|
+
if (r?.role) {
|
|
268
|
+
takenBy = { by: 'recruit', roleId: addRole(r.role, job), agentId: r.agentId || r.role.agent || null, engine: r.engine || null, why: r.why || '', fit: r.fit ?? null };
|
|
269
|
+
} else {
|
|
270
|
+
// 3. Nobody applies: propose the agent the job describes — a person decides; nothing
|
|
271
|
+
// is created here. With asks on, the person is asked now and the run goes on either way.
|
|
272
|
+
const card = r?.proposal || proposalToAgent(proposalFromNeeds(job), job, { by: 'runner' });
|
|
273
|
+
const agentCard = card?.ok === false ? null : (card?.agent || card);
|
|
274
|
+
const pth = board.openThread({ kind: 'proposal', title: `New agent for "${sub.title}"`, by: RUNNER, parent: parentTask.id });
|
|
275
|
+
const post = board.post({ threadId: pth.id, by: RUNNER, kind: 'draft', status: 'proposed', text: `No one in the pool fits "${sub.title}"${r?.why ? ` — ${r.why}` : ''}. Proposed: ${agentCard?.name || sub.title}${agentCard?.skills?.length ? ` — skills ${agentCard.skills.join(', ')}` : ''}${agentCard?.grants?.length ? `; grants ${agentCard.grants.join(', ')}` : ''}.`, refs: [`task:${sub.id}`], ...(agentCard ? { proposal: { kind: 'agent', agent: agentCard, jobId: job.id } } : {}) });
|
|
276
|
+
say('task.proposed', { taskId: sub.id, threadId: pth.id, postId: post.id, agent: agentCard, job, why: r?.why || pick.why });
|
|
277
|
+
const a = agentCard ? await askPerson({ type: 'permission', taskId: sub.id, text: `No one fits "${sub.title}". Create the agent "${agentCard.name}" (${describeNeeds({ skills: agentCard.skills, grants: agentCard.grants })}) and give it the job?`, options: ['Create it', 'Skip'] }) : null;
|
|
278
|
+
if (a && /create|yes|approve|allow|go/i.test(a.text)) {
|
|
279
|
+
board.decide(post.id, 'approved', a.by || PERSON);
|
|
280
|
+
let made = null;
|
|
281
|
+
try { made = await recruit(job, { runId: id, requestedBy: role.id, create: agentCard }); } catch (e) { board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Creating the agent failed: ${String(e?.message || e).slice(0, 200)}` }); }
|
|
282
|
+
if (made?.role) takenBy = { by: 'created', roleId: addRole(made.role, job), agentId: made.agentId || made.role.agent || null, engine: made.engine || null, why: made.why || 'created for this job', fit: null };
|
|
283
|
+
} else if (a) board.decide(post.id, 'rejected', a.by || PERSON);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!takenBy) {
|
|
287
|
+
const why = typeof recruit === 'function' ? 'nobody in the run fits and no agent was recruited' : `${pick.why}; this run has no pool to recruit from`;
|
|
288
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Not taken: ${why}.` });
|
|
289
|
+
board.setThreadStatus(thread.id, 'failed');
|
|
290
|
+
say('task.unassigned', { taskId: sub.id, why });
|
|
291
|
+
return { taskId: sub.id, takenBy: null, why, hint: 'Nobody could take this. Do what you can yourself and say what is missing.' };
|
|
292
|
+
}
|
|
293
|
+
sub.role = takenBy.roleId;
|
|
294
|
+
board.setThreadStatus(thread.id, 'open', { holder: takenBy.roleId });
|
|
295
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'decision', text: takenBy.by === 'fit' ? takeUpLine(sub, pick) : `${takenBy.roleId} ${takenBy.by === 'created' ? 'was created and' : 'was recruited from the pool and'} took: ${sub.title}${takenBy.why ? ` — ${takenBy.why}` : ''}` });
|
|
296
|
+
say('task.taken', { taskId: sub.id, role: takenBy.roleId, by: takenBy.by, fit: takenBy.fit ?? null, reasons: takenBy.reasons || [], agentId: takenBy.agentId || null, engine: takenBy.engine || null, why: takenBy.why || '' });
|
|
297
|
+
if (sub.wait) {
|
|
298
|
+
// Nested: the requester's turn holds while the sub-task runs; its findings come back here.
|
|
299
|
+
const row = await runTask(sub);
|
|
300
|
+
return { taskId: sub.id, takenBy: takenBy.roleId, status: row.status, ...(row.error ? { error: row.error } : {}), findings: (row.findings || []).map((f) => ({ kind: f.kind, text: f.text, refs: f.refs })), text: String(row.text || '').slice(0, 4000) };
|
|
301
|
+
}
|
|
302
|
+
const extended = extendDependents(tasks, parentTask.id, sub.id);
|
|
303
|
+
return { taskId: sub.id, takenBy: takenBy.roleId, queued: true, hint: `It runs after your task; ${extended.length ? `${extended.join(', ')} will read it too` : 'its findings will be on the board'}. Finish your task and say what depends on it.` };
|
|
304
|
+
};
|
|
305
|
+
/** A recruited agent joins the run as a role — the job's grants narrow it — and is appointed like any other. */
|
|
306
|
+
const addRole = (role, job) => {
|
|
307
|
+
const base = { ...role, id: String(role.id || job.id), grants: role.grants || job.needs?.grants || ['none'] };
|
|
308
|
+
const nt = normalizeTeam({ name: t.name, roles: [base], budget: t.budget });
|
|
309
|
+
const r = { ...nt.roles[0], ...(role.model ? { model: role.model } : {}), ...(role.engine ? { engine: role.engine } : {}), ...(role.skills ? { skills: role.skills } : {}), ...(role.workdir ? { workdir: role.workdir } : {}), recruited: true };
|
|
310
|
+
if (!t.roles.some((x) => x.id === r.id)) { t.roles.push(r); say('run.role-added', { role: { id: r.id, name: r.name, agent: r.agent || null, grants: r.grants, model: r.model || null, engine: r.engine || null }, jobId: job.id }); }
|
|
311
|
+
return r.id;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const runTask = async (task) => {
|
|
315
|
+
running.add(task.id);
|
|
316
|
+
try { return await runTaskInner(task); } finally { running.delete(task.id); }
|
|
317
|
+
};
|
|
318
|
+
const runTaskInner = async (task) => {
|
|
319
|
+
if (stopped() || overBudget || waitingOnPerson) { const row = { id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }; tasksOut.push(row); return row; }
|
|
215
320
|
const role = roleOf(task.role);
|
|
216
321
|
const t0 = now();
|
|
217
322
|
const was = interrupted.get(task.id) || null;
|
|
218
|
-
say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
|
|
323
|
+
say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(task.parent ? { parent: task.parent } : {}), ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
|
|
219
324
|
let text = '';
|
|
220
325
|
let usage = null;
|
|
221
326
|
let status = 'ok';
|
|
@@ -247,7 +352,14 @@ export async function runTeam({
|
|
|
247
352
|
const added = newSteps(before, after);
|
|
248
353
|
if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(stamp) });
|
|
249
354
|
};
|
|
355
|
+
// The tool-choice guard (§15.2): what this task's wording — and the planner's proposal —
|
|
356
|
+
// say it needs among what the role holds. A model attempt that ends with zero calls
|
|
357
|
+
// while holding one of these is nudged once, then may finish.
|
|
358
|
+
const grantsHeld = role?.grants || [];
|
|
359
|
+
const mustUse = role?.mode === 'model' ? [...new Set([...grantsNeededFor(task.prompt, { held: grantsHeld }), ...(task.grants || []).filter((g) => grantsHeld.includes(g) || (g.startsWith('mcp:') && grantsHeld.includes('mcp')))])] : [];
|
|
360
|
+
let nudged = false;
|
|
250
361
|
try {
|
|
362
|
+
if (!role) throw new Error(`no role "${task.role}" in the team`);
|
|
251
363
|
if (role.mode === 'recipe') {
|
|
252
364
|
if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
|
|
253
365
|
const r = await runRecipe(role.recipe, { request: String(request || ''), task: task.prompt });
|
|
@@ -265,6 +377,7 @@ export async function runTeam({
|
|
|
265
377
|
const boardTool = boardToolProvider({
|
|
266
378
|
board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
|
|
267
379
|
onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
|
|
380
|
+
onRequest: (req) => onRequest(task, role, req),
|
|
268
381
|
waitFor: askMs > 0 ? async (threadId, ms, sig) => {
|
|
269
382
|
const a = await box.wait(threadId, ms, sig);
|
|
270
383
|
if (!a && !stopped()) { askedAndWaiting = threadId; taskAc.abort(); }
|
|
@@ -293,6 +406,8 @@ export async function runTeam({
|
|
|
293
406
|
note = continuationNote({ kind: 'handoff', from: lastModel, to: handoffTo.model, reason: `handed off by ${handoffTo.by}` });
|
|
294
407
|
handoffTo = null;
|
|
295
408
|
taskAc = new AbortController(); signal?.addEventListener?.('abort', onRunAbort, { once: true });
|
|
409
|
+
} else if (nudged && lastModel && !exclude.has(lastModel)) {
|
|
410
|
+
m = { model: lastModel, mode: role.mode }; // the nudge continues on the same model
|
|
296
411
|
} else {
|
|
297
412
|
m = modelFor(role, excluding(exclude));
|
|
298
413
|
}
|
|
@@ -337,7 +452,20 @@ export async function runTeam({
|
|
|
337
452
|
// died after its tool calls — is not a done task: three members "completed" empty
|
|
338
453
|
// once, the run merged nothing, and the caller ran the team again. It is treated
|
|
339
454
|
// like an unavailable model, so the next one on the roster gets the task.
|
|
340
|
-
if (res?.ok && String(res?.text || '').trim()) {
|
|
455
|
+
if (res?.ok && String(res?.text || '').trim()) {
|
|
456
|
+
// Answered from memory while holding a tool the task calls for: one nudge, on
|
|
457
|
+
// the same model, continuing the transcript — then whatever it says stands.
|
|
458
|
+
const usedNone = !nudged && mustUse.length && routed?.engine?.kind !== 'harness' && !transcript.some((x) => x.role === 'assistant' && Array.isArray(x.tool_calls) && x.tool_calls.some((c) => c.function?.name && c.function.name !== 'board'));
|
|
459
|
+
if (usedNone && budget.canAfford({ tokens: 0 }) && !stopped()) {
|
|
460
|
+
nudged = true;
|
|
461
|
+
note = continuationNote({ kind: 'nudge', reason: mustUse.join(', ') });
|
|
462
|
+
const th = board.threadForTask(task.id);
|
|
463
|
+
if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'note', text: `${role.id} answered without using ${mustUse.join(', ')}, which the task calls for — asked once to use it before finishing.` });
|
|
464
|
+
say('task.nudged', { taskId: task.id, role: role.id, grants: mustUse });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
text = String(res.text); break;
|
|
468
|
+
}
|
|
341
469
|
// A member that wrote on the board and then ran out of turn has still answered:
|
|
342
470
|
// what it posted in its own thread during this attempt is its answer. A relayed
|
|
343
471
|
// agent that posted its assessment and kept searching past the cap was being
|
|
@@ -363,45 +491,54 @@ export async function runTeam({
|
|
|
363
491
|
} finally {
|
|
364
492
|
unsubscribe?.();
|
|
365
493
|
}
|
|
366
|
-
const findings = status === 'ok' ? parseFindings(text, { role: role
|
|
367
|
-
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role
|
|
494
|
+
const findings = status === 'ok' ? parseFindings(text, { role: role?.id, taskId: task.id }) : [];
|
|
495
|
+
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role?.id, finding: f }); }
|
|
368
496
|
const thread = board.threadForTask(task.id);
|
|
369
497
|
// The thread says how the task ended. A failure is posted in it as well — a person reading
|
|
370
498
|
// the board sees "researcher failed: network error" where it happened, and what was tried.
|
|
371
499
|
if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
|
|
372
500
|
else if (thread && status !== 'waiting') {
|
|
373
|
-
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role.
|
|
501
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.model).join(', ')})` : ''}.` });
|
|
374
502
|
board.setThreadStatus(thread.id, 'failed');
|
|
375
503
|
}
|
|
376
|
-
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
504
|
+
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(task.parent ? { parent: task.parent } : {}), ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
377
505
|
tasksOut.push(row);
|
|
378
506
|
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
|
379
507
|
// The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
|
|
380
508
|
// in which role — produced here, attested by the store, never written by the agent.
|
|
381
|
-
if (status === 'ok' || status === 'failed') {
|
|
509
|
+
if (role && (status === 'ok' || status === 'failed')) {
|
|
382
510
|
say('task.scored', {
|
|
383
511
|
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',
|
|
384
512
|
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 },
|
|
385
513
|
roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
386
514
|
refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
|
|
515
|
+
...(task.parent ? { parent: task.parent, requestedBy: task.requestedBy || null } : {}),
|
|
387
516
|
});
|
|
388
517
|
}
|
|
389
518
|
// The spend so far, after every task — a ledger reads it live instead of at the end.
|
|
390
519
|
say('run.usage', { usage: budget.snapshot() });
|
|
391
520
|
if (budget.exhausted()) overBudget = true;
|
|
392
|
-
|
|
521
|
+
return row;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
for (;;) {
|
|
525
|
+
if (stopped() || overBudget || waitingOnPerson) break;
|
|
526
|
+
const wave = ready();
|
|
527
|
+
if (!wave.length) break;
|
|
528
|
+
await pool(wave, maxConcurrency, runTask);
|
|
393
529
|
// Over budget with work left: ask the person ONCE for more, on the board, before stopping.
|
|
394
530
|
if (overBudget && !budgetAsked && !stopped()) {
|
|
395
531
|
budgetAsked = true;
|
|
396
|
-
const left = tasks.filter((x) => !tasksOut.some((y) => y.id === x.id)).length;
|
|
532
|
+
const left = tasks.filter((x) => x.role && !tasksOut.some((y) => y.id === x.id)).length;
|
|
397
533
|
const spent = budget.snapshot().spent;
|
|
398
534
|
const what = left ? `${left} task${left === 1 ? '' : 's'} and the merge left` : 'only the merge left';
|
|
399
535
|
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'] });
|
|
400
536
|
if (a && /raise|allow|yes|more|continue/i.test(a.text)) { budget.raise(1.5); overBudget = false; }
|
|
401
537
|
}
|
|
402
538
|
}
|
|
403
|
-
// Every planned task gets a row — what never ran is recorded as skipped, not forgotten
|
|
404
|
-
|
|
539
|
+
// Every planned task gets a row — what never ran is recorded as skipped, not forgotten;
|
|
540
|
+
// a sub-task nobody took is `unassigned`, which is its own kind of undone.
|
|
541
|
+
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 } : {}) });
|
|
405
542
|
if (stopped()) return finish('stopped');
|
|
406
543
|
if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
|
|
407
544
|
// Over budget is a STOP only when it left work undone; a budget spent on the last task
|
|
@@ -485,6 +622,7 @@ export function resumeTeam({ checkpoint, ...deps } = {}) {
|
|
|
485
622
|
}
|
|
486
623
|
|
|
487
624
|
const lastModelOf = (attempts) => (attempts || []).at(-1)?.model || null;
|
|
625
|
+
const describeNeeds = (n) => [n?.skills?.length ? `skills ${n.skills.join(', ')}` : '', n?.grants?.length ? `grants ${n.grants.join(', ')}` : '', n?.tools?.length ? `tools ${n.tools.join(', ')}` : ''].filter(Boolean).join('; ') || 'nothing in particular';
|
|
488
626
|
const toolNamesOf = (transcript) => [...new Set((transcript || []).flatMap((m) => (m.role === 'assistant' && Array.isArray(m.tool_calls) ? m.tool_calls.map((c) => c.function?.name).filter(Boolean) : [])))];
|
|
489
627
|
|
|
490
628
|
/** No model: the members' work side by side, findings first — always available. */
|
package/team-subtask.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A sub-task — a member's request turned into a job on the run (F8 §15.2).
|
|
2
|
+
//
|
|
3
|
+
// A member breaks its task down by posting a REQUEST in its thread ("writer: I need the
|
|
4
|
+
// valuation checked"); the runner turns each into a sub-task with its own thread, a `parent`
|
|
5
|
+
// and the parent's dependencies, so the plan is a TREE. Take-up is proactive: the sub-task
|
|
6
|
+
// is offered to the run's other members first — `takeUp` scores each on the skills and
|
|
7
|
+
// grants the request names (scorecard.js `fit`, the same score a job posting uses) and the
|
|
8
|
+
// best fit that meets the hard needs claims it. Nobody fits → the job board: `jobFromSubtask`
|
|
9
|
+
// is the posting the pool applies to (recruit.js), and the host's `recruit` hook answers with
|
|
10
|
+
// a role or nothing; nothing → an agent is PROPOSED to a person, never created (§7).
|
|
11
|
+
//
|
|
12
|
+
// Pure: no model call, no clock beyond what is handed in. The runner (team-run.js) owns the
|
|
13
|
+
// flow; this module owns the shapes and the decisions that can be tested without a run.
|
|
14
|
+
|
|
15
|
+
import { fit } from './scorecard.js';
|
|
16
|
+
import { GRANT_RE, normalizeGrants } from './team.js';
|
|
17
|
+
import { normalizeJob } from './job.js';
|
|
18
|
+
|
|
19
|
+
export const MAX_SUBTASKS = 8; // per run — a member that needs more than this is re-planning, not delegating
|
|
20
|
+
export const MAX_DEPTH = 2; // a sub-task may request a sub-sub-task; not deeper
|
|
21
|
+
export const MIN_TAKEUP_FIT = 0.5;
|
|
22
|
+
export const MAX_BRIEF = 4000;
|
|
23
|
+
|
|
24
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
25
|
+
const lower = (xs) => (Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => String(x).trim().toLowerCase()).filter(Boolean);
|
|
26
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
27
|
+
|
|
28
|
+
/** The request as a member states it, checked: a title, a brief, and what it needs. */
|
|
29
|
+
export function normalizeRequest(input) {
|
|
30
|
+
const title = clip(input?.title, 120);
|
|
31
|
+
const brief = clip(input?.brief ?? input?.text, MAX_BRIEF);
|
|
32
|
+
if (!title && !brief) return { ok: false, error: 'a request needs a title and a brief — what to do and what done looks like' };
|
|
33
|
+
const grants = uniq(lower(input?.grants).filter((g) => GRANT_RE.test(g) && g !== 'none'));
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
request: {
|
|
37
|
+
title: title || clip(brief, 80),
|
|
38
|
+
brief: brief || title,
|
|
39
|
+
needs: { skills: uniq(lower(input?.skills)).slice(0, 12), tools: uniq(lower(input?.tools)).slice(0, 12), grants: grants.slice(0, 8) },
|
|
40
|
+
wait: input?.wait !== false && input?.wait !== 'false',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The sub-task a request becomes: a task row like any planned one (team-plan.js) plus
|
|
47
|
+
* `parent`, `depth`, `needs`, `requestedBy`; `role` is null until someone takes it. It
|
|
48
|
+
* inherits the parent's dependencies so it may read what the parent read.
|
|
49
|
+
*/
|
|
50
|
+
export function subtaskFromRequest(request, parent, { id, by, now = Date.now() } = {}) {
|
|
51
|
+
const r = request;
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
role: null,
|
|
55
|
+
title: r.title,
|
|
56
|
+
prompt: [
|
|
57
|
+
`Sub-task requested by ${by} while working on "${clip(parent?.title || parent?.id, 120)}": ${r.title}`,
|
|
58
|
+
r.brief,
|
|
59
|
+
'Do this one thing and finish with your findings; whoever asked reads them on the board.',
|
|
60
|
+
].join('\n\n'),
|
|
61
|
+
dependsOn: uniq([...(parent?.dependsOn || [])]).filter((d) => d !== id),
|
|
62
|
+
parent: parent?.id || null,
|
|
63
|
+
depth: (Number(parent?.depth) || 0) + 1,
|
|
64
|
+
needs: { skills: [...r.needs.skills], tools: [...r.needs.tools], grants: [...r.needs.grants] },
|
|
65
|
+
requestedBy: by,
|
|
66
|
+
requestedAt: now,
|
|
67
|
+
wait: !!r.wait,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Does this role hold every grant the request names? `mcp` covers `mcp:<server>`. */
|
|
72
|
+
export function holdsGrants(role, grants) {
|
|
73
|
+
const have = new Set(normalizeGrants(role?.grants || []));
|
|
74
|
+
if (have.has('none') && (grants || []).length) return false;
|
|
75
|
+
return (grants || []).every((g) => have.has(g) || (g.startsWith('mcp:') && have.has('mcp')));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const coversSkills = (role, skills) => {
|
|
79
|
+
if (!skills?.length) return true;
|
|
80
|
+
const has = new Set(lower(role?.skills));
|
|
81
|
+
return skills.some((s) => has.has(s));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Offer a sub-task to the run's members: the best fit among those that hold every grant it
|
|
86
|
+
* needs and at least one skill it names (when it names any), at or above `minFit`. The
|
|
87
|
+
* requester is excluded — a request is a delegation — as are recipe roles (a recipe cannot
|
|
88
|
+
* take an arbitrary task) and anything in `exclude`. `summaries` are scorecard cards by
|
|
89
|
+
* agent or role id, when the host has them. Returns `{ roleId, fit, reasons }` or null with
|
|
90
|
+
* `why` on the side: `{ roleId: null, why }`.
|
|
91
|
+
*/
|
|
92
|
+
export function takeUp(task, roles, { exclude = [], summaries = {}, minFit = MIN_TAKEUP_FIT } = {}) {
|
|
93
|
+
const skip = new Set([task?.requestedBy, ...exclude].filter(Boolean));
|
|
94
|
+
const needs = task?.needs || { skills: [], tools: [], grants: [] };
|
|
95
|
+
const candidates = (roles || []).filter((r) => r && !skip.has(r.id) && r.mode !== 'recipe');
|
|
96
|
+
if (!candidates.length) return { roleId: null, why: 'no other member in the run' };
|
|
97
|
+
const scored = [];
|
|
98
|
+
const rejected = [];
|
|
99
|
+
for (const r of candidates) {
|
|
100
|
+
if (!holdsGrants(r, needs.grants)) { rejected.push(`${r.id} lacks ${needs.grants.filter((g) => !holdsGrants(r, [g])).join(', ')}`); continue; }
|
|
101
|
+
if (!coversSkills(r, needs.skills)) { rejected.push(`${r.id} has none of: ${needs.skills.join(', ')}`); continue; }
|
|
102
|
+
const f = fit({ needs, size: { steps: 0 } }, { skills: r.skills || [], tools: r.grants || [], grants: r.grants || [] }, summaries[r.agent || r.id] || null, { adjust: false });
|
|
103
|
+
scored.push({ roleId: r.id, fit: f.score, reasons: f.reasons });
|
|
104
|
+
}
|
|
105
|
+
scored.sort((a, b) => b.fit - a.fit);
|
|
106
|
+
const best = scored[0];
|
|
107
|
+
if (best && best.fit >= minFit) return best;
|
|
108
|
+
const why = best ? `${best.roleId} fits best at ${Math.round(best.fit * 100)}%, under the ${Math.round(minFit * 100)}% floor` : (rejected.length ? rejected.join('; ') : 'no member fits');
|
|
109
|
+
return { roleId: null, why };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The posting the pool applies to when nobody in the run fits (job.js). The run stands in
|
|
114
|
+
* for a project when there is none: `projectId` is the run's. The brief carries who asked
|
|
115
|
+
* and for what, so the evaluator and a person read the context.
|
|
116
|
+
*/
|
|
117
|
+
export function jobFromSubtask(task, { runId, projectId = null, budget = null, by = null } = {}) {
|
|
118
|
+
return normalizeJob({
|
|
119
|
+
id: task.id,
|
|
120
|
+
projectId: projectId || runId,
|
|
121
|
+
title: task.title,
|
|
122
|
+
brief: task.prompt,
|
|
123
|
+
needs: task.needs,
|
|
124
|
+
...(budget ? { budget } : {}),
|
|
125
|
+
status: 'open',
|
|
126
|
+
postedBy: by || task.requestedBy || 'runner',
|
|
127
|
+
postedAt: task.requestedAt || Date.now(),
|
|
128
|
+
dependsOn: [],
|
|
129
|
+
origin: { kind: 'subtask', runId, parent: task.parent || null, requestedBy: task.requestedBy || null },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A queued sub-task (the requester did not wait for it) must be read by whoever would have
|
|
135
|
+
* read the requester: every task that depends on the parent now depends on the sub-task too.
|
|
136
|
+
* Mutates the plan in place; returns the ids it extended.
|
|
137
|
+
*/
|
|
138
|
+
export function extendDependents(tasks, parentId, subtaskId) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const t of tasks || []) {
|
|
141
|
+
if (!t || t.id === subtaskId) continue;
|
|
142
|
+
if ((t.dependsOn || []).includes(parentId) && !(t.dependsOn || []).includes(subtaskId)) { t.dependsOn = [...t.dependsOn, subtaskId]; out.push(t.id); }
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The plan as a tree — roots first, each with its children — for a board that draws it as one. */
|
|
148
|
+
export function taskTree(tasks) {
|
|
149
|
+
const list = (tasks || []).filter(Boolean);
|
|
150
|
+
const ids = new Set(list.map((t) => t.id));
|
|
151
|
+
const byParent = new Map();
|
|
152
|
+
for (const t of list) {
|
|
153
|
+
const key = t.parent && ids.has(t.parent) ? t.parent : null;
|
|
154
|
+
if (!byParent.has(key)) byParent.set(key, []);
|
|
155
|
+
byParent.get(key).push(t);
|
|
156
|
+
}
|
|
157
|
+
const build = (key, seen) => (byParent.get(key) || []).filter((t) => !seen.has(t.id)).map((t) => ({ task: t, children: build(t.id, new Set([...seen, t.id])) }));
|
|
158
|
+
return build(null, new Set());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One line a person reads: "researcher took: check valuation (fit 85%)". */
|
|
162
|
+
export function takeUpLine(task, pick) {
|
|
163
|
+
if (!pick?.roleId) return `nobody in the run fits "${task.title}"${pick?.why ? `: ${pick.why}` : ''}`;
|
|
164
|
+
return `${pick.roleId} took: ${task.title} (fit ${Math.round((pick.fit || 0) * 100)}%${pick.reasons?.length ? ` — ${pick.reasons[0]}` : ''})`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A run's threads as the board lists them: a sub-task's thread (one whose `parent` names a
|
|
169
|
+
* task) follows its parent's thread, indented — `depth` on each row — so the tree reads as
|
|
170
|
+
* one. Roots keep the order given (a board sorts them newest first); children come in the
|
|
171
|
+
* order they were requested. A thread whose parent is not on the board is a root.
|
|
172
|
+
*/
|
|
173
|
+
export function threadRows(threads) {
|
|
174
|
+
const list = (threads || []).filter(Boolean);
|
|
175
|
+
const byTask = new Map(list.filter((t) => t.kind === 'task' && t.taskId).map((t) => [t.taskId, t]));
|
|
176
|
+
const children = new Map();
|
|
177
|
+
for (const t of list) {
|
|
178
|
+
if (!t.parent || !byTask.has(t.parent) || byTask.get(t.parent) === t) continue;
|
|
179
|
+
const key = byTask.get(t.parent).id;
|
|
180
|
+
if (!children.has(key)) children.set(key, []);
|
|
181
|
+
children.get(key).push(t);
|
|
182
|
+
}
|
|
183
|
+
const isChild = new Set([...children.values()].flat().map((t) => t.id));
|
|
184
|
+
const out = [];
|
|
185
|
+
const walk = (t, depth, seen) => {
|
|
186
|
+
if (seen.has(t.id)) return;
|
|
187
|
+
out.push({ ...t, depth });
|
|
188
|
+
for (const c of (children.get(t.id) || []).sort((a, b) => (a.at || 0) - (b.at || 0))) walk(c, depth + 1, new Set([...seen, t.id]));
|
|
189
|
+
};
|
|
190
|
+
for (const t of list) if (!isChild.has(t.id)) walk(t, 0, new Set());
|
|
191
|
+
return out;
|
|
192
|
+
}
|
package/team-task.js
CHANGED
|
@@ -64,9 +64,11 @@ export function newSteps(prev, next) {
|
|
|
64
64
|
* handoff — the previous attempt (another model / agent) stopped: an error, a person's choice
|
|
65
65
|
* resume — the run itself was stopped or died and is being resumed from the record
|
|
66
66
|
* answer — the task waited on a person and the answer is now on the board
|
|
67
|
+
* nudge — the attempt answered without touching a tool the task was said to need (§15.2)
|
|
67
68
|
*/
|
|
68
69
|
export function continuationNote({ kind = 'handoff', from = '', to = '', reason = '', answer = '' } = {}) {
|
|
69
70
|
const who = from ? ` by ${from}` : '';
|
|
71
|
+
if (kind === 'nudge') return `You answered without using ${reason || 'the tools you were given'}, which this task calls for. Use them now — look it up rather than answer from memory — then finish with your findings. If a tool is truly not needed, say why in one line and finish.`;
|
|
70
72
|
if (kind === 'answer') return `The user has answered your question on the board (see the board, or: ${answer}). Continue the task from where you left off — do not repeat work already done above — and finish with your findings.`;
|
|
71
73
|
if (kind === 'resume') return `This task was interrupted (${reason || 'the run was stopped'}) and is being resumed${to ? ` by ${to}` : ''}. Everything above is the work done so far — read it, do not redo it. Continue from where it stopped and finish with your findings.`;
|
|
72
74
|
return `You are continuing this task. A previous attempt${who} stopped (${reason || 'it did not finish'}). Everything above is its work so far — read it, do not redo lookups already made. Continue from where it stopped and finish with your findings.`;
|
package/team-worklog.js
CHANGED
|
@@ -131,7 +131,7 @@ export function workLogText(entries, { resultChars = 200 } = {}) {
|
|
|
131
131
|
* any opinion (a judge's, a peer's, a person's) is added beside it.
|
|
132
132
|
*/
|
|
133
133
|
export function workLogEvidence(entries) {
|
|
134
|
-
const ev = { calls: 0, results: 0, resultErrors: 0, thoughts: 0, texts: 0, attempts: 0, handoffs: 0, posts: 0, findings: 0, decided: { approved: 0, rejected: 0 }, status: null, ms: 0 };
|
|
134
|
+
const ev = { calls: 0, results: 0, resultErrors: 0, thoughts: 0, texts: 0, attempts: 0, handoffs: 0, posts: 0, findings: 0, requests: 0, decided: { approved: 0, rejected: 0 }, status: null, ms: 0 };
|
|
135
135
|
for (const e of entries || []) {
|
|
136
136
|
if (e.kind === 'call') ev.calls++;
|
|
137
137
|
else if (e.kind === 'result') { ev.results++; if (e.error) ev.resultErrors++; }
|
|
@@ -139,7 +139,7 @@ export function workLogEvidence(entries) {
|
|
|
139
139
|
else if (e.kind === 'text') ev.texts++;
|
|
140
140
|
else if (e.kind === 'attempt') ev.attempts++;
|
|
141
141
|
else if (e.kind === 'handoff') ev.handoffs++;
|
|
142
|
-
else if (e.kind === 'post') { ev.posts++; if (e.post?.kind === 'finding') ev.findings++; if (e.post?.status === 'approved') ev.decided.approved++; if (e.post?.status === 'rejected') ev.decided.rejected++; }
|
|
142
|
+
else if (e.kind === 'post') { ev.posts++; if (e.post?.kind === 'finding') ev.findings++; if (e.post?.kind === 'request') ev.requests++; if (e.post?.status === 'approved') ev.decided.approved++; if (e.post?.status === 'rejected') ev.decided.rejected++; }
|
|
143
143
|
else if (e.kind === 'end') { ev.status = e.status; ev.ms = e.ms; ev.findings = Math.max(ev.findings, e.findings || 0); }
|
|
144
144
|
}
|
|
145
145
|
return ev;
|
package/tool-need.js
CHANGED
|
@@ -94,3 +94,31 @@ export function toolNeedFor({ request = null, signals = null, attachments = [],
|
|
|
94
94
|
|
|
95
95
|
return { tools: false, why: 'a greeting — nothing to look up' };
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
// ── Which GRANTS a task's wording calls for (F8 §15.2) ──────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// A team member holding `web` that answers a "latest price" question from memory has not
|
|
101
|
+
// done the task. This is the deterministic half of the tool-choice guard: read the task's
|
|
102
|
+
// text for the vocabulary that names a source — the web, the person's own history, attached
|
|
103
|
+
// material — and return the grants that vocabulary fits. The runner nudges a member ONCE
|
|
104
|
+
// when it ends with zero calls while holding one of these. Conservative by construction:
|
|
105
|
+
// nothing here fires on a task that could plausibly be answered from what the model knows.
|
|
106
|
+
const NEEDS = Object.freeze([
|
|
107
|
+
['web', /\b(search|google|look ?up|latest|current|recent|today|this (week|month|year)|news|price|prices|pricing|cost of|quote|stock|market|website|url|online|web|docs?umentation|release notes|changelog|versions?)\b/i],
|
|
108
|
+
['history', /\b(our (meeting|call|notes?|chats?|conversation)|what (did|was) (we|i)|we (decided|agreed|discussed|said)|in (my|our) (notes?|meetings?|history|chats?)|past (chats?|meetings?|notes?)|earlier (meeting|conversation|chat|note)|transcript|standup|retro)\b/i],
|
|
109
|
+
['data', /\b(attached|attachment|this (page|document|file|pdf|spreadsheet|sheet)|the (document|file|pdf|spreadsheet) (above|provided|attached))\b/i],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The grants a task's text calls for, in the order they fit: `['web']`, `['history', 'web']`,
|
|
114
|
+
* `[]`. `held` narrows to what the role actually has, so the caller gets what is BOTH needed
|
|
115
|
+
* and available — an empty list means no nudge.
|
|
116
|
+
*/
|
|
117
|
+
export function grantsNeededFor(text, { held = null } = {}) {
|
|
118
|
+
const t = String(text || '');
|
|
119
|
+
if (!t.trim()) return [];
|
|
120
|
+
const out = NEEDS.filter(([, re]) => re.test(t)).map(([g]) => g);
|
|
121
|
+
if (!held) return out;
|
|
122
|
+
const have = new Set((Array.isArray(held) ? held : []).map((g) => String(g).toLowerCase()));
|
|
123
|
+
return out.filter((g) => have.has(g) || (g === 'history' && have.has('data')));
|
|
124
|
+
}
|