agent-from-zero 0.5.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 +44 -18
- package/dist/agent.js +446 -23
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/editor.js +307 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +862 -22
- package/dist/markdown.js +152 -0
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +124 -4
- package/dist/prompt.js +32 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +133 -5
- package/dist/tui.js +67 -0
- package/dist/ui.js +138 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +7 -3
package/dist/loop.js
CHANGED
|
@@ -3,11 +3,19 @@ import ora from "ora"; // the "thinking..." spinner while waiting for the first
|
|
|
3
3
|
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
4
4
|
import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
5
5
|
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
6
|
+
import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
|
|
6
7
|
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
7
|
-
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
|
|
8
11
|
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
9
12
|
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
10
|
-
|
|
13
|
+
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
14
|
+
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
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
|
|
11
19
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
12
20
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
13
21
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -20,7 +28,6 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
|
|
|
20
28
|
export var TerminateReason;
|
|
21
29
|
(function (TerminateReason) {
|
|
22
30
|
TerminateReason["Done"] = "done";
|
|
23
|
-
TerminateReason["RoundCap"] = "round_cap";
|
|
24
31
|
TerminateReason["CircuitBreaker"] = "circuit_breaker";
|
|
25
32
|
TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
|
|
26
33
|
TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
|
|
@@ -29,6 +36,12 @@ export var TerminateReason;
|
|
|
29
36
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
30
37
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
31
38
|
})(TerminateReason || (TerminateReason = {}));
|
|
39
|
+
// Which model should a delegated sub-agent run on? The configured sub-agent
|
|
40
|
+
// model if set and non-blank, otherwise the same model as the orchestrator.
|
|
41
|
+
// Pulled out as a pure function so the tiering rule is testable on its own.
|
|
42
|
+
export function subAgentModelFor(opts) {
|
|
43
|
+
return opts.subAgentModel?.trim() || opts.model;
|
|
44
|
+
}
|
|
32
45
|
// The task tool: the parent's handle on sub-agents. Defined here (not in
|
|
33
46
|
// tools.ts) because running it needs the loop itself — it IS a loop.
|
|
34
47
|
const taskTool = {
|
|
@@ -49,19 +62,234 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
|
|
|
49
62
|
},
|
|
50
63
|
},
|
|
51
64
|
};
|
|
65
|
+
// The ask_user tool: the model's handle on the user. When it needs the human to
|
|
66
|
+
// choose between options, it calls this instead of asking in prose — and gets
|
|
67
|
+
// back clean, structured selections rather than a free-text paragraph to parse.
|
|
68
|
+
const askUserTool = {
|
|
69
|
+
type: "function",
|
|
70
|
+
function: {
|
|
71
|
+
name: "ask_user",
|
|
72
|
+
description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
|
|
73
|
+
Provide each question with 2–4 concrete options. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
|
|
74
|
+
If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
|
|
75
|
+
parameters: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
questions: {
|
|
79
|
+
type: "array",
|
|
80
|
+
description: "The questions to ask, each with a list of options",
|
|
81
|
+
items: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
question: { type: "string", description: "The question text" },
|
|
85
|
+
options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
|
|
86
|
+
},
|
|
87
|
+
required: ["question", "options"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
required: ["questions"],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
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
|
+
}
|
|
52
276
|
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
53
277
|
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
54
278
|
async function runSubAgent(description, opts) {
|
|
55
279
|
emit("agent_subagent_spawn"); // delegation is worth counting
|
|
280
|
+
const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
|
|
281
|
+
// Show the delegation, and the model when it differs from the orchestrator's —
|
|
282
|
+
// the tiering should be visible, not a silent surprise on the bill.
|
|
56
283
|
if (!opts.quiet)
|
|
57
|
-
console.log(
|
|
284
|
+
console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
|
|
285
|
+
await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
|
|
58
286
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
59
287
|
try {
|
|
60
288
|
const subMessages = [
|
|
61
289
|
{ role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
|
|
62
290
|
{ role: "user", content: description }, // the task is its entire world
|
|
63
291
|
];
|
|
64
|
-
const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse
|
|
292
|
+
const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
|
|
65
293
|
if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
|
|
66
294
|
// The framing matters: the parent must treat this as material to verify,
|
|
67
295
|
// not as conclusions to copy — the most common multi-agent failure mode.
|
|
@@ -71,8 +299,9 @@ async function runSubAgent(description, opts) {
|
|
|
71
299
|
}
|
|
72
300
|
finally {
|
|
73
301
|
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
302
|
+
await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
|
|
74
303
|
if (!opts.quiet)
|
|
75
|
-
console.log(
|
|
304
|
+
console.log(mark.subAgentDone); // close the bracket
|
|
76
305
|
}
|
|
77
306
|
}
|
|
78
307
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
@@ -89,21 +318,28 @@ async function interruptibleSleep(ms, isInterrupted) {
|
|
|
89
318
|
await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
|
|
90
319
|
}
|
|
91
320
|
}
|
|
321
|
+
let modelCallSeq = 0; // increments per model call — seeds the rotating spinner word
|
|
92
322
|
// One streaming model call: prints text as it arrives, assembles tool calls
|
|
93
323
|
// from their deltas, and guards the stream with a two-level watchdog.
|
|
94
324
|
async function streamModelCall(messages, // the full history to send
|
|
95
325
|
opts) {
|
|
96
326
|
const idleAbort = new AbortController(); // the watchdog's own kill switch
|
|
97
327
|
const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
|
|
328
|
+
const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
|
|
329
|
+
const startedAt = Date.now(); // for the spinner's live elapsed counter
|
|
330
|
+
let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
|
|
98
331
|
const spinner = opts.quiet
|
|
99
332
|
? null // the eval harness wants silence
|
|
100
|
-
: ora({ text: opts.subAgent
|
|
333
|
+
: ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
|
|
101
334
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
102
335
|
let stallWarned = false; // only warn once per quiet stretch
|
|
103
336
|
// The two-level watchdog, checked once per second:
|
|
104
337
|
// - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
|
|
105
338
|
// - stall (slow but alive for 30s) → log it and keep waiting
|
|
339
|
+
// It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
|
|
106
340
|
const watchdog = setInterval(() => {
|
|
341
|
+
if (spinner?.isSpinning)
|
|
342
|
+
spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
|
|
107
343
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
108
344
|
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
109
345
|
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
@@ -120,29 +356,62 @@ opts) {
|
|
|
120
356
|
const stream = await opts.client.chat.completions.create(
|
|
121
357
|
// Sub-agents do NOT get the task tool: one level of delegation only.
|
|
122
358
|
// Nested spawning means orphan processes and debugging hell.
|
|
123
|
-
|
|
359
|
+
// include_usage adds a final chunk carrying token counts — that is how we
|
|
360
|
+
// meter cost without a second (counting) request.
|
|
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 });
|
|
124
365
|
let content = ""; // accumulated answer text
|
|
125
|
-
let printedPrefix = false; // have we printed the
|
|
366
|
+
let printedPrefix = false; // have we printed the answer prefix yet?
|
|
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
|
|
126
369
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
127
370
|
for await (const chunk of stream) {
|
|
128
371
|
lastEvent = Date.now(); // feed the watchdog
|
|
129
372
|
stallWarned = false; // the stream spoke — reset the stall warning
|
|
373
|
+
if (chunk.usage)
|
|
374
|
+
recordUsage(chunk.usage); // the final usage chunk — meter it
|
|
130
375
|
const delta = chunk.choices[0]?.delta; // this chunk's increment
|
|
131
376
|
if (!delta)
|
|
132
377
|
continue; // keep-alive or usage chunk — nothing to do
|
|
378
|
+
// Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
|
|
379
|
+
// separate `reasoning_content` field BEFORE the answer. Show it dimly so a
|
|
380
|
+
// switch to a reasoning model is visibly different — but do NOT keep it:
|
|
381
|
+
// the reasoning is not the answer and must not be replayed in later turns.
|
|
382
|
+
const reasoning = delta.reasoning_content;
|
|
383
|
+
if (reasoning) {
|
|
384
|
+
streamedChars += reasoning.length; // count reasoning toward the live token estimate
|
|
385
|
+
if (spinner?.isSpinning)
|
|
386
|
+
spinner.stop();
|
|
387
|
+
if (!opts.quiet && !opts.subAgent) {
|
|
388
|
+
if (!printedThinking) {
|
|
389
|
+
process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
|
|
390
|
+
printedThinking = true;
|
|
391
|
+
}
|
|
392
|
+
process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
|
|
393
|
+
}
|
|
394
|
+
}
|
|
133
395
|
if (delta.content) {
|
|
396
|
+
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
134
397
|
if (spinner?.isSpinning)
|
|
135
398
|
spinner.stop(); // first token: replace the spinner with real output
|
|
136
399
|
if (!opts.quiet && !opts.subAgent) {
|
|
137
400
|
// Only the top-level agent streams to the screen — a sub-agent's
|
|
138
401
|
// inner monologue would be mistaken for the answer.
|
|
402
|
+
if (printedThinking && !printedPrefix)
|
|
403
|
+
process.stdout.write("\n"); // end the thinking block before the answer
|
|
139
404
|
if (!printedPrefix) {
|
|
140
|
-
|
|
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: " " });
|
|
141
410
|
printedPrefix = true;
|
|
142
411
|
}
|
|
143
|
-
|
|
412
|
+
md.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
144
413
|
}
|
|
145
|
-
content += delta.content; // always keep it for the history
|
|
414
|
+
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
146
415
|
}
|
|
147
416
|
for (const tc of delta.tool_calls ?? []) {
|
|
148
417
|
if (spinner?.isSpinning)
|
|
@@ -156,6 +425,8 @@ opts) {
|
|
|
156
425
|
slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
|
|
157
426
|
}
|
|
158
427
|
}
|
|
428
|
+
if (md)
|
|
429
|
+
md.end(); // flush the final (un-terminated) block through the renderer
|
|
159
430
|
if (printedPrefix)
|
|
160
431
|
process.stdout.write("\n"); // end the streamed line cleanly
|
|
161
432
|
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
@@ -171,25 +442,53 @@ opts) {
|
|
|
171
442
|
// calls (allow/deny only, never "ask") are safe to run via this from inside a
|
|
172
443
|
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
173
444
|
async function runOneCall(call, opts) {
|
|
174
|
-
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent
|
|
175
|
-
if (!opts.quiet)
|
|
176
|
-
|
|
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
|
|
446
|
+
if (!opts.quiet) {
|
|
447
|
+
// Compact one-line summary — long args are hidden behind Tab.
|
|
448
|
+
const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
|
|
449
|
+
const full = `tool: ${call.name}\nargs: ${call.args}`;
|
|
450
|
+
printToolSummary(summary, full);
|
|
451
|
+
}
|
|
177
452
|
// The permission gate sits between the model's intent and execution.
|
|
178
453
|
const v = checkPermission(call.name, call.args);
|
|
179
454
|
emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
|
|
455
|
+
// For file-changing tools, show the diff BEFORE the gate decides. A filename
|
|
456
|
+
// ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
|
|
457
|
+
// are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
|
|
458
|
+
if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
|
|
459
|
+
const preview = previewChange(call.name, call.args);
|
|
460
|
+
if (preview)
|
|
461
|
+
console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
|
|
462
|
+
}
|
|
180
463
|
let content; // what goes back to the model as the tool result
|
|
181
464
|
if (v.decision === "deny") {
|
|
182
465
|
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
183
466
|
if (!opts.quiet)
|
|
184
|
-
console.log(
|
|
467
|
+
console.log(mark.denied(v.reason)); // tell the user we blocked it
|
|
185
468
|
content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
|
|
186
469
|
}
|
|
187
470
|
else if (v.decision === "ask") {
|
|
188
|
-
|
|
471
|
+
// Optional LLM judge: for a run_bash command the rules couldn't classify,
|
|
472
|
+
// ask the judge first. It can only DOWNGRADE ask→allow for the clearly
|
|
473
|
+
// safe; anything else still goes to the human. The judge never sees a deny.
|
|
474
|
+
let autoAllowed = false;
|
|
475
|
+
if (opts.judge && call.name === "run_bash") {
|
|
476
|
+
let cmd = "";
|
|
477
|
+
try {
|
|
478
|
+
cmd = JSON.parse(call.args).command ?? "";
|
|
479
|
+
}
|
|
480
|
+
catch { /* leave empty → judge will ask */ }
|
|
481
|
+
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
482
|
+
autoAllowed = true;
|
|
483
|
+
if (!opts.quiet)
|
|
484
|
+
console.log(mark.judge); // visible — the user can see the judge worked
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
|
|
189
488
|
if (!ok)
|
|
190
489
|
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
191
490
|
if (!ok && !opts.quiet)
|
|
192
|
-
console.log(
|
|
491
|
+
console.log(mark.declined); // make the refusal visible
|
|
193
492
|
content = ok
|
|
194
493
|
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
195
494
|
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
@@ -197,6 +496,11 @@ async function runOneCall(call, opts) {
|
|
|
197
496
|
else {
|
|
198
497
|
content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
|
|
199
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
|
+
}
|
|
200
504
|
return { id: call.id, content }; // paired by id for the API
|
|
201
505
|
}
|
|
202
506
|
// Run an approved tool call, surrounded by the user's lifecycle hooks.
|
|
@@ -213,9 +517,23 @@ async function runWithHooks(call, opts) {
|
|
|
213
517
|
if (pre.block) {
|
|
214
518
|
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
215
519
|
if (!opts.quiet)
|
|
216
|
-
console.log(
|
|
520
|
+
console.log(mark.hookBlock("PreToolUse")); // make it visible
|
|
217
521
|
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
218
522
|
}
|
|
523
|
+
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
524
|
+
// permission gate already ran on the original args; the rewrite only narrows or
|
|
525
|
+
// annotates, never escalates past a deny. Validate it's parseable, then use it.
|
|
526
|
+
if (pre.rewrite) {
|
|
527
|
+
try {
|
|
528
|
+
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
529
|
+
if (!opts.quiet)
|
|
530
|
+
console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
|
|
531
|
+
call = { ...call, args: pre.rewrite };
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* malformed rewrite — ignore, run the original args */
|
|
535
|
+
}
|
|
536
|
+
}
|
|
219
537
|
const result = await execute(call, opts); // the actual work
|
|
220
538
|
const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
|
|
221
539
|
if (post.block) {
|
|
@@ -228,6 +546,33 @@ async function runWithHooks(call, opts) {
|
|
|
228
546
|
// Execute one approved tool call. The task tool is special — it is not in the
|
|
229
547
|
// registry because running it needs the loop itself (it IS a loop).
|
|
230
548
|
async function execute(call, opts) {
|
|
549
|
+
// ask_user is special like task: running it needs the CLI's prompt, not the
|
|
550
|
+
// tool registry. It presents a form and feeds the selections back to the model.
|
|
551
|
+
if (call.name === "ask_user")
|
|
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);
|
|
231
576
|
if (call.name !== "task")
|
|
232
577
|
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
233
578
|
if (opts.subAgent)
|
|
@@ -243,6 +588,305 @@ async function execute(call, opts) {
|
|
|
243
588
|
return "[error] task requires a non-empty description argument.";
|
|
244
589
|
return runSubAgent(description, opts); // spawn the worker
|
|
245
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
|
+
}
|
|
867
|
+
// Present the ask_user form and turn the selections into a tool result. The
|
|
868
|
+
// model called this to get a decision; we give it clean question→answer pairs.
|
|
869
|
+
async function runAskUser(call, opts) {
|
|
870
|
+
if (opts.subAgent)
|
|
871
|
+
return "[error] Sub-agents cannot ask the user. Decide yourself, or report back to the parent."; // only the top-level agent talks to the human
|
|
872
|
+
if (!opts.askUser)
|
|
873
|
+
return "[error] No interactive prompt is available in this session — ask in plain text instead.";
|
|
874
|
+
let questions = [];
|
|
875
|
+
try {
|
|
876
|
+
questions = JSON.parse(call.args).questions ?? [];
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
/* fall through */
|
|
880
|
+
}
|
|
881
|
+
// Keep only well-formed questions (text + at least two options).
|
|
882
|
+
questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
|
|
883
|
+
if (!questions.length)
|
|
884
|
+
return "[error] ask_user needs at least one question with two or more options.";
|
|
885
|
+
const answers = await opts.askUser(questions);
|
|
886
|
+
if (!answers)
|
|
887
|
+
return "The user dismissed the question form without answering. Proceed with your best judgment, or ask again if you truly need their input."; // cancelled / non-interactive
|
|
888
|
+
return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
|
|
889
|
+
}
|
|
246
890
|
// Run one compaction attempt and keep score. Returns true on success.
|
|
247
891
|
// Every automatic behavior keeps a failure count — that is what lets the
|
|
248
892
|
// caller stop a hot failure loop instead of compacting forever.
|
|
@@ -250,10 +894,14 @@ async function tryCompact(messages, // history to compact (mutated in place)
|
|
|
250
894
|
opts, // for client/model/signal
|
|
251
895
|
compaction) {
|
|
252
896
|
try {
|
|
897
|
+
if (!opts.subAgent)
|
|
898
|
+
await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
|
|
253
899
|
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
254
900
|
compaction.count++; // one more successful compaction this query
|
|
255
901
|
compaction.failures = 0; // success resets the failure streak
|
|
256
902
|
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
903
|
+
if (!opts.subAgent)
|
|
904
|
+
await runHooks("PostCompact", {}); // observational
|
|
257
905
|
return true;
|
|
258
906
|
}
|
|
259
907
|
catch {
|
|
@@ -262,6 +910,152 @@ compaction) {
|
|
|
262
910
|
return false;
|
|
263
911
|
}
|
|
264
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
|
+
}
|
|
265
1059
|
// The agent main loop, as a state machine: every iteration either continues
|
|
266
1060
|
// for a named reason or terminates for a named reason — nothing implicit.
|
|
267
1061
|
export async function runLoop(messages, // conversation history (mutated in place)
|
|
@@ -269,7 +1063,14 @@ opts) {
|
|
|
269
1063
|
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
270
1064
|
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
271
1065
|
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
272
|
-
|
|
1066
|
+
let lastText = null; // most recent assistant text — what we return if a round cap (teammates) stops us mid-flight
|
|
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
|
+
}
|
|
273
1074
|
// One round = one successful model call + its tool results.
|
|
274
1075
|
// The inner loop retries the model call until it succeeds or a budget dies.
|
|
275
1076
|
while (true) {
|
|
@@ -344,6 +1145,8 @@ opts) {
|
|
|
344
1145
|
? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
|
|
345
1146
|
: {}), // omit the field entirely when there were no calls
|
|
346
1147
|
});
|
|
1148
|
+
if (out.content)
|
|
1149
|
+
lastText = out.content; // remember the latest answer text — the round-cap path returns it
|
|
347
1150
|
if (!out.toolCalls.length) {
|
|
348
1151
|
// The model wants to stop. A Stop hook gets the last word: if it exits
|
|
349
1152
|
// 2, the agent is NOT done — its stderr becomes a new instruction and
|
|
@@ -357,9 +1160,25 @@ opts) {
|
|
|
357
1160
|
if (!opts.quiet)
|
|
358
1161
|
console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
359
1162
|
messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
|
|
360
|
-
break; // exit the inner while → advance the round counter
|
|
1163
|
+
break; // exit the inner while → advance the round counter
|
|
361
1164
|
}
|
|
362
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);
|
|
363
1182
|
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
364
1183
|
}
|
|
365
1184
|
// Execute the tool calls. Read-only calls (read_file/search) in a
|
|
@@ -384,8 +1203,29 @@ opts) {
|
|
|
384
1203
|
const r = await runOneCall(out.toolCalls[i++], opts);
|
|
385
1204
|
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
386
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
|
+
}
|
|
387
1227
|
break; // round complete, move to the next one
|
|
388
1228
|
}
|
|
389
1229
|
}
|
|
390
|
-
|
|
1230
|
+
// loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
|
|
391
1231
|
}
|