agent-from-zero 0.1.0 → 0.5.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 +15 -2
- package/dist/agent.js +176 -26
- package/dist/config.js +54 -12
- package/dist/context.js +4 -3
- package/dist/hooks.js +67 -0
- package/dist/loop.js +107 -27
- package/dist/permissions.js +27 -1
- package/dist/session.js +61 -0
- package/dist/telemetry.js +43 -0
- package/dist/tools.js +75 -16
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# mini-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**跟着 AI,从零搓一个自己的 Claude Code**——一个能读文件、改代码、跑命令的命令行 Agent。TypeScript 实现,默认 DeepSeek 驱动(任何 OpenAI 兼容模型可配)。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
人定方向、做决策、验收,AI 写大部分代码——这既是本仓库的诞生方式,也是这门课教你的工作方式。
|
|
6
6
|
|
|
7
|
+
课程拆成 10 个 Day,一个 Day 一个 commit,按一晚一章的节奏跟做。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 章对应的完整代码。
|
|
8
|
+
|
|
9
|
+
> **设计有出处,不是凭感觉搭的**:蓝本是《Harness Engineering:从 Claude Code 看 AI 编码工程》提炼的 22 个工程模式——博主在公众号用 30+ 篇拆完了全书(本仓库就是那次拆解的实践篇)。书的收官章,作者用 800 行 Rust 写了一个代码审查 Agent,验证这些模式能跨语言、跨场景活下来;**这个仓库是同一套模式的又一次迁移**:场景换成 Claude Code 式的 CLI 编码 agent,语言换成 TypeScript——不跟着用 Rust,是因为新手教程的第一原则是别让语言难度挡路。
|
|
10
|
+
>
|
|
11
|
+
> **用 AI 搓出一个类 Claude Code,这件事本身就是这套方法最好的 demo**——教程里每个「踩过的坑」都真实发生在开发过程中,不是编的。
|
|
12
|
+
>
|
|
7
13
|
> 代码、注释、commit message 用英文——模型的工具调用主要在英文数据上训练,英文指令引导更稳,也方便国际读者;配套教程是中文。
|
|
8
14
|
|
|
9
15
|
## 快速开始
|
|
@@ -50,9 +56,16 @@ MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
50
56
|
| `day7` | 子 agent(task 工具:独立上下文、一层分身不套娃、报告是材料不是结论、文件状态快照隔离)+ 最小 eval(10 道固定题、物证判分、8/10 及格线,`npm run eval`) |
|
|
51
57
|
| `day8` | 封装发布:`bin` 入口 + tsc 构建,npx 一行启动(包名 `agent-from-zero`);内置 .env 加载;缺 key 人话报错 |
|
|
52
58
|
| `day9` | 教程本身:11 章中文教程开源进 [docs/](docs/README.md) |
|
|
59
|
+
| `day10` | Bring your own model:三个环境变量换任意 OpenAI 兼容端点,`npm run eval` 兼职模型横评 |
|
|
60
|
+
| `day11` | 工业级 CLI 表面:settings.json 双层配置(用户权限规则,deny 永远赢)、slash 命令(/help /model /compact)、`-p` 单发模式、`--version`、测试套件进仓库(`npm test`,60 例) |
|
|
61
|
+
| `day12` | 会话持久化(`--resume`,快照式 + 原子写)+ 本地遥测(事件 JSONL + `/stats`,绝不出本机,`MINI_AGENT_NO_TELEMETRY=1` 关闭) |
|
|
62
|
+
| `day13` | 异步执行:run_bash 换 spawn(不冻 UI、Ctrl+C 秒杀、30s 超时 SIGKILL)、超长输出落盘、只读工具并行;`npm test` 65 例 |
|
|
63
|
+
| `day14` | Hook 生命周期系统:PreToolUse(拦截工具)/PostToolUse(追加校验)/SessionStart(注入上下文)/Stop(测试驱动 AI),退出码协议、stderr 喂回模型;`npm test` 76 例 |
|
|
53
64
|
|
|
54
65
|
## 📖 配套教程(免费开源)
|
|
55
66
|
|
|
56
67
|
**[完整教程在 docs/ 目录](docs/README.md)**——11 章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
|
|
57
68
|
|
|
69
|
+
想看更深的「为什么」:公众号的 **Harness Engineering 拆书系列**(30+ 篇)逐章拆了《Harness Engineering:从 Claude Code 看 AI 编码工程》——本仓库每个设计决策背后的原理、数据和事故案例都在那里。公众号 @(待填)。
|
|
70
|
+
|
|
58
71
|
如果这个项目帮你搞懂了 agent,点个 ⭐ 就是最大的支持。
|
package/dist/agent.js
CHANGED
|
@@ -2,32 +2,153 @@
|
|
|
2
2
|
import OpenAI from "openai"; // the API client
|
|
3
3
|
import chalk from "chalk"; // terminal colors
|
|
4
4
|
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
|
-
import {
|
|
5
|
+
import { createRequire } from "node:module"; // to read package.json for --version
|
|
6
|
+
import { CONFIG, requireApiKey, PROJECT_SETTINGS_PATH, GLOBAL_SETTINGS_PATH } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
6
7
|
import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
7
8
|
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
8
9
|
import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
import { compactHistory, COMPACT_AT } from "./context.js"; // for the manual /compact command
|
|
11
|
+
import { newSessionId, saveSession, latestSession } from "./session.js"; // conversation persistence (project-local)
|
|
12
|
+
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
13
|
+
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
14
|
+
// package.json sits one level above both src/ (dev) and dist/ (built) — same path either way.
|
|
15
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
16
|
+
// ---- CLI arguments --------------------------------------------------------------
|
|
17
|
+
// Tiny by design: a help text, a version, and a one-shot print mode. Anything
|
|
18
|
+
// fancier belongs in settings files, not flags.
|
|
19
|
+
const USAGE = `mini-agent ${pkg.version} — a Claude Code-style CLI agent (any OpenAI-compatible model)
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
mini-agent interactive session (REPL)
|
|
23
|
+
mini-agent -r | --resume continue the most recent session in this directory
|
|
24
|
+
mini-agent -p "<task>" one-shot: run a single task, print the result, exit
|
|
25
|
+
mini-agent -v | --version print the version
|
|
26
|
+
mini-agent -h | --help this text
|
|
27
|
+
|
|
28
|
+
Configuration (optional):
|
|
29
|
+
${GLOBAL_SETTINGS_PATH} your defaults (model, baseURL, permissions, hooks)
|
|
30
|
+
${PROJECT_SETTINGS_PATH} per-project rules — both layers apply, deny always wins
|
|
31
|
+
MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL override everything.
|
|
32
|
+
|
|
33
|
+
In a session, type /help for the in-session commands.`;
|
|
16
34
|
// What we tell the user for every way a query can end. No raw stack traces.
|
|
17
35
|
const EXIT_NOTES = {
|
|
18
36
|
[TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
|
|
19
37
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
20
38
|
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
21
|
-
[TerminateReason.RateLimitBudgetExhausted]: "
|
|
39
|
+
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then try again.",
|
|
22
40
|
[TerminateReason.ContextTooLong]: "The conversation no longer fits the model's context window, and compaction could not shrink it enough. Use /clear to start fresh.",
|
|
23
41
|
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
|
|
24
42
|
[TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
|
|
25
43
|
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
26
44
|
};
|
|
45
|
+
// The in-session command reference, shown by /help.
|
|
46
|
+
const SESSION_HELP = `commands:
|
|
47
|
+
/help this text
|
|
48
|
+
/clear wipe the conversation (and the file read-state) — fresh start
|
|
49
|
+
/compact compact the history into a summary right now (happens automatically near the limit)
|
|
50
|
+
/model show which model and endpoint this session is talking to
|
|
51
|
+
/stats event counts for this session (local telemetry — nothing leaves this machine)
|
|
52
|
+
exit leave (Ctrl+C at the prompt does the same)`;
|
|
27
53
|
async function main() {
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
// ---- Flag handling, before anything touches the network -----------------------
|
|
55
|
+
const argv = process.argv.slice(2); // everything after "mini-agent"
|
|
56
|
+
if (argv.includes("-v") || argv.includes("--version")) {
|
|
57
|
+
console.log(pkg.version); // just the number — script-friendly
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
61
|
+
console.log(USAGE); // the full help text
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Print mode: -p / --print takes the task from the remaining arguments.
|
|
65
|
+
const pIdx = argv.findIndex((a) => a === "-p" || a === "--print"); // where the flag sits
|
|
66
|
+
const printTask = pIdx >= 0 ? argv.slice(pIdx + 1).join(" ").trim() : null; // everything after it is the task
|
|
67
|
+
if (pIdx >= 0 && !printTask) {
|
|
68
|
+
console.error('Usage: mini-agent -p "<task>"'); // -p without a task is a usage error
|
|
69
|
+
process.exitCode = 2;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
|
|
73
|
+
// Build the API client once, for the whole session.
|
|
74
|
+
const client = new OpenAI({
|
|
75
|
+
baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via config
|
|
76
|
+
apiKey: CONFIG.apiKey, // from .env or the shell
|
|
77
|
+
maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
|
|
78
|
+
});
|
|
79
|
+
// The system message is built ONCE per session: stable prefix = cache hits
|
|
80
|
+
// on every request. Nothing session-specific may sneak into it later.
|
|
81
|
+
const systemMessage = buildSystemMessage();
|
|
82
|
+
let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
|
|
83
|
+
// ---- Session persistence ---------------------------------------------------------
|
|
84
|
+
// Every conversation is snapshotted to .mini-agent/sessions/ after each turn;
|
|
85
|
+
// -r / --resume picks up the most recent one. The system message is NOT
|
|
86
|
+
// restored from disk — it is rebuilt fresh, because AGENT.md may have changed.
|
|
87
|
+
let sessionId = newSessionId(); // this session's identity (and file name)
|
|
88
|
+
if (argv.includes("-r") || argv.includes("--resume")) {
|
|
89
|
+
const prev = latestSession(); // newest snapshot in this directory, if any
|
|
90
|
+
if (prev) {
|
|
91
|
+
messages.push(...prev.messages); // the old conversation joins the fresh constitution
|
|
92
|
+
sessionId = prev.id; // keep appending to the same session file
|
|
93
|
+
console.log(chalk.dim(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(chalk.dim("(no previous session here — starting fresh)")); // resume with nothing to resume is not an error
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
initTelemetry(sessionId); // arm the local event log (no-op under MINI_AGENT_NO_TELEMETRY=1)
|
|
100
|
+
emit("agent_session_start", { mode: printTask !== null ? "print" : "repl" }); // how this session was started
|
|
101
|
+
// SessionStart hooks run once, here. Their stdout is injected as context the
|
|
102
|
+
// model sees on its first turn — e.g. inject the current git branch, an
|
|
103
|
+
// on-call rota, today's deploy freeze status. (exit 2 here is ignored: there
|
|
104
|
+
// is no tool to block, only context to add.)
|
|
105
|
+
const sessionStart = await runHooks("SessionStart", {});
|
|
106
|
+
if (sessionStart.stdout)
|
|
107
|
+
messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
|
|
108
|
+
// Per-task interrupt state. `running` decides what Ctrl+C means right now.
|
|
109
|
+
let running = false; // is a task currently executing?
|
|
110
|
+
let interrupted = false; // has the current task been interrupted?
|
|
111
|
+
let controller = new AbortController(); // aborts the in-flight API request
|
|
112
|
+
// ---- One-shot print mode -------------------------------------------------------
|
|
113
|
+
// The same loop, the same permission gate (fail closed without a TTY), no REPL.
|
|
114
|
+
// Designed for scripts: the exit code distinguishes success from every failure.
|
|
115
|
+
if (printTask !== null) {
|
|
116
|
+
process.on("SIGINT", () => {
|
|
117
|
+
if (interrupted)
|
|
118
|
+
process.exit(130); // second press: force quit
|
|
119
|
+
interrupted = true; // first press: wind down
|
|
120
|
+
controller.abort(); // cancel the in-flight request
|
|
121
|
+
});
|
|
122
|
+
messages.push({ role: "user", content: printTask }); // the single task
|
|
123
|
+
const result = await runLoop(messages, {
|
|
124
|
+
client,
|
|
125
|
+
model: CONFIG.model,
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
isInterrupted: () => interrupted,
|
|
128
|
+
// Print mode never prompts: unattended means nobody can say yes.
|
|
129
|
+
// MINI_AGENT_AUTO_APPROVE=1 still works for scripted use; hard denies
|
|
130
|
+
// were enforced in permissions.ts long before this runs.
|
|
131
|
+
confirm: async (q) => {
|
|
132
|
+
if (process.env.MINI_AGENT_AUTO_APPROVE === "1")
|
|
133
|
+
return true; // explicit bypass
|
|
134
|
+
console.error(chalk.dim(`[print mode] declined (no prompt available): ${q.split("\n")[0]}`)); // visible in stderr, not stdout
|
|
135
|
+
return false; // fail closed
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
saveSession(sessionId, CONFIG.model, messages); // print-mode runs are resumable too
|
|
139
|
+
emit("agent_session_end"); // close the books
|
|
140
|
+
if (result.reason !== TerminateReason.Done) {
|
|
141
|
+
console.error(chalk.yellow(EXIT_NOTES[result.reason])); // human note on stderr
|
|
142
|
+
if (result.detail)
|
|
143
|
+
console.error(chalk.dim(result.detail.slice(0, 300))); // raw detail on stderr
|
|
144
|
+
process.exitCode = result.reason === TerminateReason.UserInterrupt ? 130 : 1; // scripts can branch on this
|
|
145
|
+
}
|
|
146
|
+
return; // the answer itself already streamed to stdout
|
|
147
|
+
}
|
|
148
|
+
// ---- Interactive session (REPL) -------------------------------------------------
|
|
149
|
+
// The banner: who am I, which model and host am I on, and how to get help.
|
|
150
|
+
console.log(chalk.bold(`mini-agent ${pkg.version}`) +
|
|
151
|
+
chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | /help for commands · Ctrl+C interrupts a running task`));
|
|
31
152
|
// ONE readline interface for the whole session — the task prompt and the
|
|
32
153
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
33
154
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -61,14 +182,6 @@ async function main() {
|
|
|
61
182
|
rl.prompt();
|
|
62
183
|
return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
|
|
63
184
|
};
|
|
64
|
-
// The system message is built ONCE per session: stable prefix = cache hits
|
|
65
|
-
// on every request. Nothing session-specific may sneak into it later.
|
|
66
|
-
const systemMessage = buildSystemMessage();
|
|
67
|
-
let messages = [{ role: "system", content: systemMessage }]; // the whole conversation lives here, across turns
|
|
68
|
-
// Per-task interrupt state. `running` decides what Ctrl+C means right now.
|
|
69
|
-
let running = false; // is a task currently executing?
|
|
70
|
-
let interrupted = false; // has the current task been interrupted?
|
|
71
|
-
let controller = new AbortController(); // aborts the in-flight API request
|
|
72
185
|
// Ctrl+C semantics, by state:
|
|
73
186
|
// - at the prompt: exit the session (standard REPL behavior)
|
|
74
187
|
// - while running: first press interrupts the task, second press force-quits
|
|
@@ -102,6 +215,45 @@ async function main() {
|
|
|
102
215
|
const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
|
|
103
216
|
return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
|
|
104
217
|
};
|
|
218
|
+
// Handle a /slash command. Returns true if the line was a command.
|
|
219
|
+
const handleCommand = async (line) => {
|
|
220
|
+
switch (line) {
|
|
221
|
+
case "/help":
|
|
222
|
+
console.log(chalk.dim(SESSION_HELP)); // the command reference
|
|
223
|
+
return true;
|
|
224
|
+
case "/clear":
|
|
225
|
+
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
226
|
+
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
227
|
+
sessionId = newSessionId(); // a fresh conversation is a fresh session file
|
|
228
|
+
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
229
|
+
return true;
|
|
230
|
+
case "/stats":
|
|
231
|
+
console.log(chalk.dim(statsReport())); // local counters, busiest first
|
|
232
|
+
return true;
|
|
233
|
+
case "/model":
|
|
234
|
+
console.log(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`)); // where this session points
|
|
235
|
+
return true;
|
|
236
|
+
case "/compact": {
|
|
237
|
+
if (messages.length <= 1) {
|
|
238
|
+
console.log(chalk.dim("(nothing to compact yet)")); // only the system message so far
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
default:
|
|
250
|
+
if (line.startsWith("/")) {
|
|
251
|
+
console.log(chalk.dim(`unknown command: ${line} — try /help`)); // typo guard
|
|
252
|
+
return true; // still consumed — don't send typos to the model
|
|
253
|
+
}
|
|
254
|
+
return false; // a normal task line
|
|
255
|
+
}
|
|
256
|
+
};
|
|
105
257
|
// The session loop: ask → run → render → ask again. This is what makes it
|
|
106
258
|
// a conversation instead of a one-shot command.
|
|
107
259
|
while (true) {
|
|
@@ -110,12 +262,8 @@ async function main() {
|
|
|
110
262
|
continue; // empty line — just re-prompt
|
|
111
263
|
if (line === "exit" || line === "quit")
|
|
112
264
|
break; // explicit goodbye
|
|
113
|
-
if (line
|
|
114
|
-
|
|
115
|
-
forgetFilesExcept([]); // the file read-state belongs to the conversation — clear it too
|
|
116
|
-
console.log(chalk.dim("(history cleared)")); // confirm the reset
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
265
|
+
if (await handleCommand(line))
|
|
266
|
+
continue; // slash commands never reach the model
|
|
119
267
|
messages.push({ role: "user", content: line }); // the new turn joins the shared history
|
|
120
268
|
running = true; // Ctrl+C now means "interrupt the task"
|
|
121
269
|
interrupted = false; // fresh interrupt state for this task
|
|
@@ -128,6 +276,7 @@ async function main() {
|
|
|
128
276
|
confirm, // for permission prompts
|
|
129
277
|
});
|
|
130
278
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
279
|
+
saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
|
|
131
280
|
// Done already streamed its answer to the screen; everything else gets a
|
|
132
281
|
// one-line human explanation. The session continues either way — except a
|
|
133
282
|
// fatal API error, which no amount of chatting will fix.
|
|
@@ -139,6 +288,7 @@ async function main() {
|
|
|
139
288
|
break; // a bad key won't fix itself mid-session
|
|
140
289
|
}
|
|
141
290
|
}
|
|
291
|
+
emit("agent_session_end"); // close the books
|
|
142
292
|
rl.close(); // release stdin
|
|
143
293
|
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
144
294
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import fs from "node:fs"; //
|
|
1
|
+
import fs from "node:fs"; // reading .env and settings files
|
|
2
|
+
import os from "node:os"; // home directory for the global settings path
|
|
3
|
+
import path from "node:path"; // path joining
|
|
2
4
|
import chalk from "chalk"; // colored error output
|
|
3
5
|
// Minimal .env loader: lets `npx mini-agent` work in any directory that has a
|
|
4
6
|
// .env file — no dotenv dependency, no --env-file flag to remember.
|
|
5
|
-
// Lives here (not in agent.ts) so the eval harness gets it for free too.
|
|
6
7
|
try {
|
|
7
8
|
for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
|
|
8
9
|
const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
|
|
@@ -13,20 +14,61 @@ try {
|
|
|
13
14
|
catch {
|
|
14
15
|
/* no .env here — the variables may come from the shell instead */
|
|
15
16
|
}
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// ---- Settings files ------------------------------------------------------------
|
|
18
|
+
// Two layers, both optional:
|
|
19
|
+
// global: ~/.config/mini-agent/settings.json (your defaults, every project)
|
|
20
|
+
// project: ./.mini-agent/settings.json (this repo's rules, commit it if you like)
|
|
21
|
+
// Scalars (model, baseURL, contextWindow): project wins over global.
|
|
22
|
+
// Permission lists: BOTH layers apply — a deny from either layer is a deny.
|
|
23
|
+
// API keys never live in settings files — secrets stay in the environment.
|
|
24
|
+
export const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), ".config", "mini-agent", "settings.json");
|
|
25
|
+
export const PROJECT_SETTINGS_PATH = path.resolve(".mini-agent", "settings.json");
|
|
26
|
+
// Read one settings file. A broken settings file is a HARD error, not a warning:
|
|
27
|
+
// silently ignoring it would silently drop the user's deny rules — fail closed.
|
|
28
|
+
function readSettings(p) {
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return {}; // absent is fine
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(p, "utf8")); // parse or throw
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.error(chalk.red(`Could not parse settings file: ${p}`));
|
|
36
|
+
console.error(chalk.dim(` ${err.message}`));
|
|
37
|
+
console.error("Fix or delete the file, then run again. (Ignoring it could drop your deny rules.)");
|
|
38
|
+
process.exit(1); // fail closed — never run with half-loaded security config
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
|
|
42
|
+
const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
|
|
43
|
+
// Provider-agnostic configuration. Resolution order, most specific wins:
|
|
44
|
+
// env MINI_AGENT_* > project settings > global settings > legacy DEEPSEEK_* env > defaults
|
|
22
45
|
export const CONFIG = {
|
|
23
|
-
baseURL: process.env.MINI_AGENT_BASE_URL || //
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
baseURL: process.env.MINI_AGENT_BASE_URL || // explicit env wins...
|
|
47
|
+
projectSettings.baseURL || // ...then the project file...
|
|
48
|
+
globalSettings.baseURL || // ...then the user's global file...
|
|
49
|
+
process.env.DEEPSEEK_BASE_URL || // ...then the legacy override (kept for back-compat and tests)
|
|
50
|
+
"https://api.deepseek.com", // ...and finally the default provider
|
|
26
51
|
apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
|
|
27
52
|
process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
|
|
28
53
|
"", // empty = missing; requireApiKey() turns that into a helpful error
|
|
29
|
-
model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
|
|
54
|
+
model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
|
|
55
|
+
contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
|
|
56
|
+
projectSettings.contextWindow || // then files
|
|
57
|
+
globalSettings.contextWindow ||
|
|
58
|
+
1_048_565, // DeepSeek's real window, learned from an actual API error
|
|
59
|
+
permissions: {
|
|
60
|
+
// Both layers apply: global rules + project rules. Mutable on purpose —
|
|
61
|
+
// the test suite injects rules here instead of writing temp files.
|
|
62
|
+
allow: [...(globalSettings.permissions?.allow ?? []), ...(projectSettings.permissions?.allow ?? [])],
|
|
63
|
+
deny: [...(globalSettings.permissions?.deny ?? []), ...(projectSettings.permissions?.deny ?? [])],
|
|
64
|
+
},
|
|
65
|
+
hooks: {
|
|
66
|
+
// Hook lists also concatenate: global hooks run first, then project hooks.
|
|
67
|
+
PreToolUse: [...(globalSettings.hooks?.PreToolUse ?? []), ...(projectSettings.hooks?.PreToolUse ?? [])],
|
|
68
|
+
PostToolUse: [...(globalSettings.hooks?.PostToolUse ?? []), ...(projectSettings.hooks?.PostToolUse ?? [])],
|
|
69
|
+
SessionStart: [...(globalSettings.hooks?.SessionStart ?? []), ...(projectSettings.hooks?.SessionStart ?? [])],
|
|
70
|
+
Stop: [...(globalSettings.hooks?.Stop ?? []), ...(projectSettings.hooks?.Stop ?? [])],
|
|
71
|
+
},
|
|
30
72
|
};
|
|
31
73
|
// No key, no point: fail with instructions instead of a stack trace later.
|
|
32
74
|
export function requireApiKey() {
|
package/dist/context.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
2
2
|
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
3
4
|
import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
|
|
4
5
|
// ---- The context window ------------------------------------------------------
|
|
5
|
-
// 1,048,565 came from a real API error message, not from
|
|
6
|
+
// The default (1,048,565) came from a real API error message, not from docs.
|
|
6
7
|
// Trust what the API tells you over what you remember reading.
|
|
7
|
-
export const CONTEXT_WINDOW =
|
|
8
|
+
export const CONTEXT_WINDOW = CONFIG.contextWindow; // resolved in config.ts: env > project file > global file > default
|
|
8
9
|
// Compact well before the hard limit: estimation is approximate and the summary
|
|
9
10
|
// itself needs room. 80% is deliberately conservative.
|
|
10
|
-
export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); //
|
|
11
|
+
export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // env override kept so tests can trigger compaction cheaply
|
|
11
12
|
// How many times one query may compact / fail to compact before we give up.
|
|
12
13
|
export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
|
|
13
14
|
export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"; // hooks are external programs
|
|
2
|
+
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // user-configured hooks
|
|
4
|
+
import { emit } from "./telemetry.js"; // count hook activity
|
|
5
|
+
// Hooks let users extend the agent without touching its source: an external
|
|
6
|
+
// program runs at a lifecycle moment and can observe — or block — what happens.
|
|
7
|
+
// The contract is deliberately language-agnostic (a shell command, JSON on
|
|
8
|
+
// stdin, the exit code as the verdict) so a hook can be a bash one-liner, a
|
|
9
|
+
// Python script, anything. This is the "keep the core small, bolt business
|
|
10
|
+
// logic on via hooks" architecture.
|
|
11
|
+
//
|
|
12
|
+
// Communication protocol (per event):
|
|
13
|
+
// exit 0 → allow. stdout is shown to the model only for SessionStart.
|
|
14
|
+
// exit 2 → block. stderr is fed back to the model as the reason.
|
|
15
|
+
// other → the hook itself errored; we log and treat it as allow (a broken
|
|
16
|
+
// hook must not wedge the agent — but it is loud, not silent).
|
|
17
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 10_000; // a hook gets 10s, then we kill it
|
|
18
|
+
// Run one hook process, feeding it the event payload on stdin.
|
|
19
|
+
function runOne(hook, payload) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const child = spawn(hook.command, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); // shell so users can write one-liners
|
|
22
|
+
let stdout = "", stderr = "", settled = false; // accumulate; resolve once
|
|
23
|
+
const done = (code) => {
|
|
24
|
+
if (settled)
|
|
25
|
+
return;
|
|
26
|
+
settled = true;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
29
|
+
};
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
child.kill("SIGKILL"); // a hung hook is killed, never hangs the agent
|
|
32
|
+
done(124); // 124 = timed out (treated as "other" → allow, logged)
|
|
33
|
+
}, hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS);
|
|
34
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
35
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
36
|
+
child.on("error", () => done(127)); // could not start (e.g. command not found)
|
|
37
|
+
child.on("close", (code) => done(code ?? 0));
|
|
38
|
+
child.stdin.end(JSON.stringify(payload)); // the event, as JSON, on stdin
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Run every hook registered for an event, in order, and combine their verdicts.
|
|
42
|
+
// `match` (when present) filters by tool name so a hook can target one tool.
|
|
43
|
+
export async function runHooks(event, payload) {
|
|
44
|
+
const hooks = (CONFIG.hooks[event] ?? []).filter((h) => !h.match || h.match === payload.tool); // applicable hooks only
|
|
45
|
+
if (!hooks.length)
|
|
46
|
+
return { block: false, feedback: "", stdout: "" }; // the common case: nothing registered
|
|
47
|
+
const feedback = []; // stderr from blocking hooks
|
|
48
|
+
const stdout = []; // stdout from passing hooks
|
|
49
|
+
let block = false; // did any hook exit 2?
|
|
50
|
+
for (const hook of hooks) {
|
|
51
|
+
const { code, stdout: out, stderr: err } = await runOne(hook, { event, ...payload }); // run it
|
|
52
|
+
emit("agent_hook_run", { event, code }); // observability for hooks themselves
|
|
53
|
+
if (code === 0) {
|
|
54
|
+
if (out)
|
|
55
|
+
stdout.push(out); // passing-hook output (used by SessionStart)
|
|
56
|
+
}
|
|
57
|
+
else if (code === 2) {
|
|
58
|
+
block = true; // a blocking verdict
|
|
59
|
+
feedback.push(err || `(${event} hook blocked with no message)`); // the reason, for the model
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Any other code: the hook is broken. Be loud, but do not wedge the agent.
|
|
63
|
+
console.error(chalk.yellow(` [hook] ${event} command exited ${code} (ignored): ${(err || out).slice(0, 120)}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { block, feedback: feedback.join("\n"), stdout: stdout.join("\n") };
|
|
67
|
+
}
|
package/dist/loop.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from "chalk"; // terminal colors for status lines
|
|
2
2
|
import ora from "ora"; // the "thinking..." spinner while waiting for the first token
|
|
3
|
-
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
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
6
|
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
7
7
|
import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
|
|
8
|
+
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
9
|
+
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
8
10
|
export const MAX_ROUNDS = 15; // model→tool round cap per query
|
|
9
11
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
10
12
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
@@ -50,6 +52,7 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
|
|
|
50
52
|
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
51
53
|
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
52
54
|
async function runSubAgent(description, opts) {
|
|
55
|
+
emit("agent_subagent_spawn"); // delegation is worth counting
|
|
53
56
|
if (!opts.quiet)
|
|
54
57
|
console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
|
|
55
58
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
@@ -102,14 +105,18 @@ opts) {
|
|
|
102
105
|
// - stall (slow but alive for 30s) → log it and keep waiting
|
|
103
106
|
const watchdog = setInterval(() => {
|
|
104
107
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
105
|
-
if (quietMs > IDLE_TIMEOUT_MS)
|
|
108
|
+
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
109
|
+
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
106
110
|
idleAbort.abort(); // dead — cut it
|
|
111
|
+
}
|
|
107
112
|
else if (quietMs > STALL_WARN_MS && !stallWarned) {
|
|
108
113
|
stallWarned = true; // don't repeat the warning every second
|
|
114
|
+
emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
|
|
109
115
|
console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
|
|
110
116
|
}
|
|
111
117
|
}, 1000);
|
|
112
118
|
try {
|
|
119
|
+
emit("agent_api_call"); // one event per attempt — retries show up as extra calls
|
|
113
120
|
const stream = await opts.client.chat.completions.create(
|
|
114
121
|
// Sub-agents do NOT get the task tool: one level of delegation only.
|
|
115
122
|
// Nested spawning means orphan processes and debugging hell.
|
|
@@ -159,11 +166,70 @@ opts) {
|
|
|
159
166
|
spinner.stop(); // and never leave a zombie spinner
|
|
160
167
|
}
|
|
161
168
|
}
|
|
169
|
+
// Run one tool call end to end: log it, run it through the permission gate,
|
|
170
|
+
// ask the human if needed, execute, and return the paired result. Read-only
|
|
171
|
+
// calls (allow/deny only, never "ask") are safe to run via this from inside a
|
|
172
|
+
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
173
|
+
async function runOneCall(call, opts) {
|
|
174
|
+
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
|
|
175
|
+
if (!opts.quiet)
|
|
176
|
+
console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
|
|
177
|
+
// The permission gate sits between the model's intent and execution.
|
|
178
|
+
const v = checkPermission(call.name, call.args);
|
|
179
|
+
emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
|
|
180
|
+
let content; // what goes back to the model as the tool result
|
|
181
|
+
if (v.decision === "deny") {
|
|
182
|
+
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
183
|
+
if (!opts.quiet)
|
|
184
|
+
console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
|
|
185
|
+
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
|
+
}
|
|
187
|
+
else if (v.decision === "ask") {
|
|
188
|
+
const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
|
|
189
|
+
if (!ok)
|
|
190
|
+
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
191
|
+
if (!ok && !opts.quiet)
|
|
192
|
+
console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
|
|
193
|
+
content = ok
|
|
194
|
+
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
195
|
+
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
|
|
199
|
+
}
|
|
200
|
+
return { id: call.id, content }; // paired by id for the API
|
|
201
|
+
}
|
|
202
|
+
// Run an approved tool call, surrounded by the user's lifecycle hooks.
|
|
203
|
+
// PreToolUse can block (exit 2) — its stderr is fed back to the model as the
|
|
204
|
+
// reason, exactly like a permission denial, so the model adapts instead of
|
|
205
|
+
// retrying. PostToolUse is observational; if it blocks, its message is appended
|
|
206
|
+
// to the tool result as extra context (e.g. "lint failed on the file you just
|
|
207
|
+
// wrote"). Sub-agents skip hooks: hooks are about the human's project policy,
|
|
208
|
+
// not internal delegation.
|
|
209
|
+
async function runWithHooks(call, opts) {
|
|
210
|
+
if (opts.subAgent)
|
|
211
|
+
return execute(call, opts); // sub-agents run hook-free
|
|
212
|
+
const pre = await runHooks("PreToolUse", { tool: call.name, args: call.args }); // before
|
|
213
|
+
if (pre.block) {
|
|
214
|
+
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
215
|
+
if (!opts.quiet)
|
|
216
|
+
console.log(chalk.red(` ⛔ blocked by PreToolUse hook`)); // make it visible
|
|
217
|
+
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
218
|
+
}
|
|
219
|
+
const result = await execute(call, opts); // the actual work
|
|
220
|
+
const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
|
|
221
|
+
if (post.block) {
|
|
222
|
+
// PostToolUse cannot undo the action, but it can tell the model something
|
|
223
|
+
// is wrong with the result — surface that as appended context.
|
|
224
|
+
return `${result}\n\n[hook] PostToolUse: ${post.feedback}`;
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
162
228
|
// Execute one approved tool call. The task tool is special — it is not in the
|
|
163
229
|
// registry because running it needs the loop itself (it IS a loop).
|
|
164
230
|
async function execute(call, opts) {
|
|
165
231
|
if (call.name !== "task")
|
|
166
|
-
return dispatch(call.name, call.args); // ordinary tools go through the registry
|
|
232
|
+
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
167
233
|
if (opts.subAgent)
|
|
168
234
|
return "[error] Sub-agents cannot spawn sub-agents. Do the work yourself."; // one level of delegation only
|
|
169
235
|
let description = ""; // the sub-task text
|
|
@@ -187,10 +253,12 @@ compaction) {
|
|
|
187
253
|
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
188
254
|
compaction.count++; // one more successful compaction this query
|
|
189
255
|
compaction.failures = 0; // success resets the failure streak
|
|
256
|
+
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
190
257
|
return true;
|
|
191
258
|
}
|
|
192
259
|
catch {
|
|
193
260
|
compaction.failures++; // failed — count it against the breaker
|
|
261
|
+
emit("agent_compaction_failed"); // a failure streak here trips the breaker
|
|
194
262
|
return false;
|
|
195
263
|
}
|
|
196
264
|
}
|
|
@@ -240,6 +308,7 @@ opts) {
|
|
|
240
308
|
}
|
|
241
309
|
if (!e.retryable)
|
|
242
310
|
return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
|
|
311
|
+
emit("agent_api_error", { kind: e.kind, attempt: attempts.total + 1 }); // classified, greppable
|
|
243
312
|
attempts.total++; // every failure consumes the overall budget
|
|
244
313
|
attempts.consecutive++; // ...and the breaker counter
|
|
245
314
|
if (e.kind === ApiErrorKind.RateLimited)
|
|
@@ -276,33 +345,44 @@ opts) {
|
|
|
276
345
|
: {}), // omit the field entirely when there were no calls
|
|
277
346
|
});
|
|
278
347
|
if (!out.toolCalls.length) {
|
|
348
|
+
// The model wants to stop. A Stop hook gets the last word: if it exits
|
|
349
|
+
// 2, the agent is NOT done — its stderr becomes a new instruction and
|
|
350
|
+
// the loop continues. This is how you build test-driven AI: a Stop hook
|
|
351
|
+
// runs the tests, blocks while they fail, and the agent keeps fixing.
|
|
352
|
+
// Sub-agents are exempt — Stop hooks are the human's project policy.
|
|
353
|
+
if (!opts.subAgent) {
|
|
354
|
+
const stop = await runHooks("Stop", { finalText: out.content }); // give hooks the last word
|
|
355
|
+
if (stop.block) {
|
|
356
|
+
emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
|
|
357
|
+
if (!opts.quiet)
|
|
358
|
+
console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
359
|
+
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 (so a stubborn Stop hook is still bounded by MAX_ROUNDS)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
279
363
|
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
280
364
|
}
|
|
281
|
-
// Execute
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
content = ok
|
|
299
|
-
? await execute(call, opts) // approved — actually run it
|
|
300
|
-
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
content = await execute(call, opts); // allow — run without ceremony
|
|
365
|
+
// Execute the tool calls. Read-only calls (read_file/search) in a
|
|
366
|
+
// contiguous run are executed CONCURRENTLY — they cannot conflict and
|
|
367
|
+
// never need an interactive prompt. Anything that writes or executes runs
|
|
368
|
+
// ALONE and in order: writes can depend on each other, and we can only ask
|
|
369
|
+
// the human one question at a time. This greedy batching mirrors how
|
|
370
|
+
// Claude Code parallelizes safe reads while serializing risky work.
|
|
371
|
+
let i = 0; // index into out.toolCalls
|
|
372
|
+
while (i < out.toolCalls.length) {
|
|
373
|
+
const batch = []; // a run of safe, parallelizable calls
|
|
374
|
+
while (i < out.toolCalls.length && isReadOnlyTool(out.toolCalls[i].name))
|
|
375
|
+
batch.push(out.toolCalls[i++]);
|
|
376
|
+
if (batch.length) {
|
|
377
|
+
// Run the whole read-only batch at once, preserving result order.
|
|
378
|
+
const results = await Promise.all(batch.map((c) => runOneCall(c, opts)));
|
|
379
|
+
for (const r of results)
|
|
380
|
+
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
381
|
+
continue; // back to the top — the next call is non-read-only
|
|
304
382
|
}
|
|
305
|
-
|
|
383
|
+
// A single non-read-only call: gate, maybe ask, execute — all serial.
|
|
384
|
+
const r = await runOneCall(out.toolCalls[i++], opts);
|
|
385
|
+
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
306
386
|
}
|
|
307
387
|
break; // round complete, move to the next one
|
|
308
388
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path"; // used to resolve and split file paths
|
|
2
|
+
import { CONFIG } from "./config.js"; // user-configured allow/deny rules from settings files
|
|
2
3
|
// ---- The no-fly zone --------------------------------------------------------
|
|
3
4
|
// Writes here are never OK, in any mode. .git can destroy history, shell rc
|
|
4
5
|
// files inject code into every future terminal, .ssh/.env leak credentials.
|
|
@@ -43,13 +44,26 @@ const BASH_ASK = [
|
|
|
43
44
|
// Single, read-only-ish commands that run without asking.
|
|
44
45
|
const BASH_ALLOW = new Set(["ls", "pwd", "echo", "wc", "which", "date", "node", "npm", "npx", "cat", "head", "tail"]);
|
|
45
46
|
const GIT_READONLY = new Set(["status", "log", "diff", "show", "branch", "remote", "tag"]); // git subcommands that only read
|
|
47
|
+
// User rules from settings files, split by kind. Computed per call (not cached)
|
|
48
|
+
// so the test suite can mutate CONFIG.permissions and see the effect.
|
|
49
|
+
function userBashRules() {
|
|
50
|
+
return {
|
|
51
|
+
allow: CONFIG.permissions.allow.filter((r) => !r.startsWith("tool:")), // bash first-words to trust
|
|
52
|
+
deny: CONFIG.permissions.deny.filter((r) => !r.startsWith("tool:")), // command substrings to block
|
|
53
|
+
};
|
|
54
|
+
}
|
|
46
55
|
// Decide what a bash command deserves: deny, ask, or allow.
|
|
47
56
|
function checkBash(command) {
|
|
48
57
|
const summary = command.trim(); // what the user will see in the prompt
|
|
49
|
-
|
|
58
|
+
const user = userBashRules(); // the user's configured additions
|
|
59
|
+
// Order is the security model: built-in deny, then user deny, then ask,
|
|
60
|
+
// then allow. A user allow can never jump this queue — allow is checked last.
|
|
50
61
|
for (const [re, why] of BASH_DENY)
|
|
51
62
|
if (re.test(command))
|
|
52
63
|
return { decision: "deny", reason: why, summary };
|
|
64
|
+
for (const d of user.deny)
|
|
65
|
+
if (command.toLowerCase().includes(d.toLowerCase()))
|
|
66
|
+
return { decision: "deny", reason: `denied by your settings ("${d}")`, summary };
|
|
53
67
|
for (const [re, why] of BASH_ASK)
|
|
54
68
|
if (re.test(command))
|
|
55
69
|
return { decision: "ask", reason: why, summary };
|
|
@@ -61,12 +75,18 @@ function checkBash(command) {
|
|
|
61
75
|
return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
|
|
62
76
|
if (BASH_ALLOW.has(words[0]))
|
|
63
77
|
return { decision: "allow", reason: "safe command", summary }; // known-harmless single command
|
|
78
|
+
if (user.allow.includes(words[0]))
|
|
79
|
+
return { decision: "allow", reason: "allowed by your settings", summary }; // the user vouched for this command
|
|
64
80
|
// Fail closed: a command we don't recognize is a question for the human.
|
|
65
81
|
return { decision: "ask", reason: "unrecognized command", summary };
|
|
66
82
|
}
|
|
67
83
|
// ---- The single entry point ---------------------------------------------------
|
|
68
84
|
// Called by the loop for EVERY tool call, before anything executes.
|
|
69
85
|
export function checkPermission(toolName, argsJson) {
|
|
86
|
+
// A user-configured tool block beats everything, including built-in allows.
|
|
87
|
+
if (CONFIG.permissions.deny.includes(`tool:${toolName}`)) {
|
|
88
|
+
return { decision: "deny", reason: `tool blocked by your settings ("tool:${toolName}")`, summary: toolName };
|
|
89
|
+
}
|
|
70
90
|
let args; // the parsed tool arguments
|
|
71
91
|
try {
|
|
72
92
|
args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
|
|
@@ -95,6 +115,12 @@ export function checkPermission(toolName, argsJson) {
|
|
|
95
115
|
const hit = noFlyHit(p); // is it in the no-fly zone?
|
|
96
116
|
if (hit)
|
|
97
117
|
return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
|
|
118
|
+
// The user may pre-approve write tools ("tool:edit_file") to skip the
|
|
119
|
+
// prompt — note this runs AFTER the no-fly check, so it widens "ask",
|
|
120
|
+
// never "deny".
|
|
121
|
+
if (CONFIG.permissions.allow.includes(`tool:${toolName}`)) {
|
|
122
|
+
return { decision: "allow", reason: "pre-approved by your settings", summary: p };
|
|
123
|
+
}
|
|
98
124
|
return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
|
|
99
125
|
}
|
|
100
126
|
case "run_bash":
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs"; // session files live on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// Sessions are project-local: .mini-agent/sessions/<id>.json under the cwd.
|
|
4
|
+
// Project-local (not global) because a conversation is about a codebase —
|
|
5
|
+
// resuming a refactor of project A from inside project B makes no sense.
|
|
6
|
+
const SESSIONS_DIR = path.resolve(".mini-agent", "sessions");
|
|
7
|
+
const KEEP_SESSIONS = 20; // how many session files survive pruning
|
|
8
|
+
// A fresh, filename-safe session id derived from the wall clock.
|
|
9
|
+
export function newSessionId() {
|
|
10
|
+
return new Date().toISOString().replace(/[:.]/g, "-"); // 2026-06-12T10-30-00-000Z
|
|
11
|
+
}
|
|
12
|
+
// Persist the conversation. Full snapshot, atomically (tmp + rename): the
|
|
13
|
+
// history is not append-only — compaction rewrites it — so a snapshot is the
|
|
14
|
+
// only representation that is always correct. Crash mid-write leaves the
|
|
15
|
+
// previous snapshot intact.
|
|
16
|
+
export function saveSession(id, model, messages) {
|
|
17
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true }); // first save creates the directory
|
|
18
|
+
const file = path.join(SESSIONS_DIR, `${id}.json`); // the real destination
|
|
19
|
+
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
|
|
20
|
+
const data = {
|
|
21
|
+
id,
|
|
22
|
+
startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
|
|
23
|
+
savedAt: new Date().toISOString(), // always refreshed
|
|
24
|
+
model, // informational — resume does not force the same model
|
|
25
|
+
messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
|
|
26
|
+
};
|
|
27
|
+
const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify(data)); // the new snapshot
|
|
29
|
+
fs.renameSync(tmp, file); // atomic swap
|
|
30
|
+
prune(); // keep the directory bounded
|
|
31
|
+
}
|
|
32
|
+
// Load the most recent session, or null if there is none (or it is unreadable —
|
|
33
|
+
// a corrupt session file should not kill the CLI, just start fresh).
|
|
34
|
+
export function latestSession() {
|
|
35
|
+
if (!fs.existsSync(SESSIONS_DIR))
|
|
36
|
+
return null; // nothing ever saved here
|
|
37
|
+
const files = fs
|
|
38
|
+
.readdirSync(SESSIONS_DIR)
|
|
39
|
+
.filter((f) => f.endsWith(".json")) // ignore tmp files and strangers
|
|
40
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs })) // newest by modification time
|
|
41
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
42
|
+
if (!files.length)
|
|
43
|
+
return null; // empty directory
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, files[0].f), "utf8")); // the newest snapshot
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null; // unreadable — treat as absent, never crash on resume
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Delete everything beyond the newest KEEP_SESSIONS files. A CLI that quietly
|
|
52
|
+
// accumulates unbounded state in every project directory is not industrial.
|
|
53
|
+
function prune() {
|
|
54
|
+
const files = fs
|
|
55
|
+
.readdirSync(SESSIONS_DIR)
|
|
56
|
+
.filter((f) => f.endsWith(".json"))
|
|
57
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtimeMs }))
|
|
58
|
+
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
59
|
+
for (const { f } of files.slice(KEEP_SESSIONS))
|
|
60
|
+
fs.rmSync(path.join(SESSIONS_DIR, f)); // drop the tail
|
|
61
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs"; // the event log lives on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// LOCAL-ONLY telemetry. Events go to .mini-agent/telemetry.jsonl in the
|
|
4
|
+
// project directory and NOWHERE else — nothing leaves the machine, ever.
|
|
5
|
+
// It exists because "no data" means every optimization is a guess: which
|
|
6
|
+
// errors actually happen, how often compaction fires, which tools get denied.
|
|
7
|
+
// Opt out entirely with MINI_AGENT_NO_TELEMETRY=1.
|
|
8
|
+
//
|
|
9
|
+
// Naming follows the unified-prefix convention (agent_<domain>_<action>) so
|
|
10
|
+
// the log stays greppable: `grep agent_api_error telemetry.jsonl | wc -l`.
|
|
11
|
+
const TELEMETRY_FILE = path.resolve(".mini-agent", "telemetry.jsonl");
|
|
12
|
+
let sessionId = null; // set by initTelemetry; emit() is a no-op before that
|
|
13
|
+
const tallies = new Map(); // per-session event counts, for /stats
|
|
14
|
+
// Arm telemetry for this session. Tests and the eval harness never call this,
|
|
15
|
+
// so library use stays silent — only the real CLI writes events.
|
|
16
|
+
export function initTelemetry(id) {
|
|
17
|
+
if (process.env.MINI_AGENT_NO_TELEMETRY === "1")
|
|
18
|
+
return; // the user opted out — stay disarmed
|
|
19
|
+
sessionId = id; // armed
|
|
20
|
+
}
|
|
21
|
+
// Record one event. Never throws: a telemetry failure must never break work.
|
|
22
|
+
export function emit(event, fields = {}) {
|
|
23
|
+
const key = event + (fields.tool ? `:${fields.tool}` : ""); // tool events tally per tool name
|
|
24
|
+
tallies.set(key, (tallies.get(key) ?? 0) + 1); // /stats counts even when disarmed
|
|
25
|
+
if (!sessionId)
|
|
26
|
+
return; // disarmed — counters only, no file
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(TELEMETRY_FILE), { recursive: true }); // ensure the directory
|
|
29
|
+
fs.appendFileSync(TELEMETRY_FILE, JSON.stringify({ ts: new Date().toISOString(), session: sessionId, event, ...fields }) + "\n"); // one JSON object per line
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* a full disk or a read-only checkout is not our problem to escalate */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// The per-session tallies, sorted by count — the data behind /stats.
|
|
36
|
+
export function statsReport() {
|
|
37
|
+
if (!tallies.size)
|
|
38
|
+
return "(no events this session yet)"; // nothing happened
|
|
39
|
+
const lines = [...tallies.entries()]
|
|
40
|
+
.sort((a, b) => b[1] - a[1]) // busiest first
|
|
41
|
+
.map(([k, n]) => ` ${String(n).padStart(5)} ${k}`); // aligned counts
|
|
42
|
+
return ["events this session:", ...lines, `\nfull log: ${TELEMETRY_FILE} (local only — nothing leaves this machine)`].join("\n");
|
|
43
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs"; // filesystem reads and writes
|
|
2
|
+
import os from "node:os"; // temp dir for spilled tool output
|
|
2
3
|
import path from "node:path"; // path resolution and joining
|
|
3
|
-
import {
|
|
4
|
+
import { spawn } from "node:child_process"; // async process execution (does NOT block the event loop)
|
|
4
5
|
// ============ Session state ============
|
|
5
6
|
// The foundation of "read before edit": which files has this session actually read?
|
|
6
7
|
// Files that were never read must not be edited — otherwise the model edits from
|
|
@@ -15,6 +16,9 @@ const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering
|
|
|
15
16
|
const READ_LIMIT = 16000; // read_file gets a larger cap of its own
|
|
16
17
|
const SEARCH_MAX_MATCHES = 50; // max matches returned by search
|
|
17
18
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
|
|
19
|
+
const BASH_TIMEOUT_MS = 30_000; // a single command may run this long before we kill it
|
|
20
|
+
const BASH_OUTPUT_LIMIT = 8_000; // chars of command output kept inline; beyond this we spill to a file
|
|
21
|
+
const SPILL_NOTE_SLACK = 400; // room for the "[...saved to FILE]" note so dispatch's cap never eats it
|
|
18
22
|
// Errors are not exceptions — they are text that tells the model what to do next.
|
|
19
23
|
const fail = (msg) => `[error] ${msg}`;
|
|
20
24
|
// Small helper to build a tool definition without repeating boilerplate.
|
|
@@ -165,24 +169,66 @@ ALWAYS use this tool to search. NEVER run grep/rg/find via run_bash.`, {
|
|
|
165
169
|
},
|
|
166
170
|
};
|
|
167
171
|
// ============ run_bash ============
|
|
172
|
+
// Spill oversized output to a file and hand the model a preview + the path, so
|
|
173
|
+
// it can read_file deeper if it wants. Returning megabytes inline would blow up
|
|
174
|
+
// the context; truncating silently would hide the part that matters.
|
|
175
|
+
function spillIfHuge(out, what) {
|
|
176
|
+
if (out.length <= BASH_OUTPUT_LIMIT)
|
|
177
|
+
return out; // fits inline — return as-is
|
|
178
|
+
const file = path.join(os.tmpdir(), `mini-agent-output-${Date.now()}.txt`); // a stable place to find it
|
|
179
|
+
fs.writeFileSync(file, out, "utf8"); // the full output lives on disk
|
|
180
|
+
const head = out.slice(0, BASH_OUTPUT_LIMIT); // a generous preview
|
|
181
|
+
return `${head}\n\n[...${what} truncated: ${out.length} chars total. Full output saved to ${file} — use read_file to inspect more.]`;
|
|
182
|
+
}
|
|
168
183
|
const runBash = {
|
|
169
184
|
definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
|
|
170
185
|
Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
|
|
171
186
|
To read files use read_file (not cat), to search use search (not grep/find), to edit files use edit_file (not sed/redirection).
|
|
172
187
|
Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
|
|
173
188
|
On error: failures return the error output — analyze it and try a different approach; do not resend the same command unchanged.`, { command: { type: "string", description: "The bash command to run" } }, ["command"]),
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
// Async + spawn: the event loop stays responsive while the command runs, so
|
|
190
|
+
// the spinner keeps spinning and Ctrl+C is heard. execSync would freeze
|
|
191
|
+
// everything — the whole process, the UI, the signal handler — until it
|
|
192
|
+
// returned. The AbortSignal lets the loop kill the child on interruption
|
|
193
|
+
// instead of waiting out the 30s timeout.
|
|
194
|
+
run: (args, signal) => new Promise((resolve) => {
|
|
195
|
+
const child = spawn(args.command, { shell: true, stdio: ["ignore", "pipe", "pipe"] }); // shell:true → bash semantics
|
|
196
|
+
let stdout = ""; // captured stdout
|
|
197
|
+
let stderr = ""; // captured stderr (for the model, not the user's terminal)
|
|
198
|
+
let settled = false; // resolve exactly once
|
|
199
|
+
const done = (text) => {
|
|
200
|
+
if (settled)
|
|
201
|
+
return; // guard against timeout/exit/abort racing
|
|
202
|
+
settled = true;
|
|
203
|
+
clearTimeout(timer); // stop the watchdog
|
|
204
|
+
signal?.removeEventListener("abort", onAbort); // unhook
|
|
205
|
+
resolve(text); // hand the result back
|
|
206
|
+
};
|
|
207
|
+
// Hard timeout: SIGKILL the whole process group, never hang the agent.
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
child.kill("SIGKILL");
|
|
210
|
+
done(fail(`Command timed out after ${BASH_TIMEOUT_MS / 1000}s and was killed.${stdout ? `\npartial stdout:\n${stdout.slice(0, 2000)}` : ""}`));
|
|
211
|
+
}, BASH_TIMEOUT_MS);
|
|
212
|
+
// Ctrl+C while a command runs: kill the child immediately.
|
|
213
|
+
const onAbort = () => {
|
|
214
|
+
child.kill("SIGKILL");
|
|
215
|
+
done(fail("Command was interrupted by the user."));
|
|
216
|
+
};
|
|
217
|
+
signal?.addEventListener("abort", onAbort);
|
|
218
|
+
child.stdout.on("data", (d) => (stdout += d)); // accumulate
|
|
219
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
220
|
+
child.on("error", (err) => done(fail(`Could not start command: ${err.message}`))); // e.g. command not found at the spawn level
|
|
221
|
+
child.on("close", (code) => {
|
|
222
|
+
if (code === 0) {
|
|
223
|
+
done(spillIfHuge(stdout, "output") || "(command succeeded, no output)"); // success
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Non-zero exit: combine streams so the model sees the actual error.
|
|
227
|
+
const combined = [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`].filter(Boolean).join("\n");
|
|
228
|
+
done(fail(`Command exited with code ${code}.\n${spillIfHuge(combined, "error output")}`));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}),
|
|
186
232
|
};
|
|
187
233
|
// ============ Registry & dispatch ============
|
|
188
234
|
// One place that knows every tool. Adding a tool = adding one entry here.
|
|
@@ -195,9 +241,16 @@ export const tools = {
|
|
|
195
241
|
};
|
|
196
242
|
// The list of manuals sent to the model on every API call.
|
|
197
243
|
export const toolDefinitions = Object.values(tools).map((t) => t.definition);
|
|
244
|
+
// Read-only tools never touch the filesystem in a conflicting way, so the loop
|
|
245
|
+
// is free to run a batch of them concurrently. Anything that writes or executes
|
|
246
|
+
// runs alone, in order. (See loop.ts for the batching.)
|
|
247
|
+
const READ_ONLY_TOOLS = new Set(["read_file", "search"]);
|
|
248
|
+
export const isReadOnlyTool = (name) => READ_ONLY_TOOLS.has(name);
|
|
198
249
|
// Single entry point: every failure becomes text fed back to the model.
|
|
199
|
-
// It never throws — the main loop must never die because of a tool.
|
|
200
|
-
|
|
250
|
+
// It never throws — the main loop must never die because of a tool. Async since
|
|
251
|
+
// Day 13 so run_bash can spawn without blocking; the signal lets Ctrl+C kill a
|
|
252
|
+
// running command.
|
|
253
|
+
export async function dispatch(name, argsJson, signal) {
|
|
201
254
|
const tool = tools[name]; // look the tool up by name
|
|
202
255
|
if (!tool)
|
|
203
256
|
return fail(`Unknown tool: ${name}. Available tools: ${Object.keys(tools).join(", ")}`); // model hallucinated a tool → list the real ones
|
|
@@ -208,8 +261,14 @@ export function dispatch(name, argsJson) {
|
|
|
208
261
|
catch {
|
|
209
262
|
return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
|
|
210
263
|
}
|
|
264
|
+
// Each tool gets a result cap sized to its job. run_bash already manages its
|
|
265
|
+
// own output (preview + spill-to-disk via spillIfHuge); its cap must leave
|
|
266
|
+
// room for the spill note, or dispatch would chop off the very pointer the
|
|
267
|
+
// model needs to read the rest.
|
|
268
|
+
const cap = name === "read_file" ? READ_LIMIT + 100 : name === "run_bash" ? BASH_OUTPUT_LIMIT + SPILL_NOTE_SLACK : TOOL_RESULT_LIMIT;
|
|
211
269
|
try {
|
|
212
|
-
|
|
270
|
+
const result = await tool.run(args, signal); // await covers both sync and async tools
|
|
271
|
+
return result.slice(0, cap); // cap the result size
|
|
213
272
|
}
|
|
214
273
|
catch (err) {
|
|
215
274
|
return fail(`Tool crashed: ${err.message}`); // even a crashing tool becomes a readable result
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mini-agent": "dist/agent.js"
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"eval": "tsx --env-file=.env eval/run.ts",
|
|
17
17
|
"build": "tsc -p tsconfig.build.json",
|
|
18
|
-
"prepare": "npm run build"
|
|
18
|
+
"prepare": "npm run build",
|
|
19
|
+
"test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts"
|
|
19
20
|
},
|
|
20
21
|
"engines": {
|
|
21
22
|
"node": ">=20.6"
|