2143 lines
112 KiB
Markdown
2143 lines
112 KiB
Markdown
# ligent - C99 实现的命令行 AI Agent
|
||
|
||
一个用 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 转义 |
|
||
| `` | 保留原文 |
|
||
| 链接的 `(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)路径只在几处有覆盖,不全面。
|