agent-from-zero 0.1.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 +58 -0
- package/dist/agent.js +145 -0
- package/dist/config.js +41 -0
- package/dist/context.js +106 -0
- package/dist/errors.js +53 -0
- package/dist/loop.js +311 -0
- package/dist/permissions.js +106 -0
- package/dist/prompt.js +53 -0
- package/dist/raw-loop.js +75 -0
- package/dist/tools.js +246 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# mini-agent
|
|
2
|
+
|
|
3
|
+
用 DeepSeek API 从零手写一个类 Claude Code 的命令行 Agent,TypeScript 实现。
|
|
4
|
+
|
|
5
|
+
10 天,每天一个 commit。**commit 历史就是课程目录**:`git log --oneline` 看大纲,`git checkout dayN` 拿到第 N 天收工时的完整代码。
|
|
6
|
+
|
|
7
|
+
> 代码、注释、commit message 用英文——模型的工具调用主要在英文数据上训练,英文指令引导更稳,也方便国际读者;配套教程是中文。
|
|
8
|
+
|
|
9
|
+
## 快速开始
|
|
10
|
+
|
|
11
|
+
最简方式(npm 包,包名 `agent-from-zero`,装完命令叫 `mini-agent`):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
echo 'DEEPSEEK_API_KEY=sk-你的key' > .env # key 在 platform.deepseek.com 获取
|
|
15
|
+
npx agent-from-zero
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
从源码跑:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/Michael-crazyman/mini-agent.git && cd mini-agent
|
|
22
|
+
npm install
|
|
23
|
+
cp .env.example .env # 填入你的 DeepSeek API Key
|
|
24
|
+
npm start # 跑最新版
|
|
25
|
+
npm run eval # 10 道题的回归考试
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 换个模型用
|
|
29
|
+
|
|
30
|
+
默认 DeepSeek,但任何 **OpenAI 兼容接口**都能跑,三个环境变量搞定:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
MINI_AGENT_API_KEY=sk-...
|
|
34
|
+
MINI_AGENT_BASE_URL=https://api.openai.com/v1 # OpenAI / Kimi / Qwen / GLM / OpenRouter / Ollama 均可
|
|
35
|
+
MINI_AGENT_MODEL=gpt-4.1-mini
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
各家 base URL 见 [.env.example](.env.example)。两个注意:模型必须支持 function calling;窗口比 DeepSeek 小的话设 `MINI_AGENT_CONTEXT_WINDOW`(单位 token)。换完跑一遍 `npm run eval`——同样 10 道题,正好当模型横评。
|
|
39
|
+
|
|
40
|
+
## 进度
|
|
41
|
+
|
|
42
|
+
| Tag | 内容 |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `day1` | ~50 行 raw tool-calling loop:一个工具、一个循环、两条保险 |
|
|
45
|
+
| `day2` | 工具五件套(read/write/edit/search/bash):编辑前必须先读、old_string 唯一性、永不抛异常的 dispatch、四要素工具说明书 |
|
|
46
|
+
| `day3` | 主循环状态机:错误分类、指数退避(带抖动)、双重重试预算、熔断器,8 种终态各配人话提示,Ctrl+C 体面退出 |
|
|
47
|
+
| `day4` | 权限系统(allow/ask/deny):禁飞区硬拦(.git/.ssh/.env)、deny 永远赢(bypass 也压不过)、非交互 fail closed、bash 按输入定危险等级、秘密文件不进上下文 |
|
|
48
|
+
| `day5` | 上下文管理:token 估算(字节法,宁多勿少)、80% 触发线、6 段结构化摘要(双保险禁工具)、文件恢复(最近 5 个,从磁盘重读)、主动+被动压缩、压缩熔断 |
|
|
49
|
+
| `day6` | 多轮 REPL(行队列输入、/clear、Ctrl+C 三态语义)、system prompt 宪法(数字锚/反面禁令/防虚报)、AGENT.md 项目记忆、流式输出 + 双重看门狗(90s 断流/30s 只警告) |
|
|
50
|
+
| `day7` | 子 agent(task 工具:独立上下文、一层分身不套娃、报告是材料不是结论、文件状态快照隔离)+ 最小 eval(10 道固定题、物证判分、8/10 及格线,`npm run eval`) |
|
|
51
|
+
| `day8` | 封装发布:`bin` 入口 + tsc 构建,npx 一行启动(包名 `agent-from-zero`);内置 .env 加载;缺 key 人话报错 |
|
|
52
|
+
| `day9` | 教程本身:11 章中文教程开源进 [docs/](docs/README.md) |
|
|
53
|
+
|
|
54
|
+
## 📖 配套教程(免费开源)
|
|
55
|
+
|
|
56
|
+
**[完整教程在 docs/ 目录](docs/README.md)**——11 章,每章对应一个 tag:为什么这么写、Claude Code 原版怎么做、你会踩的坑。每章五段结构:做完长什么样 → 跟着写 → 为什么 → 坑 → 小结。
|
|
57
|
+
|
|
58
|
+
如果这个项目帮你搞懂了 agent,点个 ⭐ 就是最大的支持。
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import OpenAI from "openai"; // the API client
|
|
3
|
+
import chalk from "chalk"; // terminal colors
|
|
4
|
+
import readline from "node:readline/promises"; // promise-based terminal input
|
|
5
|
+
import { CONFIG, requireApiKey } from "./config.js"; // provider-agnostic settings (.env loaded there)
|
|
6
|
+
import { runLoop, TerminateReason, MAX_ROUNDS, MAX_RETRIES } from "./loop.js"; // the state machine
|
|
7
|
+
import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
|
|
8
|
+
import { forgetFilesExcept } from "./tools.js"; // to reset file state on /clear
|
|
9
|
+
requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
|
|
10
|
+
// Build the API client once, for the whole session.
|
|
11
|
+
const client = new OpenAI({
|
|
12
|
+
baseURL: CONFIG.baseURL, // DeepSeek by default; any OpenAI-compatible endpoint via MINI_AGENT_BASE_URL
|
|
13
|
+
apiKey: CONFIG.apiKey, // from .env or the shell
|
|
14
|
+
maxRetries: 0, // we own the retry policy — never let the SDK retry underneath us
|
|
15
|
+
});
|
|
16
|
+
// What we tell the user for every way a query can end. No raw stack traces.
|
|
17
|
+
const EXIT_NOTES = {
|
|
18
|
+
[TerminateReason.RoundCap]: `Hit the ${MAX_ROUNDS}-round cap. The task may be too big for one go — try splitting it.`,
|
|
19
|
+
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here instead of burning money.",
|
|
20
|
+
[TerminateReason.RetryBudgetExhausted]: `${MAX_RETRIES} failed API calls in this query — giving up. Check your network and try again.`,
|
|
21
|
+
[TerminateReason.RateLimitBudgetExhausted]: "DeepSeek keeps rate-limiting us. Wait a minute, then try again.",
|
|
22
|
+
[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
|
+
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing — stopping instead of looping. Use /clear to start fresh.",
|
|
24
|
+
[TerminateReason.FatalApiError]: "Unrecoverable API error — retrying would not help. Check your API key and request.",
|
|
25
|
+
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
26
|
+
};
|
|
27
|
+
async function main() {
|
|
28
|
+
// The banner: who am I, which model and host am I on, and the three things worth knowing.
|
|
29
|
+
console.log(chalk.bold("mini-agent") +
|
|
30
|
+
chalk.dim(` — ${CONFIG.model} @ ${new URL(CONFIG.baseURL).host} | "exit" to quit · "/clear" to reset · Ctrl+C interrupts a running task`));
|
|
31
|
+
// ONE readline interface for the whole session — the task prompt and the
|
|
32
|
+
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
33
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
// Input plumbing: a line QUEUE instead of rl.question(). With piped stdin
|
|
35
|
+
// all lines arrive at once — question() would catch the first and lose the
|
|
36
|
+
// rest. The queue catches every line; asking pops one (or waits for one).
|
|
37
|
+
// Bonus: in a terminal, lines typed while a task runs queue up as the next
|
|
38
|
+
// commands instead of vanishing.
|
|
39
|
+
const pendingLines = []; // lines that arrived before anyone asked
|
|
40
|
+
const lineWaiters = []; // askers waiting for a line
|
|
41
|
+
let stdinDone = false; // has stdin ended?
|
|
42
|
+
rl.on("line", (line) => {
|
|
43
|
+
const waiter = lineWaiters.shift(); // is somebody waiting?
|
|
44
|
+
if (waiter)
|
|
45
|
+
waiter(line); // hand the line over directly
|
|
46
|
+
else
|
|
47
|
+
pendingLines.push(line); // nobody waiting — queue it
|
|
48
|
+
});
|
|
49
|
+
rl.on("close", () => {
|
|
50
|
+
stdinDone = true; // no more input will ever come
|
|
51
|
+
while (lineWaiters.length)
|
|
52
|
+
lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
|
|
53
|
+
});
|
|
54
|
+
// Get the next line of user input, showing a prompt if we have to wait.
|
|
55
|
+
const readUserLine = (prompt) => {
|
|
56
|
+
if (pendingLines.length)
|
|
57
|
+
return Promise.resolve(pendingLines.shift()); // typed-ahead (or piped) line — use it
|
|
58
|
+
if (stdinDone)
|
|
59
|
+
return Promise.resolve("exit"); // stdin is gone — leave politely
|
|
60
|
+
rl.setPrompt(prompt); // show the prompt the redraw-safe way
|
|
61
|
+
rl.prompt();
|
|
62
|
+
return new Promise((resolve) => lineWaiters.push(resolve)); // wait for the next line
|
|
63
|
+
};
|
|
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
|
+
// Ctrl+C semantics, by state:
|
|
73
|
+
// - at the prompt: exit the session (standard REPL behavior)
|
|
74
|
+
// - while running: first press interrupts the task, second press force-quits
|
|
75
|
+
const onSigint = () => {
|
|
76
|
+
if (!running) {
|
|
77
|
+
console.log(); // end the prompt line cleanly
|
|
78
|
+
process.exit(0); // at the prompt, Ctrl+C = goodbye
|
|
79
|
+
}
|
|
80
|
+
if (interrupted)
|
|
81
|
+
process.exit(130); // second press while running: force quit (130 = killed by SIGINT)
|
|
82
|
+
interrupted = true; // first press: raise the flag...
|
|
83
|
+
controller.abort(); // ...and cancel the in-flight API request
|
|
84
|
+
console.log(chalk.yellow("\n(interrupt — finishing up, Ctrl+C again to force quit)")); // tell the user we heard them
|
|
85
|
+
};
|
|
86
|
+
process.on("SIGINT", onSigint); // covers non-TTY runs
|
|
87
|
+
rl.on("SIGINT", onSigint); // covers TTY raw mode, where readline swallows the signal itself
|
|
88
|
+
// Ask the human to approve a dangerous action — shares the session readline.
|
|
89
|
+
// Fail closed: in a non-interactive session nobody can say yes, so the
|
|
90
|
+
// answer is no. MINI_AGENT_AUTO_APPROVE=1 bypasses the QUESTION, but hard
|
|
91
|
+
// denies were already enforced in permissions.ts before we get here.
|
|
92
|
+
const confirm = async (question) => {
|
|
93
|
+
console.log(chalk.yellow(`\n⚠️ approval needed — ${question}`)); // always show what is being asked
|
|
94
|
+
if (process.env.MINI_AGENT_AUTO_APPROVE === "1") {
|
|
95
|
+
console.log(chalk.dim(" auto-approved (MINI_AGENT_AUTO_APPROVE=1)")); // bypass mode — say so out loud
|
|
96
|
+
return true; // approve without asking
|
|
97
|
+
}
|
|
98
|
+
if (!process.stdin.isTTY) {
|
|
99
|
+
console.log(chalk.dim(" non-interactive session — denied by default (fail closed)")); // nobody can answer — refuse
|
|
100
|
+
return false; // fail closed
|
|
101
|
+
}
|
|
102
|
+
const answer = await readUserLine(" Allow? [y/N] "); // wait for the human (shares the line queue)
|
|
103
|
+
return /^y(es)?$/i.test(answer.trim()); // anything but y/yes means no
|
|
104
|
+
};
|
|
105
|
+
// The session loop: ask → run → render → ask again. This is what makes it
|
|
106
|
+
// a conversation instead of a one-shot command.
|
|
107
|
+
while (true) {
|
|
108
|
+
const line = (await readUserLine(chalk.green("\n> "))).trim(); // next input, or "exit" on EOF
|
|
109
|
+
if (!line)
|
|
110
|
+
continue; // empty line — just re-prompt
|
|
111
|
+
if (line === "exit" || line === "quit")
|
|
112
|
+
break; // explicit goodbye
|
|
113
|
+
if (line === "/clear") {
|
|
114
|
+
messages = [{ role: "system", content: systemMessage }]; // drop everything but the constitution
|
|
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
|
+
}
|
|
119
|
+
messages.push({ role: "user", content: line }); // the new turn joins the shared history
|
|
120
|
+
running = true; // Ctrl+C now means "interrupt the task"
|
|
121
|
+
interrupted = false; // fresh interrupt state for this task
|
|
122
|
+
controller = new AbortController(); // fresh abort signal for this task
|
|
123
|
+
const result = await runLoop(messages, {
|
|
124
|
+
client, // the configured API client
|
|
125
|
+
model: CONFIG.model, // whatever the user configured — must support function calling
|
|
126
|
+
signal: controller.signal, // for aborting requests
|
|
127
|
+
isInterrupted: () => interrupted, // for stopping between steps
|
|
128
|
+
confirm, // for permission prompts
|
|
129
|
+
});
|
|
130
|
+
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
131
|
+
// Done already streamed its answer to the screen; everything else gets a
|
|
132
|
+
// one-line human explanation. The session continues either way — except a
|
|
133
|
+
// fatal API error, which no amount of chatting will fix.
|
|
134
|
+
if (result.reason !== TerminateReason.Done) {
|
|
135
|
+
console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[result.reason]}`)); // the human explanation
|
|
136
|
+
if (result.detail)
|
|
137
|
+
console.log(chalk.dim(` ${result.detail.slice(0, 300)}`)); // raw detail for the curious
|
|
138
|
+
if (result.reason === TerminateReason.FatalApiError)
|
|
139
|
+
break; // a bad key won't fix itself mid-session
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
rl.close(); // release stdin
|
|
143
|
+
console.log(chalk.dim("bye.")); // a clean goodbye
|
|
144
|
+
}
|
|
145
|
+
main(); // kick everything off
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs"; // for the minimal .env loader
|
|
2
|
+
import chalk from "chalk"; // colored error output
|
|
3
|
+
// Minimal .env loader: lets `npx mini-agent` work in any directory that has a
|
|
4
|
+
// .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
|
+
try {
|
|
7
|
+
for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
|
|
8
|
+
const m = line.match(/^([A-Za-z0-9_]+)=(.*)$/); // KEY=value, nothing fancier
|
|
9
|
+
if (m && !process.env[m[1]])
|
|
10
|
+
process.env[m[1]] = m[2]; // real env vars always win over the file
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* no .env here — the variables may come from the shell instead */
|
|
15
|
+
}
|
|
16
|
+
// Provider-agnostic configuration. mini-agent talks the OpenAI wire protocol,
|
|
17
|
+
// so ANY compatible endpoint works — DeepSeek, OpenAI, Kimi, Qwen, GLM,
|
|
18
|
+
// OpenRouter, a local Ollama... three variables and you're on a new model.
|
|
19
|
+
//
|
|
20
|
+
// Resolution order, most specific wins:
|
|
21
|
+
// MINI_AGENT_* (generic) > DEEPSEEK_* (back-compat) > DeepSeek defaults
|
|
22
|
+
export const CONFIG = {
|
|
23
|
+
baseURL: process.env.MINI_AGENT_BASE_URL || // bring your own provider...
|
|
24
|
+
process.env.DEEPSEEK_BASE_URL || // ...or the original DeepSeek override (kept for back-compat and tests)
|
|
25
|
+
"https://api.deepseek.com", // the default provider
|
|
26
|
+
apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
|
|
27
|
+
process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
|
|
28
|
+
"", // empty = missing; requireApiKey() turns that into a helpful error
|
|
29
|
+
model: process.env.MINI_AGENT_MODEL || "deepseek-chat", // must support function calling
|
|
30
|
+
};
|
|
31
|
+
// No key, no point: fail with instructions instead of a stack trace later.
|
|
32
|
+
export function requireApiKey() {
|
|
33
|
+
if (CONFIG.apiKey)
|
|
34
|
+
return; // all good
|
|
35
|
+
console.error(chalk.red("Missing API key."));
|
|
36
|
+
console.error("mini-agent works with any OpenAI-compatible provider. Either:");
|
|
37
|
+
console.error(" echo 'DEEPSEEK_API_KEY=sk-...' > .env (DeepSeek, the default)");
|
|
38
|
+
console.error(" or set MINI_AGENT_API_KEY / MINI_AGENT_BASE_URL / MINI_AGENT_MODEL");
|
|
39
|
+
console.error(" to use OpenAI, Kimi, Qwen, GLM, OpenRouter, Ollama... (see .env.example)");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
2
|
+
import chalk from "chalk"; // status lines
|
|
3
|
+
import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
|
|
4
|
+
// ---- The context window ------------------------------------------------------
|
|
5
|
+
// 1,048,565 came from a real API error message, not from documentation.
|
|
6
|
+
// Trust what the API tells you over what you remember reading.
|
|
7
|
+
export const CONTEXT_WINDOW = Number(process.env.MINI_AGENT_CONTEXT_WINDOW || 1_048_565); // overridable for other models
|
|
8
|
+
// Compact well before the hard limit: estimation is approximate and the summary
|
|
9
|
+
// 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)); // overridable so tests can trigger compaction cheaply
|
|
11
|
+
// How many times one query may compact / fail to compact before we give up.
|
|
12
|
+
export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
|
|
13
|
+
export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
|
|
14
|
+
// ---- Token estimation ---------------------------------------------------------
|
|
15
|
+
// Estimation is a safety mechanism, not an optimization: overestimating costs
|
|
16
|
+
// one early compaction; underestimating costs a failed request. Always round up.
|
|
17
|
+
export function estimateTokens(text, dense = false) {
|
|
18
|
+
const bytes = Buffer.byteLength(text, "utf8"); // Chinese/emoji take multiple bytes — count bytes, not chars
|
|
19
|
+
return Math.ceil(bytes / (dense ? 2 : 4)); // JSON-ish text packs ~2 bytes/token, prose/code ~4 — and we round UP
|
|
20
|
+
}
|
|
21
|
+
// Estimate the whole conversation, message by message.
|
|
22
|
+
export function estimateHistoryTokens(messages) {
|
|
23
|
+
let total = 0; // running sum
|
|
24
|
+
for (const m of messages) {
|
|
25
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); // content can be structured — serialize it
|
|
26
|
+
const isDense = m.role === "tool"; // tool results are JSON-ish → denser tokens
|
|
27
|
+
total += estimateTokens(content, isDense) + 8; // +8 per message for role/formatting overhead
|
|
28
|
+
if ("tool_calls" in m && m.tool_calls)
|
|
29
|
+
total += estimateTokens(JSON.stringify(m.tool_calls), true); // count the call arguments too
|
|
30
|
+
}
|
|
31
|
+
return total;
|
|
32
|
+
}
|
|
33
|
+
// ---- The summary prompt ---------------------------------------------------------
|
|
34
|
+
// Six numbered sections. Each exists because losing that piece of information
|
|
35
|
+
// causes a specific, real failure mode after compaction (e.g. losing section 4
|
|
36
|
+
// means the agent re-tries approaches that already failed).
|
|
37
|
+
const SUMMARY_PROMPT = `You are about to lose your conversation history. Summarize it so the work can continue seamlessly in a fresh context.
|
|
38
|
+
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Tool calls will be REJECTED and will waste your only turn.
|
|
39
|
+
|
|
40
|
+
Write exactly these 6 sections:
|
|
41
|
+
1. Primary request and intent — what the user originally asked for, as precisely as possible
|
|
42
|
+
2. Key technical context — languages, paths, commands, constraints that matter
|
|
43
|
+
3. Files read or edited — exact paths, and what was changed in each
|
|
44
|
+
4. Errors and fixes — every error hit so far and how it was (or was not) resolved
|
|
45
|
+
5. Current state — what has just been done, what is in progress
|
|
46
|
+
6. Next step — the single most likely next action
|
|
47
|
+
|
|
48
|
+
CRITICAL: TEXT ONLY. No tool calls.`;
|
|
49
|
+
// ---- Post-compaction file recovery ------------------------------------------------
|
|
50
|
+
const RECOVER_MAX_FILES = 5; // restore at most this many recently-used files
|
|
51
|
+
const RECOVER_FILE_CHARS = 4000; // per-file cap
|
|
52
|
+
const RECOVER_TOTAL_CHARS = 16000; // total cap across all recovered files
|
|
53
|
+
// Compaction may forget the conversation, but it must not forget which files
|
|
54
|
+
// were being worked on. Restore the most recent ones — re-read FROM DISK,
|
|
55
|
+
// never from memory: another process may have changed them since we last looked.
|
|
56
|
+
export function recoverFileState() {
|
|
57
|
+
const candidates = recentFiles(RECOVER_MAX_FILES); // most recently touched first
|
|
58
|
+
const blocks = []; // formatted file blocks for the model
|
|
59
|
+
const recovered = []; // paths that actually made it in
|
|
60
|
+
let total = 0; // chars used so far
|
|
61
|
+
for (const p of candidates) {
|
|
62
|
+
if (!fs.existsSync(p))
|
|
63
|
+
continue; // the file may have been deleted — trust the disk
|
|
64
|
+
const content = fs.readFileSync(p, "utf8").slice(0, RECOVER_FILE_CHARS); // fresh from disk, capped
|
|
65
|
+
if (total + content.length > RECOVER_TOTAL_CHARS)
|
|
66
|
+
break; // budget exhausted — stop here
|
|
67
|
+
total += content.length; // account for it
|
|
68
|
+
recovered.push(p); // this one made it in
|
|
69
|
+
blocks.push(`--- ${p} (re-read from disk, may be truncated) ---\n${content}`); // format the block
|
|
70
|
+
}
|
|
71
|
+
forgetFilesExcept(recovered); // files NOT recovered must be re-read before any future edit
|
|
72
|
+
if (!blocks.length)
|
|
73
|
+
return null; // nothing worth recovering
|
|
74
|
+
return `[Recovered file state after compaction — most recently used files, re-read fresh from disk:]\n\n${blocks.join("\n\n")}`;
|
|
75
|
+
}
|
|
76
|
+
// ---- Compaction itself -----------------------------------------------------------
|
|
77
|
+
// Replace the whole history with a structured summary + recovered file state.
|
|
78
|
+
// Throws on failure — the loop decides what a failure means.
|
|
79
|
+
export async function compactHistory(messages, // the history, mutated in place
|
|
80
|
+
client, // the same client the loop uses
|
|
81
|
+
model, // the same model summarizes its own conversation
|
|
82
|
+
signal) {
|
|
83
|
+
const before = estimateHistoryTokens(messages); // for the log line
|
|
84
|
+
console.log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
85
|
+
const res = await client.chat.completions.create({
|
|
86
|
+
model, // same model — no need for a fancier one to summarize
|
|
87
|
+
messages: [...messages, { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
88
|
+
// Deliberately NO `tools` parameter: with no tools declared, the API
|
|
89
|
+
// cannot accept a tool call — that is the hard guarantee. The CRITICAL
|
|
90
|
+
// lines in the prompt are the soft second layer of the same defense.
|
|
91
|
+
}, { signal });
|
|
92
|
+
const summary = res.choices[0].message.content ?? ""; // the structured summary text
|
|
93
|
+
if (!summary.trim())
|
|
94
|
+
throw new Error("compaction returned an empty summary"); // empty summary = failed compaction
|
|
95
|
+
// The constitution survives compaction: keep the leading system message and
|
|
96
|
+
// drop everything else. Losing the system prompt would silently change the
|
|
97
|
+
// agent's behavior mid-session.
|
|
98
|
+
const system = messages[0]?.role === "system" ? [messages[0]] : []; // there is at most one, at index 0
|
|
99
|
+
messages.length = 0; // drop the old history entirely
|
|
100
|
+
messages.push(...system); // the constitution goes back first
|
|
101
|
+
messages.push({ role: "user", content: `[Context was compacted. Summary of the conversation so far:]\n\n${summary}` }); // the summary becomes the new history
|
|
102
|
+
const recoveredNote = recoverFileState(); // restore working-file contents from disk
|
|
103
|
+
if (recoveredNote)
|
|
104
|
+
messages.push({ role: "user", content: recoveredNote }); // attach as a second message
|
|
105
|
+
console.log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
|
|
106
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import OpenAI from "openai"; // imported for its typed error classes (APIError and friends)
|
|
2
|
+
// Every failure gets a named kind, because every kind has a different recovery
|
|
3
|
+
// strategy and a different retry budget. "Error rate is up" takes hours to
|
|
4
|
+
// debug; "rate_limited jumped from 0 to 5%" takes minutes.
|
|
5
|
+
export var ApiErrorKind;
|
|
6
|
+
(function (ApiErrorKind) {
|
|
7
|
+
ApiErrorKind["Network"] = "network";
|
|
8
|
+
ApiErrorKind["Timeout"] = "timeout";
|
|
9
|
+
ApiErrorKind["RateLimited"] = "rate_limited";
|
|
10
|
+
ApiErrorKind["ServerError"] = "server_error";
|
|
11
|
+
ApiErrorKind["ContextTooLong"] = "context_too_long";
|
|
12
|
+
ApiErrorKind["AuthFailed"] = "auth_failed";
|
|
13
|
+
ApiErrorKind["BadRequest"] = "bad_request";
|
|
14
|
+
ApiErrorKind["Aborted"] = "aborted";
|
|
15
|
+
ApiErrorKind["Unknown"] = "unknown";
|
|
16
|
+
})(ApiErrorKind || (ApiErrorKind = {}));
|
|
17
|
+
// Providers phrase "your input is too big" in many ways — match loosely.
|
|
18
|
+
const CONTEXT_TOO_LONG_RE = /context.*length|maximum.*(context|length|tokens)|too (long|large)|exceed/i;
|
|
19
|
+
// Translate whatever the SDK throws into exactly one ClassifiedError.
|
|
20
|
+
export function classifyError(err) {
|
|
21
|
+
const message = err instanceof Error ? err.message : String(err); // normalize: not everything thrown is an Error
|
|
22
|
+
// User cancellation. The SDK has a dedicated class for it, but an aborted
|
|
23
|
+
// fetch can also surface as a plain Error named "AbortError" — catch both.
|
|
24
|
+
if (err instanceof OpenAI.APIUserAbortError || (err instanceof Error && err.name === "AbortError")) {
|
|
25
|
+
return { kind: ApiErrorKind.Aborted, retryable: false, message }; // not an error to recover from
|
|
26
|
+
}
|
|
27
|
+
// Timeout is checked before Network because it is a subclass of it.
|
|
28
|
+
if (err instanceof OpenAI.APIConnectionTimeoutError) {
|
|
29
|
+
return { kind: ApiErrorKind.Timeout, retryable: true, message }; // slow network — try again
|
|
30
|
+
}
|
|
31
|
+
// Could not reach the server at all (DNS, connection refused, reset...).
|
|
32
|
+
if (err instanceof OpenAI.APIConnectionError) {
|
|
33
|
+
return { kind: ApiErrorKind.Network, retryable: true, message }; // transient — try again
|
|
34
|
+
}
|
|
35
|
+
// The server answered with an error — classify by HTTP status code.
|
|
36
|
+
if (err instanceof OpenAI.APIError) {
|
|
37
|
+
const status = err.status ?? 0; // some APIError instances carry no status — treat as 0
|
|
38
|
+
if (status === 429)
|
|
39
|
+
return { kind: ApiErrorKind.RateLimited, retryable: true, message }; // server says: back off
|
|
40
|
+
if (status === 401 || status === 403)
|
|
41
|
+
return { kind: ApiErrorKind.AuthFailed, retryable: false, message }; // bad key — retries are useless
|
|
42
|
+
if (status === 400 && CONTEXT_TOO_LONG_RE.test(message)) {
|
|
43
|
+
return { kind: ApiErrorKind.ContextTooLong, retryable: false, message }; // needs compaction, not retries
|
|
44
|
+
}
|
|
45
|
+
if (status >= 500)
|
|
46
|
+
return { kind: ApiErrorKind.ServerError, retryable: true, message }; // their side broke — retry
|
|
47
|
+
if (status >= 400)
|
|
48
|
+
return { kind: ApiErrorKind.BadRequest, retryable: false, message }; // our request is wrong — don't
|
|
49
|
+
}
|
|
50
|
+
// Default for the unknown: retry, but it still consumes the budgets —
|
|
51
|
+
// an unknown error that keeps happening will trip the circuit breaker.
|
|
52
|
+
return { kind: ApiErrorKind.Unknown, retryable: true, message };
|
|
53
|
+
}
|
package/dist/loop.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import chalk from "chalk"; // terminal colors for status lines
|
|
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
|
|
4
|
+
import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
5
|
+
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
6
|
+
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
7
|
+
import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
|
|
8
|
+
export const MAX_ROUNDS = 15; // model→tool round cap per query
|
|
9
|
+
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
10
|
+
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
11
|
+
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
12
|
+
const BACKOFF_BASE_MS = 500; // first retry waits ~this long
|
|
13
|
+
const BACKOFF_CAP_MS = 15_000; // no single wait longer than this
|
|
14
|
+
const IDLE_TIMEOUT_MS = 90_000; // no stream events AT ALL for this long → the stream is dead, cut it
|
|
15
|
+
const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is not dead — cutting a live stream wastes every token already paid for
|
|
16
|
+
// A query has many ways to die. Name every one of them — each gets its own
|
|
17
|
+
// user-facing explanation and exit code, instead of a generic "error".
|
|
18
|
+
export var TerminateReason;
|
|
19
|
+
(function (TerminateReason) {
|
|
20
|
+
TerminateReason["Done"] = "done";
|
|
21
|
+
TerminateReason["RoundCap"] = "round_cap";
|
|
22
|
+
TerminateReason["CircuitBreaker"] = "circuit_breaker";
|
|
23
|
+
TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
|
|
24
|
+
TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
|
|
25
|
+
TerminateReason["ContextTooLong"] = "context_too_long";
|
|
26
|
+
TerminateReason["CompactionFailed"] = "compaction_failed";
|
|
27
|
+
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
28
|
+
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
29
|
+
})(TerminateReason || (TerminateReason = {}));
|
|
30
|
+
// The task tool: the parent's handle on sub-agents. Defined here (not in
|
|
31
|
+
// tools.ts) because running it needs the loop itself — it IS a loop.
|
|
32
|
+
const taskTool = {
|
|
33
|
+
type: "function",
|
|
34
|
+
function: {
|
|
35
|
+
name: "task",
|
|
36
|
+
description: `Delegate one self-contained subtask to a sub-agent that works in a FRESH context.
|
|
37
|
+
The sub-agent has the same tools (except task) but knows NOTHING about this conversation — put every detail it needs into the description.
|
|
38
|
+
Use it for exploration that would flood your context: reading many files, broad searches, summarizing a directory.
|
|
39
|
+
The sub-agent's report is INPUT MATERIAL, not verified truth — re-check key claims yourself before acting on them.
|
|
40
|
+
On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a clearer description, or do the work yourself.`,
|
|
41
|
+
parameters: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
description: { type: "string", description: "The complete, self-contained task for the sub-agent" },
|
|
45
|
+
},
|
|
46
|
+
required: ["description"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
51
|
+
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
52
|
+
async function runSubAgent(description, opts) {
|
|
53
|
+
if (!opts.quiet)
|
|
54
|
+
console.log(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
|
|
55
|
+
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
56
|
+
try {
|
|
57
|
+
const subMessages = [
|
|
58
|
+
{ role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
|
|
59
|
+
{ role: "user", content: description }, // the task is its entire world
|
|
60
|
+
];
|
|
61
|
+
const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse, flagged as sub-agent
|
|
62
|
+
if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
|
|
63
|
+
// The framing matters: the parent must treat this as material to verify,
|
|
64
|
+
// not as conclusions to copy — the most common multi-agent failure mode.
|
|
65
|
+
return `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
|
|
66
|
+
}
|
|
67
|
+
return `[sub-agent failed: ${result.reason}]`; // any non-Done ending, compressed to one line
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
71
|
+
if (!opts.quiet)
|
|
72
|
+
console.log(chalk.blue(" ⎿ sub-agent done")); // close the bracket
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
76
|
+
// Without jitter, every client that failed at the same second retries at the
|
|
77
|
+
// same second — a thundering herd against an already-struggling server.
|
|
78
|
+
function backoffMs(consecutiveFailures) {
|
|
79
|
+
const base = Math.min(BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), BACKOFF_CAP_MS); // grow exponentially, then cap
|
|
80
|
+
return Math.round(base * (1 + Math.random() * 0.25)); // add 0–25% random jitter
|
|
81
|
+
}
|
|
82
|
+
// Sleep in small slices so Ctrl+C never has to wait out a long backoff.
|
|
83
|
+
async function interruptibleSleep(ms, isInterrupted) {
|
|
84
|
+
const SLICE = 200; // check for interruption every 200ms
|
|
85
|
+
for (let waited = 0; waited < ms && !isInterrupted(); waited += SLICE) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// One streaming model call: prints text as it arrives, assembles tool calls
|
|
90
|
+
// from their deltas, and guards the stream with a two-level watchdog.
|
|
91
|
+
async function streamModelCall(messages, // the full history to send
|
|
92
|
+
opts) {
|
|
93
|
+
const idleAbort = new AbortController(); // the watchdog's own kill switch
|
|
94
|
+
const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
|
|
95
|
+
const spinner = opts.quiet
|
|
96
|
+
? null // the eval harness wants silence
|
|
97
|
+
: ora({ text: opts.subAgent ? "sub-agent working..." : "thinking...", discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
|
|
98
|
+
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
99
|
+
let stallWarned = false; // only warn once per quiet stretch
|
|
100
|
+
// The two-level watchdog, checked once per second:
|
|
101
|
+
// - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
|
|
102
|
+
// - stall (slow but alive for 30s) → log it and keep waiting
|
|
103
|
+
const watchdog = setInterval(() => {
|
|
104
|
+
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
105
|
+
if (quietMs > IDLE_TIMEOUT_MS)
|
|
106
|
+
idleAbort.abort(); // dead — cut it
|
|
107
|
+
else if (quietMs > STALL_WARN_MS && !stallWarned) {
|
|
108
|
+
stallWarned = true; // don't repeat the warning every second
|
|
109
|
+
console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
|
|
110
|
+
}
|
|
111
|
+
}, 1000);
|
|
112
|
+
try {
|
|
113
|
+
const stream = await opts.client.chat.completions.create(
|
|
114
|
+
// Sub-agents do NOT get the task tool: one level of delegation only.
|
|
115
|
+
// Nested spawning means orphan processes and debugging hell.
|
|
116
|
+
{ model: opts.model, messages, tools: opts.subAgent ? toolDefinitions : [...toolDefinitions, taskTool], stream: true }, { signal });
|
|
117
|
+
let content = ""; // accumulated answer text
|
|
118
|
+
let printedPrefix = false; // have we printed the 🤖 prefix yet?
|
|
119
|
+
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
120
|
+
for await (const chunk of stream) {
|
|
121
|
+
lastEvent = Date.now(); // feed the watchdog
|
|
122
|
+
stallWarned = false; // the stream spoke — reset the stall warning
|
|
123
|
+
const delta = chunk.choices[0]?.delta; // this chunk's increment
|
|
124
|
+
if (!delta)
|
|
125
|
+
continue; // keep-alive or usage chunk — nothing to do
|
|
126
|
+
if (delta.content) {
|
|
127
|
+
if (spinner?.isSpinning)
|
|
128
|
+
spinner.stop(); // first token: replace the spinner with real output
|
|
129
|
+
if (!opts.quiet && !opts.subAgent) {
|
|
130
|
+
// Only the top-level agent streams to the screen — a sub-agent's
|
|
131
|
+
// inner monologue would be mistaken for the answer.
|
|
132
|
+
if (!printedPrefix) {
|
|
133
|
+
process.stdout.write("\n🤖 "); // prefix once, then stream raw
|
|
134
|
+
printedPrefix = true;
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
|
|
137
|
+
}
|
|
138
|
+
content += delta.content; // always keep it for the history
|
|
139
|
+
}
|
|
140
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
141
|
+
if (spinner?.isSpinning)
|
|
142
|
+
spinner.stop(); // tool call starting — spinner served its purpose
|
|
143
|
+
const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
|
|
144
|
+
if (tc.id)
|
|
145
|
+
slot.id = tc.id; // id arrives once
|
|
146
|
+
if (tc.function?.name)
|
|
147
|
+
slot.name += tc.function.name; // name usually arrives whole; += is safe either way
|
|
148
|
+
if (tc.function?.arguments)
|
|
149
|
+
slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (printedPrefix)
|
|
153
|
+
process.stdout.write("\n"); // end the streamed line cleanly
|
|
154
|
+
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
clearInterval(watchdog); // always stop the timer
|
|
158
|
+
if (spinner?.isSpinning)
|
|
159
|
+
spinner.stop(); // and never leave a zombie spinner
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Execute one approved tool call. The task tool is special — it is not in the
|
|
163
|
+
// registry because running it needs the loop itself (it IS a loop).
|
|
164
|
+
async function execute(call, opts) {
|
|
165
|
+
if (call.name !== "task")
|
|
166
|
+
return dispatch(call.name, call.args); // ordinary tools go through the registry
|
|
167
|
+
if (opts.subAgent)
|
|
168
|
+
return "[error] Sub-agents cannot spawn sub-agents. Do the work yourself."; // one level of delegation only
|
|
169
|
+
let description = ""; // the sub-task text
|
|
170
|
+
try {
|
|
171
|
+
description = JSON.parse(call.args).description ?? ""; // arguments arrive as JSON
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* fall through to the error below */
|
|
175
|
+
}
|
|
176
|
+
if (!description)
|
|
177
|
+
return "[error] task requires a non-empty description argument.";
|
|
178
|
+
return runSubAgent(description, opts); // spawn the worker
|
|
179
|
+
}
|
|
180
|
+
// Run one compaction attempt and keep score. Returns true on success.
|
|
181
|
+
// Every automatic behavior keeps a failure count — that is what lets the
|
|
182
|
+
// caller stop a hot failure loop instead of compacting forever.
|
|
183
|
+
async function tryCompact(messages, // history to compact (mutated in place)
|
|
184
|
+
opts, // for client/model/signal
|
|
185
|
+
compaction) {
|
|
186
|
+
try {
|
|
187
|
+
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
188
|
+
compaction.count++; // one more successful compaction this query
|
|
189
|
+
compaction.failures = 0; // success resets the failure streak
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
compaction.failures++; // failed — count it against the breaker
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// The agent main loop, as a state machine: every iteration either continues
|
|
198
|
+
// for a named reason or terminates for a named reason — nothing implicit.
|
|
199
|
+
export async function runLoop(messages, // conversation history (mutated in place)
|
|
200
|
+
opts) {
|
|
201
|
+
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
202
|
+
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
203
|
+
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
204
|
+
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
|
205
|
+
// One round = one successful model call + its tool results.
|
|
206
|
+
// The inner loop retries the model call until it succeeds or a budget dies.
|
|
207
|
+
while (true) {
|
|
208
|
+
if (opts.isInterrupted())
|
|
209
|
+
return { reason: TerminateReason.UserInterrupt }; // user asked us to stop — obey before spending money
|
|
210
|
+
// Proactive compaction: act BEFORE the API rejects us. Waiting for the
|
|
211
|
+
// hard limit means the failure already happened.
|
|
212
|
+
if (estimateHistoryTokens(messages) > COMPACT_AT) {
|
|
213
|
+
if (compaction.count >= MAX_COMPACTIONS_PER_QUERY)
|
|
214
|
+
return { reason: TerminateReason.CompactionFailed, detail: `already compacted ${compaction.count}x this query — the task is too big for one session` };
|
|
215
|
+
const ok = await tryCompact(messages, opts, compaction); // shrink the history
|
|
216
|
+
if (!ok && compaction.failures >= MAX_COMPACT_FAILURES)
|
|
217
|
+
return { reason: TerminateReason.CompactionFailed, detail: `${compaction.failures} consecutive compaction failures` }; // the compaction circuit breaker
|
|
218
|
+
}
|
|
219
|
+
let out; // the assembled reply for this round
|
|
220
|
+
try {
|
|
221
|
+
out = await streamModelCall(messages, opts); // streaming call with spinner + watchdog
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// Interrupt first: an aborted request can surface as all kinds of
|
|
225
|
+
// errors, and none of them deserve a retry line.
|
|
226
|
+
if (opts.isInterrupted())
|
|
227
|
+
return { reason: TerminateReason.UserInterrupt };
|
|
228
|
+
let e = classifyError(err); // turn the raw exception into a named kind
|
|
229
|
+
if (e.kind === ApiErrorKind.Aborted) {
|
|
230
|
+
// The user did NOT press Ctrl+C (checked above), so this abort came
|
|
231
|
+
// from the idle watchdog — treat it as a retryable timeout.
|
|
232
|
+
e = { kind: ApiErrorKind.Timeout, retryable: true, message: "stream went silent for 90s — cut by the idle watchdog" };
|
|
233
|
+
}
|
|
234
|
+
if (e.kind === ApiErrorKind.ContextTooLong) {
|
|
235
|
+
// Reactive compaction: the API just told us we're too big — our
|
|
236
|
+
// estimate was wrong. Compact and retry instead of giving up.
|
|
237
|
+
if (compaction.count < MAX_COMPACTIONS_PER_QUERY && (await tryCompact(messages, opts, compaction)))
|
|
238
|
+
continue; // history is smaller now — retry the same round
|
|
239
|
+
return { reason: TerminateReason.ContextTooLong, detail: e.message }; // compaction could not save us
|
|
240
|
+
}
|
|
241
|
+
if (!e.retryable)
|
|
242
|
+
return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
|
|
243
|
+
attempts.total++; // every failure consumes the overall budget
|
|
244
|
+
attempts.consecutive++; // ...and the breaker counter
|
|
245
|
+
if (e.kind === ApiErrorKind.RateLimited)
|
|
246
|
+
attempts.rateLimited++; // 429s also consume their own budget
|
|
247
|
+
// Order matters: the breaker fires first (hot failure loop), then the
|
|
248
|
+
// specific 429 budget, then the overall budget.
|
|
249
|
+
if (attempts.consecutive >= MAX_CONSECUTIVE_FAILURES)
|
|
250
|
+
return { reason: TerminateReason.CircuitBreaker, detail: `${e.kind}: ${e.message}` };
|
|
251
|
+
if (attempts.rateLimited >= MAX_RATE_LIMIT_RETRIES)
|
|
252
|
+
return { reason: TerminateReason.RateLimitBudgetExhausted, detail: e.message };
|
|
253
|
+
if (attempts.total >= MAX_RETRIES)
|
|
254
|
+
return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
|
|
255
|
+
const wait = backoffMs(attempts.consecutive); // how long to back off this time
|
|
256
|
+
console.log(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
|
|
257
|
+
await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
|
|
258
|
+
continue; // retry the same round
|
|
259
|
+
}
|
|
260
|
+
// An aborted stream does not always throw — sometimes it just ends
|
|
261
|
+
// early and looks like success. Check the flag explicitly, and keep the
|
|
262
|
+
// partial text in history (clearly marked) so the next turn makes sense.
|
|
263
|
+
if (opts.isInterrupted()) {
|
|
264
|
+
if (out.content)
|
|
265
|
+
messages.push({ role: "assistant", content: out.content + "\n[interrupted by user]" }); // text only — partial tool calls must NOT go in (they would need paired results)
|
|
266
|
+
return { reason: TerminateReason.UserInterrupt };
|
|
267
|
+
}
|
|
268
|
+
attempts.consecutive = 0; // success resets the breaker — but never the total budget
|
|
269
|
+
// Rebuild the assistant message from the assembled stream and add it to
|
|
270
|
+
// history — tool results must stay paired with their calls.
|
|
271
|
+
messages.push({
|
|
272
|
+
role: "assistant", // the model's turn
|
|
273
|
+
content: out.content || null, // null when the turn was tool calls only
|
|
274
|
+
...(out.toolCalls.length
|
|
275
|
+
? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
|
|
276
|
+
: {}), // omit the field entirely when there were no calls
|
|
277
|
+
});
|
|
278
|
+
if (!out.toolCalls.length) {
|
|
279
|
+
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
280
|
+
}
|
|
281
|
+
// Execute every tool call, each behind the permission gate.
|
|
282
|
+
for (const call of out.toolCalls) {
|
|
283
|
+
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
|
|
284
|
+
if (!opts.quiet)
|
|
285
|
+
console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
|
|
286
|
+
// The permission gate sits between the model's intent and execution.
|
|
287
|
+
const v = checkPermission(call.name, call.args);
|
|
288
|
+
let content; // what goes back to the model as the tool result
|
|
289
|
+
if (v.decision === "deny") {
|
|
290
|
+
if (!opts.quiet)
|
|
291
|
+
console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
|
|
292
|
+
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
|
|
293
|
+
}
|
|
294
|
+
else if (v.decision === "ask") {
|
|
295
|
+
const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
|
|
296
|
+
if (!ok && !opts.quiet)
|
|
297
|
+
console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
|
|
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
|
|
304
|
+
}
|
|
305
|
+
messages.push({ role: "tool", tool_call_id: call.id, content }); // feed the result back, paired by id
|
|
306
|
+
}
|
|
307
|
+
break; // round complete, move to the next one
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { reason: TerminateReason.RoundCap }; // ran out of rounds before a final answer
|
|
311
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import path from "node:path"; // used to resolve and split file paths
|
|
2
|
+
// ---- The no-fly zone --------------------------------------------------------
|
|
3
|
+
// Writes here are never OK, in any mode. .git can destroy history, shell rc
|
|
4
|
+
// files inject code into every future terminal, .ssh/.env leak credentials.
|
|
5
|
+
const NO_FLY_DIRS = new Set([".git", ".ssh", ".claude"]); // directory names that must never be written into
|
|
6
|
+
const NO_FLY_FILES = new Set([".env", ".bashrc", ".zshrc", ".gitconfig", ".mcp.json"]); // file names that must never be written
|
|
7
|
+
// Files whose CONTENT must never enter the model's context: once a secret is
|
|
8
|
+
// in the conversation, it is in every later API request (and maybe in logs).
|
|
9
|
+
const SECRET_FILE_RE = /(^|\/)(\.env[^/]*|id_rsa[^/]*|[^/]+\.(pem|key))$/;
|
|
10
|
+
// Does this path touch the no-fly zone? Returns the reason, or null if clean.
|
|
11
|
+
function noFlyHit(p) {
|
|
12
|
+
const abs = path.resolve(p); // normalize to an absolute path first
|
|
13
|
+
for (const part of abs.split(path.sep)) {
|
|
14
|
+
// match whole path segments — ".github" must NOT be caught by ".git"
|
|
15
|
+
if (NO_FLY_DIRS.has(part))
|
|
16
|
+
return `${part}/ is a no-fly zone`;
|
|
17
|
+
}
|
|
18
|
+
if (NO_FLY_FILES.has(path.basename(abs)))
|
|
19
|
+
return `${path.basename(abs)} is a no-fly zone file`; // exact file-name match
|
|
20
|
+
return null; // not in the zone
|
|
21
|
+
}
|
|
22
|
+
// ---- Bash command analysis --------------------------------------------------
|
|
23
|
+
// Input-aware safety: run_bash is not one tool, it is a thousand tools wearing
|
|
24
|
+
// a coat. "ls" and "rm -rf" deserve different treatment, so we look at the input.
|
|
25
|
+
// Hard stops. Not a question — these never run.
|
|
26
|
+
const BASH_DENY = [
|
|
27
|
+
[/\brm\s+(-[a-zA-Z]+\s+)*['"]?(\/|~)['"]?(\s|$)/, "rm targeting / or ~ — catastrophic"], // rm -rf / and rm -rf ~
|
|
28
|
+
[/(^|[\s;&|/])\.git(\/|\s|$)/, "touches the .git directory"], // any command naming .git — the / in the prefix class catches absolute paths like /repo/.git
|
|
29
|
+
[/(^|[\s;&|/])\.env(\s|$|[;&|])/, ".env holds secrets"], // any command that names .env
|
|
30
|
+
[/(^|[\s;&|/])\.ssh(\/|\s|$)/, ".ssh holds credentials"], // any command that names .ssh — same / fix
|
|
31
|
+
];
|
|
32
|
+
// Dangerous but sometimes legitimate — stop and ask the human.
|
|
33
|
+
const BASH_ASK = [
|
|
34
|
+
[/\brm\b.*-[a-zA-Z]*r/, "recursive delete"], // rm -r / -rf — deletes whole trees
|
|
35
|
+
[/\brm\b/, "deletes files"], // plain rm — still destructive
|
|
36
|
+
[/\bsudo\b/, "runs as root"], // privilege escalation
|
|
37
|
+
[/\bdrop\s+(table|database)\b/i, "destroys database objects"], // SQL drop
|
|
38
|
+
[/\b(curl|wget)\b[^|;&]*\|\s*(ba|z)?sh\b/, "pipes the internet into a shell"], // curl ... | sh
|
|
39
|
+
[/\bgit\s+push\b.*(--force|\s-f\b)/, "force-push rewrites remote history"], // git push --force
|
|
40
|
+
[/\bgit\s+(reset\s+--hard|clean\b)/, "discards local changes"], // git reset --hard / git clean
|
|
41
|
+
[/\bchmod\b/, "changes file permissions"], // permission changes
|
|
42
|
+
];
|
|
43
|
+
// Single, read-only-ish commands that run without asking.
|
|
44
|
+
const BASH_ALLOW = new Set(["ls", "pwd", "echo", "wc", "which", "date", "node", "npm", "npx", "cat", "head", "tail"]);
|
|
45
|
+
const GIT_READONLY = new Set(["status", "log", "diff", "show", "branch", "remote", "tag"]); // git subcommands that only read
|
|
46
|
+
// Decide what a bash command deserves: deny, ask, or allow.
|
|
47
|
+
function checkBash(command) {
|
|
48
|
+
const summary = command.trim(); // what the user will see in the prompt
|
|
49
|
+
// Order is the security model: deny first, ask second, allow last.
|
|
50
|
+
for (const [re, why] of BASH_DENY)
|
|
51
|
+
if (re.test(command))
|
|
52
|
+
return { decision: "deny", reason: why, summary };
|
|
53
|
+
for (const [re, why] of BASH_ASK)
|
|
54
|
+
if (re.test(command))
|
|
55
|
+
return { decision: "ask", reason: why, summary };
|
|
56
|
+
// Compound commands (&&, ;, |, $(), ``) are too hard to reason about — ask.
|
|
57
|
+
if (/[;&|]|\$\(|`/.test(command))
|
|
58
|
+
return { decision: "ask", reason: "compound command", summary };
|
|
59
|
+
const words = summary.split(/\s+/); // tokenize to inspect the first word
|
|
60
|
+
if (words[0] === "git" && GIT_READONLY.has(words[1] ?? ""))
|
|
61
|
+
return { decision: "allow", reason: "read-only git", summary }; // safe git subcommands
|
|
62
|
+
if (BASH_ALLOW.has(words[0]))
|
|
63
|
+
return { decision: "allow", reason: "safe command", summary }; // known-harmless single command
|
|
64
|
+
// Fail closed: a command we don't recognize is a question for the human.
|
|
65
|
+
return { decision: "ask", reason: "unrecognized command", summary };
|
|
66
|
+
}
|
|
67
|
+
// ---- The single entry point ---------------------------------------------------
|
|
68
|
+
// Called by the loop for EVERY tool call, before anything executes.
|
|
69
|
+
export function checkPermission(toolName, argsJson) {
|
|
70
|
+
let args; // the parsed tool arguments
|
|
71
|
+
try {
|
|
72
|
+
args = JSON.parse(argsJson); // arguments arrive as a raw JSON string from the model
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Let dispatch produce the proper JSON error for the model.
|
|
76
|
+
return { decision: "allow", reason: "unparseable args", summary: toolName };
|
|
77
|
+
}
|
|
78
|
+
switch (toolName) {
|
|
79
|
+
case "search":
|
|
80
|
+
return { decision: "allow", reason: "read-only", summary: "search" }; // searching never mutates anything
|
|
81
|
+
case "task":
|
|
82
|
+
// Spawning a sub-agent is orchestration, not action: every tool the
|
|
83
|
+
// sub-agent uses goes through this same gate individually.
|
|
84
|
+
return { decision: "allow", reason: "sub-agent tools are gated individually", summary: "task" };
|
|
85
|
+
case "read_file": {
|
|
86
|
+
const p = args.path ?? ""; // the file the model wants to read
|
|
87
|
+
if (SECRET_FILE_RE.test(path.resolve(p))) {
|
|
88
|
+
return { decision: "deny", reason: "secret files must never enter the model's context", summary: p }; // keys stay out of the conversation
|
|
89
|
+
}
|
|
90
|
+
return { decision: "allow", reason: "read-only", summary: p }; // normal reads are free
|
|
91
|
+
}
|
|
92
|
+
case "write_file":
|
|
93
|
+
case "edit_file": {
|
|
94
|
+
const p = args.path ?? ""; // the file the model wants to change
|
|
95
|
+
const hit = noFlyHit(p); // is it in the no-fly zone?
|
|
96
|
+
if (hit)
|
|
97
|
+
return { decision: "deny", reason: hit, summary: p }; // hard stop — deny always wins
|
|
98
|
+
return { decision: "ask", reason: "writes to your filesystem", summary: p }; // normal writes need a human yes
|
|
99
|
+
}
|
|
100
|
+
case "run_bash":
|
|
101
|
+
return checkBash(args.command ?? ""); // bash gets input-aware analysis
|
|
102
|
+
default:
|
|
103
|
+
// A tool we don't know gets the most suspicious treatment, not the least.
|
|
104
|
+
return { decision: "ask", reason: "unknown tool", summary: toolName };
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs"; // to load the optional AGENT.md project memory
|
|
2
|
+
import path from "node:path"; // to resolve it from the current directory
|
|
3
|
+
// The static constitution: identical for every session, every user, every day.
|
|
4
|
+
// NOTHING session-specific belongs here — DeepSeek caches by prefix, and a
|
|
5
|
+
// single changed byte (a date, a username, a cwd) would break the cache for
|
|
6
|
+
// the entire system prompt on every single request.
|
|
7
|
+
//
|
|
8
|
+
// Style notes, learned from Claude Code's own prompt:
|
|
9
|
+
// - Numeric anchors ("under 150 words"), not adjectives ("be concise") —
|
|
10
|
+
// adjectives make the model guess a different size every time.
|
|
11
|
+
// - Negative rules ("never add comments to code you did not change") — their
|
|
12
|
+
// boundaries are binary and checkable; positive virtues are not.
|
|
13
|
+
// - The accuracy paragraph is False-Claim mitigation. The "don't be overly
|
|
14
|
+
// self-doubting" half is load-bearing: without it the model swings to the
|
|
15
|
+
// other extreme and hedges everything.
|
|
16
|
+
export const SYSTEM_PROMPT = `You are mini-agent, a coding agent that works in the user's terminal.
|
|
17
|
+
|
|
18
|
+
Behavior rules:
|
|
19
|
+
- Use the dedicated tools for file work: read_file, edit_file, write_file, search. Use run_bash only for things that truly need execution.
|
|
20
|
+
- Report results accurately. If a test fails, say it failed. Don't claim success when you're not sure. Don't be overly self-doubting either — distinguish "verified working" from "I think it works".
|
|
21
|
+
- Never add comments to code you did not change. Never reformat code you did not change.
|
|
22
|
+
- Keep final answers under 150 words unless the task genuinely requires more.
|
|
23
|
+
- Between tool calls, write at most one short sentence — usually nothing.
|
|
24
|
+
- A [permission] denial is a hard boundary. Do not attempt workarounds — explain and ask the user.
|
|
25
|
+
- If the task is ambiguous, ask one clarifying question instead of guessing.`;
|
|
26
|
+
// The sub-agent constitution: a worker with a fresh context and one job.
|
|
27
|
+
// Key line: its FINAL message is the entire deliverable — the parent sees
|
|
28
|
+
// nothing else of what it did.
|
|
29
|
+
export const SUB_AGENT_PROMPT = `You are a sub-agent: a worker spawned by a parent agent to complete one self-contained task.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- You know nothing about the parent's conversation. Work only from the task description.
|
|
33
|
+
- Use your tools to do the work. Verify what you claim — do not guess.
|
|
34
|
+
- Your FINAL message is returned to the parent verbatim. Make it a compact, factual report: findings, exact paths, numbers, code snippets. No greetings, no questions back.
|
|
35
|
+
- You cannot talk to the user. If the task cannot be completed, report precisely what is missing.`;
|
|
36
|
+
// Project memory: an optional AGENT.md in the current directory — the
|
|
37
|
+
// equivalent of Claude Code's CLAUDE.md. Loaded once per session: stable
|
|
38
|
+
// within a session (cache-friendly), different across projects (by design).
|
|
39
|
+
const MEMORY_CAP_CHARS = 8000; // hard cap — a runaway memory file dilutes attention
|
|
40
|
+
// Build the complete system message for this session.
|
|
41
|
+
export function buildSystemMessage() {
|
|
42
|
+
const p = path.join(process.cwd(), "AGENT.md"); // only the current directory — V1 keeps lookup simple
|
|
43
|
+
if (!fs.existsSync(p))
|
|
44
|
+
return SYSTEM_PROMPT; // no project memory — constitution only
|
|
45
|
+
const memory = fs.readFileSync(p, "utf8").slice(0, MEMORY_CAP_CHARS); // read and cap
|
|
46
|
+
// The OVERRIDE/MUST wrapper is deliberate: advisory phrasing gets partial
|
|
47
|
+
// compliance; imperative wrappers measurably do better.
|
|
48
|
+
return `${SYSTEM_PROMPT}
|
|
49
|
+
|
|
50
|
+
The user's project instructions from AGENT.md. These OVERRIDE any default behavior and you MUST follow them exactly as written:
|
|
51
|
+
|
|
52
|
+
${memory}`;
|
|
53
|
+
}
|
package/dist/raw-loop.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import OpenAI from "openai"; // the official OpenAI SDK — DeepSeek is compatible with it
|
|
2
|
+
import { execSync } from "node:child_process"; // runs a shell command and waits for it to finish
|
|
3
|
+
import readline from "node:readline/promises"; // promise-based terminal input (for the question prompt)
|
|
4
|
+
// Create the API client. DeepSeek speaks the OpenAI wire protocol,
|
|
5
|
+
// so the only DeepSeek-specific thing here is the base URL.
|
|
6
|
+
const client = new OpenAI({
|
|
7
|
+
baseURL: "https://api.deepseek.com", // point the SDK at DeepSeek instead of OpenAI
|
|
8
|
+
apiKey: process.env.DEEPSEEK_API_KEY, // never hardcode keys — read from the environment
|
|
9
|
+
});
|
|
10
|
+
// Our first (and only) tool. This is NOT code the model runs — it is a
|
|
11
|
+
// machine-readable manual telling the model that a tool called "run_bash"
|
|
12
|
+
// exists and what arguments it takes.
|
|
13
|
+
const tools = [
|
|
14
|
+
{
|
|
15
|
+
type: "function", // the only tool type the chat API supports
|
|
16
|
+
function: {
|
|
17
|
+
name: "run_bash", // the name the model will call us by
|
|
18
|
+
description: "Run a bash command on the user's machine and return its output.", // when/why to use it
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object", // arguments always arrive as one JSON object
|
|
21
|
+
properties: {
|
|
22
|
+
command: { type: "string", description: "The bash command to run" }, // the single argument
|
|
23
|
+
},
|
|
24
|
+
required: ["command"], // the model must always provide it
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
// The real implementation of the tool — plain TypeScript, invoked by US
|
|
30
|
+
// after the model asks for it. The model never executes anything itself.
|
|
31
|
+
function runBash(command) {
|
|
32
|
+
console.log(`\n🔧 running: ${command}`); // show the user what is about to run
|
|
33
|
+
try {
|
|
34
|
+
const out = execSync(command, { encoding: "utf8", timeout: 30_000 }); // run it, 30s time limit
|
|
35
|
+
return out || "(command succeeded, no output)"; // the API rejects empty strings — always return something
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return `Command failed: ${err.message}`; // don't crash — feed the error back to the model
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
// Ask the user for a task, once, via stdin.
|
|
43
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); // wire up terminal I/O
|
|
44
|
+
const task = await rl.question("What should the AI do?\n> "); // block until the user answers
|
|
45
|
+
rl.close(); // release stdin so the process can exit cleanly later
|
|
46
|
+
// The conversation history. This array IS the model's entire memory —
|
|
47
|
+
// it re-reads the whole thing on every call and remembers nothing else.
|
|
48
|
+
const messages = [{ role: "user", content: task }];
|
|
49
|
+
// The essence of an agent is this loop: model → tool → model → … → final state.
|
|
50
|
+
// The hard cap is a safety net: never loop (and never burn money) forever.
|
|
51
|
+
for (let round = 1; round <= 10; round++) {
|
|
52
|
+
// One API call: send the full history plus the tool manual.
|
|
53
|
+
const res = await client.chat.completions.create({ model: "deepseek-chat", messages, tools });
|
|
54
|
+
const msg = res.choices[0].message; // the model's reply for this round
|
|
55
|
+
messages.push(msg); // the reply must go into history, or tool results would be orphaned
|
|
56
|
+
// No tool calls = the model considers the task done. That is the final state.
|
|
57
|
+
if (!msg.tool_calls?.length) {
|
|
58
|
+
console.log(`\n🤖 ${msg.content}`); // print the model's final answer
|
|
59
|
+
return; // normal exit
|
|
60
|
+
}
|
|
61
|
+
// Otherwise: execute every tool call and feed each result back.
|
|
62
|
+
for (const call of msg.tool_calls) {
|
|
63
|
+
if (call.type !== "function")
|
|
64
|
+
continue; // ignore non-function call types
|
|
65
|
+
const args = JSON.parse(call.function.arguments); // arguments arrive as a JSON string
|
|
66
|
+
messages.push({
|
|
67
|
+
role: "tool", // a special message role: "here is what your tool returned"
|
|
68
|
+
tool_call_id: call.id, // must match the call — the API enforces this pairing
|
|
69
|
+
content: runBash(args.command).slice(0, 4000), // crude cap so huge outputs don't blow up the context
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
console.log("\n⚠️ Hit the 10-round cap, stopping."); // loop guard: stop at the cap, tell the user why
|
|
74
|
+
}
|
|
75
|
+
main(); // kick everything off
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "node:fs"; // filesystem reads and writes
|
|
2
|
+
import path from "node:path"; // path resolution and joining
|
|
3
|
+
import { execSync } from "node:child_process"; // runs a shell command and waits for it
|
|
4
|
+
// ============ Session state ============
|
|
5
|
+
// The foundation of "read before edit": which files has this session actually read?
|
|
6
|
+
// Files that were never read must not be edited — otherwise the model edits from
|
|
7
|
+
// (possibly stale) memory and wrecks the file.
|
|
8
|
+
// A Map instead of a Set since Day 5: the touch order ranks files for recovery
|
|
9
|
+
// after compaction (most recently touched = most likely needed next).
|
|
10
|
+
// A monotonic counter, not Date.now(): two reads in the same millisecond would
|
|
11
|
+
// get identical wall-clock stamps and the ordering would be arbitrary.
|
|
12
|
+
let touchCounter = 0; // increments on every read/write — strictly ordered
|
|
13
|
+
const readFiles = new Map(); // absolute path → touch sequence number
|
|
14
|
+
const TOOL_RESULT_LIMIT = 4000; // cap (chars) for a normal tool result entering the context
|
|
15
|
+
const READ_LIMIT = 16000; // read_file gets a larger cap of its own
|
|
16
|
+
const SEARCH_MAX_MATCHES = 50; // max matches returned by search
|
|
17
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); // directories search never enters
|
|
18
|
+
// Errors are not exceptions — they are text that tells the model what to do next.
|
|
19
|
+
const fail = (msg) => `[error] ${msg}`;
|
|
20
|
+
// Small helper to build a tool definition without repeating boilerplate.
|
|
21
|
+
function def(name, // tool name the model calls
|
|
22
|
+
description, // the manual: what / boundaries / preconditions / on error
|
|
23
|
+
properties, // parameter schema
|
|
24
|
+
required) {
|
|
25
|
+
return {
|
|
26
|
+
type: "function", // the only tool type the chat API supports
|
|
27
|
+
function: { name, description, parameters: { type: "object", properties, required } },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// ============ read_file ============
|
|
31
|
+
const readFile = {
|
|
32
|
+
definition: def("read_file", `Read the full content of a text file.
|
|
33
|
+
Boundaries: text files only; content beyond ${READ_LIMIT} chars is truncated; do not use it on binaries like images.
|
|
34
|
+
Precondition: before editing a file (edit_file) or overwriting an existing one (write_file), you MUST read it with this tool first.
|
|
35
|
+
On error: a missing file returns an error — check the path and retry.
|
|
36
|
+
ALWAYS use this tool to read files. NEVER run cat/head/tail via run_bash.`, { path: { type: "string", description: "File path (absolute or relative)" } }, ["path"]),
|
|
37
|
+
run: (args) => {
|
|
38
|
+
const p = path.resolve(args.path); // normalize to an absolute path
|
|
39
|
+
if (!fs.existsSync(p))
|
|
40
|
+
return fail(`File not found: ${p}. Check the path — you can locate files with search.`); // wrong path → tell the model how to find it
|
|
41
|
+
if (!fs.statSync(p).isFile())
|
|
42
|
+
return fail(`${p} is a directory, not a file.`); // directories are not readable
|
|
43
|
+
const content = fs.readFileSync(p, "utf8"); // read the whole file as text
|
|
44
|
+
readFiles.set(p, ++touchCounter); // remember: this session has read this file (unlocks edit/overwrite)
|
|
45
|
+
if (content.length > READ_LIMIT) {
|
|
46
|
+
return content.slice(0, READ_LIMIT) + `\n... (file is ${content.length} chars, truncated)`; // cap huge files, but say so
|
|
47
|
+
}
|
|
48
|
+
return content || "(empty file)"; // the API rejects empty strings — always return something
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
// ============ write_file ============
|
|
52
|
+
const writeFile = {
|
|
53
|
+
definition: def("write_file", `Create a new file, or fully overwrite an existing one.
|
|
54
|
+
Boundaries: the whole file is replaced by content; to change a small part of a file use edit_file instead — do not rewrite whole files.
|
|
55
|
+
Precondition: overwriting an existing file requires reading it with read_file first. Writes may require user approval.
|
|
56
|
+
On error: follow the error message — read the file first, then retry.`, {
|
|
57
|
+
path: { type: "string", description: "File path; parent directories are created automatically" },
|
|
58
|
+
content: { type: "string", description: "The complete new content of the file" },
|
|
59
|
+
}, ["path", "content"]),
|
|
60
|
+
run: (args) => {
|
|
61
|
+
const p = path.resolve(args.path); // normalize to an absolute path
|
|
62
|
+
if (fs.existsSync(p) && !readFiles.has(p)) {
|
|
63
|
+
return fail(`${p} already exists but you have not read it. Use read_file to check its current content before overwriting.`); // no blind overwrites
|
|
64
|
+
}
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true }); // create parent directories as needed
|
|
66
|
+
fs.writeFileSync(p, args.content, "utf8"); // write the new content
|
|
67
|
+
readFiles.set(p, ++touchCounter); // you wrote it, so you know its content — counts as read
|
|
68
|
+
return `Wrote ${p} (${args.content.length} chars)`; // confirm with the size as a sanity check
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
// ============ edit_file ============
|
|
72
|
+
const editFile = {
|
|
73
|
+
definition: def("edit_file", `Replace one exact string in a file: old_string → new_string.
|
|
74
|
+
Boundaries: old_string must match the file content exactly (including whitespace and indentation) and must be unique in the file; if it is not unique, include a few surrounding lines as context.
|
|
75
|
+
Precondition: you MUST have read the file with read_file first. Edits may require user approval.
|
|
76
|
+
On error: "not found" means old_string does not match the file — re-read and copy it exactly; "not unique" means include more surrounding context.
|
|
77
|
+
ALWAYS use this tool to edit files. NEVER use sed/awk or shell redirection via run_bash.`, {
|
|
78
|
+
path: { type: "string", description: "Path of the file to edit" },
|
|
79
|
+
old_string: { type: "string", description: "The exact text to replace (must be unique)" },
|
|
80
|
+
new_string: { type: "string", description: "The replacement text" },
|
|
81
|
+
}, ["path", "old_string", "new_string"]),
|
|
82
|
+
run: (args) => {
|
|
83
|
+
const p = path.resolve(args.path); // normalize to an absolute path
|
|
84
|
+
if (!fs.existsSync(p))
|
|
85
|
+
return fail(`File not found: ${p}`); // can't edit what isn't there
|
|
86
|
+
if (!readFiles.has(p)) {
|
|
87
|
+
return fail(`You have not read ${p} yet. Use read_file first and edit based on its real content.`); // the read-before-edit rule
|
|
88
|
+
}
|
|
89
|
+
if (args.old_string === args.new_string)
|
|
90
|
+
return fail(`old_string and new_string are identical — nothing to change.`); // pointless edit, don't waste a round
|
|
91
|
+
const content = fs.readFileSync(p, "utf8"); // current file content
|
|
92
|
+
const count = content.split(args.old_string).length - 1; // how many times old_string occurs
|
|
93
|
+
if (count === 0) {
|
|
94
|
+
return fail(`old_string not found in the file. It must match exactly (including whitespace and indentation) — re-read the file and copy it precisely.`); // model's memory is stale → re-read
|
|
95
|
+
}
|
|
96
|
+
if (count > 1) {
|
|
97
|
+
return fail(`old_string appears ${count} times — ambiguous. Include a few surrounding lines to make it unique.`); // refuse to guess which one
|
|
98
|
+
}
|
|
99
|
+
// Note: content.replace(old, newStr) would expand $& $' etc. in newStr as special
|
|
100
|
+
// replacement patterns — pass a function so the replacement is taken literally.
|
|
101
|
+
fs.writeFileSync(p, content.replace(args.old_string, () => args.new_string), "utf8"); // apply the single replacement
|
|
102
|
+
return `Edited ${p}: 1 replacement made.`; // confirm exactly what happened
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
// ============ search ============
|
|
106
|
+
// Convert a shell-style glob (*.ts) into a regex for file-name filtering.
|
|
107
|
+
function globToRegex(glob) {
|
|
108
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); // escape regex chars, then * → .*
|
|
109
|
+
return new RegExp(`^${escaped}$`); // anchor so the whole name must match
|
|
110
|
+
}
|
|
111
|
+
// Recursively yield every file under dir, skipping junk and hidden entries.
|
|
112
|
+
function* walk(dir) {
|
|
113
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
|
|
116
|
+
yield* walk(path.join(dir, entry.name)); // recurse, but never into node_modules/.git/hidden dirs
|
|
117
|
+
}
|
|
118
|
+
else if (entry.isFile() && !entry.name.startsWith(".")) {
|
|
119
|
+
// dotfiles (.env and friends) often hold secrets — search never surfaces them
|
|
120
|
+
yield path.join(dir, entry.name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const search = {
|
|
125
|
+
definition: def("search", `Search file contents under a directory with a regex. Returns a list of "file:line: content" matches.
|
|
126
|
+
Boundaries: skips node_modules/.git/hidden dirs/binary files; returns at most ${SEARCH_MAX_MATCHES} matches; results are locations only — use read_file to see full content.
|
|
127
|
+
On error: an invalid regex returns an error; on "no matches", loosen the pattern or widen the directory and retry.
|
|
128
|
+
ALWAYS use this tool to search. NEVER run grep/rg/find via run_bash.`, {
|
|
129
|
+
pattern: { type: "string", description: "The regex to search for" },
|
|
130
|
+
path: { type: "string", description: "Directory to start from, defaults to the current directory" },
|
|
131
|
+
file_glob: { type: "string", description: "Only search files whose name matches, e.g. *.ts (optional)" },
|
|
132
|
+
}, ["pattern"]),
|
|
133
|
+
run: (args) => {
|
|
134
|
+
const dir = path.resolve(args.path || "."); // default to the current directory
|
|
135
|
+
if (!fs.existsSync(dir))
|
|
136
|
+
return fail(`Directory not found: ${dir}`); // bad starting point
|
|
137
|
+
let regex; // the compiled search pattern
|
|
138
|
+
try {
|
|
139
|
+
regex = new RegExp(args.pattern); // compile the model's pattern
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
return fail(`Invalid regex: ${e.message}`); // bad pattern → tell the model to fix it
|
|
143
|
+
}
|
|
144
|
+
const nameFilter = args.file_glob ? globToRegex(args.file_glob) : null; // optional file-name filter
|
|
145
|
+
const matches = []; // accumulated "file:line: content" lines
|
|
146
|
+
for (const file of walk(dir)) {
|
|
147
|
+
if (nameFilter && !nameFilter.test(path.basename(file)))
|
|
148
|
+
continue; // skip files the glob excludes
|
|
149
|
+
if (fs.statSync(file).size > 1_000_000)
|
|
150
|
+
continue; // skip huge files (logs, bundles)
|
|
151
|
+
const text = fs.readFileSync(file, "utf8"); // read the candidate file
|
|
152
|
+
if (text.includes("\0"))
|
|
153
|
+
continue; // skip binaries (NUL byte = not text)
|
|
154
|
+
const lines = text.split("\n"); // search line by line
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
if (regex.test(lines[i])) {
|
|
157
|
+
matches.push(`${path.relative(dir, file)}:${i + 1}: ${lines[i].trim()}`); // record location + the matching line
|
|
158
|
+
if (matches.length >= SEARCH_MAX_MATCHES) {
|
|
159
|
+
return matches.join("\n") + `\n... (hit the ${SEARCH_MAX_MATCHES}-match cap, narrow your pattern)`; // stop early, tell the model why
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return matches.length ? matches.join("\n") : "No matches. Loosen the pattern or try another directory."; // empty result still guides the next step
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
// ============ run_bash ============
|
|
168
|
+
const runBash = {
|
|
169
|
+
definition: def("run_bash", `Run a bash command on the user's machine (30s timeout) and return its output.
|
|
170
|
+
Boundaries: only for things that truly need execution — installing dependencies, running tests/scripts, git operations.
|
|
171
|
+
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
|
+
Dangerous commands require user approval; some are denied outright. A [permission] result is a hard boundary — never try to work around it.
|
|
173
|
+
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
|
+
run: (args) => {
|
|
175
|
+
try {
|
|
176
|
+
// stderr is piped (not inherited): it belongs in the tool result for the
|
|
177
|
+
// model to read, not splattered onto the user's terminal.
|
|
178
|
+
const out = execSync(args.command, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
179
|
+
return out || "(command succeeded, no output)"; // never return an empty string
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const e = err; // execSync attaches the captured stderr on failure
|
|
183
|
+
return fail(`Command failed: ${e.message}${e.stderr ? `\nstderr: ${e.stderr.slice(0, 1000)}` : ""}`); // failure becomes guidance, not a crash
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
// ============ Registry & dispatch ============
|
|
188
|
+
// One place that knows every tool. Adding a tool = adding one entry here.
|
|
189
|
+
export const tools = {
|
|
190
|
+
read_file: readFile,
|
|
191
|
+
write_file: writeFile,
|
|
192
|
+
edit_file: editFile,
|
|
193
|
+
search,
|
|
194
|
+
run_bash: runBash,
|
|
195
|
+
};
|
|
196
|
+
// The list of manuals sent to the model on every API call.
|
|
197
|
+
export const toolDefinitions = Object.values(tools).map((t) => t.definition);
|
|
198
|
+
// 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
|
+
export function dispatch(name, argsJson) {
|
|
201
|
+
const tool = tools[name]; // look the tool up by name
|
|
202
|
+
if (!tool)
|
|
203
|
+
return fail(`Unknown tool: ${name}. Available tools: ${Object.keys(tools).join(", ")}`); // model hallucinated a tool → list the real ones
|
|
204
|
+
let args; // the parsed arguments
|
|
205
|
+
try {
|
|
206
|
+
args = JSON.parse(argsJson); // arguments arrive as a raw JSON string
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return fail(`Arguments are not valid JSON: ${argsJson.slice(0, 200)}`); // malformed JSON → show what we got
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
return tool.run(args).slice(0, name === "read_file" ? READ_LIMIT + 100 : TOOL_RESULT_LIMIT); // run it, cap the result size
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
return fail(`Tool crashed: ${err.message}`); // even a crashing tool becomes a readable result
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ============ File-state queries (used by compaction recovery) ============
|
|
219
|
+
// The most recently touched files, newest first.
|
|
220
|
+
export function recentFiles(limit) {
|
|
221
|
+
return [...readFiles.entries()] // [path, sequence] pairs
|
|
222
|
+
.sort((a, b) => b[1] - a[1]) // newest first
|
|
223
|
+
.slice(0, limit) // top N only
|
|
224
|
+
.map(([p]) => p); // just the paths
|
|
225
|
+
}
|
|
226
|
+
// Evict everything except the given paths. After compaction, a file the model
|
|
227
|
+
// can no longer see must also lose its "already read" status — otherwise the
|
|
228
|
+
// model could edit it based on content that is no longer in the conversation.
|
|
229
|
+
export function forgetFilesExcept(keep) {
|
|
230
|
+
const keepSet = new Set(keep); // for O(1) lookup
|
|
231
|
+
for (const p of [...readFiles.keys()])
|
|
232
|
+
if (!keepSet.has(p))
|
|
233
|
+
readFiles.delete(p); // drop the rest
|
|
234
|
+
}
|
|
235
|
+
// Snapshot / restore the read-state around a sub-agent run. The sub-agent
|
|
236
|
+
// shares this module's state, but what IT read was never seen by the parent's
|
|
237
|
+
// conversation — restoring the snapshot keeps read-before-edit honest across
|
|
238
|
+
// agent boundaries.
|
|
239
|
+
export function snapshotFileState() {
|
|
240
|
+
return new Map(readFiles); // shallow copy is enough — values are numbers
|
|
241
|
+
}
|
|
242
|
+
export function restoreFileState(snapshot) {
|
|
243
|
+
readFiles.clear(); // drop whatever the sub-agent read or wrote
|
|
244
|
+
for (const [p, seq] of snapshot)
|
|
245
|
+
readFiles.set(p, seq); // bring back the parent's view
|
|
246
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-from-zero",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Claude Code-style CLI agent built from scratch with the DeepSeek API — companion code for a 10-day tutorial",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mini-agent": "dist/agent.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "tsx --env-file=.env src/agent.ts",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"eval": "tsx --env-file=.env eval/run.ts",
|
|
17
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
|
+
"prepare": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20.6"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"chalk": "^5.6.2",
|
|
26
|
+
"openai": "^6.42.0",
|
|
27
|
+
"ora": "^9.4.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.9.2",
|
|
31
|
+
"tsx": "^4.22.4",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|