Files
ligent/README.zh.md
T
2026-10-03 14:56:56 +08:00

109 KiB
Raw Blame History

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 开发包.

# 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
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 应该只剩你自己的东西.

代价是明确的: 清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟:

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".

静态分析

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 也能编

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):

./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 里带的这几个开关是体积优先的默认值; 要回到调试友好版:

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 相同代码折叠在这份代码上一点没省, 所以没换链接器.

验证方式 (这些数字都是量出来的, 不是估的):

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)

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 把它排除了), 脚本在里面问不出这些, 只能由外面算好传进去:

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 就 绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用".

实测 (同一份源码, 本机 + 容器):

$ ./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 哈希

$ 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 读取. 所以最简单的用法是:

export DEEPSEEK_API_KEY=sk-xxxxxxxx
ligent "你好"

配置文件: 只有一个位置 —— 二进制旁边

配置只从可执行文件所在目录读取, 文件名固定 ligent.json:

/path/to/ligent          ← 可执行文件
/path/to/ligent.json     ← 配置 (不存在就用内置默认值)
cp ligent.example.json ./ligent.json    # 二进制就在当前目录时
$EDITOR ./ligent.json

不做用户级查找 (没有 ~/.config、没有 $XDG_CONFIG_HOME、没有 ~/.ligent.json), 不做系统级查找 (没有 /etc), 也不看当前工作目录.

这样整个程序是自包含的: 拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走.

**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 ~/bin:

ln -s /opt/ligent/ligent ~/bin/ligent

那么配置目录是 ~/bin, 而不是 /opt/ligent —— 你放在哪、就认哪. 四种调用方式结果一致: 绝对路径、相对路径、链接链 (取第一层)、以及经 $PATH 调用 (argv[0] 里没有斜杠的情况).

实现上优先用 argv[0] (不解析最后一段软链接), 只在拿不到时才退回 /proc/self/exe. 目录部分会被规范化 (. / .. / 目录自身的软链接都会解析), 但最后一段不会 —— 那正是"软链接所在目录"的关键. 另外会用 inode 比对确认 找到的确实是 ligent 自己, 避免 $PATH 里恰好有个同名文件把目录带偏.

$ ./ligent --print-config | grep config_file
config_file  : /home/you/ligent/ligent.json  (不存在, 使用内置默认值)

--print-config 会显示规范位置, 所以不用猜它在哪找.

想临时换一份配置时有两个显式入口 (都不是"默认查找位置"):

ligent -c /path/to/other.json "..."     # 命令行显式指定
LIGENT_CONFIG=/path/to/other.json ligent "..."

整体优先级: 内置默认值 → 配置文件 → 环境变量 → 命令行参数 (api_key 按上面的规则细化).

配置文件里可以用 ${VAR} 引用环境变量, 避免把密钥明文写进磁盘:

{
  "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, 所以什么都不配也能用; 想控制就加这两项:

{
  "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 惯例一致.

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. 用法

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 的历史.

开关和覆盖:

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 也写进去 —— 这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来:

{
  "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> 敲过什么", 和当前装的是哪段对话无关. )

先跑一次体检

配置调不通时, 先别猜:

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:

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:

ligent "看看 build/screenshot.png 里的报错是什么"

内部做了四件事, 每件都有它的理由:

**1. 按内容认格式, 不认扩展名. ** 读文件头判断 PNG / JPEG / GIF / WebP. 一个叫 .png 的文本文件会被明确拒绝, 而不是发出去等一个莫名其妙的 400.

**2. 请求里走标准的 image_url + base64 内联. ** 也就是:

{"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. 测试

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 字节才定位到根因的.

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 跑一遍:

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 增量重组, 全都在真实字节上跑一遍.

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, 跑一次真问题):

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)路径只在几处有覆盖, 不全面.