agent-from-zero 0.27.0 → 0.28.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/README.md +43 -38
- package/dist/agent.js +121 -14
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/editor.js +307 -0
- package/dist/loop.js +697 -5
- package/dist/markdown.js +152 -0
- package/dist/permissions.js +51 -3
- package/dist/prompt.js +16 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +86 -1
- package/dist/ui.js +5 -1
- package/package.json +7 -3
package/dist/loop.js
CHANGED
|
@@ -5,12 +5,17 @@ import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
|
5
5
|
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
6
6
|
import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
|
|
7
7
|
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
8
|
-
import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent
|
|
8
|
+
import { SUB_AGENT_PROMPT, TEAMMATE_PROMPT } from "./prompt.js"; // the sub-agent + teammate constitutions
|
|
9
|
+
import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
|
|
10
|
+
import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
|
|
9
11
|
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
10
12
|
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
11
13
|
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
12
14
|
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
13
15
|
import { printToolSummary } from "./tui.js"; // collapsible tool output
|
|
16
|
+
import { MarkdownStream } from "./markdown.js"; // render the streamed answer as terminal markdown
|
|
17
|
+
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
18
|
+
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
14
19
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
15
20
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
16
21
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -87,6 +92,187 @@ If the user cancels, you'll be told — then proceed with your best judgment or
|
|
|
87
92
|
},
|
|
88
93
|
},
|
|
89
94
|
};
|
|
95
|
+
// The spawn_teammate tool (Day 38): the Lead's handle on a PERSISTENT worker.
|
|
96
|
+
// Unlike `task` (one-shot, returns once), a teammate runs concurrently and keeps
|
|
97
|
+
// talking — it messages the lead as it works and is reachable via send_message.
|
|
98
|
+
// Defined here (not tools.ts) because running it needs the loop itself.
|
|
99
|
+
const spawnTeammateTool = {
|
|
100
|
+
type: "function",
|
|
101
|
+
function: {
|
|
102
|
+
name: "spawn_teammate",
|
|
103
|
+
description: `Spawn a PERSISTENT teammate that works in parallel with you and the rest of the team. Use this for a large task with distinct streams of work that need to coordinate as they go (e.g. one teammate on the API, one on the database) — not for a quick lookup (use task) or a single linear job (do it yourself).
|
|
104
|
+
The teammate runs concurrently in a fresh context with read/write/search/bash tools. It can message you and other teammates at any time, and you'll receive its result when it finishes. You can run a few at once.
|
|
105
|
+
Give it a short unique name and a one-line role, and a complete self-contained task (it knows nothing about this conversation). After spawning, keep coordinating: you'll get its messages in your inbox; reply with send_message.`,
|
|
106
|
+
parameters: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
name: { type: "string", description: "A short, unique name for the teammate (e.g. 'api', 'db', 'tests')" },
|
|
110
|
+
role: { type: "string", description: "One line describing its specialty/responsibility" },
|
|
111
|
+
task: { type: "string", description: "The complete, self-contained task for the teammate to carry out" },
|
|
112
|
+
},
|
|
113
|
+
required: ["name", "role", "task"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
// The send_message tool (Day 38): drop a message in another agent's mailbox.
|
|
118
|
+
// Available to the Lead AND to teammates — it is how a team coordinates.
|
|
119
|
+
const sendMessageTool = {
|
|
120
|
+
type: "function",
|
|
121
|
+
function: {
|
|
122
|
+
name: "send_message",
|
|
123
|
+
description: `Send a message to another agent on the team (a teammate by name, or "lead"). The message lands in their inbox and they see it on their next round. Use it to share findings, hand off work, ask a specific question, or report progress to the lead. Returns once delivered; it does NOT wait for a reply — keep working, and their response will arrive in your inbox.`,
|
|
124
|
+
parameters: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
to: { type: "string", description: 'Recipient agent name, or "lead" for the coordinator' },
|
|
128
|
+
content: { type: "string", description: "The message text" },
|
|
129
|
+
},
|
|
130
|
+
required: ["to", "content"],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
// The team protocol tools (Day 39). Both sides of each contract are a tool:
|
|
135
|
+
// the lead asks for shutdown / reviews a plan; a teammate submits a plan. Each
|
|
136
|
+
// carries a request_id so the answer can be correlated to the ask.
|
|
137
|
+
const requestShutdownTool = {
|
|
138
|
+
type: "function",
|
|
139
|
+
function: {
|
|
140
|
+
name: "request_shutdown",
|
|
141
|
+
description: `Ask a teammate to shut down GRACEFULLY. It finishes what it's mid-way through, confirms, and exits cleanly — far better than abandoning it (which can leave a half-written file). Use it when a teammate's part is done, or when you're wrapping up the whole task. You'll get a shutdown confirmation back. (When you finish, any teammates still up are shut down for you automatically.)`,
|
|
142
|
+
parameters: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
teammate: { type: "string", description: "Name of the teammate to shut down" },
|
|
146
|
+
reason: { type: "string", description: "Optional short reason, shown to the teammate" },
|
|
147
|
+
},
|
|
148
|
+
required: ["teammate"],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const requestPlanTool = {
|
|
153
|
+
type: "function",
|
|
154
|
+
function: {
|
|
155
|
+
name: "request_plan",
|
|
156
|
+
description: `Tell a teammate to submit a PLAN (via submit_plan) and wait for your approval BEFORE it carries out a risky or far-reaching change (e.g. an auth refactor, a schema migration). Use this when you want to pre-authorize high-stakes work instead of reviewing the damage after. The teammate will send you a plan_approval_request; approve or reject it with review_plan.`,
|
|
157
|
+
parameters: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
teammate: { type: "string", description: "Name of the teammate" },
|
|
161
|
+
task: { type: "string", description: "The risky work it must plan before doing" },
|
|
162
|
+
},
|
|
163
|
+
required: ["teammate", "task"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const reviewPlanTool = {
|
|
168
|
+
type: "function",
|
|
169
|
+
function: {
|
|
170
|
+
name: "review_plan",
|
|
171
|
+
description: `Approve or reject a plan a teammate submitted for approval. You'll have received a plan_approval_request with a request_id — pass that id and your decision. On approval the teammate proceeds; on rejection it revises and may submit again. Always include a brief reason on a rejection so it can fix the plan.`,
|
|
172
|
+
parameters: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
request_id: { type: "string", description: "The request_id from the plan_approval_request" },
|
|
176
|
+
decision: { type: "string", enum: ["approve", "reject"], description: "approve | reject" },
|
|
177
|
+
reason: { type: "string", description: "Short reason (required-in-spirit on a rejection)" },
|
|
178
|
+
},
|
|
179
|
+
required: ["request_id", "decision"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const submitPlanTool = {
|
|
184
|
+
type: "function",
|
|
185
|
+
function: {
|
|
186
|
+
name: "submit_plan",
|
|
187
|
+
description: `Submit a PLAN to the lead and wait for approval BEFORE carrying out a risky or far-reaching change. Use it whenever you're about to do something high-stakes (refactor auth, migrate a schema, delete/rewrite many files) — propose first, act after. After you call this, STOP and wait: the lead's approval (or rejection) will arrive in your inbox. Do not start the work until it's approved.`,
|
|
188
|
+
parameters: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
plan: { type: "string", description: "The concise, ordered plan you intend to carry out" },
|
|
192
|
+
},
|
|
193
|
+
required: ["plan"],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
// The task board tools (Day 40). The board is how teammates find work without
|
|
198
|
+
// the lead handing it out: create tasks, see the board, claim a ready one, mark
|
|
199
|
+
// it done. claim/complete are loop-level (not registry tools) because they need
|
|
200
|
+
// to know WHO is calling — the claimer's identity is the agent name, which only
|
|
201
|
+
// the loop has.
|
|
202
|
+
const createTaskTool = {
|
|
203
|
+
type: "function",
|
|
204
|
+
function: {
|
|
205
|
+
name: "create_task",
|
|
206
|
+
description: `Add a task to the shared board for a teammate to pick up. Use it to break a big job into independent, claimable pieces. A task can declare blockedBy (ids of tasks that must finish first), so you can lay out a dependency order (e.g. schema → routes → tests) and teammates will respect it. Returns the new task id.`,
|
|
207
|
+
parameters: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
subject: { type: "string", description: "Short one-line title of the task" },
|
|
211
|
+
description: { type: "string", description: "The full, self-contained instruction for whoever claims it" },
|
|
212
|
+
blockedBy: { type: "array", items: { type: "string" }, description: "Tasks that must be completed before this one can start — by task id or by their subject (optional)" },
|
|
213
|
+
},
|
|
214
|
+
required: ["subject"],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const listTasksTool = {
|
|
219
|
+
type: "function",
|
|
220
|
+
function: {
|
|
221
|
+
name: "list_tasks",
|
|
222
|
+
description: `Show the shared task board: every task with its status (pending / in_progress / completed), owner, and dependencies. Use it to see what's left, what's claimed, and what's ready to start.`,
|
|
223
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
const claimTaskTool = {
|
|
227
|
+
type: "function",
|
|
228
|
+
function: {
|
|
229
|
+
name: "claim_task",
|
|
230
|
+
description: `Claim a pending, unblocked task from the board as your own — it becomes in_progress and owned by you. Claim BEFORE you start working it. If the claim fails (someone beat you to it, or it's still blocked), pick another task or wait. When you're done, call complete_task. (You usually don't need to call this by hand: while idle you are offered ready work automatically.)`,
|
|
231
|
+
parameters: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: { task_id: { type: "string", description: "The id of the task to claim" } },
|
|
234
|
+
required: ["task_id"],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
const completeTaskTool = {
|
|
239
|
+
type: "function",
|
|
240
|
+
function: {
|
|
241
|
+
name: "complete_task",
|
|
242
|
+
description: `Mark a task you own as completed. This may unblock downstream tasks (you'll be told which). Only call it once the work is actually done and verified.`,
|
|
243
|
+
parameters: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: { task_id: { type: "string", description: "The id of the task to complete" } },
|
|
246
|
+
required: ["task_id"],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
// Which tool manuals does THIS agent get? One place so the three agent kinds
|
|
251
|
+
// stay clearly distinct:
|
|
252
|
+
// - Lead / top-level: everything, plus task, ask_user, and the team tools
|
|
253
|
+
// (spawn + message + the protocol tools it drives: shutdown / plan review).
|
|
254
|
+
// - Teammate: a focused worker set + send_message + submit_plan; NO task, NO
|
|
255
|
+
// spawn_teammate (nested spawning is forbidden), NO ask_user (can't reach
|
|
256
|
+
// the human), and none of the planning/background extras.
|
|
257
|
+
// - Plain sub-agent (task): all built-ins except todo_write (unchanged).
|
|
258
|
+
const TEAMMATE_TOOLS = new Set(["read_file", "write_file", "edit_file", "search", "run_bash"]); // the teammate's focused kit
|
|
259
|
+
function toolsFor(opts) {
|
|
260
|
+
const builtins = toolDefinitions();
|
|
261
|
+
if (opts.teammate) {
|
|
262
|
+
// A teammate works the board: it can add tasks it discovers, see the board,
|
|
263
|
+
// claim ready work, and complete what it owns.
|
|
264
|
+
const kit = builtins.filter((t) => t.type === "function" && TEAMMATE_TOOLS.has(t.function.name));
|
|
265
|
+
return [...kit, sendMessageTool, submitPlanTool, createTaskTool, listTasksTool, claimTaskTool, completeTaskTool];
|
|
266
|
+
}
|
|
267
|
+
if (opts.subAgent) {
|
|
268
|
+
// Sub-agents don't get todo_write: the plan is the top-level agent's, and a
|
|
269
|
+
// sub-agent writing to the shared list would clobber it mid-task.
|
|
270
|
+
return builtins.filter((t) => !(t.type === "function" && t.function.name === "todo_write"));
|
|
271
|
+
}
|
|
272
|
+
// The Lead can do everything: orchestration, the protocols, and authoring +
|
|
273
|
+
// watching the board (it lays out tasks; teammates pull from it).
|
|
274
|
+
return [...builtins, taskTool, askUserTool, spawnTeammateTool, sendMessageTool, requestShutdownTool, requestPlanTool, reviewPlanTool, createTaskTool, listTasksTool];
|
|
275
|
+
}
|
|
90
276
|
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
91
277
|
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
92
278
|
async function runSubAgent(description, opts) {
|
|
@@ -172,10 +358,14 @@ opts) {
|
|
|
172
358
|
// Nested spawning means orphan processes and debugging hell.
|
|
173
359
|
// include_usage adds a final chunk carrying token counts — that is how we
|
|
174
360
|
// meter cost without a second (counting) request.
|
|
175
|
-
|
|
361
|
+
// The tool manual depends on the agent kind (Lead / teammate / sub-agent)
|
|
362
|
+
// — see toolsFor(). This is also where nested spawning is prevented: a
|
|
363
|
+
// teammate's manual simply omits spawn_teammate and task.
|
|
364
|
+
{ model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
176
365
|
let content = ""; // accumulated answer text
|
|
177
366
|
let printedPrefix = false; // have we printed the answer prefix yet?
|
|
178
367
|
let printedThinking = false; // have we started printing the reasoning trace yet?
|
|
368
|
+
let md = null; // renders the streamed answer as markdown, block by block
|
|
179
369
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
180
370
|
for await (const chunk of stream) {
|
|
181
371
|
lastEvent = Date.now(); // feed the watchdog
|
|
@@ -212,10 +402,14 @@ opts) {
|
|
|
212
402
|
if (printedThinking && !printedPrefix)
|
|
213
403
|
process.stdout.write("\n"); // end the thinking block before the answer
|
|
214
404
|
if (!printedPrefix) {
|
|
215
|
-
|
|
405
|
+
// Stream the answer through the markdown renderer: it buffers a block
|
|
406
|
+
// (paragraph / heading / list / table) and prints it formatted the
|
|
407
|
+
// moment that block completes — still live, but no raw `##`/`|---|`.
|
|
408
|
+
// The ⏺ marker leads the first line; wrapped lines align under it.
|
|
409
|
+
md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
|
|
216
410
|
printedPrefix = true;
|
|
217
411
|
}
|
|
218
|
-
|
|
412
|
+
md.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
219
413
|
}
|
|
220
414
|
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
221
415
|
}
|
|
@@ -231,6 +425,8 @@ opts) {
|
|
|
231
425
|
slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
|
|
232
426
|
}
|
|
233
427
|
}
|
|
428
|
+
if (md)
|
|
429
|
+
md.end(); // flush the final (un-terminated) block through the renderer
|
|
234
430
|
if (printedPrefix)
|
|
235
431
|
process.stdout.write("\n"); // end the streamed line cleanly
|
|
236
432
|
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
@@ -246,7 +442,7 @@ opts) {
|
|
|
246
442
|
// calls (allow/deny only, never "ask") are safe to run via this from inside a
|
|
247
443
|
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
248
444
|
async function runOneCall(call, opts) {
|
|
249
|
-
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent
|
|
445
|
+
const indent = opts.teammate ? chalk.magenta(` ⎿ [${opts.teammate.name}] `) : opts.subAgent ? chalk.blue(" ⎿ ") : ""; // teammate activity is tagged by name; plain sub-agent is nested in blue
|
|
250
446
|
if (!opts.quiet) {
|
|
251
447
|
// Compact one-line summary — long args are hidden behind Tab.
|
|
252
448
|
const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
|
|
@@ -300,6 +496,11 @@ async function runOneCall(call, opts) {
|
|
|
300
496
|
else {
|
|
301
497
|
content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
|
|
302
498
|
}
|
|
499
|
+
// todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
|
|
500
|
+
// the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
|
|
501
|
+
if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
|
|
502
|
+
console.log(renderTodos(getTodos()));
|
|
503
|
+
}
|
|
303
504
|
return { id: call.id, content }; // paired by id for the API
|
|
304
505
|
}
|
|
305
506
|
// Run an approved tool call, surrounded by the user's lifecycle hooks.
|
|
@@ -349,6 +550,29 @@ async function execute(call, opts) {
|
|
|
349
550
|
// tool registry. It presents a form and feeds the selections back to the model.
|
|
350
551
|
if (call.name === "ask_user")
|
|
351
552
|
return runAskUser(call, opts);
|
|
553
|
+
// The team tools (Day 38/39) are loop-level like task: they need the loop and
|
|
554
|
+
// the shared mailbox + protocol state, not the tool registry.
|
|
555
|
+
if (call.name === "send_message")
|
|
556
|
+
return runSendMessage(call, opts);
|
|
557
|
+
if (call.name === "spawn_teammate")
|
|
558
|
+
return runSpawnTeammate(call, opts);
|
|
559
|
+
if (call.name === "request_shutdown")
|
|
560
|
+
return runRequestShutdown(call, opts);
|
|
561
|
+
if (call.name === "request_plan")
|
|
562
|
+
return runRequestPlan(call, opts);
|
|
563
|
+
if (call.name === "review_plan")
|
|
564
|
+
return runReviewPlan(call, opts);
|
|
565
|
+
if (call.name === "submit_plan")
|
|
566
|
+
return runSubmitPlan(call, opts);
|
|
567
|
+
// The task board tools (Day 40) — claim/complete need the caller's identity.
|
|
568
|
+
if (call.name === "create_task")
|
|
569
|
+
return runCreateTask(call, opts);
|
|
570
|
+
if (call.name === "list_tasks")
|
|
571
|
+
return runListTasks(call, opts);
|
|
572
|
+
if (call.name === "claim_task")
|
|
573
|
+
return runClaimTask(call, opts);
|
|
574
|
+
if (call.name === "complete_task")
|
|
575
|
+
return runCompleteTask(call, opts);
|
|
352
576
|
if (call.name !== "task")
|
|
353
577
|
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
354
578
|
if (opts.subAgent)
|
|
@@ -364,6 +588,282 @@ async function execute(call, opts) {
|
|
|
364
588
|
return "[error] task requires a non-empty description argument.";
|
|
365
589
|
return runSubAgent(description, opts); // spawn the worker
|
|
366
590
|
}
|
|
591
|
+
// The sender's mailbox name: a teammate sends as itself, everyone else as "lead".
|
|
592
|
+
const senderName = (opts) => opts.teammate?.name ?? LEAD;
|
|
593
|
+
// send_message: deliver one message to another agent's inbox. Available to the
|
|
594
|
+
// Lead and to teammates. Pure coordination — it never blocks on a reply.
|
|
595
|
+
async function runSendMessage(call, opts) {
|
|
596
|
+
let to = "", content = "";
|
|
597
|
+
try {
|
|
598
|
+
const a = JSON.parse(call.args);
|
|
599
|
+
to = (a.to ?? "").trim();
|
|
600
|
+
content = a.content ?? "";
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
/* fall through */
|
|
604
|
+
}
|
|
605
|
+
if (!to || !content.trim())
|
|
606
|
+
return "[error] send_message needs a non-empty 'to' and 'content'.";
|
|
607
|
+
const from = senderName(opts);
|
|
608
|
+
if (to === from)
|
|
609
|
+
return "[error] You cannot message yourself.";
|
|
610
|
+
// A teammate can only reach the lead or another teammate; the lead can reach
|
|
611
|
+
// any teammate. We don't hard-check the recipient exists (it may be spawning) —
|
|
612
|
+
// an undeliverable message just sits in a file no one reads, which is harmless.
|
|
613
|
+
sendMessage(from, to, content);
|
|
614
|
+
return `Message delivered to ${to}'s inbox.`;
|
|
615
|
+
}
|
|
616
|
+
// request_shutdown (lead → teammate): open a shutdown contract. We stamp the
|
|
617
|
+
// teammate with the request_id (it replies with that id and exits) AND send the
|
|
618
|
+
// request message so it shows up in the teammate's inbox and conversation.
|
|
619
|
+
async function runRequestShutdown(call, opts) {
|
|
620
|
+
if (opts.teammate || opts.subAgent)
|
|
621
|
+
return "[error] Only the lead can request a shutdown.";
|
|
622
|
+
let teammate = "", reason = "";
|
|
623
|
+
try {
|
|
624
|
+
const a = JSON.parse(call.args);
|
|
625
|
+
teammate = (a.teammate ?? "").trim();
|
|
626
|
+
reason = a.reason ?? "";
|
|
627
|
+
}
|
|
628
|
+
catch { /* fall through */ }
|
|
629
|
+
if (!teammate)
|
|
630
|
+
return "[error] request_shutdown needs a 'teammate' name.";
|
|
631
|
+
if (!teammateExists(teammate))
|
|
632
|
+
return `[error] No teammate named "${teammate}".`;
|
|
633
|
+
if (shutdownRequestId(teammate))
|
|
634
|
+
return `Shutdown already requested for "${teammate}" — waiting for it to confirm and exit.`;
|
|
635
|
+
const requestId = createRequest("shutdown", LEAD, teammate, reason || "task complete");
|
|
636
|
+
markShutdown(teammate, requestId);
|
|
637
|
+
sendProtocol(LEAD, teammate, "shutdown_request", requestId, reason || "Your part is done — please wrap up and exit.");
|
|
638
|
+
return `Requested shutdown of "${teammate}" (${requestId}). It will finish up, confirm, and exit; you'll get the confirmation in your inbox.`;
|
|
639
|
+
}
|
|
640
|
+
// request_plan (lead → teammate): ask the teammate to plan-before-acting. This
|
|
641
|
+
// is a directive message; the teammate answers it by calling submit_plan, which
|
|
642
|
+
// opens the actual plan_approval contract.
|
|
643
|
+
async function runRequestPlan(call, opts) {
|
|
644
|
+
if (opts.teammate || opts.subAgent)
|
|
645
|
+
return "[error] Only the lead can request a plan.";
|
|
646
|
+
let teammate = "", task = "";
|
|
647
|
+
try {
|
|
648
|
+
const a = JSON.parse(call.args);
|
|
649
|
+
teammate = (a.teammate ?? "").trim();
|
|
650
|
+
task = a.task ?? "";
|
|
651
|
+
}
|
|
652
|
+
catch { /* fall through */ }
|
|
653
|
+
if (!teammate || !task.trim())
|
|
654
|
+
return "[error] request_plan needs a 'teammate' and a 'task'.";
|
|
655
|
+
if (!teammateExists(teammate))
|
|
656
|
+
return `[error] No teammate named "${teammate}".`;
|
|
657
|
+
sendMessage(LEAD, teammate, `Before doing this, call submit_plan with your plan and WAIT for my approval — do not start until approved:\n${task}`);
|
|
658
|
+
return `Asked "${teammate}" to submit a plan before doing: ${task.slice(0, 80)}. Approve/reject it with review_plan when it arrives.`;
|
|
659
|
+
}
|
|
660
|
+
// review_plan (lead): the answer half of the plan_approval contract. Correlate
|
|
661
|
+
// to the request via match_response (resolveResponse), then send the decision
|
|
662
|
+
// back to the teammate that submitted it.
|
|
663
|
+
async function runReviewPlan(call, opts) {
|
|
664
|
+
if (opts.teammate || opts.subAgent)
|
|
665
|
+
return "[error] Only the lead can review a plan.";
|
|
666
|
+
let requestId = "", decision = "", reason = "";
|
|
667
|
+
try {
|
|
668
|
+
const a = JSON.parse(call.args);
|
|
669
|
+
requestId = (a.request_id ?? "").trim();
|
|
670
|
+
decision = (a.decision ?? "").trim();
|
|
671
|
+
reason = a.reason ?? "";
|
|
672
|
+
}
|
|
673
|
+
catch { /* fall through */ }
|
|
674
|
+
if (!requestId || (decision !== "approve" && decision !== "reject"))
|
|
675
|
+
return '[error] review_plan needs a request_id and decision "approve" or "reject".';
|
|
676
|
+
const approved = decision === "approve";
|
|
677
|
+
const res = resolveResponse(requestId, "plan_approval", approved); // match_response: validates kind + state
|
|
678
|
+
if (!res.ok)
|
|
679
|
+
return `[error] ${res.error}`;
|
|
680
|
+
sendProtocol(LEAD, res.state.from, "plan_approval_response", requestId, reason || (approved ? "approved" : "rejected"), approved ? "approved" : "rejected");
|
|
681
|
+
return `Plan ${approved ? "approved" : "rejected"} (${requestId}); told ${res.state.from}.`;
|
|
682
|
+
}
|
|
683
|
+
// submit_plan (teammate → lead): open a plan_approval contract and STOP. The
|
|
684
|
+
// teammate idles after this until the lead's decision lands in its inbox.
|
|
685
|
+
async function runSubmitPlan(call, opts) {
|
|
686
|
+
if (!opts.teammate)
|
|
687
|
+
return "[error] Only a teammate can submit a plan (the lead reviews them).";
|
|
688
|
+
let plan = "";
|
|
689
|
+
try {
|
|
690
|
+
plan = JSON.parse(call.args).plan ?? "";
|
|
691
|
+
}
|
|
692
|
+
catch { /* fall through */ }
|
|
693
|
+
if (!plan.trim())
|
|
694
|
+
return "[error] submit_plan needs a non-empty 'plan'.";
|
|
695
|
+
const requestId = createRequest("plan_approval", opts.teammate.name, LEAD, plan);
|
|
696
|
+
sendProtocol(opts.teammate.name, LEAD, "plan_approval_request", requestId, plan);
|
|
697
|
+
return `Plan submitted to the lead for approval (${requestId}). STOP here and wait — the lead's decision will arrive in your inbox. Do NOT start the work until it is approved.`;
|
|
698
|
+
}
|
|
699
|
+
// ---- The task board tools (Day 40) ------------------------------------------
|
|
700
|
+
// The claimer's identity is the calling agent's name — the lead or a teammate.
|
|
701
|
+
const boardActor = (opts) => opts.teammate?.name ?? LEAD;
|
|
702
|
+
async function runCreateTask(call, opts) {
|
|
703
|
+
if (opts.subAgent && !opts.teammate)
|
|
704
|
+
return "[error] Only team agents use the task board."; // plain sub-agents have no board
|
|
705
|
+
let subject = "", description = "", blockedBy = [];
|
|
706
|
+
try {
|
|
707
|
+
const a = JSON.parse(call.args);
|
|
708
|
+
subject = (a.subject ?? "").trim();
|
|
709
|
+
description = a.description ?? "";
|
|
710
|
+
if (Array.isArray(a.blockedBy))
|
|
711
|
+
blockedBy = a.blockedBy.filter((x) => typeof x === "string");
|
|
712
|
+
}
|
|
713
|
+
catch { /* fall through */ }
|
|
714
|
+
if (!subject)
|
|
715
|
+
return "[error] create_task needs a 'subject'.";
|
|
716
|
+
const task = createTask(subject, description, blockedBy);
|
|
717
|
+
if (!opts.quiet)
|
|
718
|
+
console.log(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
|
|
719
|
+
return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
|
|
720
|
+
}
|
|
721
|
+
async function runListTasks(_call, opts) {
|
|
722
|
+
if (opts.subAgent && !opts.teammate)
|
|
723
|
+
return "[error] Only team agents use the task board.";
|
|
724
|
+
return `Task board:\n${boardSummary()}`;
|
|
725
|
+
}
|
|
726
|
+
async function runClaimTask(call, opts) {
|
|
727
|
+
if (opts.subAgent && !opts.teammate)
|
|
728
|
+
return "[error] Only team agents use the task board.";
|
|
729
|
+
let taskId = "";
|
|
730
|
+
try {
|
|
731
|
+
taskId = JSON.parse(call.args).task_id?.trim() ?? "";
|
|
732
|
+
}
|
|
733
|
+
catch { /* fall through */ }
|
|
734
|
+
if (!taskId)
|
|
735
|
+
return "[error] claim_task needs a 'task_id'.";
|
|
736
|
+
const res = claimTask(taskId, boardActor(opts));
|
|
737
|
+
if (!res.ok)
|
|
738
|
+
return `[error] ${res.error} Pick another task or check list_tasks.`;
|
|
739
|
+
return `Claimed ${res.task.id}: "${res.task.subject}". It's yours and in_progress. Do it, then call complete_task("${res.task.id}").\n${res.task.description}`;
|
|
740
|
+
}
|
|
741
|
+
async function runCompleteTask(call, opts) {
|
|
742
|
+
if (opts.subAgent && !opts.teammate)
|
|
743
|
+
return "[error] Only team agents use the task board.";
|
|
744
|
+
let taskId = "";
|
|
745
|
+
try {
|
|
746
|
+
taskId = JSON.parse(call.args).task_id?.trim() ?? "";
|
|
747
|
+
}
|
|
748
|
+
catch { /* fall through */ }
|
|
749
|
+
if (!taskId)
|
|
750
|
+
return "[error] complete_task needs a 'task_id'.";
|
|
751
|
+
const res = completeTask(taskId, boardActor(opts));
|
|
752
|
+
if (!res.ok)
|
|
753
|
+
return `[error] ${res.error}`;
|
|
754
|
+
if (!opts.quiet)
|
|
755
|
+
console.log(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
|
|
756
|
+
const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
|
|
757
|
+
return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
|
|
758
|
+
}
|
|
759
|
+
// spawn_teammate: start a persistent worker that runs concurrently. Top-level
|
|
760
|
+
// only — a teammate calling this is the nested-spawn case (its manual omits the
|
|
761
|
+
// tool, but we guard anyway). Returns IMMEDIATELY; the teammate's loop runs in
|
|
762
|
+
// the background and reports back through the lead's inbox.
|
|
763
|
+
async function runSpawnTeammate(call, opts) {
|
|
764
|
+
if (opts.subAgent || opts.teammate)
|
|
765
|
+
return "[error] Only the lead can spawn teammates — teammates cannot spawn teammates. Do the work yourself or message the lead.";
|
|
766
|
+
let name = "", role = "", task = "";
|
|
767
|
+
try {
|
|
768
|
+
const a = JSON.parse(call.args);
|
|
769
|
+
name = (a.name ?? "").trim();
|
|
770
|
+
role = (a.role ?? "").trim();
|
|
771
|
+
task = a.task ?? "";
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
/* fall through */
|
|
775
|
+
}
|
|
776
|
+
if (!name || !role || !task.trim())
|
|
777
|
+
return "[error] spawn_teammate needs a non-empty name, role, and task.";
|
|
778
|
+
if (name === LEAD)
|
|
779
|
+
return `[error] "${LEAD}" is reserved for the coordinator — pick another name.`;
|
|
780
|
+
if (teammateExists(name))
|
|
781
|
+
return `[error] A teammate named "${name}" already exists — pick a unique name or message the existing one.`;
|
|
782
|
+
if (teammateCount() >= MAX_TEAMMATES)
|
|
783
|
+
return `[error] The team is full (${MAX_TEAMMATES} teammates max). Wait for one to finish, or do the work yourself.`;
|
|
784
|
+
// Kick off the teammate's loop WITHOUT awaiting it — that is what makes it
|
|
785
|
+
// concurrent. It progresses whenever the event loop is free (e.g. while the
|
|
786
|
+
// lead awaits its own API stream). We keep the promise so the lead and
|
|
787
|
+
// shutdown know when it has ended.
|
|
788
|
+
const done = runTeammate(name, role, task, opts);
|
|
789
|
+
registerTeammate(name, role, done);
|
|
790
|
+
if (!opts.quiet)
|
|
791
|
+
console.log(mark.teammateStart(name, role));
|
|
792
|
+
return `Teammate "${name}" (${role}) spawned and is now working in parallel. It will message you (as "lead") with progress and its final result. Reply with send_message; keep coordinating the rest of the team. Do not wait idly — continue your own work.`;
|
|
793
|
+
}
|
|
794
|
+
// Run one teammate to completion: a fresh conversation seeded with its task, a
|
|
795
|
+
// focused toolset, its own mailbox, and a hard round cap. On any ending it sends
|
|
796
|
+
// a `result` message back to the lead — so the lead always learns the outcome,
|
|
797
|
+
// success or failure. The teammate's file read-state is isolated by snapshot,
|
|
798
|
+
// exactly like a sub-agent (it reads things the lead's conversation never saw).
|
|
799
|
+
async function runTeammate(name, role, task, opts) {
|
|
800
|
+
emit("agent_teammate_spawn");
|
|
801
|
+
await runHooks("SubagentStart", { description: `teammate ${name}: ${role}`.slice(0, 200), model: opts.model });
|
|
802
|
+
const snapshot = snapshotFileState();
|
|
803
|
+
const system = TEAMMATE_PROMPT.replace("{name}", name).replace("{role}", role);
|
|
804
|
+
try {
|
|
805
|
+
const messages = [
|
|
806
|
+
{ role: "system", content: system },
|
|
807
|
+
{ role: "user", content: task }, // its first instruction; more arrive via the inbox
|
|
808
|
+
];
|
|
809
|
+
// A teammate is a sub-agent (quiet streaming, no hooks, isolated) PLUS team
|
|
810
|
+
// wiring: a name, its inbox, a non-interactive permission policy (it cannot
|
|
811
|
+
// prompt the human, so writes auto-proceed but risky bash is declined — see
|
|
812
|
+
// teammateConfirm; deny ALWAYS still wins), and a runaway backstop. Day 39:
|
|
813
|
+
// it is no longer bounded by a low round cap — it idle-waits for work and
|
|
814
|
+
// exits on the shutdown handshake (continueForTeam); maxRounds is only a
|
|
815
|
+
// generous guard against an active loop that never stops calling tools.
|
|
816
|
+
const result = await runLoop(messages, {
|
|
817
|
+
...opts,
|
|
818
|
+
subAgent: true,
|
|
819
|
+
teammate: { name },
|
|
820
|
+
maxRounds: TEAMMATE_MAX_ROUNDS,
|
|
821
|
+
confirm: teammateConfirm(name),
|
|
822
|
+
askUser: undefined, // teammates have no line to the human
|
|
823
|
+
});
|
|
824
|
+
// How it ended decides what the lead hears. A shutdown handshake gets a
|
|
825
|
+
// shutdown_response(approved) carrying the request_id; anything else is a
|
|
826
|
+
// plain result. Either way the lead always learns the outcome.
|
|
827
|
+
const summary = result.reason === TerminateReason.Done && result.finalText?.trim() ? result.finalText.trim() : `[ended: ${result.reason}]`;
|
|
828
|
+
const sd = shutdownRequestId(name);
|
|
829
|
+
if (sd)
|
|
830
|
+
sendProtocol(name, LEAD, "shutdown_response", sd, summary || "shut down", "approved");
|
|
831
|
+
else
|
|
832
|
+
sendMessage(name, LEAD, summary, "result");
|
|
833
|
+
finishTeammate(name, result.reason === TerminateReason.Done);
|
|
834
|
+
if (!opts.quiet)
|
|
835
|
+
console.log(mark.teammateDone(name, result.reason === TerminateReason.Done));
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
// A teammate must never take the whole process down. Report the failure to
|
|
839
|
+
// the lead and mark it failed.
|
|
840
|
+
sendMessage(name, LEAD, `[teammate crashed: ${err.message}]`, "result");
|
|
841
|
+
finishTeammate(name, false);
|
|
842
|
+
}
|
|
843
|
+
finally {
|
|
844
|
+
restoreFileState(snapshot);
|
|
845
|
+
await runHooks("SubagentStop", { description: `teammate ${name}` });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const TEAMMATE_MAX_ROUNDS = 30; // runaway backstop for an active teammate (not the lifecycle — idle-wait + shutdown handshake end it normally)
|
|
849
|
+
const TEAM_POLL_MS = 250; // how often an idle agent polls its inbox while waiting
|
|
850
|
+
const TEAMMATE_MAX_IDLE_MS = 60_000; // an abandoned teammate self-exits after this long with nothing to do (the lead normally shuts it down first)
|
|
851
|
+
const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to exit cleanly when it disbands the team
|
|
852
|
+
// A teammate's permission policy. It cannot stop and ask the human, so: file
|
|
853
|
+
// writes/edits auto-proceed (that is the teammate's job, and they are reversible
|
|
854
|
+
// via /undo and reviewable via /diff), but anything else the gate rated "ask"
|
|
855
|
+
// (unrecognized or dangerous bash) is DECLINED rather than run unattended. Hard
|
|
856
|
+
// DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
|
|
857
|
+
// called, so they always hold. Production Claude Code instead "bubbles" the
|
|
858
|
+
// request up to the lead and on to the human — omitted here, like the reference.
|
|
859
|
+
function teammateConfirm(name) {
|
|
860
|
+
return async (_question, toolName) => {
|
|
861
|
+
const ok = toolName === "write_file" || toolName === "edit_file";
|
|
862
|
+
if (!ok)
|
|
863
|
+
console.log(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
|
|
864
|
+
return ok;
|
|
865
|
+
};
|
|
866
|
+
}
|
|
367
867
|
// Present the ask_user form and turn the selections into a tool result. The
|
|
368
868
|
// model called this to get a decision; we give it clean question→answer pairs.
|
|
369
869
|
async function runAskUser(call, opts) {
|
|
@@ -410,6 +910,152 @@ compaction) {
|
|
|
410
910
|
return false;
|
|
411
911
|
}
|
|
412
912
|
}
|
|
913
|
+
// Surface any FINISHED background tasks (Day 37) as a user turn, so the model
|
|
914
|
+
// sees the result of work it kicked off earlier. Mirrors the tutorial reference,
|
|
915
|
+
// adapted to the OpenAI wire protocol: tool results are their own role:"tool"
|
|
916
|
+
// messages, so a notification can't ride inside them — it follows as a user
|
|
917
|
+
// message instead (exactly how the todo nag and Stop hook already inject turns).
|
|
918
|
+
// Top-level only: a sub-agent has no separate turn loop to inject into, and the
|
|
919
|
+
// notification belongs to the agent that started the job. Returns true if it
|
|
920
|
+
// injected something. Each finished task notifies exactly once (background.ts).
|
|
921
|
+
function injectBackgroundNotifications(messages, opts) {
|
|
922
|
+
if (opts.subAgent)
|
|
923
|
+
return false; // notifications belong to the top-level conversation
|
|
924
|
+
const note = pendingNotifications(); // "" unless a task finished since we last checked
|
|
925
|
+
if (!note)
|
|
926
|
+
return false;
|
|
927
|
+
const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
|
|
928
|
+
messages.push({ role: "user", content: note }); // the model reacts to it on the next round
|
|
929
|
+
if (!opts.quiet)
|
|
930
|
+
console.log(mark.bgNote(count));
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
// Consume this agent's mailbox into its conversation, ROUTING protocol messages
|
|
934
|
+
// (Day 39) as it goes — the reference's consume_lead_inbox + dispatch_message in
|
|
935
|
+
// one pass. The lead reads "lead"; a teammate reads its own name. The read is
|
|
936
|
+
// consumptive (team.ts), so each message is handled exactly once. Protocol
|
|
937
|
+
// messages update state (correlate responses via match_response, flag a shutdown)
|
|
938
|
+
// AND get a plain-language line so the model can react; plain chat is passed
|
|
939
|
+
// through. Returns whether anything was injected. (Plan-approval is a handshake,
|
|
940
|
+
// not a hard gate: the teammate is TOLD to wait and does — execution gating by
|
|
941
|
+
// permission mode is what production Claude Code adds; omitted here, per s16.)
|
|
942
|
+
function consumeInbox(messages, opts) {
|
|
943
|
+
if (opts.subAgent && !opts.teammate)
|
|
944
|
+
return false; // a plain sub-agent is not on the team
|
|
945
|
+
const me = opts.teammate?.name ?? LEAD;
|
|
946
|
+
const msgs = readInbox(me);
|
|
947
|
+
if (!msgs.length)
|
|
948
|
+
return false;
|
|
949
|
+
const lines = [];
|
|
950
|
+
for (const m of msgs) {
|
|
951
|
+
switch (m.kind) {
|
|
952
|
+
case "shutdown_request": // (teammate side) the lead asked us to shut down
|
|
953
|
+
markShutdown(me, m.requestId ?? "");
|
|
954
|
+
lines.push(`[lead] SHUTDOWN REQUESTED (${m.requestId}): ${m.content}. Finish any in-progress write, then you will exit cleanly.`);
|
|
955
|
+
break;
|
|
956
|
+
case "shutdown_response": // (lead side) a teammate confirmed it shut down
|
|
957
|
+
if (m.requestId)
|
|
958
|
+
resolveResponse(m.requestId, "shutdown", m.status === "approved");
|
|
959
|
+
finishTeammate(m.from, true);
|
|
960
|
+
lines.push(`[${m.from}] confirmed shutdown (${m.requestId}) and has exited.`);
|
|
961
|
+
break;
|
|
962
|
+
case "plan_approval_request": // (lead side) a teammate wants approval before risky work
|
|
963
|
+
lines.push(`[${m.from}] PLAN APPROVAL REQUESTED (${m.requestId}). Review it, then call review_plan with this id. Plan:\n${m.content}`);
|
|
964
|
+
break;
|
|
965
|
+
case "plan_approval_response": { // (teammate side) the lead decided on our plan
|
|
966
|
+
if (m.requestId)
|
|
967
|
+
resolveResponse(m.requestId, "plan_approval", m.status === "approved");
|
|
968
|
+
const ok = m.status === "approved";
|
|
969
|
+
lines.push(`[lead] plan ${ok ? "APPROVED" : "REJECTED"} (${m.requestId})${m.content ? `: ${m.content}` : ""}. ${ok ? "Proceed with the plan now." : "Revise it and submit_plan again."}`);
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
default: // plain chat / a teammate's result
|
|
973
|
+
lines.push(`[message from ${m.from}${m.type === "result" ? " · RESULT" : ""}] ${m.content}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
|
|
977
|
+
messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
|
|
978
|
+
if (!opts.quiet)
|
|
979
|
+
console.log(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
// At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
|
|
983
|
+
// conversation alive (Day 38/39).
|
|
984
|
+
// - A teammate now IDLE-LOOPS instead of exiting: it processes its inbox, and
|
|
985
|
+
// if there's nothing to do it waits (yielding the event loop) for new work —
|
|
986
|
+
// bounded, so an abandoned teammate self-exits instead of hanging forever. It
|
|
987
|
+
// leaves only on the shutdown handshake (its request_id is set) or that
|
|
988
|
+
// backstop. While waiting it marks itself "idle" so the lead can tell the
|
|
989
|
+
// team is quiescent.
|
|
990
|
+
// - The lead waits while any teammate is actively working, then drains its
|
|
991
|
+
// inbox. When the team has gone quiet and nothing is waiting, it returns
|
|
992
|
+
// false → the lead finishes (and disbands the team, see runLoop).
|
|
993
|
+
async function continueForTeam(messages, opts) {
|
|
994
|
+
if (opts.teammate) {
|
|
995
|
+
const me = opts.teammate.name;
|
|
996
|
+
let waited = 0;
|
|
997
|
+
while (true) {
|
|
998
|
+
const injected = consumeInbox(messages, opts); // INBOX FIRST (priority) — route protocol + plain; may set the shutdown flag
|
|
999
|
+
if (shutdownRequestId(me))
|
|
1000
|
+
return false; // shutdown handshake → exit (runTeammate sends the response)
|
|
1001
|
+
if (injected) {
|
|
1002
|
+
setTeammateState(me, "active");
|
|
1003
|
+
return true;
|
|
1004
|
+
} // a direct message arrived → run a round
|
|
1005
|
+
// s17 autonomy: nothing in the inbox — pull ready work off the shared board
|
|
1006
|
+
// yourself. claimNextAvailable atomically claims the next pending+unblocked
|
|
1007
|
+
// task; the claim is what makes this safe with several teammates scanning.
|
|
1008
|
+
const claimed = claimNextAvailable(me);
|
|
1009
|
+
if (claimed) {
|
|
1010
|
+
// The claimed task becomes a fresh WORK instruction. The leading identity
|
|
1011
|
+
// line also re-anchors the persona if the history was compacted (the
|
|
1012
|
+
// reference's identity-reinjection, folded into the work hand-off).
|
|
1013
|
+
messages.push({ role: "user", content: `You are ${me}. You claimed task ${claimed.id} from the board: "${claimed.subject}".\n${claimed.description}\nWhen it is done and verified, call complete_task("${claimed.id}") — then you'll be offered the next ready task.` });
|
|
1014
|
+
if (!opts.quiet)
|
|
1015
|
+
console.log(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
|
|
1016
|
+
setTeammateState(me, "active");
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
setTeammateState(me, "idle"); // inbox empty AND no claimable work — wait
|
|
1020
|
+
if (opts.isInterrupted() || waited >= TEAMMATE_MAX_IDLE_MS)
|
|
1021
|
+
return false; // abandoned → self-exit backstop
|
|
1022
|
+
await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
|
|
1023
|
+
waited += TEAM_POLL_MS;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (opts.subAgent)
|
|
1027
|
+
return false; // a plain sub-agent has no team
|
|
1028
|
+
// The lead: wait for the team to act rather than spinning or ending early.
|
|
1029
|
+
while (inboxCount(LEAD) === 0 && anyTeammateBusy() && !opts.isInterrupted()) {
|
|
1030
|
+
await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted); // yield to the teammates; they message us when they have something
|
|
1031
|
+
}
|
|
1032
|
+
return consumeInbox(messages, opts); // process whatever is waiting; false if the team is quiescent (→ the lead may finish)
|
|
1033
|
+
}
|
|
1034
|
+
// The lead finished its work but teammates may still be idling. Disband the team
|
|
1035
|
+
// GRACEFULLY (Day 39): open a shutdown contract with each, let them confirm and
|
|
1036
|
+
// exit (bounded by a grace window so a wedged teammate can't hang the prompt),
|
|
1037
|
+
// then drain the lead's inbox of their confirmations and clear the team.
|
|
1038
|
+
async function shutdownTeam(opts) {
|
|
1039
|
+
const running = runningTeammates();
|
|
1040
|
+
if (!running.length)
|
|
1041
|
+
return;
|
|
1042
|
+
if (!opts.quiet)
|
|
1043
|
+
console.log(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
|
|
1044
|
+
for (const name of running) {
|
|
1045
|
+
const id = createRequest("shutdown", LEAD, name, "task complete");
|
|
1046
|
+
markShutdown(name, id);
|
|
1047
|
+
sendProtocol(LEAD, name, "shutdown_request", id, "The task is complete — wrap up and exit.");
|
|
1048
|
+
}
|
|
1049
|
+
// Yield the event loop so the teammates wake, confirm, and exit — but never
|
|
1050
|
+
// block the human forever on a stuck one.
|
|
1051
|
+
const start = Date.now();
|
|
1052
|
+
while (runningTeammates().length && Date.now() - start < SHUTDOWN_GRACE_MS && !opts.isInterrupted()) {
|
|
1053
|
+
await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted);
|
|
1054
|
+
}
|
|
1055
|
+
readInbox(LEAD); // drop the shutdown confirmations the lead won't read
|
|
1056
|
+
resetTeam(); // clear the registry + protocol state; the next task starts with a fresh team
|
|
1057
|
+
resetBoard(); // and a fresh task board — its work is done with this team
|
|
1058
|
+
}
|
|
413
1059
|
// The agent main loop, as a state machine: every iteration either continues
|
|
414
1060
|
// for a named reason or terminates for a named reason — nothing implicit.
|
|
415
1061
|
export async function runLoop(messages, // conversation history (mutated in place)
|
|
@@ -417,7 +1063,14 @@ opts) {
|
|
|
417
1063
|
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
418
1064
|
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
419
1065
|
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
1066
|
+
let lastText = null; // most recent assistant text — what we return if a round cap (teammates) stops us mid-flight
|
|
420
1067
|
for (let round = 1;; round++) {
|
|
1068
|
+
// A bounded loop (teammates, Day 38) stops cleanly at its round cap with
|
|
1069
|
+
// whatever it last said, so a team can never run away. Unset maxRounds keeps
|
|
1070
|
+
// the usual unbounded-with-safeguards loop for the lead and the user's agent.
|
|
1071
|
+
if (opts.maxRounds && round > opts.maxRounds) {
|
|
1072
|
+
return { reason: TerminateReason.Done, finalText: lastText ?? `(reached the ${opts.maxRounds}-round limit)` };
|
|
1073
|
+
}
|
|
421
1074
|
// One round = one successful model call + its tool results.
|
|
422
1075
|
// The inner loop retries the model call until it succeeds or a budget dies.
|
|
423
1076
|
while (true) {
|
|
@@ -492,6 +1145,8 @@ opts) {
|
|
|
492
1145
|
? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
|
|
493
1146
|
: {}), // omit the field entirely when there were no calls
|
|
494
1147
|
});
|
|
1148
|
+
if (out.content)
|
|
1149
|
+
lastText = out.content; // remember the latest answer text — the round-cap path returns it
|
|
495
1150
|
if (!out.toolCalls.length) {
|
|
496
1151
|
// The model wants to stop. A Stop hook gets the last word: if it exits
|
|
497
1152
|
// 2, the agent is NOT done — its stderr becomes a new instruction and
|
|
@@ -508,6 +1163,22 @@ opts) {
|
|
|
508
1163
|
break; // exit the inner while → advance the round counter
|
|
509
1164
|
}
|
|
510
1165
|
}
|
|
1166
|
+
// A background task may have finished in the very round the model decided
|
|
1167
|
+
// to stop. Don't return with an unread result on the table: surface it and
|
|
1168
|
+
// run one more round so the model can react. Each task notifies once, so
|
|
1169
|
+
// this adds at most one round per finished task — never an infinite stop.
|
|
1170
|
+
if (injectBackgroundNotifications(messages, opts))
|
|
1171
|
+
break; // exit the inner while → advance the round counter
|
|
1172
|
+
// Agent teams (Day 38/39): the lead waits for teammate messages and
|
|
1173
|
+
// reacts; a teammate idle-loops for new work and leaves only on the
|
|
1174
|
+
// shutdown handshake (or its idle backstop). If there's something to
|
|
1175
|
+
// process, run another round instead of stopping.
|
|
1176
|
+
if (await continueForTeam(messages, opts))
|
|
1177
|
+
break; // exit the inner while → advance the round counter
|
|
1178
|
+
// The lead is really finishing: disband any teammates still idling so
|
|
1179
|
+
// they exit cleanly (Day 39) instead of being orphaned.
|
|
1180
|
+
if (!opts.subAgent && !opts.teammate)
|
|
1181
|
+
await shutdownTeam(opts);
|
|
511
1182
|
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
512
1183
|
}
|
|
513
1184
|
// Execute the tool calls. Read-only calls (read_file/search) in a
|
|
@@ -532,6 +1203,27 @@ opts) {
|
|
|
532
1203
|
const r = await runOneCall(out.toolCalls[i++], opts);
|
|
533
1204
|
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
534
1205
|
}
|
|
1206
|
+
// Background tasks (Day 37): if a job the model started has finished, slip
|
|
1207
|
+
// its <task_notification> in here, right after this round's tool results —
|
|
1208
|
+
// the model sees the outcome on the next round without ever having blocked
|
|
1209
|
+
// on it. Tasks that are still running stay silent until they finish.
|
|
1210
|
+
injectBackgroundNotifications(messages, opts);
|
|
1211
|
+
// Agent teams (Day 38/39): drain any messages that arrived during this
|
|
1212
|
+
// round (results, hand-offs, protocol requests/responses) so the agent
|
|
1213
|
+
// reacts to them next round. A shutdown_request seen here just flags the
|
|
1214
|
+
// teammate; it acts on it at the next stop-point (continueForTeam).
|
|
1215
|
+
consumeInbox(messages, opts);
|
|
1216
|
+
// The nag: if there's an unfinished plan the model has stopped touching for
|
|
1217
|
+
// a few rounds, slip in a reminder so the list doesn't go stale and
|
|
1218
|
+
// meaningless. Top-level only — a sub-agent has no plan of its own.
|
|
1219
|
+
if (!opts.subAgent) {
|
|
1220
|
+
const nag = todoNag();
|
|
1221
|
+
if (nag) {
|
|
1222
|
+
messages.push({ role: "user", content: nag });
|
|
1223
|
+
if (!opts.quiet)
|
|
1224
|
+
console.log(chalk.dim(" ⎿ plan reminder injected"));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
535
1227
|
break; // round complete, move to the next one
|
|
536
1228
|
}
|
|
537
1229
|
}
|