agent-from-zero 0.5.0 → 0.27.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 +23 -2
- package/dist/agent.js +334 -18
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +167 -19
- 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 +77 -5
- package/dist/prompt.js +16 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/tools.js +47 -4
- package/dist/tui.js +67 -0
- package/dist/ui.js +134 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +2 -2
package/dist/loop.js
CHANGED
|
@@ -3,11 +3,14 @@ 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
8
|
import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
|
|
8
9
|
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
9
10
|
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
10
|
-
|
|
11
|
+
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
12
|
+
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
13
|
+
import { printToolSummary } from "./tui.js"; // collapsible tool output
|
|
11
14
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
12
15
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
13
16
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -20,7 +23,6 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
|
|
|
20
23
|
export var TerminateReason;
|
|
21
24
|
(function (TerminateReason) {
|
|
22
25
|
TerminateReason["Done"] = "done";
|
|
23
|
-
TerminateReason["RoundCap"] = "round_cap";
|
|
24
26
|
TerminateReason["CircuitBreaker"] = "circuit_breaker";
|
|
25
27
|
TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
|
|
26
28
|
TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
|
|
@@ -29,6 +31,12 @@ export var TerminateReason;
|
|
|
29
31
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
30
32
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
31
33
|
})(TerminateReason || (TerminateReason = {}));
|
|
34
|
+
// Which model should a delegated sub-agent run on? The configured sub-agent
|
|
35
|
+
// model if set and non-blank, otherwise the same model as the orchestrator.
|
|
36
|
+
// Pulled out as a pure function so the tiering rule is testable on its own.
|
|
37
|
+
export function subAgentModelFor(opts) {
|
|
38
|
+
return opts.subAgentModel?.trim() || opts.model;
|
|
39
|
+
}
|
|
32
40
|
// The task tool: the parent's handle on sub-agents. Defined here (not in
|
|
33
41
|
// tools.ts) because running it needs the loop itself — it IS a loop.
|
|
34
42
|
const taskTool = {
|
|
@@ -49,19 +57,53 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
|
|
|
49
57
|
},
|
|
50
58
|
},
|
|
51
59
|
};
|
|
60
|
+
// The ask_user tool: the model's handle on the user. When it needs the human to
|
|
61
|
+
// choose between options, it calls this instead of asking in prose — and gets
|
|
62
|
+
// back clean, structured selections rather than a free-text paragraph to parse.
|
|
63
|
+
const askUserTool = {
|
|
64
|
+
type: "function",
|
|
65
|
+
function: {
|
|
66
|
+
name: "ask_user",
|
|
67
|
+
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.
|
|
68
|
+
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.
|
|
69
|
+
If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
questions: {
|
|
74
|
+
type: "array",
|
|
75
|
+
description: "The questions to ask, each with a list of options",
|
|
76
|
+
items: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
question: { type: "string", description: "The question text" },
|
|
80
|
+
options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
|
|
81
|
+
},
|
|
82
|
+
required: ["question", "options"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["questions"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
52
90
|
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
53
91
|
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
54
92
|
async function runSubAgent(description, opts) {
|
|
55
93
|
emit("agent_subagent_spawn"); // delegation is worth counting
|
|
94
|
+
const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
|
|
95
|
+
// Show the delegation, and the model when it differs from the orchestrator's —
|
|
96
|
+
// the tiering should be visible, not a silent surprise on the bill.
|
|
56
97
|
if (!opts.quiet)
|
|
57
|
-
console.log(
|
|
98
|
+
console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
|
|
99
|
+
await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
|
|
58
100
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
59
101
|
try {
|
|
60
102
|
const subMessages = [
|
|
61
103
|
{ role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
|
|
62
104
|
{ role: "user", content: description }, // the task is its entire world
|
|
63
105
|
];
|
|
64
|
-
const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse
|
|
106
|
+
const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
|
|
65
107
|
if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
|
|
66
108
|
// The framing matters: the parent must treat this as material to verify,
|
|
67
109
|
// not as conclusions to copy — the most common multi-agent failure mode.
|
|
@@ -71,8 +113,9 @@ async function runSubAgent(description, opts) {
|
|
|
71
113
|
}
|
|
72
114
|
finally {
|
|
73
115
|
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
116
|
+
await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
|
|
74
117
|
if (!opts.quiet)
|
|
75
|
-
console.log(
|
|
118
|
+
console.log(mark.subAgentDone); // close the bracket
|
|
76
119
|
}
|
|
77
120
|
}
|
|
78
121
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
@@ -89,21 +132,28 @@ async function interruptibleSleep(ms, isInterrupted) {
|
|
|
89
132
|
await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
|
|
90
133
|
}
|
|
91
134
|
}
|
|
135
|
+
let modelCallSeq = 0; // increments per model call — seeds the rotating spinner word
|
|
92
136
|
// One streaming model call: prints text as it arrives, assembles tool calls
|
|
93
137
|
// from their deltas, and guards the stream with a two-level watchdog.
|
|
94
138
|
async function streamModelCall(messages, // the full history to send
|
|
95
139
|
opts) {
|
|
96
140
|
const idleAbort = new AbortController(); // the watchdog's own kill switch
|
|
97
141
|
const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
|
|
142
|
+
const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
|
|
143
|
+
const startedAt = Date.now(); // for the spinner's live elapsed counter
|
|
144
|
+
let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
|
|
98
145
|
const spinner = opts.quiet
|
|
99
146
|
? null // the eval harness wants silence
|
|
100
|
-
: ora({ text: opts.subAgent
|
|
147
|
+
: ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
|
|
101
148
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
102
149
|
let stallWarned = false; // only warn once per quiet stretch
|
|
103
150
|
// The two-level watchdog, checked once per second:
|
|
104
151
|
// - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
|
|
105
152
|
// - stall (slow but alive for 30s) → log it and keep waiting
|
|
153
|
+
// It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
|
|
106
154
|
const watchdog = setInterval(() => {
|
|
155
|
+
if (spinner?.isSpinning)
|
|
156
|
+
spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
|
|
107
157
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
108
158
|
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
109
159
|
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
@@ -120,29 +170,54 @@ opts) {
|
|
|
120
170
|
const stream = await opts.client.chat.completions.create(
|
|
121
171
|
// Sub-agents do NOT get the task tool: one level of delegation only.
|
|
122
172
|
// Nested spawning means orphan processes and debugging hell.
|
|
123
|
-
|
|
173
|
+
// include_usage adds a final chunk carrying token counts — that is how we
|
|
174
|
+
// meter cost without a second (counting) request.
|
|
175
|
+
{ model: opts.model, messages, tools: opts.subAgent ? toolDefinitions() : [...toolDefinitions(), taskTool, askUserTool], stream: true, stream_options: { include_usage: true } }, { signal });
|
|
124
176
|
let content = ""; // accumulated answer text
|
|
125
|
-
let printedPrefix = false; // have we printed the
|
|
177
|
+
let printedPrefix = false; // have we printed the answer prefix yet?
|
|
178
|
+
let printedThinking = false; // have we started printing the reasoning trace yet?
|
|
126
179
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
127
180
|
for await (const chunk of stream) {
|
|
128
181
|
lastEvent = Date.now(); // feed the watchdog
|
|
129
182
|
stallWarned = false; // the stream spoke — reset the stall warning
|
|
183
|
+
if (chunk.usage)
|
|
184
|
+
recordUsage(chunk.usage); // the final usage chunk — meter it
|
|
130
185
|
const delta = chunk.choices[0]?.delta; // this chunk's increment
|
|
131
186
|
if (!delta)
|
|
132
187
|
continue; // keep-alive or usage chunk — nothing to do
|
|
188
|
+
// Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
|
|
189
|
+
// separate `reasoning_content` field BEFORE the answer. Show it dimly so a
|
|
190
|
+
// switch to a reasoning model is visibly different — but do NOT keep it:
|
|
191
|
+
// the reasoning is not the answer and must not be replayed in later turns.
|
|
192
|
+
const reasoning = delta.reasoning_content;
|
|
193
|
+
if (reasoning) {
|
|
194
|
+
streamedChars += reasoning.length; // count reasoning toward the live token estimate
|
|
195
|
+
if (spinner?.isSpinning)
|
|
196
|
+
spinner.stop();
|
|
197
|
+
if (!opts.quiet && !opts.subAgent) {
|
|
198
|
+
if (!printedThinking) {
|
|
199
|
+
process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
|
|
200
|
+
printedThinking = true;
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
|
|
203
|
+
}
|
|
204
|
+
}
|
|
133
205
|
if (delta.content) {
|
|
206
|
+
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
134
207
|
if (spinner?.isSpinning)
|
|
135
208
|
spinner.stop(); // first token: replace the spinner with real output
|
|
136
209
|
if (!opts.quiet && !opts.subAgent) {
|
|
137
210
|
// Only the top-level agent streams to the screen — a sub-agent's
|
|
138
211
|
// inner monologue would be mistaken for the answer.
|
|
212
|
+
if (printedThinking && !printedPrefix)
|
|
213
|
+
process.stdout.write("\n"); // end the thinking block before the answer
|
|
139
214
|
if (!printedPrefix) {
|
|
140
|
-
process.stdout.write("\n
|
|
215
|
+
process.stdout.write("\n" + mark.answer); // prefix once (⏺), then stream raw
|
|
141
216
|
printedPrefix = true;
|
|
142
217
|
}
|
|
143
218
|
process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
|
|
144
219
|
}
|
|
145
|
-
content += delta.content; // always keep it for the history
|
|
220
|
+
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
146
221
|
}
|
|
147
222
|
for (const tc of delta.tool_calls ?? []) {
|
|
148
223
|
if (spinner?.isSpinning)
|
|
@@ -172,24 +247,52 @@ opts) {
|
|
|
172
247
|
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
173
248
|
async function runOneCall(call, opts) {
|
|
174
249
|
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
|
|
175
|
-
if (!opts.quiet)
|
|
176
|
-
|
|
250
|
+
if (!opts.quiet) {
|
|
251
|
+
// Compact one-line summary — long args are hidden behind Tab.
|
|
252
|
+
const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
|
|
253
|
+
const full = `tool: ${call.name}\nargs: ${call.args}`;
|
|
254
|
+
printToolSummary(summary, full);
|
|
255
|
+
}
|
|
177
256
|
// The permission gate sits between the model's intent and execution.
|
|
178
257
|
const v = checkPermission(call.name, call.args);
|
|
179
258
|
emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
|
|
259
|
+
// For file-changing tools, show the diff BEFORE the gate decides. A filename
|
|
260
|
+
// ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
|
|
261
|
+
// are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
|
|
262
|
+
if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
|
|
263
|
+
const preview = previewChange(call.name, call.args);
|
|
264
|
+
if (preview)
|
|
265
|
+
console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
|
|
266
|
+
}
|
|
180
267
|
let content; // what goes back to the model as the tool result
|
|
181
268
|
if (v.decision === "deny") {
|
|
182
269
|
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
183
270
|
if (!opts.quiet)
|
|
184
|
-
console.log(
|
|
271
|
+
console.log(mark.denied(v.reason)); // tell the user we blocked it
|
|
185
272
|
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
273
|
}
|
|
187
274
|
else if (v.decision === "ask") {
|
|
188
|
-
|
|
275
|
+
// Optional LLM judge: for a run_bash command the rules couldn't classify,
|
|
276
|
+
// ask the judge first. It can only DOWNGRADE ask→allow for the clearly
|
|
277
|
+
// safe; anything else still goes to the human. The judge never sees a deny.
|
|
278
|
+
let autoAllowed = false;
|
|
279
|
+
if (opts.judge && call.name === "run_bash") {
|
|
280
|
+
let cmd = "";
|
|
281
|
+
try {
|
|
282
|
+
cmd = JSON.parse(call.args).command ?? "";
|
|
283
|
+
}
|
|
284
|
+
catch { /* leave empty → judge will ask */ }
|
|
285
|
+
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
286
|
+
autoAllowed = true;
|
|
287
|
+
if (!opts.quiet)
|
|
288
|
+
console.log(mark.judge); // visible — the user can see the judge worked
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
|
|
189
292
|
if (!ok)
|
|
190
293
|
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
191
294
|
if (!ok && !opts.quiet)
|
|
192
|
-
console.log(
|
|
295
|
+
console.log(mark.declined); // make the refusal visible
|
|
193
296
|
content = ok
|
|
194
297
|
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
195
298
|
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
@@ -213,9 +316,23 @@ async function runWithHooks(call, opts) {
|
|
|
213
316
|
if (pre.block) {
|
|
214
317
|
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
215
318
|
if (!opts.quiet)
|
|
216
|
-
console.log(
|
|
319
|
+
console.log(mark.hookBlock("PreToolUse")); // make it visible
|
|
217
320
|
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
218
321
|
}
|
|
322
|
+
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
323
|
+
// permission gate already ran on the original args; the rewrite only narrows or
|
|
324
|
+
// annotates, never escalates past a deny. Validate it's parseable, then use it.
|
|
325
|
+
if (pre.rewrite) {
|
|
326
|
+
try {
|
|
327
|
+
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
328
|
+
if (!opts.quiet)
|
|
329
|
+
console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
|
|
330
|
+
call = { ...call, args: pre.rewrite };
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* malformed rewrite — ignore, run the original args */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
219
336
|
const result = await execute(call, opts); // the actual work
|
|
220
337
|
const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
|
|
221
338
|
if (post.block) {
|
|
@@ -228,6 +345,10 @@ async function runWithHooks(call, opts) {
|
|
|
228
345
|
// Execute one approved tool call. The task tool is special — it is not in the
|
|
229
346
|
// registry because running it needs the loop itself (it IS a loop).
|
|
230
347
|
async function execute(call, opts) {
|
|
348
|
+
// ask_user is special like task: running it needs the CLI's prompt, not the
|
|
349
|
+
// tool registry. It presents a form and feeds the selections back to the model.
|
|
350
|
+
if (call.name === "ask_user")
|
|
351
|
+
return runAskUser(call, opts);
|
|
231
352
|
if (call.name !== "task")
|
|
232
353
|
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
233
354
|
if (opts.subAgent)
|
|
@@ -243,6 +364,29 @@ async function execute(call, opts) {
|
|
|
243
364
|
return "[error] task requires a non-empty description argument.";
|
|
244
365
|
return runSubAgent(description, opts); // spawn the worker
|
|
245
366
|
}
|
|
367
|
+
// Present the ask_user form and turn the selections into a tool result. The
|
|
368
|
+
// model called this to get a decision; we give it clean question→answer pairs.
|
|
369
|
+
async function runAskUser(call, opts) {
|
|
370
|
+
if (opts.subAgent)
|
|
371
|
+
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
|
|
372
|
+
if (!opts.askUser)
|
|
373
|
+
return "[error] No interactive prompt is available in this session — ask in plain text instead.";
|
|
374
|
+
let questions = [];
|
|
375
|
+
try {
|
|
376
|
+
questions = JSON.parse(call.args).questions ?? [];
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* fall through */
|
|
380
|
+
}
|
|
381
|
+
// Keep only well-formed questions (text + at least two options).
|
|
382
|
+
questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
|
|
383
|
+
if (!questions.length)
|
|
384
|
+
return "[error] ask_user needs at least one question with two or more options.";
|
|
385
|
+
const answers = await opts.askUser(questions);
|
|
386
|
+
if (!answers)
|
|
387
|
+
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
|
|
388
|
+
return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
|
|
389
|
+
}
|
|
246
390
|
// Run one compaction attempt and keep score. Returns true on success.
|
|
247
391
|
// Every automatic behavior keeps a failure count — that is what lets the
|
|
248
392
|
// caller stop a hot failure loop instead of compacting forever.
|
|
@@ -250,10 +394,14 @@ async function tryCompact(messages, // history to compact (mutated in place)
|
|
|
250
394
|
opts, // for client/model/signal
|
|
251
395
|
compaction) {
|
|
252
396
|
try {
|
|
397
|
+
if (!opts.subAgent)
|
|
398
|
+
await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
|
|
253
399
|
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
254
400
|
compaction.count++; // one more successful compaction this query
|
|
255
401
|
compaction.failures = 0; // success resets the failure streak
|
|
256
402
|
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
403
|
+
if (!opts.subAgent)
|
|
404
|
+
await runHooks("PostCompact", {}); // observational
|
|
257
405
|
return true;
|
|
258
406
|
}
|
|
259
407
|
catch {
|
|
@@ -269,7 +417,7 @@ opts) {
|
|
|
269
417
|
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
270
418
|
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
271
419
|
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
272
|
-
for (let round = 1
|
|
420
|
+
for (let round = 1;; round++) {
|
|
273
421
|
// One round = one successful model call + its tool results.
|
|
274
422
|
// The inner loop retries the model call until it succeeds or a budget dies.
|
|
275
423
|
while (true) {
|
|
@@ -357,7 +505,7 @@ opts) {
|
|
|
357
505
|
if (!opts.quiet)
|
|
358
506
|
console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
359
507
|
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
|
|
508
|
+
break; // exit the inner while → advance the round counter
|
|
361
509
|
}
|
|
362
510
|
}
|
|
363
511
|
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
@@ -387,5 +535,5 @@ opts) {
|
|
|
387
535
|
break; // round complete, move to the next one
|
|
388
536
|
}
|
|
389
537
|
}
|
|
390
|
-
|
|
538
|
+
// loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
|
|
391
539
|
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"; // stdio servers are subprocesses
|
|
2
|
+
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // configured servers
|
|
4
|
+
import { registerExternalTool } from "./tools.js"; // expose discovered tools
|
|
5
|
+
import { emit } from "./telemetry.js"; // observability
|
|
6
|
+
// MCP (Model Context Protocol) lets the agent borrow tools from external
|
|
7
|
+
// servers — a filesystem server, a GitHub server, a database server, anything.
|
|
8
|
+
// The wire protocol is JSON-RPC 2.0. We do the initialize handshake, ask for the
|
|
9
|
+
// server's tools, and register each one so it flows through the SAME dispatch and
|
|
10
|
+
// the SAME permission gate as a built-in. There is one execution path, period.
|
|
11
|
+
//
|
|
12
|
+
// Since Day 24 there are TWO ways to reach a server: spawn it and talk over
|
|
13
|
+
// stdio (local), or POST to a URL (remote, "Streamable HTTP"). The protocol is
|
|
14
|
+
// identical; only the pipe differs. That difference lives behind a Transport
|
|
15
|
+
// interface, so McpClient — handshake, ids, tool flattening — never has to care.
|
|
16
|
+
//
|
|
17
|
+
// Discovered tools are named mcp__<server>__<tool> so they never collide with
|
|
18
|
+
// built-ins and the user can see (and gate) exactly where each came from.
|
|
19
|
+
const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
|
|
20
|
+
const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
|
|
21
|
+
// ---- stdio transport: one JSON object per line, to a subprocess --------------
|
|
22
|
+
class StdioTransport {
|
|
23
|
+
child;
|
|
24
|
+
pending = new Map(); // id → resolver (responses arrive async on one stream)
|
|
25
|
+
buffer = ""; // partial stdout, reassembled into whole lines
|
|
26
|
+
constructor(def) {
|
|
27
|
+
this.child = spawn(def.command, def.args ?? [], {
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"], // we own stdin/stdout; stderr is the server's log
|
|
29
|
+
env: { ...process.env, ...def.env }, // pass through + extras
|
|
30
|
+
});
|
|
31
|
+
// Reassemble newline-delimited JSON from the stdout stream.
|
|
32
|
+
this.child.stdout.on("data", (chunk) => {
|
|
33
|
+
this.buffer += chunk.toString();
|
|
34
|
+
let nl;
|
|
35
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
36
|
+
const line = this.buffer.slice(0, nl).trim(); // one complete message
|
|
37
|
+
this.buffer = this.buffer.slice(nl + 1); // keep the remainder
|
|
38
|
+
if (line)
|
|
39
|
+
this.onMessage(line);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.child.stderr.on("data", () => { }); // the server's stderr is its own logging — never our stdout
|
|
43
|
+
}
|
|
44
|
+
onMessage(line) {
|
|
45
|
+
let msg;
|
|
46
|
+
try {
|
|
47
|
+
msg = JSON.parse(line);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return; // ignore anything that isn't valid JSON-RPC
|
|
51
|
+
}
|
|
52
|
+
const resolver = this.pending.get(msg.id);
|
|
53
|
+
if (resolver) {
|
|
54
|
+
this.pending.delete(msg.id);
|
|
55
|
+
resolver(msg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
request(payload, timeoutMs) {
|
|
59
|
+
const { id } = payload;
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
const finish = (r) => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
this.pending.delete(id);
|
|
68
|
+
resolve(r);
|
|
69
|
+
};
|
|
70
|
+
const timer = setTimeout(() => finish({ id, error: { message: `MCP request '${payload.method}' timed out` } }), timeoutMs);
|
|
71
|
+
this.pending.set(id, finish);
|
|
72
|
+
this.child.stdin.write(JSON.stringify(payload) + "\n"); // one line, newline-terminated
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
notify(method, params) {
|
|
76
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
|
77
|
+
}
|
|
78
|
+
close() {
|
|
79
|
+
this.child.kill(); // best-effort cleanup
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Pull JSON-RPC messages out of a Server-Sent-Events body. SSE frames are
|
|
83
|
+
// separated by blank lines; the payload lives on one or more `data:` lines.
|
|
84
|
+
// Exported because parsing is the fiddly part worth testing on its own.
|
|
85
|
+
export function parseSseData(body) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const frame of body.split(/\r?\n\r?\n/)) {
|
|
88
|
+
const data = frame
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.filter((l) => l.startsWith("data:"))
|
|
91
|
+
.map((l) => l.slice(5).trim()) // strip "data:" and surrounding space
|
|
92
|
+
.join("\n");
|
|
93
|
+
if (data)
|
|
94
|
+
out.push(data);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
// ---- HTTP transport: POST to a URL, read JSON or SSE back --------------------
|
|
99
|
+
class HttpTransport {
|
|
100
|
+
url;
|
|
101
|
+
headers;
|
|
102
|
+
sessionId; // assigned by the server on initialize, echoed on every later request
|
|
103
|
+
constructor(def) {
|
|
104
|
+
this.url = def.url;
|
|
105
|
+
this.headers = { ...def.headers };
|
|
106
|
+
}
|
|
107
|
+
// The response to a POST may come back as a single JSON object OR as an SSE
|
|
108
|
+
// stream carrying it. Either way we want the JSON-RPC message matching our id.
|
|
109
|
+
async post(body, timeoutMs, isRequest) {
|
|
110
|
+
const res = await fetch(this.url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
accept: "application/json, text/event-stream", // we accept either shape
|
|
115
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
116
|
+
...this.headers,
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
120
|
+
});
|
|
121
|
+
const sid = res.headers.get("mcp-session-id"); // initialize hands us a session to keep
|
|
122
|
+
if (sid)
|
|
123
|
+
this.sessionId = sid;
|
|
124
|
+
if (!isRequest)
|
|
125
|
+
return null; // a notification expects no body (202 Accepted)
|
|
126
|
+
const raw = await res.text();
|
|
127
|
+
const payloads = res.headers.get("content-type")?.includes("text/event-stream") ? parseSseData(raw) : [raw];
|
|
128
|
+
for (const p of payloads) {
|
|
129
|
+
try {
|
|
130
|
+
const msg = JSON.parse(p);
|
|
131
|
+
if (msg && (msg.result !== undefined || msg.error !== undefined))
|
|
132
|
+
return msg; // the response we came for
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* skip non-JSON frames (SSE comments, keep-alives) */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
async request(payload, timeoutMs) {
|
|
141
|
+
try {
|
|
142
|
+
const msg = await this.post(payload, timeoutMs, true);
|
|
143
|
+
return msg ?? { id: payload.id, error: { message: `MCP request '${payload.method}' returned no response` } };
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
notify(method, params) {
|
|
150
|
+
// Fire-and-forget; errors on a notification are not worth surfacing.
|
|
151
|
+
void this.post({ jsonrpc: "2.0", method, params }, INIT_TIMEOUT_MS, false).catch(() => { });
|
|
152
|
+
}
|
|
153
|
+
close() {
|
|
154
|
+
// Best-effort: tell the server to drop the session. No await, no error care.
|
|
155
|
+
if (this.sessionId) {
|
|
156
|
+
void fetch(this.url, { method: "DELETE", headers: { "mcp-session-id": this.sessionId, ...this.headers } }).catch(() => { });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Choose the pipe from the config: a url means HTTP, otherwise spawn over stdio.
|
|
161
|
+
function makeTransport(def) {
|
|
162
|
+
return def.url ? new HttpTransport(def) : new StdioTransport(def);
|
|
163
|
+
}
|
|
164
|
+
// The protocol layer: handshake, request ids, tool flattening. It talks to a
|
|
165
|
+
// Transport and is blind to whether that's a subprocess or an HTTP endpoint.
|
|
166
|
+
class McpClient {
|
|
167
|
+
transport;
|
|
168
|
+
nextId = 1; // JSON-RPC request id counter
|
|
169
|
+
constructor(def) {
|
|
170
|
+
this.transport = makeTransport(def);
|
|
171
|
+
}
|
|
172
|
+
request(method, params, timeoutMs) {
|
|
173
|
+
return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
|
|
174
|
+
}
|
|
175
|
+
// The MCP startup dance: initialize → initialized → list tools.
|
|
176
|
+
async start() {
|
|
177
|
+
const init = await this.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mini-agent", version: "0" } }, INIT_TIMEOUT_MS);
|
|
178
|
+
if (init.error)
|
|
179
|
+
throw new Error(init.error.message); // server refused to initialize
|
|
180
|
+
this.transport.notify("notifications/initialized", {}); // tell the server we're ready
|
|
181
|
+
const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS); // ask what it offers
|
|
182
|
+
if (list.error)
|
|
183
|
+
throw new Error(list.error.message);
|
|
184
|
+
return list.result?.tools ?? [];
|
|
185
|
+
}
|
|
186
|
+
// Invoke one tool and flatten its content into plain text for the model.
|
|
187
|
+
async callTool(name, args) {
|
|
188
|
+
const res = await this.request("tools/call", { name, arguments: args }, CALL_TIMEOUT_MS);
|
|
189
|
+
if (res.error)
|
|
190
|
+
return `[error] MCP tool failed: ${res.error.message}`; // surface, don't throw
|
|
191
|
+
const text = (res.result?.content ?? []).map((c) => c.text ?? `[${c.type} content]`).join("\n"); // flatten parts
|
|
192
|
+
const body = text || "(no output)";
|
|
193
|
+
return res.result?.isError ? `[error] ${body}` : body; // MCP can flag a logical error in a successful response
|
|
194
|
+
}
|
|
195
|
+
kill() {
|
|
196
|
+
this.transport.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Connect to every configured MCP server, register their tools, and return a
|
|
200
|
+
// cleanup function. Failures are isolated: a server that won't start is logged
|
|
201
|
+
// and skipped — it never stops the agent or the other servers.
|
|
202
|
+
export async function connectMcpServers() {
|
|
203
|
+
const entries = Object.entries(CONFIG.mcpServers); // [name, def] pairs
|
|
204
|
+
if (!entries.length)
|
|
205
|
+
return () => { }; // the common case: nothing configured
|
|
206
|
+
const clients = []; // for cleanup
|
|
207
|
+
for (const [serverName, def] of entries) {
|
|
208
|
+
const client = new McpClient(def);
|
|
209
|
+
try {
|
|
210
|
+
const specs = await client.start(); // handshake + discover
|
|
211
|
+
clients.push(client);
|
|
212
|
+
for (const spec of specs) {
|
|
213
|
+
const toolName = `mcp__${serverName}__${spec.name}`; // namespaced, collision-proof
|
|
214
|
+
const tool = {
|
|
215
|
+
definition: {
|
|
216
|
+
type: "function",
|
|
217
|
+
function: {
|
|
218
|
+
name: toolName,
|
|
219
|
+
description: `[MCP:${serverName}] ${spec.description ?? spec.name}`, // mark the origin in the manual
|
|
220
|
+
parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
run: async (args) => client.callTool(spec.name, args), // dispatch routes here; the permission gate ran first
|
|
224
|
+
};
|
|
225
|
+
registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
|
|
226
|
+
}
|
|
227
|
+
emit("agent_mcp_connected", { server: serverName, tools: specs.length, transport: def.url ? "http" : "stdio" });
|
|
228
|
+
console.log(chalk.dim(`(mcp: ${serverName} — ${specs.length} tools${def.url ? " over http" : ""})`)); // visible at startup
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
client.kill(); // don't leak the process/session
|
|
232
|
+
emit("agent_mcp_failed", { server: serverName });
|
|
233
|
+
console.error(chalk.yellow(` [mcp] ${serverName} failed to start (skipped): ${err.message}`)); // loud, but not fatal
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return () => clients.forEach((c) => c.kill()); // the caller calls this on exit
|
|
237
|
+
}
|