# minimal-agent：一个能跑起来的最小 Agent

这是「Agent 的四块积木」（模型 / 工具 / 循环 / 记忆）的**完整可运行版**。
单文件、零 npm 依赖，只用 Node ≥ 22 的内置模块。默认走离线**确定性 stub**，
所以**不需要 API key、不需要联网**，下载即跑。

## 5 分钟跑起来

前置：Node ≥ 22（`node --version` 应输出 `v22.x` 或更高）。

```bash
# 1. 进入本目录
cd examples/minimal-agent

# 2. 直接运行（默认 --model stub，离线、确定性）
node agent.mjs "列出沙箱里有哪些文件"
```

首次运行会在本目录下自动创建 `sandbox/`，并播种 3 个示例文件
（`notes.md` / `welcome.txt` / `config.json`），无需任何准备。

预期输出（transcript 含**真实工具输出**，不是写死的常量）：

```
[提示] 当前使用离线确定性 stub（无需 API key、无需网络）。接真实模型请加 --model openai 并提供 API key。
===== transcript =====
[system] 你是一个最小 Agent：只输出工具调用意图或最终回答；工具执行由外围代码负责。
[user] 列出沙箱里有哪些文件
[assistant] tool_call list_dir({"path":"."})
[tool:list_dir] {"ok":true,"path":".","entries":["config.json","notes.md","welcome.txt"]}
[assistant] （stub 确定性回答）任务「列出沙箱里有哪些文件」已完成。
沙箱中共 3 个文件：config.json、notes.md、welcome.txt。
===== 终止原因：final_answer（2 步）=====
```

想多试几种行为：

```bash
node agent.mjs "读取 notes.md 和 welcome.txt 的内容"   # 一轮发多个 tool_calls，串行执行
node agent.mjs "演示参数校验"                          # 先发非法参数 → 回填 → 修正后成功
node agent.mjs "直接回答：你好"                        # 无工具直答，1 步终止
```

## 接真实模型（OpenAI-compatible）

`--model openai` 走 OpenAI-compatible 的 `/chat/completions`，可对接 OpenAI、DeepSeek 等。
API key 解析顺序沿用本仓库既有先例：环境变量 → `--key-file`（默认 `os.tmpdir()/opencode/ds-api-key.txt`）。

```bash
export DEEPSEEK_API_KEY=sk-...        # 或 OPENAI_API_KEY
node agent.mjs --model openai \
  --base-url https://api.deepseek.com/v1 \
  --model-name deepseek-chat \
  "列出沙箱里有哪些文件"
```

鉴权失败（401/403）只会给出通用提示，**绝不回显 key**。

## 命令行参数

| 参数 | 说明 | 默认 |
|---|---|---|
| `--model` | `stub` 或 `openai` | `stub` |
| `--task` | 任务文本；也可直接用位置参数 | `列出沙箱里有哪些文件` |
| `--max-steps` | 主循环最大步数（正整数） | `6` |
| `--base-url` | OpenAI-compatible base URL | `https://api.deepseek.com/v1` |
| `--model-name` | 模型名 | `deepseek-chat` |
| `--key-file` | 密钥文件路径 | `os.tmpdir()/opencode/ds-api-key.txt` |

非法参数（未知 flag、非法 `--model`、非正整数 `--max-steps`）会以**非 0 退出码**打印
结构化 JSON 错误（`{"ok":false,"error":{"code":...,"message":...}}`）。

## 文章小节 ↔ 代码位置

| 文章小节 | `agent.mjs` 位置 |
|---|---|
| 四块积木总览（`agent-four-building-blocks.md:63`） | 四个分节横幅：`积木 1：模型` / `积木 2：工具` / `积木 3：循环` / `积木 4：记忆` |
| 模型是大脑（`agent-four-building-blocks.md:65`） | `// ===== 积木 1：模型 =====`（第 12 行）：`createStubModel` / `buildRequestBody` / `parseResponse` / `createOpenAIModel` |
| 工具是手；拿走即用模板（`agent-give-it-tools.md:236`） | `// ===== 积木 2：工具 =====`（第 112 行）：`createTools` / `resolveInSandbox` / `ensureSandbox` |
| 循环一定要有上限（`agent-give-it-tools.md:259`） | `// ===== 积木 3：循环 =====`（第 200 行）：`runAgent` / `TERMINATION` |
| 短期记忆 vs 长期记忆（`agent-memory-context.md:83`） | `// ===== 积木 4：记忆 =====`（第 224 行）：`createMemory`（窗口化 + 压缩标记） |
| 把错误告诉模型：让它自己修（`agent-tool-error-handling.md:140`） | `// ===== 横切：错误处理 =====`（第 249 行）：`ToolError` / `validateArgs` / `executeToolCall` |
| （无对应文章）命令行入口 | `// ===== CLI =====`（第 318 行）：`parseCli` / `main` / `resolveApiKey` |

## 四块积木在这份代码里具体是什么

- **模型**：`chat(messages, tools, { fetchImpl })`。`stub` 是**确定性模型替身**——输出是它收到的
  `messages`/`tools` 的函数（不用 `Date.now`/`Math.random`），能发出真实 `tool_calls`，
  并充当**前置条件探针**：工具声明缺失、或 tool 结果没有匹配的 `tool_call_id` 时直接抛错。
  最终回答会**插值真实工具输出**（例如沙箱里实际的文件名）。
- **工具**：`list_dir` / `read_file` / `write_file`，全部限制在 `sandbox/` 内。路径包含性用
  `path.resolve(root, input)` + `path.relative(root, resolved)` 判定，拒绝 `..` 逃逸、绝对路径、
  Windows 盘符（`C:\`）、UNC（`\\server\share`），并对 `realpath` 后的真实路径再判一次以防 symlink 逃逸。
  参数 schema 是手写子集校验器，显式支持 `type`/`required`/`properties`/`enum`；
  **未知关键字 fail-closed（抛错）**，不静默放行。
- **循环**：`for step < MAX_STEPS`，三条可断言的终止原因：`final_answer` / `max_steps` / `fatal_error`。
  一轮多个 `tool_calls` **串行**执行，并各自按 id 回填。
- **记忆**：消息历史 + 窗口化（保留 system 与最近 N 轮），被丢弃的轮次用压缩标记替代；
  轮次按 `user` 消息切分，`assistant(tool_calls)` 与其 `tool` 结果同属一轮，绝不拆散。

## 这份示例刻意不做什么

MCP、流式输出、多 Agent、向量检索/长期记忆库、真实沙箱执行（跑代码）、自动并行工具调用、
多 provider、TypeScript、**任何 npm 依赖**。需要这些能力时，请**另起新示例**，不要把这份本体撑大。
