Files
ligent/README2.md
T
2026-10-03 14:43:43 +08:00

2143 lines
110 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ligent: Self-contained yet powerful command-line AI Agent, implemented in C99
C99 从零写成的命令行 AI Agent:连接 **OpenAI 兼容**的 Chat Completions
接口, 具备**原生 function calling** (工具调用)能力, 能读写文件、执行命令、
搜索代码, 并在终端里流式输出.
对话、工具调用、图片走的都是通用协议 (`image_url` + base64 内联, DeepSeek 和
MiMo 都收); **只有思考模式的字段是方言**, 不做跨厂商探测. 代价见 §9.
除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**:
JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的.
```
$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈, 然后编译验证"
{/} read_file "src/util.c"
│ 1 /* util.c - 基础工具实现 */
│ 2 #include "util.h"
│ ...
{/} edit_file "src/util.c" [41]
│ - 41 exit(1);
│ + 41 print_backtrace(); /* 失败时打印调用栈 */
│ + 42 exit(1);
{/} run_command make 2>&1 | tail -20
│ cc ... -o ligent ...
│ [exit code: 0]
已按要求改造 xmalloc 的失败路径, 并确认编译通过.
```
---
## 1. 编译
依赖:`gcc` (或 `clang`)、`make`、`libcurl` 开发包.
```bash
# Debian / Ubuntu
sudo apt install build-essential libcurl4-openssl-dev
# Arch
sudo pacman -S base-devel curl
# Fedora
sudo dnf install gcc make libcurl-devel
```
```bash
make # 生成 ./ligent
make test # 跑单元测试 + 端到端测试
```
没有 `make install`. 这是个自包含的小工具, 不是系统级程序 —— 可执行文件在哪,
配置就在哪 (见下一节), 所以直接拷走 `ligent` 和 `ligent.json` 就是"安装".
依赖只有 **libcurl** 和 libc, 外加一个 C99 编译器 (gcc / clang / tcc 都编得过).
跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端, **不联网、不需要密钥**.
其它目标:
| 目标 | 作用 |
|---|---|
| `make test` | 单元测试 (16 个可执行)+ 端到端测试 (318 项) |
| `make test-unit` | 只跑单元测试, 不需要网络 |
| `make test-fuzz` | 模糊测试 20 万轮 (`test-unit` 里跑的是 4000 轮) |
| `make analyze` | `gcc -fanalyzer` 静态分析, 无告警才算过 |
| `make static` | 完全静态的产物 (先跑 `./tools/build-static-deps.sh`) |
| `make static-test` | 拿同一套 e2e 打静态产物 |
| `make docker-build` | 在容器里构建静态产物 (podman/docker) |
| `make docker-extract` | 把产物从镜像拷到 `./ligent-static` |
| `make docker-test` | 拿同一套 e2e 打容器产物 |
| `make check-docs` | 校验本文里引用的测试数量与现实一致 |
| `tools/check-colors.sh` | 审计 ANSI 颜色码是否都在 16 色内 (`make test-unit` 里会跑) |
### 构建目录按编译器和配置分开
产物不在 `build/` 正下方, 而是 `build/<编译器>[-san]/`:
```
build/cc/ # 默认 CC=cc
build/clang/
build/tcc/
build/cc-san/ # make SAN=1
```
原因很实在:**make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`,
只有被改过的那几个 `.o` 是 clang 编的, 其余还是 tcc 的, 链接时就是一堆
`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`:
一个 `.o` 都不会重编, 而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制,
于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有,
什么都不会检测. 换句话说, "没先 `make clean` 的 ASan 结论"全是假绿灯.
把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应目录就是空的, 该编的
自然会全编.
`make clean` 清掉**一切**构建产物:各 `<编译器>[-san]` 对象目录、`build/static`、
测试可执行文件、e2e 的临时文件、`ligent` / `ligent-static`, 以及
`tools/build-static-deps.sh` 编出来的 `build/static-prefix` (静态库)和
`build/static-src` (源码 + 压缩包缓存), 外加 `tests/__pycache__`.
换句话说, `make clean` 之后 `git status --ignored` 应该只剩你自己的东西.
代价是明确的:**清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟**:
```bash
make clean
./tools/build-static-deps.sh # 要联网、几分钟
make static
```
`make static` 自己**不会**去编依赖 (那等于让一个构建目标偷偷联网下载), 它会明确
报错并告诉你该跑上面那条命令.
特意**不动 `ligent.history`**:那不是构建产物, 是你的输入历史, 删了找不回来.
(e2e 自己写的是 `build/e2e.history`, 在 `build/` 里, 跟着一起清. )
### 让编译器检查我们自己的 printf
项目里有一层自己的 printf 风格包装:`xasprintf` / `sb_printf` / `err_result` /
`log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`, 一共
11 个函数、约 120 个调用点. 它们的格式串**编译器一个都没在看** —— 因为全项目
`__attribute__` 出现 0 次, 编译器不知道这些函数有 printf 语义.
直接写 `fprintf` 的地方是查的 (`confirm.c` 那次 `%s` 对 `int` 就是这么抓到的),
走包装的就全漏过去了. 现在用 `LIGENT_PRINTF(fmt_idx, arg_idx)` 宏 (gcc/clang 展开成
`__attribute__((format(printf, …)))`, tcc 没有这个机制、展开成空)把 11 个函数全标上,
再加 `-Wformat=2`.
结果:120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当
格式串**的写法 —— 代理 URL 里带 `%` 是常事 (密码里的 `%40`、路径里的 `%2F`),
那会去读一个不存在的参数. 改成 `field("代理", "%s", …)`.
`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了:
包装函数内部转发 `fmt` 的地方 (`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量,
而**调用点**那一侧已经由属性查完了, 留着只是噪声.
`-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条.
注意属性写在**函数定义**上时要放在声明符之前 (`LIGENT_PRINTF(2, 3) static void field(...)`),
写在原型上则放末尾. gcc 对前者会直接报
"attributes should be specified before the declarator in a function definition".
### 静态分析
```bash
make analyze # gcc -fanalyzer, 独立目标, 故意不进 make test (它比全套测试还慢)
```
它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug:`attempt()` 里
`curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读; POST 分支对
`body == NULL` 不设防 (无条件 `strlen`). 两个都不在任何测试的覆盖里 —— 前者要
`curl_easy_init` 真的失败, 后者要一个当前调用方不会传的参数组合.
用 `-O0` 跑是有意的:开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到
内联并不可靠 (`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的
FILE 泄漏, `fclose` 明明就写在 `fread` 下一行; `-O0` 下干净). 分析器自身就是跨
函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`:不然
它永远打印"通过"—— gcc 的分析器告警不影响退出码, 这是我第一版踩的坑.
### 换个编译器:tcc 也能编
```bash
make CC=tcc # 全套测试 (3692 单元 + 318 e2e)实测通过
```
tcc 0.9.28 能把 ligent 完整编出来并链接成功, 产物 1.0 MB. CPU 密集的基准
(`fuzz 4000`)上 **218 ms vs gcc 的 171 ms, 只慢约 1.3 倍** —— 对 ligent 这种
瓶颈在网络的程序完全无所谓.
它换不来三样东西, 用之前得知道:
- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang.
- **没有那些严格警告** (`-Wshadow` / `-Wmissing-prototypes` 等). tcc 的诊断
弱得多, 所以"零警告"这条纪律只在 gcc/clang 下成立.
- 没有优化器 (上面那 1.3 倍就是它的全部代价).
顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs`
却没链 `-lm` (gcc 把它折成内建函数, 所以看不出来), 以及 6 条测试链接规则漏了
`$(LDLIBS)`. 两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞, 平时是看不见的.
### 静态构建:musl libc + 静态 libcurl
想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL):
```bash
./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl (要联网、几分钟)
make static # 产物:ligent-static
file ligent-static # → ELF 64-bit ... statically linked
```
**为什么不能直接拿发行版的 `libcurl.a` 链接. ** 那是针对 glibc 编的, 和 musl
混在一起会得到一堆 `undefined reference to __libc_start_main`. 必须有一份用
musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事:
用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`, 最后**真的链一个
最小程序**自检 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露).
产物是 `ligent-static`, 和常规构建**互不干扰**:静态目标用自己的对象目录
(`build/static/`), 因为那边的 `.o` 是 musl 编的, 混在一起会互相覆盖.
| 变量 | 默认 | 说明 |
|---|---|---|
| `STATIC_CC` | 依次找 `musl-gcc` / `x86_64-linux-musl-gcc` | 没有就报错并告诉你装什么 |
| `STATIC_PREFIX` | `build/static-prefix` | 静态依赖所在的前缀 |
| `STATIC_RELEASE` | `1` | 体积优先 (`-Os` + strip + `--gc-sections`…). 设 0 回到能调试的版本 |
### 把静态产物压到最小
静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以它的取舍和常规构建相反.
**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣:
**A. 链接我们自己的东西 (Makefile, `STATIC_RELEASE=1`)**
| 开关 | 效果 |
|---|---|
| `-s` | 4.92 MB. **单项最大**:`-g` 带进来的调试信息 (551 KB)加符号表共 1.31 MB |
| `-ffunction-sections -fdata-sections` + `--gc-sections` | 让链接器能按函数丢没被引用的段 |
| `-fno-asynchronous-unwind-tables -fno-unwind-tables` | `.eh_frame` 本来 514 KB, C 代码用不上 |
| `-Os` / `-g0` | 优先体积; 不生成反正要 strip 掉的调试信息 |
| `-z noseparate-code`、`--build-id=none` | 页对齐空洞和 note 段 |
**B. 链接依赖库 (`tools/build-static-deps.sh`)**
4.84 MB → 3.29 MB. 这一段**必须改依赖的编译选项**才有用:`-ffunction-sections`
是**编译期**的事, 依赖的 `.a` 不按函数分段的话, `--gc-sections` 只能整份整份地丢,
几乎没有效果 (实测只对我们自己的 `.o` 开这三件套, 产物反而大了 1.6 KB —— 分段本身
也有开销). 给 zlib/OpenSSL/curl 都加上 `-Os -ffunction-sections -fdata-sections
-fno-*-unwind-tables` 之后, libcurl+OpenSSL 的 `.text` 从 **3.45 MB 掉到 2.41 MB** ——
静态归档里本来就有一大堆我们永远走不到的算法和协议实现.
`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值; 要回到调试友好版:
```bash
CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh
```
脚本会把用过的选项记在 `$PREFIX/.build-flags` 里, **选项变了会自动重建**.
没有这一层的话, 下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过, 你会一直用着
旧选项编出来的依赖, 症状是"我明明加了 `-Os`, 产物怎么一点没小" —— 和 Makefile 里
对象目录按配置分标签是同一个坑的两处.
顺手排掉的:**`ld.gold` 不是更小而是更大** (4.89 MB vs 4.86 MB), `--icf=all`
相同代码折叠在这份代码上一点没省, 所以没换链接器.
**验证方式** (这些数字都是量出来的, 不是估的):
```bash
make static # → 3,292,568 字节
make static-test # 拿同一套 318 项 e2e 打静态产物 (而不是只看它能不能启动)
```
TLS 单独验:本地起 `openssl s_server` 拿自签证书, 让静态产物连它 —— 它会报
`SSL certificate problem: self-signed certificate`. 这个**失败**恰恰是成功的证据:
能走到"证书不被信任"说明 TCP、ClientHello、证书解析、证书链校验整条 OpenSSL 路径
都通了. 这也是静态二进制的真实 HTTPS 路径**第一次被实际验证** (依赖脚本的自检
只做 `curl_easy_init` + `setopt`, 从不发请求, 验不到这一层).
顺带发现 (既有限制, 与本次改动无关):**libcurl 库不读 `CURL_CA_BUNDLE`**,
只有 `curl` 命令行工具读它 —— `CURLINFO_CAINFO` 显示它永远用编进去的
`/etc/ssl/certs/ca-certificates.crt`. 所以 ligent 只能用系统 CA 包, 想指定别的
得改成显式 `CURLOPT_CAINFO`.
几个刻意的取舍:
- **依赖列表交给 `curl-config --static-libs`**, 不手写 `-lssl -lcrypto -lz`.
curl 最清楚自己链了哪些库、什么顺序; 手写的那串会过期, 顺序错了还链不上.
- **`-fno-pie` / `-no-pie`**:现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 `.a`
会报 `relocation R_X86_64_32 ... can not be used when making a PIE object`.
- **不用 nghttp2**:静态构建只留 ligent 真正要的东西, 于是它说 HTTP/1.1 ——
服务端都支持, 我们也不需要多路复用.
- **CA 路径编进去** (`/etc/ssl/certs`):静态二进制跑到别的机器上, 得知道去哪
找根证书.
### 在容器里构建 (CI/CD)
```bash
make docker-build # podman build -t ligent-static .
make docker-extract # 产物拷到 ./ligent-static
make docker-test # 拿同一套 318 项 e2e 打容器产物
```
`make docker-*` 默认用 **podman** (本项目的开发环境是 rootless), 换 docker
加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的.
**为什么用 Alpine 做构建环境. ** 静态目标要一份用 musl 编出来的 libcurl,
而 Alpine 本身就是 musl 发行版:`apk add build-base` 得到的 gcc 直接就是 musl
目标 (`--target=x86_64-alpine-linux-musl`), `tools/build-static-deps.sh` 里
那套"找 musl-gcc、配 spec 文件"的麻烦在容器里根本不存在.
**最终镜像 3.49 MB, 里面只有两个文件**:静态二进制和 CA 证书.
```
$ podman export $(podman create ligent-static) | tar -t
etc/ssl/certs/ca-certificates.crt
ligent
```
用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要, 所以镜像里没有
shell、没有包管理器、没有任何可被利用的东西. CA 证书放在
`/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个
路径 (见上一条), 放别处它读不到.
版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git` (`.dockerignore`
把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去:
```bash
podman build \
--build-arg VERSION=$(date -u +%Y.%m.%d) \
--build-arg GIT_HASH=$(git rev-parse --short=7 HEAD) \
--build-arg GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \
--build-arg BUILD_EPOCH=$(date -u +%s) .
```
`make docker-build` 会自动在本机算好传进去, 不用手写.
两个容易踩的点:
- **`BUILD_EPOCH` 必须传完整的秒数, 不能只传日期. ** 它被当成
`SOURCE_DATE_EPOCH`, 而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00
UTC** —— 于是容器产物的构建时间永远显示午夜. 传秒数没有这个歧义, 而且仍然
可复现 (设了 `SOURCE_DATE_EPOCH` 时优先沿用它).
- **分支也要传. ** 不传的话二进制里会是 `unknown`, 而那**不是事实** ——
源码来自哪个分支是外面知道的, 只是容器里看不到而已.
Dockerfile 里有自检:产物必须存在且可执行、必须真的是静态的、传了 VERSION 就
绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用".
**实测** (同一份源码, 本机 + 容器):
```console
$ ./ligent --version # 本机, Artix + gcc 16.2.1
ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:39:59) [GCC 16.2.1 20260810] on linux/x86_64
$ podman run --rm ligent-static --version # 容器, Alpine 3.20 + gcc 13.2.1
ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 13.2.1 20240309] on linux/x86_64
```
编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因:
两个产物的行为可能因编译器版本不同而有细微差异, 排查时得知道是哪个编的.
容器产物同样过了**全套 318 项 e2e** (`make docker-test`).
### 版本号:CalVer + git 哈希
```console
$ ligent --version
ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 20260810] on linux/x86_64
```
```
│ │ │ │ │ │
│ │ │ └ 构建时间 (UTC) └ 编译器 └ 目标平台
│ │ └ 分支 (detached HEAD 时为 unknown)
│ └ git 短哈希, .dirty 表示工作区有未提交改动
└ CalVer:发布日
```
前两段 (`2026.09.22-209538e.dirty`)是**版本号主体**, 也是下面几节引用它时
用的形式. 后面几段是"这份二进制从哪来的", 排查时要看的就是它们.
| 形态 | 什么时候出现 |
|---|---|
| `2026.02.14` | 打在 tag 上的发布版 |
| `2026.02.14-1a2b3c4` | tag 之后又提交了 |
| `2026.02.14-1a2b3c4.dirty` | 工作区还有没提交的改动 |
分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git, 不存在"区分是
哪种 SCM"的需求 (那正是 `g` 前缀存在的理由), 而少两个符号念起来也顺.
**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的:那份信息是
"这份代码被什么编出来、给谁跑的", 编译器自己最清楚. 构建脚本去猜反而多一个
出错点, 而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪", `__linux__`
说的是"编给谁跑", 静态产物会被拷到别的机器上跑, 该记的是后者.
三个编译器各有一条腿, 因为它们的宏并不统一:
| 编译器 | 取法 |
|---|---|
| gcc | `__VERSION__` → `16.2.1 20260810` |
| clang | `__clang_version__`, **要掐掉尾随空格** (它自带一个) |
| tcc | 没有 `__VERSION__`, 只有整数 `__TINYC__`, 按"去掉小数点当十进制读"还原 |
判断顺序也有讲究:**clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以
clang 必须排在 gcc 前面, 否则会被认成 GCC.
(这里踩过两个坑, 都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到
`tcc 9.2.8`, 正确是 `/1000` → `0.9.28`; clang 的版本串带尾随空格, 会输出成
`Clang 22.1.8 ]`. 单测 gcc 永远发现不了这两个. )
**为什么 CalVer 而不是 SemVer. ** ligent 是个终端工具, 不是被别人链接的库 ——
"第几个大版本"这种信息对它没什么用, "这是哪天的东西"才有用. 用户看到
`2026.02.14` 立刻知道新旧, 看到 `1.0.0` 还得去查.
**为什么还要带哈希. ** CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上
的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位.
`.dirty` 标记尤其重要:不带它的话, 改过源码的构建和干净构建会报**同样的**
版本号, 排查时会被完全误导. 未跟踪的新文件也算脏 —— 它可能正是被编进去的那个
源文件.
**版本号不写死在源码里. ** 构建期用 `tools/get-version.sh` 算出真值, Makefile
用 `-D` 注入; `src/version.h` 里只留一个回退默认值, 给"没有构建系统"的场景
(比如有人直接拿 gcc 编 `src` 下的所有 `.c`). 这样从 git 编和从 tarball 编
不会出现版本号不一致, 也不必为发版多做一个改源码的提交. 这是 dunst / curl
这类项目的通行做法.
取值的优先级 (`tools/get-version.sh` 里有完整注释):
| 场景 | 结果 |
|---|---|
| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它 (CI 用, tag 打错时能救) |
| git 里有 `v2026.02.14` 形态的 tag | 取最新的那个 |
| 没有 tag (tarball) | 回退到源码文件的日期 |
| 没有 `sh` 或全失败 | `0.0.0-unknown`, **绝不让构建挂掉** |
`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定):设了它, 同一份源码在不同
时间编出来的二进制是逐字节相同的, CI 上才能校验产物哈希.
版本号只在 `--version` 和 `ligent doctor` 里出现, 不参与任何协议交互.
`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本".
**实测过了** (Artix, musl-gcc / gcc 16.2.1, zlib 1.3.1 + OpenSSL 3.0.15 +
curl 8.11.1):
```
ligent-static: ELF 64-bit LSB executable, x86-64, statically linked
ldd ligent-static → 不是动态可执行文件
体积 3.29 MB (压缩前 6.24 MB, 见上一节); 318 项 e2e 全过;
TLS 真握手验过 (本地 s_server, 见上一节)
```
一路上真正会绊人的坑 (脚本里都处理了, 记下来免得重踩):
1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上:
`fatal error: linux/mman.h: No such file or directory`.
要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底.
写成 `-I` 就反了, glibc 的头会盖过 musl 的.
2. **同一个原因, 编译 ligent 自己时要 `-I$(STATIC_PREFIX)/include`. **
系统那份 `curl/curl.h` 不是给 musl 用的.
3. **依赖别加 `-fno-pie`. ** 这些项目在 `make` 里会顺带链几个测试程序, 而链接
用的是默认的 PIE, 于是
`relocation R_X86_64_32 against ... can not be used when making a PIE object`;
更阴的是 zlib 的 configure 探测 `vsnprintf` 也会因此失败, **悄悄退化成不安全
的 vsprintf 版本**. PIE 与否由 ligent 最后那一步用 `-no-pie` 决定就够.
4. **OpenSSL 3 在 x86_64 默认装 `lib64`**, 和"守卫查 lib、curl 找 lib"对不上,
表现为 curl 报 `--with-openssl was given but OpenSSL could not be detected`.
统一用 `--libdir=lib`.
5. **下载地址会过期. ** zlib 把旧版本挪到了 `/fossils/`. 脚本对每个包试多个
地址 (官网 / fossils / GitHub release), 全失败才退出并告诉你怎么改版本.
6. **路径里有非 ASCII 字符时, `pkg-config` 会吐出非法 UTF-8. **
这是最阴的一个:只在"仓库路径带中文"这类情况下才出现, 而症状离原因非常远.
- curl 的 configure 挑 OpenSSL 时, 只要 `<prefix>/lib/pkgconfig/openssl.pc`
存在, 就走 `pkg-config` 那条路 (`configure` 里的 `PKGTEST` 逻辑);
- 而 configure 开头就是 autoconf 的标准开头 `LC_ALL=C; export LC_ALL`,
**脚本外面设 `LC_ALL=C.UTF-8` 压不住它** (我试过, 没用);
- pkg-config 在 C locale 下用 `isprint()` 判断字节要不要转义, 每个 ≥ 0x80
的字节都被判成"不可打印", 于是它给路径里每个非 ASCII 字节前面加一个反斜杠:
`-L/…/开发/…` → `-L/…/\开\发/…`. 反斜杠后面跟 `E5` 不是合法续字节,
所以那串是**非法 UTF-8**;
- curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile (实测 26 个文件).
后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把
`curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 →
进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**. 而且坏
数据已经留在历史里, 之后每一次请求都失败, 只能丢掉整轮对话.
修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去):curl
看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路, 路径干干净净.
脚本末尾还有一道 `iconv` 往返自检, curl-config 输出不是合法 UTF-8 就直接退出.
(这条不需要你做什么, 脚本会处理. 但如果你在别处手写 curl/OpenSSL 的构建、
又碰巧路径带中文, 记住别让它走 pkg-config. )
## 2. 配置 API key
默认就是 DeepSeek:`https://api.deepseek.com/v1` + `deepseek-flash`,
密钥默认从 **`$DEEPSEEK_API_KEY`** 读取. 所以最简单的用法是:
```bash
export DEEPSEEK_API_KEY=sk-xxxxxxxx
ligent "你好"
```
### 配置文件:只有一个位置 —— 二进制旁边
配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`:
```
/path/to/ligent ← 可执行文件
/path/to/ligent.json ← 配置 (不存在就用内置默认值)
```
```bash
cp ligent.example.json ./ligent.json # 二进制就在当前目录时
$EDITOR ./ligent.json
```
**不做用户级查找** (没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`),
**不做系统级查找** (没有 `/etc`), **也不看当前工作目录**.
这样整个程序是自包含的:拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走.
**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 `~/bin`:
```bash
ln -s /opt/ligent/ligent ~/bin/ligent
```
那么配置目录是 `~/bin`, 而不是 `/opt/ligent` —— 你放在哪、就认哪.
四种调用方式结果一致:绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH`
调用 (`argv[0]` 里没有斜杠的情况).
实现上优先用 `argv[0]` (不解析最后一段软链接), 只在拿不到时才退回
`/proc/self/exe`. 目录部分会被规范化 (`.` / `..` / 目录自身的软链接都会解析),
但**最后一段不会** —— 那正是"软链接所在目录"的关键. 另外会用 inode 比对确认
找到的确实是 ligent 自己, 避免 `$PATH` 里恰好有个同名文件把目录带偏.
```bash
$ ./ligent --print-config | grep config_file
config_file : /home/you/ligent/ligent.json (不存在, 使用内置默认值)
```
`--print-config` 会显示规范位置, 所以不用猜它在哪找.
想临时换一份配置时有两个显式入口 (都不是"默认查找位置"):
```bash
ligent -c /path/to/other.json "..." # 命令行显式指定
LIGENT_CONFIG=/path/to/other.json ligent "..."
```
整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数**
(`api_key` 按上面的规则细化).
配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘:
```json
{
"base_url": "https://api.deepseek.com/v1",
"api_key": "${DEEPSEEK_API_KEY}",
"model": "deepseek-flash"
}
```
想接别家服务, 只改 `base_url` 和 `model` 即可, 例如:
| 服务 | base_url | model 示例 |
|---|---|---|
| DeepSeek (默认) | `https://api.deepseek.com/v1` | `deepseek-flash` / `deepseek-chat` / `deepseek-reasoner` |
| OpenAI | `https://api.openai.com/v1` | `gpt-4o` |
| 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:14b` |
| 本地 vLLM | `http://localhost:8000/v1` | 你部署的模型名 |
其他可选环境变量:`LIGENT_BASE_URL`、`LIGENT_MODEL`、`LIGENT_SYSTEM`、
`LIGENT_TEMPERATURE`、`LIGENT_MAX_TOKENS`、`LIGENT_THINKING`、
`LIGENT_REASONING_EFFORT`、`LIGENT_TIMEOUT`、`LIGENT_MAX_STEPS`、
`LIGENT_STREAM`、`LIGENT_TOOLS`.
### 思考模式与级别
推理模型可以先"想"一段再回答. DeepSeek 的思考模式**默认就是开着的, 默认级别 `high`**,
所以什么都不配也能用; 想控制就加这两项:
```json
{
"thinking": "auto",
"reasoning_effort": "high"
}
```
- `thinking`:`"on"` / `"off"` / `"auto"`, 也接受 `true` / `false`.
`auto` (默认)表示**请求里根本不带这个字段**, 完全由服务端决定.
开启时下发 `{"thinking":{"type":"enabled"}}`, 关闭则是 `disabled`.
- `reasoning_effort`:`minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`,
留空则不发. DeepSeek 会把它映射到 `low` / `high` / `max` 三档
(`minimal`/`low`→low, `medium`/`high`/`xhigh`→high, `max`/`ultra`→max).
对应的开关:`--thinking on|off|auto`、`--effort LEVEL`、
`LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`, 以及 REPL 里的
```
/think 查看当前设置
/think on | off | auto 切换开关
/think high 设置级别
```
**思考过程不会显示, 也不会进上下文** (只累积、不回调). 但它必须跟着 assistant
消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**,
漏了下一轮直接回 400. ligent 默认开着工具, 所以这一条是硬要求 —— 流式和非流式
两条解析路径都会保存它.
另外, 思考模式下 `temperature` 是**不起作用**的 (服务端会忽略), `top_p` 会被抬到
≥ 0.95. `ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点.
### 取密钥的优先级
```
LIGENT_API_KEY ← 显式覆盖, 任何时候都最高
ligent.json 的 api_key ← 你写死在配置里的
DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底, 且只在端点确实是那一家时才读
```
最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**.
否则会出这种事:配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的
`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家,
对方回一个含糊的 401, 你明明配对了却怎么都调不通, 排查方向还会被带偏.
(OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`,
听起来像"请求里没带认证头", 实际上带了, 只是钥匙不是这家的. )
所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时,
ligent **不会**去猜 `DEEPSEEK_API_KEY`, 而是明确告诉你"未配置 API key".
`OPENAI_BASE_URL` 同理, 只在 `base_url` 还是内置默认值时才兜底.
想临时切换服务商, 用 `LIGENT_API_KEY` 或 `-k`.
### 界面语言
界面文案 (帮助、错误、诊断、确认提示、等待动画)有中英两份. **发给模型的
那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息,
跟界面语言是两件事 (见 §9).
优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`.
都没设、或者认不出来 (`fr_FR` 这类), 按英文处理 —— `LANG=C` 也是英文,
这跟 POSIX 惯例一致.
```bash
ligent --lang zh "……" # 这一次用中文
export LIGENT_LANG=en # 这个 shell 里都用英文
```
译文在 `src/i18n.c` 的一张表里, 键是点分 ID (`cli.err.unknown_option` 这种),
两种语言各写一份. 少一条、或者占位符对不上 (`{path}` 写成 `{paths}`),
`tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现,
不能靠人眼扫. `--print-config` 会打出当前生效的 `language`.
哪些**故意不译** (都不在这里, 也不是漏翻):
- **给模型看的**:系统提示词、工具描述、工具回执, 以及插进对话历史的说明
(`/include` 的文本块头、图片降级说明、`/shell` 的操作记录). 切成英文之后
模型读到的仍然是中文 —— 想换就得改提示词本身.
- **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记
(`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、
`cap_name()` 这类只给日志和断言用的状态常量.
`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI, 文案在表里
(`tools.ui.*`), 跟着界面语言走; 发给模型的那份说明仍然只有中文.
`src/json.c` 是唯一"一份代码、两种语气"的地方, 用两个入口分开:
- `json_parse_ui()` —— 报错跟随界面语言. 配置、存档、服务端响应这些给人看的
路径用它, 用户看到的是 `line 1, column 3: expected an object key (a string)`.
- `json_parse()` —— 报错固定中文. 解析**工具参数**用它:那条结论会被原样回填给
模型, 属于"给模型看的信息", 不随界面语言变 (`agent.c` 只在这一处用).
两种语气共用一份文案表 (`json.err.*`), 只是各取一列. 这几条是 printf 模板而
不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符
数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数.
## 3. 用法
```bash
ligent "统计当前目录下 C 代码的总行数, 并找出最大的三个文件"
ligent # 进入交互式 REPL
echo "解释一下 Makefile" | ligent # 从管道读取任务
ligent --print-config # 查看最终生效的配置 (key 已打码)
```
### 命令行选项
```
-m, --model NAME 模型名
-u, --base-url URL API 基地址
-k, --api-key KEY API key
-c, --config PATH 指定配置文件
--history FILE 指定输入历史文件 (默认 <二进制目录>/ligent.history)
--no-history 不读写输入历史
-s, --system TEXT 覆盖 system 提示词
-t, --temperature N 采样温度
--max-tokens N 单次回复最大 token 数
--thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定)
--effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra
--max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限)
--timeout N 请求超时秒数
--no-stream 关闭流式输出
--no-tools 禁用工具, 纯对话
--no-probe 首个请求前不探测模型能力 (不额外发请求)
--lang LANG 界面语言:auto / en / zh (默认 auto)
-q, --quiet 不打印工具的输出内容
-v, --verbose 打印 HTTP 细节
--color/--no-color 强制开关彩色输出 (同时控制 Markdown 渲染)
--print-config 打印生效配置后退出
-h, --help / -V, --version
```
### 子命令
```
ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查
```
`--offline` 跳过联网检查.
### REPL 命令
```
/help 帮助 /model [NAME] 切换模型; 不带名字则在线选
/exit, /quit 退出 /system TEXT 重设提示词并清空历史
/reset 清空对话历史 /cls 清屏 (不动对话历史)
/meta 元编辑模式:让模型帮你改这段历史
/think [开关|级别] 思考模式与级别 /tools 列出可用工具 (已按能力隐藏的不列)
/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存
/config 显示当前配置 /cd PATH 切换工作目录
/history 打印完整历史 /usage token 用量
/log [FILE] 导出收发记录 /yolo 临时切换写操作确认
/export 存档当前对话 /import [名] 载入存档 (会问要不要打印历史)
/shell [命令] 进 shell / 直接执行命令
/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消
/back 删除最近一轮 (破坏性) /unback 撤销 /back
```
`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混:
| | `/reset`、`/clear` | `/cls` |
|---|---|---|
| 作用 | 清空对话历史, 重新开始 | 清屏 |
| 影响模型看到的上下文 | **是**, 历史没了 | 否, 一字不动 |
| 可恢复 | `/unback` 不行, 只能 `/import` 旧存档 | 本来就没删东西 |
`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致:可见区和回滚缓冲
一起清. 所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档.
### `/model`:不带参数就在线挑
`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你:
```
ligent> /model
正在获取模型列表 (https://api.deepseek.com/v1/models)...
服务端列出了 3 个模型:
1) deepseek-flash <- 当前
2) deepseek-chat
3) deepseek-reasoner
选择序号, 或直接输入模型名 (回车取消)> 2
模型已切换为 deepseek-chat
```
输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的:列表里没有的
东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名, 都得能填进去.
列表本身也是诚实的:顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的
坏条目会被跳过. 序号越界会明确报出来 (`序号 9 超出范围 (1..3), 已取消. `),
`Ctrl-C` 或直接回车取消.
拉列表走的是标准的 `GET {base_url}/models`. **拿不到就明说是什么情况**,
而不是笼统地报一句失败:
```
拿不到模型列表:服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型
可以直接用 /model <名字> 指定, 列表拿不到不影响这条.
```
"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方,
后者是账号/权限问题, 下一步动作完全不同.
> **为什么不做成方向键菜单. ** 试过一版, 撤了. 那种界面要靠 ANSI 反复重画,
> 会把 stdout 搅乱 (重定向、日志、粘贴都跟着遭殃); 更要命的是它**只让你在
> 给定的选项里挑**, 恰好堵死了"填一个列表里没有的模型名"这条最需要的路.
> 所以这里是 `/import` 那一套:列表打在上面, 输入行是普通的一行, 随便写.
### `/include`:把文件附带给模型
模型看不到你的文件, 除非你给它. `/include` 把文件**暂存**起来, 跟着下一条消息
一起发出去 —— 因为附带文件的意义几乎总是配合一个提问:
```
ligent> /include src/*.c
已附带 3 个文件:
src/agent.c
src/json.c
src/util.c
下一条消息会带上它们. 用 /include clear 取消.
ligent> 这三个文件里有没有重复的逻辑?
```
不带参数则进**交互式挑选**, 可以逐层进目录 (这就是"递归"的走法 —— glob 本身
是 shell 语义, `*` 不会跨目录):
```
ligent> /include
进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束.
当前目录:/home/you/proj
1) src/ (目录)
2) README.md 12.3 KB
3) logo.png 4.5 KB 〔图片〕
.) .. 返回上级
已选 0 个. 选择 > 1
```
同样是 `/import` 那种普通输入行, 不是方向键菜单:**序号、`..`、glob、路径都能
敲**, 空格分隔不支持的 (一次一个模式). `/include clear` 清空暂存, `/reset`
也会清.
几件事是有意为之的:
- **认内容, 不认扩展名. ** 叫 `.png` 的文本文件当文本带; 反过来, 内容真是图片
的才走图片那条路.
- **图片不进历史. ** 图片存成本机备份, 历史里只放一个几十字节的引用, 发请求时
才展开成 base64 —— 和 `read_image` 完全同一条路.
- **图片要先确认模型能看图. ** 遇到图片时才会去跑能力探测 (纯文本的附带不该为
了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明:
```
跳过 src/logo.png:当前模型不支持图片输入
没有附带任何文件 (1 个被跳过).
```
只有**明确不支持**才拦; 探测关了或结论不确定时按支持放行 —— 和别处一个原则.
- **体积有上限. ** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件. 超了会
跳过并说明, 让你改用 `read_file` 分段读. 二进制文件 (含 NUL 字节)直接跳过.
- 暂存的文件名只用于在终端里给你看, **不会**塞进内容块 —— 那会跟着请求发给
服务端, 而自造字段可能被严格的服务端判 400.
输入行尾加 `\` 可以续行写多行任务. `Ctrl-C` 中断当前请求 (不会退出程序),
`Ctrl-D` 退出.
### 输入历史:像 bash 一样跨进程
你在 `ligent>` 敲过的东西会**实时**写进一个历史文件, 新的 ligent 实例启动时读回来,
上下方向键就能翻, 跟 bash 一样:
```
ligent> ↑ → 上一条
ligent> ↑ ↑ → 再上一条
ligent> ↓ → 回来一条
```
- **落盘位置**:`<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/`
同一个原则:只放二进制旁边).
- **实时**:每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个
标签页)马上就能翻到, 不用等谁退出.
- **权限 0600**:历史里可能顺手敲过密钥, 不给同机其他用户读的机会.
- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**, 上下键整条召回.
- **上限 1000 条**:启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长.
- **连续重复不记**:和 bash 的 `ignoredups` 一个意思.
- **只记 REPL 里敲的**:单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是
"在提示符前敲的". `/shell` 里执行的命令也不算, 那是另一个 shell 的历史.
开关和覆盖:
```bash
ligent --no-history # 本次不读也不写
ligent --history /tmp/my.history # 换个文件
export LIGENT_HISTORY=/tmp/my.history # 同上, 环境变量
```
文件是一条历史一行. 条目里的反斜杠和换行会被转义 (`\` → `\\`, 换行 → `\n`),
保证一条占一行且能原样读回; 不含反斜杠的普通命令在文件里与输入完全一致.
### `/shell`:在子进程里干活
```
ligent> /shell 进入交互式 shell, 退出后整段会话记入上下文
ligent> /shell git log --oneline -5 带参数则直接执行, 不进入 shell
```
交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上, 所以行编辑、
颜色、Tab 补全、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来.
```
ligent> /shell
进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; 整段会话会在退出后记入上下文.
[you@host ligent]$ git status
[you@host ligent]$ exit
已把 165 字节会话记录加入上下文 (不自动提问).
```
录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、
丢掉退格, 否则模型看到的是一堆 `^[[32m`. 长度上限 100KB.
清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上, 不会串味.
**全屏程序的内容完全不记进上下文. ** `nano`、`vi`、`vim`、`nvim`、`less`、`top`
这类程序会切到终端的"备用屏幕", 这段画面照常显示在你眼前, 但记录里只剩你敲的
命令行、下一行提示符, 以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的
一模一样. 中间留一对标记:
```
[全屏程序运行中, 画面内容已省略]
[全屏程序结束]
```
理由是那一屏全是光标定位和重复重绘, 记下来纯属噪音; 而且 `vim` 打开的整个文件
都会被灌进上下文. 程序被强杀、来不及发退出序列时, 收尾会补一个结束标记,
保证首尾配对.
全屏程序只在 `TERM` 声称支持时才切备用屏幕, 所以 `/shell` 会在 `TERM` 缺失或为
`dumb` 时给子进程补一个 `xterm-256color` (用户显式设了别的值就原样传下去).
依据是 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是
ANSI 兼容的, `TERM=dumb` 属于谎报. 补上之后, 即使是 `TERM=dumb`, `nano` 也会
正常进入备用屏幕并被整段忽略.
对确实不用备用屏幕、靠成片空格清屏重绘的程序, 退而求其次把连续空白压掉
(短于 8 个空格的缩进原样保留).
**记入上下文但不会自动提问**, 你想问什么再问. 这样你在 shell 里翻了一圈之后,
可以直接说"刚才那个报错怎么修".
选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**:
用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳, 在容器或受限环境里
经常起不来, 反而把 `/shell` 弄坏.
### `/usage`
```
ligent> /usage
token 用量 (本次会话)
API 调用 : 3 次
输入 token : 12345
输出 token : 678
合计 : 13023
最近一次输入 : 8192 <- 大致等于当前上下文大小
历史消息 : 15 条
```
服务端在每次响应里回报 `usage`, 这里只做累加. 有些服务端不返回该字段,
那样会明确说"还没回报过用量"而不是显示一堆 0.
### `/back` 与 `/unback`
```
ligent> /back 删除最近一轮 (从最后一条 user 消息到末尾, 整组删)
ligent> /unback 撤销, 把删掉的装回去
```
是**按轮**删的:模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400.
`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么, 就没有
"撤销"这一说了, 会明确拒绝而不是悄悄恢复到一半状态.
### `/yolo`
```
ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配置文件)
```
### 写操作确认 (默认开启)
任何**会改动磁盘或执行命令**的工具 (`run_command` / `write_file` / `edit_file`)
在执行前都会先说明要做什么, 然后问一句:
```
</> 即将执行 run_command
命令: rm -rf build/
继续?(Y/N/Always/neVer)
```
四种回答:
| 回答 | 含义 |
|---|---|
| **回车** / `Y` | 允许这一次 (默认) |
| `N` | 拒绝这一次 |
| `A` / `Always` | 本轮剩下的改动**一律放行, 不再询问** |
| `V` / `neVer` | 本轮剩下的改动**一律拒绝** |
`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用.
下一条消息进来时自动复位, 所以它不会像 `/yolo` 那样"一直生效到你关掉",
也不会写进配置. 选了哪个都会当场提示你:
```
继续?(Y/N/Always/neVer) A
本轮后续的改动都不再询问.
```
拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法,
而不是让整轮对话就此中断. 选了 `neVer` 的话, 回填的话会更硬 ("不要再尝试改动,
直接说明你的结论"), 免得模型一个接一个地试.
只读工具 (`read_file` / `list_dir`)不打扰.
> **非交互输入时按默认值放行. ** stdin 不是终端 (管道、重定向)时没有人能回答,
> 此时直接按 Y 处理、不显示提示. 所以 `echo 任务 | ligent` 里的确认实际上是
> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面, 也就是别指望它兜底.
关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`.
### 自动存档 (默认开启)
在三个时间点自动把当前对话存进 `ligent.archive/`:
- **退出**时 (`/exit`、`Ctrl-D`、单次任务结束)
- **`/reset` 前** —— 免得清空历史时把刚才那段丢掉
- **`/import` 前** —— 免得载入别的存档时覆盖掉当前的
只在"有内容"且"自上次存档后又变了"时才写, 所以不会产生一堆重复文件;
只有 system 一条的空对话不会留下存档. 关掉用 `--no-auto-save`.
### `/meta`:让模型帮你改对话历史
`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的. 真正难的是
"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的, 改掉".
`/meta` 就是干这个的:进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作
对象不是继续对话, 而是**编辑这段历史**.
```
ligent> /meta
已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话.
用自然语言说要改什么; /help 看命令, /exit 返回.
meta> 第 3 轮里我说的"用 Python"删掉
{/} list_rounds
{/} read_messages {"from":5,"to":7}
{/} replace_message {"index":5,"content":"帮我写个脚本"}
</> 即将执行 replace_message
#5 的正文改成:帮我写个脚本
继续?(Y/N/Always/neVer)
[meta] 已改写 #5 (user):28 -> 15 字节.
meta> /exit
退出 meta 模式. 本次共改动 1 次, meta 自身消耗:3 次调用, 1240 输入 / 86 输出 token.
这段对话已被编辑过:下次 /export 会另起一根, 名字形如 <原名>_m<3位>.
```
meta 里的命令只有四个:`/exit` `/diff` (看当前历史, 只读)`/undo` `/help`.
其余输入都是给 meta 模型的指令.
#### 和主模型完全隔离
meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` 上的两个函数
指针 (`exec_tool` / `is_mutating`). 主模型看不到也调不到这些工具, 反过来也一样 ——
它就算瞎编一个 `delete_rounds`, 也只会拿到"未知工具".
| 工具 | 作用 | 参数里带整段正文 |
|---|---|---|
| `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 |
| `read_messages from,to` | 读指定消息的全文 (含工具调用参数) | 否 |
| `replace_message index,content` | 改写某条消息的正文 | 是 |
| `delete_messages indices` | 删若干条 (只限可安全单删的) | 否 |
| `delete_rounds from,to` | 删连续整轮 (含轮内所有工具往返) | 否 |
| `insert_message after,role,content` | 插一条 (补一句迟到的约束) | 是 |
| `undo_last_edit` | 撤销上一次改动 | 否 |
#### 结构安全是硬约束
对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时, 后面必须紧跟
`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以:
- `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**; 碰结构消息
一律拒绝, 并让模型改用 `delete_rounds`
- 一批删除**先整体校验再动手** —— 宁可一条不改, 也不要改一半
- 动作粒度以"轮"为单位:user → 中间所有工具往返 → 最终回答
- 删掉某轮的提问时, 工具会提醒"那轮剩下的回答现在没有对应的提问了"
#### 四道安全网
1. 进 `/meta` 前自动存一份 `tmp_` 保底存档 (文件名会打印出来)
2. 每个破坏性操作走 `(Y/n)` 确认 (`--no-confirm` / `/yolo` 下自动放行)
3. `/undo` 或工具 `undo_last_edit`, 最多回退 10 步
4. 每次改动都打印增减字节, 不静默
#### 改过之后另起一根
只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**:
名字 `<原名>_m<3位>`, `parent` 记空.
```
a1b2c3d4 原版
a1b2c3d4_mq3z meta 改过的 (新根)
a1b2c3d4_mq3z_p8w 从 meta 版再分支
```
`_m` 后面是 3 位随机字符, 所以下划线后面一共 **4** 个字符; 普通分支固定 3 个.
按长度就能分开, 不会歧义. 原版仍在 `tmp_` 存档里, 丢不了.
meta 自身的 token 消耗单独汇报, 不混进主对话的 `/usage`.
#### 它不是压缩工具
meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**.
它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的
结论, 让这段历史不再诱导助手重蹈覆辙.
> ⚠️ 让 AI 改自己的历史, 等于允许它改写"发生过什么". 排查问题时如果关键证据
> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档.
### 等待时的动画:两段标签
模型不输出思考过程是有意的, 但等待期间屏幕完全静止, 和"卡死了"看不出区别.
所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**:
```
等待响应.... 请求发出去了, 但一个字节都还没回来 (网络、排队、服务端没醒)
思考中...... 思维链开始流回来了, 模型确实在推理
```
这个区分是有用的:同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`"
是两种完全不同的处境 —— 前者多半该去查网络或端点, 后者只是模型想得久.
切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以:
- 关掉思考 (`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`.
- 非流式 (`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`.
- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了.
计时是按阶段算的:标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来
(`等待响应.... (12s)`), 进一步和卡顿区分. 只在 stderr 是终端时才转,
所以重定向或接管道时不会污染输出.
### 对话存档:文件名就是谱系
```
ligent> /export 把当前对话存下来
ligent> /import 列出全部存档, 选一份载入
ligent> /import a1b2c3d4_a9x 按名字直接载入 (支持唯一前缀)
```
存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则:
整个程序连同它的数据都待在一个目录里, 拷走即走.
**命名不做时间戳, 而是把分支关系写进文件名**:
```
a1b2c3d4 从零开始的一段对话
a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支
a1b2c3d4_a9x_k2m 再往下分支
a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支 (兄弟)
```
新会话第一次存档时随机取 8 位小写字母数字作为根; 之后每存一次,
就在**当前所在的节点**后面接 `_` 加 3 位随机字符.
`/import` 之前那次保底存档会统一加上 `tmp_` 前缀:
```
tmp_mimgkjex 换掉当前对话之前先留的一手
znmjjhzl 你主动存下来的
znmjjhzl_ocq 从 znmjjhzl 分出的分支
```
临时存档**不会成为以后分支的祖先** (它只是保底, 不属于分支树),
所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`.
好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来,
也不会出现索引与实际文件不同步的问题.
```
ligent.archive/
mvd2bl1h 根
mvd2bl1h_cpc 分支
mvd2bl1h_cpc_3oe 分支的分支
mvd2bl1h_q4k 另一支
```
**长度上限**:完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符,
所以大约能到 58 层. 再往下接就**丢掉谱系、重新起一个 8 位根**
(名字撑爆文件系统总不是个办法).
存档内容仍是自描述的 JSON, 并把 `name` 与 `parent` 也写进去 ——
这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来:
```json
{
"format": "ligent.archive", "version": 2,
"name": "mvd2bl1h_cpc", "parent": "mvd2bl1h",
"created": "2026-09-18 22:49:39",
"saved": "2026-09-18 23:10:02",
"model": "deepseek-flash", "base_url": "...",
"message_count": 7,
"messages": [ ... ]
}
```
`/import` 载入后会问一句要不要把这段历史打出来:
```
已载入 mvd2bl1h_cpc (7 条消息). 之后 /export 会存成 mvd2bl1h_cpc_xxx
是否输出这段历史的内容?(Y/n)
```
回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色:
```
-- 1/7 系统 --
你是用户的 AI 助手
... (system 提示词全文)
-- 2/7 你 --
帮我把 build 目录清一下
-- 3/7 助手 --
-> 调用 run_command id=call_1
{"command": "rm -rf build/*"}
-- 4/7 工具 --
[tool_call_id=call_1]
已删除 12 个文件
[exit code: 0]
```
"完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system`
提示词也全文展开**. 都回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里
本来就这么点东西. `tool_call_id` 是特意标出来的, 否则一堆工具输出看不出
谁在回应谁.
回 `n` 就只装进上下文、屏幕保持干净. 之后想随时再看一遍, 敲 `/history` ——
它打的就是同一份东西 (当前这段对话, 一条不落).
两点行为约定:存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在
的提示词换掉), 当前生效的那条保留; 载入后对话开始时间重置. 空对话不会产生
存档. `/reset` 会断开谱系, 下一次存档另起一个根.
(输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源, 记的是
"我在 `ligent>` 敲过什么", 和当前装的是哪段对话无关. )
### 先跑一次体检
配置调不通时, **先别猜**:
```bash
ligent doctor
```
它会把整条链路逐环打出来 —— 二进制与配置目录怎么定的、配置文件在不在、
密钥从哪来、格式与端点是否匹配、环境变量有没有互相顶掉、参数是否离谱、
网络通不通、端点有没有跳转.
```
【API key】
来源 配置文件
值 sk-or-v1... (73 字符)
格式推断 OpenRouter
与端点匹配 [v] 端点属于 OpenRouter, 密钥格式吻合
【网络】
连通性 [v] 可连接, TLS 校验通过
HTTP 状态 307
重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions
ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头,
服务端只会回 401 "Missing Authentication header", 看起来像是密钥没带,
实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可.
```
它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、
密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误.
退出码:有问题为 1, 否则 0.
> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻.
### 行编辑
REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程:
| 按键 | 作用 |
|---|---|
| `←` `→` | 光标左右移动 (按**字符**而不是字节, 中文不会移半个字) |
| `↑` `↓` | 翻历史记录 |
| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾 (粘进来多行时不会跳到整块两端) |
| `Backspace` / `Delete` | 删除光标前 / 光标处的字符 |
| `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 往回删一个词 |
| `Ctrl-C` | 丢弃当前输入行并开新行 (请求执行中则是中断请求, 不退出程序); **在写操作确认提示上等于"拒绝"** |
| `Ctrl-D` | 空行时退出; 行非空时删一个字符 |
| **粘贴多行** | 整段插入, **不会被逐行执行** |
粘贴用 bracketed paste (`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开,
收到粘贴块就整段放进缓冲区, 里面的换行不再触发提交. 所以可以直接把一段
多行需求、一个代码片段、一段日志粘进来, 编辑好再回车, 它会作为**一条**消息发出.
`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路.
**粘贴进来的内容会被清洗一遍. ** 因为 `read_paste()` 收的是终端给的原始字节、
不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上:
| 字符 | 不清洗会怎样 | 清洗后 |
|---|---|---|
| `\t` | 终端按制表位 (8 的倍数)展开, 编辑器只当 1 列 —— 粘一段带缩进的代码, **光标从此偏掉, 之后每敲一个字都在错位置上** | 就地展开成空格, 缓冲区里不留制表符 |
| `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 |
| `\r` | 把终端光标拉回行首, 屏幕当场错位 | 丢弃 (CRLF 归一成 LF) |
| `NUL` | 会把 C 字符串截断 | 丢弃 |
制表符展开是按**插入点当时的列号**算的, 不是从 0 开始 —— 在行中间粘贴时缩进才对.
为此缓冲区支持多行, 渲染也相应升级为多行 + 折行感知.
实现方式是进 raw 模式自己接管按键. 最早用 `fgets` 直接读时终端停在规范模式,
方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`, 所以必须自己处理.
超出终端宽度时自动折行, 光标位置按行列重算; 宽度计算复用与表格渲染同一套
CJK 规则 (中文按 2 列). 行尾 `\` 续行仍然可用, 续行的每一段都能独立编辑.
**你敲的那一行整个是绿色的** (提示符 + 输入的文字), 翻历史或滚屏时一眼能
看出哪些是自己的输入、哪些是模型输出:
```
ligent> 你好 ← 绿色, 整行都是输入
你好! 有什么可以帮你的吗? ← 普通色, 是模型正文
```
颜色是行编辑器统一加在整行两头的, 所以提示符字符串本身必须保持纯文本 ——
在提示符里自带一个 reset, 后面的输入就会掉回默认色.
配色跟着正文那个开关走 (`--color` / `--no-color` / 非终端自动关闭).
绿色区间那两串转义序列在算光标位置时会被跳过 —— 否则 `\033[32m` 会被当成
5 个可见字符, 光标每次重绘都会偏 5 列.
## 4. 终端渲染
助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记
在终端里直接变成样式, 而不是原样显示. 工具输出 (文件内容、命令结果)**不做**
任何渲染, 保持原样, 避免破坏代码.
### 只用 16 色
全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37, 加粗/暗淡/斜体/下划线
用 SGR 属性 1/2/3/4. 不用 90-97 那一段 (aixterm 的"亮色"), 也不用 256 色或
真彩.
理由是这个项目**硬编码 SGR、不查 terminfo** (也不查 `COLORTERM`)—— 既然不去
探测终端能力, 就该待在**所有终端都认**的那一档里. 90-97 虽然到处都是, 但严格
讲不属于 16 色, 在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上,
它可能被当成两个参数解析或者直接忽略.
这条约束由 `tools/check-colors.sh` 守着, `make test-unit` 里会跑. **它值得单独
一个脚本, 是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个
蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处:链接原本用的
`\033[94m` 就是越界的亮蓝. )
配色语义在各模块之间是一致的:
| 用途 | 码 | 出现在 |
|---|---|---|
| 成功 / 工具执行成功 / 输入行 | 32 绿 | agent、doctor、main、lineedit |
| 失败 / 错误 | 31 红 | agent、doctor |
| 警告 / 需要注意 | 33 黄 | doctor、confirm、main |
| 次要信息 (提示、路径、引用) | 2 暗淡 | 各模块 |
| 强调 (工具名、标题) | 1 粗体 | 各模块 |
| 链接 | 4;36 下划线+青 | markdown |
链接用**下划线**而不是只靠颜色:下划线是 SGR 属性, 在单色终端、色盲用户、
以及把颜色重映射过的主题里都还在 —— 颜色会丢, 属性不会.
| 语法 | 效果 |
|---|---|
| `#` ~ `######` | 标题, 按层级着色 (H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) |
| `**粗体**` | 粗体, 标记被吃掉 |
| `*斜体*` / `_斜体_` | 斜体 |
| `` `代码` `` | 青色的行内代码, **反引号保留** (方便复制到 shell), 内容不做任何解析 |
| `-` `*` `+` 列表 | **保留原始标记字符**并着青色 (不替换成 `•`) |
| `> 引用` | 暗色 `│ ` 前缀 |
| `---` | 暗色分隔线 |
| `\| a \| b \|` 表格 | 带边框对齐的表格, 表头加粗, 支持 `:---` / `:---:` / `---:` 对齐 |
| `[链接文字](url)` | `[链接文字]` 加下划线并着青色, `(url)` 原样保留 |
**完全不做渲染、逐字节原样保留**的四种情况:
| 语法 | 处理 |
|---|---|
| ` ``` ` 围栏代码块 | 整块 (含 ` ``` ` 标记本身)原样输出, 零 ANSI 转义 |
| `![图片](src)` | 保留原文 |
| 链接的 `(url)` 部分 | 保留原文 (不隐藏、不做超链接跳转) |
| 行内代码的内容 | 不做任何解析 |
表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐):
```
┌──────┬──────┬──────┐
│ 名称 │ 数量 │ 说明 │
├──────┼──────┼──────┤
│ 苹果 │ 3 │ 常见 │
│ 香蕉 │ 12 │ 较长 │
└──────┴──────┴──────┘
```
### 两个刻意的取舍
**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调":
`foo_bar_baz`、`a_b_c` 原样保留; 与另一个下划线相邻时也一律当字面量,
所以 Python 的 `__init__`、`__main__` 不会被拆成斜体. 中文不算词字符,
因此 `中文_斜体_` 能正常生效.
**表格必须等整块收完才能渲染. ** 列宽取决于所有行, 所以表格在流式过程中会先缓存在
缓冲区, 遇到非表格行 (或消息结束)才一次性输出. 行数超过 200 行会先渲染已有部分,
避免超大表格一直不显示.
**表格必须顶格写 `|`. ** 不带首竖线的写法 (`a | b` / `--- | ---`)按普通文本处理.
这条限制不是偷懒, 而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息
必须在行的第 0 个字节就能拿到. 如果允许"整行任意位置含竖线", 那么当 SSE 的分片边界
恰好落在竖线之前时, 行的前半段已经被当成普通文本输出、无法收回; 而同一行若整块到达
则会被识别为表格——**同样的内容, 渲染结果取决于网络怎么切的包**. 这个问题是模糊测试
找出来的 (最小复现只有 4 字节:`s|\n-`).
### 流式是这里的难点
SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld**` 两片,
甚至两个 `*` 分别落在两片里. 所以渲染器是一个**带状态、可挂起**的状态机
(`src/markdown.c`):
- 遇到可能是标记前缀的尾部 (结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`),
它**不立即输出**, 而是把这段字节留在缓冲区里等下一个分片, 凑齐后再判定.
- 绝大多数情况下挂起只有几个字节, 延迟不可感知; 唯一的例外是**表格**
(列宽依赖所有行, 必须整块收完才能渲染).
- 缓冲区有 64KB 硬上限兜底, 表格另有 200 行 / 单行 4KB 的上限,
防止异常输入把内存吃满或让输出长时间卡住.
- 流式与非流式走同一个渲染器, 因此两种模式的输出**逐字节一致**.
`tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按
**一次性 / 3 字节 / 逐字节** 三种方式喂进去, 输出必须完全相同.
### 想看效果?
仓库里带了一个独立的小演示程序, 不需要 API key:
```bash
make demo # 编译并运行, 用内置示例展示所有语法
./build/md_demo --plain # 对照:输出原始 Markdown
./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms
./build/md_demo --compare # 自检:不同分片方式输出是否一致
./build/md_demo README.md # 渲染任意 Markdown 文件
echo '# hi **there**' | ./build/md_demo
```
`-s` 看起来和普通输出几乎一样, 这正是重点:分片边界落在 `**`、行首 `#`、
表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定.
`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍,
逐字节比对结果, 这也是 `make test` 会跑的一项.
### 什么时候不渲染
只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**:
- 输出被重定向或接管道 (`ligent "..." > out.md`)
- 显式 `--no-color`
这样 `ligent "生成一份 README" > README.md` 拿到的仍然是干净的 Markdown.
判断依据是 `isatty(stdout)`, 可用 `--color` / `--no-color` 强制覆盖.
## 5. 内置工具
| 工具 | 作用 | 关键参数 |
|---|---|---|
| `run_command` | 执行 shell 命令, 合并捕获 stdout/stderr 与退出码, 超时后杀掉整个进程组 | `command`, `timeout` |
| `read_file` | 按行读取文本文件, 带行号 (可直接喂给 `edit_file` 的 `line` 集合), 大文件分段读; **二进制文件拒绝** | `path`, `offset`, `limit` |
| `write_file` | 创建/整体覆盖**文本**文件, 自动创建父目录; 目标是二进制文件时拒绝覆盖 | `path`, `content` |
| `edit_file` | 按**行号集合**做精确字符串替换:`line` 是集合型 (整数或整数数组), 替换只发生在集合圈住的行里且只要**行内唯一**; **省略 `line` = 全文件替换所有匹配** | `path`, `line`, `old_string`, `new_string`, `replace_all` |
| `list_dir` | 列出目录, 标注类型与大小 | `path` |
| `read_image` | 读入本地图片交给模型看:本机留备份, 请求时以 base64 内联发出 | `path` |
单次工具输出上限 100 KB, 超出会截断并提示, 避免一次 `cat` 把上下文撑爆.
### `edit_file`:行号集合钉住的替换
`line` 是**集合型**参数, 而且**可以整个省略**:省略 = 不设行范围 = **全文件替换
所有匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数
(`"line": 41`)或整数数组 (`"line": [41, 42]`)都收, 行号就是 `read_file` 打出来
的那些 (从 1 开始). 给了行号之后, 替换只发生在这些行里, `old_string` 也只需
**在这些行里**唯一.
判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数.
所以 `old_string` 跨好几行时, 照 `read_file` 的显示把覆盖到的那几行都填进数组就行,
不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍":
真改动的仍然只有唯一那一处 (`replace_all=true` 或省略 `line` 时才动全文件).
**省略 `line`** 时没有范围约束:整个文件都是搜索范围, 而且**命中几处就换几处**
(不再要求唯一), 回执里会写明"全文件替换 N 处". 这是故意留着的逃生门 —— 有些
改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径:不给行号就没法
声称"我知道改的是哪一处", 要改一处仍得给行号.
为什么不再只靠"全文唯一":行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里
恰好只有一处 —— 文件一长这个条件经常不成立 (同一个 `return 0;` 到处都是), 模型
只能不断加长 `old_string` 去凑唯一性; 凑不成要么放弃, 要么赌一把改错地方. 把行号
钉住之后, "改哪一处"由坐标决定, 不再是个概率问题, `old_string` 也不必为了唯一而写长.
负面路径都给出**可照做的下一步**, 而不是干说一句"不行":
| 情况 | 反馈 |
|---|---|
| `line` 写成了字符串之类 | 报错说明它要的是整数或整数数组, 并提示"要全文件替换就整个省略" |
| 给了空数组 | 报错说明至少要给一个行号, 或整个省略 `line` |
| 集合里有越界行号 | 报出文件总行数 ("只有 3 行, line 里的 99 越界") |
| 集合里的元素不是 >=1 的整数 | 报错并指出是第几个元素 (空数组、非整数都在这里拦掉) |
| 集合里的行号太多了 (>64) | 报错说明上限, 并提示先 `read_file` 看清再分几次改 |
| 集合圈住的行里没有 `old_string` | **报出它真正在哪几行** ("第 3 行没有, 它在第 7 行" / "在第 2、9、14 行等共 6 处"), 照着改 `line` 即可 |
| 集合圈住的行里有多处 | 要求写长 `old_string`, 或设 `replace_all=true` |
| 省略了 `line`, 而文件里没有 `old_string` | 报"整个文件里没有", 并提示要给行范围就填 `line` |
| 文件是二进制 | 拒绝并说明理由 (见下节) |
集合里的重复行号会被**去重** (`[1,1]` 等价于 `[1]`):不然同一处匹配会被数成两处,
"行内只有一处"的判断就会莫名其妙地失败.
`replace_all=true` 的含义没变:替换文件里**所有**匹配; 但 `line` 集合仍必须圈住其中
真实的一处 —— 等于强制模型先看过文件, 而不是凭印象做全文替换.
### 文本工具只处理文本
`read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**, 而不是硬读或
硬写. 判据只有一条:**出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样,
否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾).
拒绝的理由是实的, 不是洁癖:
- `read_file` 会把二进制读成乱码, 还会在第一个 NUL 处被截断 —— 模型看到的是**残缺**
的内容, 却没有任何提示说它是残缺的;
- `edit_file` 更糟:它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个
NUL 都会让后面的内容被当成结束, **改下去就是把原文件毁掉**. 所以它探的是全文件,
不像读类工具只看前 8 KB;
- `write_file` 只写文本, 用文本整体覆盖一个二进制文件等于把原内容毁掉.
拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`, 看其它
二进制用 `run_command` (`file` / `xxd` / `strings` / `od`), 要改二进制用
`printf` / `dd` / `perl -pi` 这类.
**搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep`
(`grep -rn 模式 目录`). 以前有个 `search_files` (后来改名 `grep`)的包装,
删掉的理由见下节.
### 工具命名约定
工具名是协议的一部分:它每个请求都要重发, 还要被人、被模型、被代码里好几处引用.
所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步):
1. **形式**:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单,
而白名单**按域划分** —— 要用新动词, 先把它加进对应域:
- 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view`
- 历史编辑域 (`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo`
两域动词不混用. 禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词.
2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`.
模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜.
3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里.
4. **不与 REPL 命令同名** (`/shell`、`/cd`、`/model`、`/log`… 已被占用),
否则人和模型两边会串. 这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一.
5. 不与别的工具只在词序、单复数或大小写上不同 (`read_file` / `file_read` 这种).
6. **长度 ≤ 24 字符**:工具表每个请求都要重发, 名字长度是重复成本.
7. **参数名与工具名同源**:路径一律 `path`, 内容 `content`. 同一个概念在两个工具里
必须同名.
8. **能不包装就不包装** (这条是人定的, 不是名字的规矩):模型手上有一个能跑任意
命令的 `run_command`, 凡是"一条标准命令就能干、而且干得更好"的事, 就别再做一个
自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的
机会, 换来的只是省一次 `run_command`.
### 为什么没有搜索工具
曾经有 `search_files` (后来按规矩改名 `grep`), 现在删了 —— 它是第 8 条规矩的
反例:真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-C`)、大小写、多模式、
二进制提示, 我们那版每一样都要自己写一遍, 还得跟着需求慢慢长 (第一版就叫错了
名字:明明是搜内容, 却叫 `search_files`, 模型自然会去传 `pattern="*.c"`).
删掉之后工具表从 8 个变成 7 个.
(顺带一提:原来还有个子代理功能 `spawn_subagent`, 它拿不到 `run_command`,
所以既搜不了内容、也没法真正独立干活, 等于只是"多花一份上下文把文件读一遍",
现在整个删掉了. )
名字只定义一次 (`src/tools.h` 的 `TOOL_*` 宏), 工具表也只有一处
(`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出
全从那张表生成. 所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp`
链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个工具).
**不保留旧名别名**, 这是刻意的:schema 里只发新名, dispatch 再认旧名只会掩盖
"调用方没跟着改" (旧存档、用户自写的提示词、测试夹具都一样). 认不出来就明确报
`未知工具 "xxx"`, 比静默兼容更容易发现漏改.
### `read_image`:图片怎么进对话
模型自己看不到磁盘. 给它一个路径, 它得先把图**读进来**——这一步就是
`read_image`:
```bash
ligent "看看 build/screenshot.png 里的报错是什么"
```
内部做了四件事, 每件都有它的理由:
**1. 按内容认格式, 不认扩展名. ** 读文件头判断 PNG / JPEG / GIF / WebP.
一个叫 `.png` 的文本文件会被明确拒绝, 而不是发出去等一个莫名其妙的 400.
**2. 请求里走标准的 `image_url` + base64 内联. ** 也就是:
```json
{"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}}
```
这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**:
DeepSeek 和 MiMo 都收, ligent 不去网上抓图, 所以只产出 base64 这一种.
图片只能出现在 `user` 消息里, 而工具结果只能是字符串. 所以工具跑完之后,
ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不算提问——`/back`
按轮删除、上下文回收时都按这个规则处理, 不会把它当成新的一轮.
**3. 但图片字节不进历史. ** base64 内联有个直接后果:字节会躺在会话里,
每轮请求都重发. 归档、`/history`、`/log` 全都跟着膨胀. 所以**存储形态和发送
形态是分开的**:
```
存进历史 {"type": "ligent_image", "backup": "1700000000_a1b2.png"}
发给服务端 {"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}}
```
`read_image` 先把图存到
`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.<ext>`, 历史里只挂一个
对备份的引用; **备份文件才是字节的真身**, 真正构造请求时才读出来编码.
`/export` 出来的存档因此还是几十 KB, 而不是几十 MB.
两个内部块类型都带 `ligent_` 前缀, 就是为了万一哪天漏了展开、直接发出去,
服务端报的错能一眼看出是我们的问题:
| 内部块 | 含义 |
|---|---|
| `ligent_image` | 引用一张本地备份 |
| `ligent_note` | 展开失败后的文字说明 (备份读不到了、体积超了) |
**4. 体积在本地就判掉. ** 图片会留在历史里, 所以一次超限不是一次失败,
而是从此**每一轮**都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了"——
而且**不备份**:一张永远发不出去的图, 留着备份没有意义.
上限取各家文档里最严的一档 (DeepSeek 单图 32 MiB、请求体 48 MiB; MiMo
base64 字符串 50 MB):
| 限制 | 值 |
|---|---|
| 单张图 (base64 之后的长度) | 32 MiB |
| 一次请求里图片的 base64 总量 | 44 MiB (给提示词/历史/工具描述留 4 MiB) |
注意判的是**编码后**的长度:base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经
超了. 被拒时工具直接把话带给模型, 让它请用户先缩小或压缩.
**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积, 但服务端还可能因为别的
原因不收 (图片损坏、这个变体不支持……). 那条引用躺在历史里, 不修的话之后
每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路.
所以 ligent 撞到 4xx 且请求里有图片时, 会把图片**从历史里就地摘掉**、换成
一句说明再重试一次.
给**模型**看的是能直接照做的一句话:
```
[图片 1700000000_a1b2.png 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试]
```
给**你**看的 (stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文
错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查:
```
服务端拒绝了请求里的图片, 已把 1 处图片从对话里移除后重试...
服务端原话:HTTP 400 —— image rejected: unsupported image variant
```
"就地"是要紧的:光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯.
`read_image` 不写文件、不执行命令, 所以**不触发写操作确认**. 备份是副作用,
不是"改动你的东西".
它会不会出现在工具表里, 取决于模型收不收图片 —— 见下一节.
### 模型能力探测:怎么知道它收不收图片
`read_image` 只对**能看图的模型**有意义. 可是**没有任何接口能问出来** ——
DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`,
没有 capability 或 modality 字段 (OpenAI 自己的也是如此). 这是 OpenAI 兼容层
的通病.
所以只能**发试探请求**问. ligent 在**首次请求之前**跑一个独立的"能力测试序列",
它不碰对话历史, 也不进历史:
| 步 | 发什么 | 结论 |
|---|---|---|
| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的, 后面的否定结论才作数 |
| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持, 到此为止 |
| 3 | 拆开单独发 (图 / tools) | 第 2 步被拒时才知道是哪一个不行 |
关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200, 差别就在
那一样东西上. 这样不需要猜服务端的错误文案 ("不支持图片"、"invalid content"、
"multimodal not supported"……千家千样), 也就不会因为文案变了而误判.
**猜不准时一律按"支持"处理**:多暴露一个工具, 代价是模型可能白调一次 (还有
4xx 降级兜底); 而错误地藏掉工具, 你会以为模型看不见图.
结果按 `base_url + model` 缓存, 所以**换模型会自动重测**, 切回来命中缓存.
探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**,
都在同一行里, 不用去猜:
```
模型能力:多模态 (图片输入)支持 * 工具调用 支持
模型能力:多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持
模型能力:多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401)
```
探测出"不支持"时把动作也写在同行, 是因为只报"不支持"而不说"我已经把工具藏了",
用户会以为工具还在.
代价要说清楚:**开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求**
(常见情况是 2 个, 模型受限时才拆开). 不想要这个开销就用 `--no-probe`
(或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、
`read_image` 照常暴露, 也就是探测功能出现之前的行为.
想临时测一次 (比如用了 `--no-probe` 又想确认模型收不收图片), 在 REPL 里敲
**`/probe`**:它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论.
#### 为什么最后没走 Files API
早先这一路走的是 Files API:上传换一个 `file_id`, 请求里只带引用. 它确实能
让历史更小, 但**它不是一个标准**. 只有最外面那层 (`POST /v1/files` +
multipart + `purpose`)因为抄 OpenAI 而看着像标准; 真正要紧的"在对话请求里
怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`,
**Chat Completions 根本不收 `file_id`**; Google 用 `file_data.file_uri`;
Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文件.
也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**.
而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状 (OpenAI 兼容层
普遍也认). 既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到,
就没有理由再为它绑定一个厂商私有的引用协议.
代价写在 §9 里, 不藏着. 思考模式则仍然是方言 (见 §9), 那一条没有通用替代.
> ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、
> 读写任意路径. 建议在容器、虚拟机或独立用户下运行, 不要在存有重要数据的
> 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉.
## 6. 源码结构
```
src/
util.c/h xmalloc、动态字符串缓冲、文件读取、mkdir -p、${VAR} 展开
json.c/h 递归下降 JSON 解析器; UTF-8/代理对解码; 构造器; 紧凑/美化序列化
http.c/h libcurl 薄封装:同步 POST、流式 POST (header 回调取状态码、
写回调分流错误体)、Ctrl-C 中断
llm.c/h OpenAI 兼容客户端:请求体构造 (含 thinking / reasoning_effort)、
SSE 逐行解析、tool_calls 按 index 增量拼接、
reasoning_content 累积与回传、非流式回退、错误体可读化
tools.c/h 6 个工具的实现 + **工具表** (唯一真相:请求 schema、执行分发、
是否要确认、/tools 的输出都从它生成; 命名约定写在表上方)
capability.c/h 模型能力探测序列 (基线 → 合并 → 拆开), 按 base_url+model 缓存
models.c/h GET {base_url}/models:拉取并解析模型列表 (保序、去重、容错)
attach.c/h /include:文件暂存、glob 展开、交互式挑选 (逐层进目录)
priv.c/h 提权:把命令包成 sudo 调用 (进程本身不提权)
lineedit.c/h REPL 行编辑器:raw 模式、光标移动、bracketed paste、UTF-8 感知,
以及输入历史的持久化 (实时追加 + 启动加载 + 上限截短)
doctor.c/h 环境体检:逐环检查并给出可操作的结论
archive.c/h 对话存档:导出/列举/载入 (文件名即谱系)
image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联
shell.c/h /shell:伪终端双向搬运 + 录制
meta.c/h /meta:元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验)
spinner.c/h 等待动画:两段标签 (等待响应 → 思考中), 后台线程,
只在 stderr 是终端时启用
i18n.c/h 界面文案表 (中英各一份)、语言探测、{name} 占位符替换
markdown.c/h 流式 Markdown 渲染状态机 (标记跨分片挂起、表格列宽对齐、
近似 wcwidth 的 CJK 宽度计算)
agent.c/h 主循环:消息历史管理、多步工具调用、流式打印
config.c/h 默认值 → 配置文件 → 环境变量
main.c 参数解析、单次任务模式、REPL
tools/
md_demo.c 渲染效果演示程序 (独立可执行, 不依赖网络)
tests/
test_json.c JSON 解析/序列化/转义/非法输入/嵌套深度
test_tools.c 工具的端到端行为 (真实执行命令、真实读写文件)
test_agent.c 上下文不变量 (system 不重复、reset 语义)
test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开
test_markdown.c Markdown 渲染 + 分片不变性 (一次性/3字节/逐字节 输出一致)
fuzz.c 对抗性模糊测试:随机/变异输入砸 JSON 与 Markdown 解析器
reduce.c delta-debugging 最小化器:把失败输入缩到最短复现
test_http.c HTTP 层纯逻辑 (重试判定、上限取值)
test_lineedit.c 行编辑器缓冲区与历史 (含 Home/End 在逻辑行内的语义、
历史文件转义往返 / 上限截短 / 文件不存在)
test_spinner.c 等待动画的两段标签与幂等性 (把 stderr 接到临时文件后读回断言)
test_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记
test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级
test_capability.c 能力探测的纯逻辑 (名字映射、关掉探测时的语义、缓存查询)
test_models.c /models 响应体解析 (标准形状、裸数组、去重、坏条目、空列表)
test_attach.c /include:glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问
repl_edit_check.py 带极简终端模拟器, 验证方向键/Home/End/退格/历史/多行粘贴
真的生效 (只对比字节流证明不了光标移动)
repl_drive.py 用 pty 驱动 REPL 跑多轮对话 (管道模式无法测多轮行为)
mock_server.py 最小 OpenAI 兼容 mock, 含 SSE 与分片 tool_calls
run_e2e.sh 端到端:流式、非流式、多步工具、错误处理、缺 key、管道、渲染
```
### 几个实现细节
- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片,
客户端按 `index` 累积成完整 JSON 字符串后再整体解析, 而不是每片单独解析.
- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`,
而不是把原始 JSON 甩给用户.
- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析.
- **行号集合钉住的替换**:`edit_file` 的 `line` (`read_file` 给的行号, 整数或整数
数组都收)把替换钉在集合圈住的行里, 且只要行内唯一 —— 从机制上避免模型改错地方,
也不逼它为了"全文唯一"把 `old_string` 写成一整段. `line` 可以省略, 省略就是
**全文件替换所有匹配**:逃生门留着 (有些改动本来就该全文件生效), 但它不是默认
路径 —— 要改一处仍得给行号.
## 7. 测试
```bash
make test # 全部
make test-unit # 纯单元测试, 不需要网络 (含演示程序自检 + 模糊测试)
make test-e2e # 用本地 mock 服务端验证完整链路, 不需要真实 API key
make test-fuzz # 只跑模糊测试, 迭代更多轮次
make demo # 编译并运行渲染效果演示
```
### 模糊测试
`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块 (JSON 解析器、
Markdown 渲染器), 检查四条不变量:
| 模块 | 不变量 |
|---|---|
| JSON | 任何输入都不崩溃、不泄漏 |
| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致 (幂等) |
| Markdown | 任何输入都不崩溃、不泄漏 |
| Markdown | **一次性喂 与 任意分片喂 输出逐字节一致** |
| Markdown | 不含元字符的输入必须原样输出 |
种子固定, 失败可复现. 失败时会把输入写到 `build/md_fail.bin`,
用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例.
`tests/reduce.c` 是个 delta-debugging 最小化器 —— 上面那条"表格必须顶格"
的规则, 就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的.
```bash
make build/fuzz build/reduce
./build/fuzz 20000 42 # 指定种子跑 2 万轮
./build/reduce build/md_fail.bin # 最小化最近一次失败
```
端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个
OpenAI 兼容服务端, 按请求内容返回确定性的流式响应, 从而验证「模型请求工具 →
本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环.
> 存档目录 (`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物, e2e 却
> 需要它干净, 所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`**
> 并打印路径, 而不是直接删掉. 移不动就直接中止 (宁可测试不跑, 也不误删).
> 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的
> 存档叠进去.
用 AddressSanitizer / UBSan 跑一遍:
```bash
make clean
make SAN=1 test-unit
```
`SAN=1` 在默认 `CFLAGS` 之上追加
`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`,
链接行也带上 (只插桩目标文件、不进运行时库的话检测器根本不生效).
`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 免得刷屏.
整套单元测试 (16 个可执行、3692 项)在这个配置下是干净的.
## 8. 稳定性设计
这一节记录为了让它在真实网络和真实模型下不崩、不卡死所做的处理.
### 网络层
| 机制 | 说明 |
|---|---|
| 瞬态失败重试 | 408/409/429/5xx 与连接级失败 (DNS/连接/收发/半包)自动重试, 指数退避 + 抖动, 并遵循服务端 `Retry-After` |
| 不重试请求超时 | 超时意味着服务端**可能已经处理并计费**了, 重发会重复扣费, 所以宁可失败 |
| 已输出则不重试 | 流式一旦向终端吐过内容就不再重试, 否则用户会看到重复输出 |
| 响应体上限 | 声明或实际超过 32MB 主动中止; 流式靠 `Content-Length` 提前判断, 非流式另有累积上限 |
| SSE 缓冲上限 | 单行 256KB、单次累积内容 32MB、单个工具调用参数 8MB. 正常 `data:` 行只有几十字节, 超长说明对端不是 SSE |
| 工具输入上限 | `edit_file` 在读之前先看文件大小, 超过 32MB 直接拒绝 (它需要整个文件进内存); `read_file` 是分段读的, 不受此限 |
| 二进制检测 | 文本工具 (`read_file` / `write_file` / `edit_file`)拒收含 NUL 字节的文件:读类只看前 8 KB, `edit_file` 看全文件 (它按 C 字符串处理, 任何 NUL 都会把结果截断) |
| 错误体上限 | 只保留 256KB 用于诊断 |
重试次数可配置:`--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`.
### 传输中断
流式请求中途断连时, **已经收到的部分内容会保留**成一条消息并提示"传输中断",
而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有, 后续对话会对不上.
### 上下文超限:如实报错, 不替你做决定
历史超过模型窗口时, 服务端回一个 4xx. ligent **不会自动删掉任何一条消息** ——
它把服务端的原始错误原样报出来, 历史一个字节都不动:
```
ligent> 第二轮
ligent: 出错:HTTP 400 — This model's maximum context length is 8192 tokens.
However, your messages resulted in 9000 tokens.
```
这里曾经有一整套"丢弃最老的一整轮再重试 (最多 5 次)"的机制, 配套
`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由:
- 触发它的判据只是错误文案里的关键词 (`too long`、`上下文`、`超出`……),
任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话.
用一句猜出来的判据去毁掉对话记录, 代价不对等.
- 它把"历史太大了"这件本该由用户决定的事 (`/back` 摘掉哪一轮、`/reset` 重来、
还是换一个窗口更大的模型)自动化掉了; 用户看到的是对话莫名其妙少了几轮.
现在怎么处理由你决定:`/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空
重来、`/export` 存档后另起一段, 或者直接换个模型. `/usage` 里的"最近一次输入
token"≈ 当前上下文大小, 可以据此判断还剩多少余量.
(`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮. )
### 对话结构不变量:tool_calls 必须连着 tool
服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个
`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是:
```
HTTP 400 — An assistant message with 'tool_calls' must be followed by tool
messages responding to each 'tool_call_id'. (insufficient tool messages
following tool_calls message)
```
麻烦在于这条历史**留在上下文里**——一旦写坏, 之后每一轮都会以同样的 400 失败,
用户只能 `/reset`. 所以 ligent 两头都做了:
**发之前先体检. ** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位
("这次调用没有拿到结果, 不要假设它执行过"), 孤儿 `tool` 消息 (找不到对应
调用的)丢掉, 并如实打一行说明. 没问题时它只是一次只读扫描, 不分配任何东西.
```
对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 1 处
—— 不修的话之后每一轮都会被服务端拒绝.
```
**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑:
- **图片附件的位置. ** 图片只能出现在 `user` 消息里, 而 `tool` 消息的内容只能是
字符串, 所以 `read_image` 之后要补一条 `user` 消息. 曾经是"每推完一条 `tool`
就补一次", 于是一条 assistant 带两个调用、第一个是读图时, 历史成了
`assistant / tool / user(附件) / tool` —— 中间那条 `user` 把 `tool` 段截断了.
现在附件**攒到所有 `tool` 消息都推完之后**再补.
- **中断. ** 一条 assistant 带多个工具调用时按 `Ctrl-C`, 以前直接 `return`,
剩下的调用没人回应 —— 同一个 400. 现在给没跑成的调用补一条"用户中断了本轮,
本次调用没有执行"的 `tool` 消息再返回.
回归用例是第 53 段, 而且 `tests/mock_server.py` **和真服务端一样校验这个结构**
(`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到
用户真拿它去连服务端才发现.
### 图片降级只在错误确实冲着图片来时动手
服务端 4xx 时会检查请求里有没有图片引用, 有就把图片从历史里摘掉再重试一次
(见 §5). 这一条**加了关键词判断**:只有错误里出现 `image` / `vision` /
`multimodal` / `content type` / `图片` 这类词才动手.
理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成
图片问题处理的话, 图白丢, 而且真正的错误被我们的提示盖住了 —— 排障方向直接被
带偏. **不确定就不动手**, 宁可让错误如实报出来. 这终究是启发式; 主要防线还是
发送前就把格式和体积查掉.
### 提权:模型请求, 你输密码
`run_command` 有一个 `elevated` 参数. 模型撞到权限墙时带上它, 那一条命令就以
root 执行:
```
run_command {"command": "apt install -y nginx", "elevated": true}
```
```
</> 即将执行 run_command
命令: apt install -y nginx
继续?(Y/N/Always/neVer) ← 让你看清要跑什么
[sudo] password for pluv: ← 真正的授权在这里
```
**先说清楚为什么没有 `/su`、也没有"授权提示". **
- **`su` 能变 root, 不是因为 PAM 验证了密码**, 而是因为 `/usr/bin/su` 本身是
setuid root:内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调
`pam_authenticate()` 只能得到「密码对不对」, uid 一位都不变. 要让 ligent
内部认证后真的变 root, 唯一的路是把它编译成 setuid root —— 而这个程序的功能
是「让模型执行任意命令」, setuid root 的它等于把 root 远程执行做成一等功能.
**那条路没走. **
- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权:模型敲
不出来, 你也不可能手滑敲出来. 前面再加一道"允许吗"只会是多余的摩擦, 还会给出
一个虚假的安全模型 —— 好像把关的是 ligent 的提示, 其实把关的是 sudo.
- 所以没有常驻提权状态, 也就没有"忘了降权"这回事. **一次命令一次身份**;
确认提示负责让你看清命令, sudo 负责授权, 各司其职.
| 环节 | 做法 | 为什么 |
|---|---|---|
| 传参 | `sudo -u root -- <argv>` | argv 形式, **不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 |
| 认证 | **不带 `-n`** | `-n` 是"不许提问", 带上它需要密码时就直接失败; 而这里要的正是让 sudo 能问 |
| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的, 摘掉控制终端它就永远问不出来 |
| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型, 和普通 `run_command` 一样 |
| 没有终端 | **直接拒绝, 不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 |
想立刻收回授权:在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时.
**踩过的坑, 值得单独记**:第一版把认证也放进通用的 `run_capture()`, 而它
`setsid()` + 全接管道 —— 真机上认证 **100% 失败**. 更糟的是当时的 e2e 替身
sudo 无条件成功, 给了个**假绿**. 现在替身必须像真 sudo 一样要求终端, 并且有一条
断言专门盯"认证时 sudo 真的拿到了终端"; `tests/test_priv.c` 还钉住了"包装里不许
出现 `-n`"和"没终端时能识别出来"这两条.
### 排障:/log 导出收发记录
进程会把每一次请求的**请求体**、响应头、以及响应原文 (流式就是原始 SSE 字节)
滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞:
```
ligent> /log # 看看现在缓存了多少
ligent> /log /tmp/session.log # 把已经积累的全部追加写入文件
ligent> /log clear # 清空缓存
```
记录里**不含请求头**, 所以不会把 API key 写进去 (有测试断言).
它的真正用途是:**录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在
自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于
我们**以为**服务端会接受什么. 有了这个文件, 真实服务端的响应就能当成测试夹具
反复使用, 不必每次都烧 token.
`tests/replay.py` 就是这个回放器. 它不重写一个"模拟客户端", 而是当**离线
服务端**:把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、
`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组, 全都在真实字节上跑一遍.
```bash
python3 tests/replay.py session.log --summary # 这份录制里有什么
python3 tests/replay.py session.log --check # 能否解析成完整往返
python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端
./ligent --no-probe --base-url http://127.0.0.1:8899/v1 "please run it"
```
`--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对**
(顶层键、消息角色序列、工具名集合), 不一致就回 400 并在 stderr 打印差异.
于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了
报文而没发现, 从此不可能.
回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求, 而那几轮不在
录制里 (录制时也建议 `LIGENT_PROBE=0`, 两边一致). 录制用完之后回放器会明确
报"没有更多响应", 而不是静静回错东西.
仓库里冻了两份夹具 (`tests/fixtures/`), 都是对着 mock 录的 —— mock 故意把
SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状:
| 夹具 | 冻住的是 |
|---|---|
| `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 |
| `error.log` | 一个 400 错误体, 以及错误信息的可读化 |
**录一份真实会话** (你自己的 key, 跑一次真问题):
```bash
ligent # 正常聊, 直到复现出你要冻住的那个行为
ligent> /log session.log # 导出
python3 tests/replay.py session.log --summary
```
之后把它放进 `tests/fixtures/` 并加段断言, 那次真实故障就永久变成了测试.
内存上限 16MB, 超出后丢弃较早的部分 (排障时通常更关心刚刚发生了什么),
导出时会在文件开头注明发生了截断.
> 注意:`/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积,
> 但没有地方把它导出来.
### 对抗性测试
`tests/fuzz.c` 用随机与变异输入砸两个"解析不可信数据"的模块 (JSON 解析器、
Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现.
详见下一节.
## 9. 已知限制
- 界面文案只有中英两份, 而且只管"给人看"的部分:**工具描述、工具回执、
系统提示词不随界面语言变** (那些是给模型的信息). 切成英文之后模型收到的
仍然是中文回执 —— 想让模型侧也换语言, 得改提示词本身.
- 只在 Linux 上验证过 (用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则).
- `--timeout` 只对非流式请求生效; 流式请求靠 120 秒无数据即断开的低速检测.
- 历史超过模型窗口时**只报错, 不自动回收** (见§8):要不要丢几轮、丢哪几轮由你
用 `/back`、`/reset`、`/meta` 决定. 单轮内工具输出过大 (超过上限被截断)时,
只能靠模型自己用 `offset/limit` 分段读.
- 每轮都会重发完整历史, token 成本随轮数平方增长.
- 终端交互是行式 REPL, 不是全屏 TUI:有行内编辑与历史, 但**没有 Tab 补全**.
- 对话历史本身只存在进程内, 退出即丢 —— 要留就 `/export`.
(输入历史是持久化的, 见下一节. )
- Markdown 渲染不处理嵌套列表缩进.
- 提权只覆盖 `run_command` (含 `/shell`). 文件类工具还没走提权通道, 要动受
保护的文件得在命令里自己用 sudo.
- 提权依赖系统的 `sudo`; 不支持 `su` (它需要终端且不缓存凭据).
- 提权命令不独立成进程组 (要保住控制终端给 sudo 问密码), 所以超时只能杀直接
子进程, 不能整组杀.
- `/include` 的 glob 是 shell 语义 (`glob(3)`), `*` 不跨目录; 要递归就进
交互式界面逐层进, 或者自己写多级模式.
- `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB.
- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么":输入行里直接写
名字可以指定任何模型, 包括服务端没列出来的内部模型.
- `/history`、`/back`、`/unback`、`/export`、`/import` 报的"历史条数"**不含
system 提示词** —— 它是配置不是对话, 删掉唯一一轮之后应当显示 0 条.
只有 `/diff` (meta)报的是数组真实条数, 因为它要跟 `#0 #1 ...` 的下标对上.
- 图片只进不出:`read_image` 能让模型看图, 但模型没有画图能力.
- 能力探测靠"发请求试", 因为**根本不存在**查询能力的接口. 代价是开启探测时
每个进程的第一次请求前会多出 2~4 个小请求 (换模型会重测, 切回来命中缓存).
`--no-probe` 可以彻底关掉, 回到"全都暴露"的老行为. 探测结论不确定时按支持
处理 —— 宁可让模型白调一次, 也不错误地藏掉工具.
- 图片以 base64 内联, 所以**每轮请求都会重发一遍图片字节** (推理侧的图片 token
有上限、不随体积涨, 但上行带宽和请求体大小是实打实要付的). 历史/归档本身
不受影响——那里只有引用.
- 历史里的图片是对 `ligent.archive/images/` 的引用, **备份才是字节的真身**.
把 `ligent.archive` 删掉或移走, 图片就展开不出来, 会被降级成文字说明
(不会让对话卡死, 但图也就没了). `/export` 的存档同理, 不是自包含的.
- 图片只支持内联, **不支持直接给一个外部 URL**:ligent 不去网上抓图, `read_image`
只收本地文件. (协议上的另一种走法是外部 URL, 需要服务端自己去下载. )
- 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝,
模型会被告知"图片太大", 需要先缩小或压缩.
- 工具结果按字节截断, 不按 token 计算, 极大文件需要配合 `offset/limit` 分段读.
- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB, 所以一个
**前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读 (内容会在第一个 NUL 处被
悄悄截断). `edit_file` 不受影响 —— 它看全文件. UTF-16、压缩包、可执行文件这类
在开头就有 NUL, 都能识别出来.
- 对话落盘要手动 `/export`, 不会自动保存 (输入历史则是自动的, 见下).
- 思考过程只是"存下来回传", 不做展示也不做摘要; 它会一直留在上下文里,
所以长对话下 token 花销比不开思考更大 (这是服务端的要求, 不是可以省的).
- 思考模式的具体字段 (顶层 `thinking` 对象 / `reasoning_effort` / 回传
`reasoning_content`)按 DeepSeek 的 OpenAI 兼容格式发送 —— 这一套 MiMo 也一样,
两边能共用. 别家如果用的是嵌套的 `reasoning` 对象 (如 OpenRouter)或
`thinking.budget_tokens` (如 Anthropic), 需要服务端自己有兼容层;
ligent 不做方言探测. 顺带一提, `reasoning_content` ligent 读的时候两种
名字都认, 但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的
服务端可能会丢失思考上下文.
- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类
用备用屏幕的程序, 记录里只剩命令行和退出后的输出. 极少数既不用备用屏幕、
又逐行重绘的程序 (`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪;
真正逐屏还原需要一整套终端模拟器, 不在当前范围内.
**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"):
- **提权的真实路径没端到端跑过. ** e2e 里用的是桩 `sudo` (它要求有 tty 并把
argv 记下来, 专门守着 `setsid` 那个回归), 真实 sudo 的密码提示路径没有测过
—— 那需要碰使用者的密码. 而这个模块当初正是因为控制终端的问题认证失败过,
所以这里确实是风险点, 不是理论上的担心.
- **静态二进制只在本机验过 HTTPS. ** TLS 栈是验过的 (本地 `openssl s_server` +
自签证书, 能走到"证书不被信任"说明整条 OpenSSL 路径在跑), 但它依赖编进去的
`/etc/ssl/certs/ca-certificates.crt`. 目标机器上没有这个文件的话, TLS 直接
不可用; 而且 **libcurl 库不读 `CURL_CA_BUNDLE`** (那是 `curl` 命令行工具的行
为), 所以使用者也没法用环境变量指一份别的 CA —— 要支持得改成显式
`CURLOPT_CAINFO`.
- **`clang --analyze` 还剩 3 条告警**, 我判断是误报 (`fgets` 前已置 `errno=0`;
`fread` 的 EOF 循环惯用法), 但没有能力证明. `gcc -fanalyzer` 在 `image.c` 报
的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也
不报, 我判断是内联导致的误报 —— 换个 gcc 版本结论可能变.
- 打断 (Ctrl-C)路径只在几处有覆盖, 不全面.