Files
ligent/src/tools.c
T
2026-10-03 14:13:21 +08:00

1301 lines
56 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* tools.c - 工具实现:shell、文件读写、编辑、列目录、搜索
*
* 设计取向:这是"全权执行"模式,不做沙箱限制;但仍对输出做截断,
* 避免一次命令把上下文撑爆。
*/
#include "tools.h"
#include "util.h"
#include "i18n.h"
#include "http.h"
#include "image.h"
#include "priv.h"
#include "llm.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>
#include <errno.h>
#include <ctype.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <limits.h>
/* ------------------------------------------------------------------ */
/* 小工具 */
/* ------------------------------------------------------------------ */
static void append_capped(StrBuf *sb, const char *data, size_t n, int *truncated)
{
if (*truncated) return;
if (sb->len + n > LIGENT_TOOL_OUTPUT_LIMIT) {
size_t take = sb->len < LIGENT_TOOL_OUTPUT_LIMIT
? LIGENT_TOOL_OUTPUT_LIMIT - sb->len : 0;
sb_append(sb, data, take);
*truncated = 1;
return;
}
sb_append(sb, data, n);
}
static int64_t now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static const char *arg_str(const JsonValue *args, const char *key)
{
return json_string_value(json_object_get(args, key));
}
LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
char *body = xvasprintf(fmt, ap);
va_end(ap);
char *out = xasprintf("错误:%s", body);
free(body);
return out;
}
/* ---- 二进制判定 ----
*
* 判据(出现 NUL 字节)与探测窗口(前 8 KB)都在 util.h 里,由所有处理文本的
* 地方共用一份 —— 以前这里和 attach.c 各写一份、窗口还不一样,于是同一个文件
* 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法。
* edit_file 是唯一的例外,它检查**全文件**(见那里的说明)。 */
/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at。
*
* 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到",
* 否则同一次失败会同时冒出两条互相竞争的错误信息。 */
static int file_is_binary(const char *path, long *at)
{
FILE *f = fopen(path, "rb");
if (!f) return 0;
char probe[LIGENT_BIN_PROBE];
size_t n = fread(probe, 1, sizeof probe, f);
fclose(f);
return looks_binary(probe, n, at);
}
/* 统一的拒绝说法:先摆事实(第几字节有 NUL),再说这个工具为什么只收文本、
* 以及该改用什么。只说"不行"等于让模型原地打转。 */
static char *binary_err(const char *path, long at, const char *why)
{
return err_result("%s 看起来是二进制文件(第 %ld 字节处有 NUL 字节)。%s",
path, at + 1, why);
}
/* ------------------------------------------------------------------ */
/* run_command */
/* ------------------------------------------------------------------ */
#define CMD_DEFAULT_TIMEOUT 60
#define CMD_MAX_TIMEOUT 600
static char *tool_run_command(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *cmd = arg_str(args, "command");
if (!cmd || !*cmd) {
*is_error = 1;
return xstrdup("错误:缺少必填参数 command");
}
double t = json_get_number(args, "timeout", CMD_DEFAULT_TIMEOUT);
if (t <= 0) t = CMD_DEFAULT_TIMEOUT;
if (t > CMD_MAX_TIMEOUT) t = CMD_MAX_TIMEOUT;
int64_t timeout_ms = (int64_t)(t * 1000);
/* elevated:以 root 执行这一条命令。
*
* 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来,你也不可能
* 手滑敲出来。确认提示(run_command 本来就有的)负责让你**看清要跑什么**,
* sudo 负责**不可伪造的授权**,各司其职。
* 没有控制终端就根本不去试:那会起一个等不到输入的进程。 */
int elevated = json_get_bool(args, "elevated", 0);
if (elevated && !priv_have_tty()) {
*is_error = 1;
return xstrdup(
"错误:这条命令需要提权(elevated),但当前没有可用的控制终端"
"(/dev/tty),sudo 没法问密码。请在终端里运行 ligent,"
"或者换一个不需要提权的做法。");
}
int fds[2];
if (pipe(fds) != 0)
{ *is_error = 1; return err_result("pipe 失败:%s", strerror(errno)); }
pid_t pid = fork();
if (pid < 0) {
close(fds[0]); close(fds[1]);
*is_error = 1;
return err_result("fork 失败:%s", strerror(errno));
}
if (pid == 0) {
/* 非提权:独立进程组,超时时可以整组杀掉。
* 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的,摘掉控制终端
* 它就永远问不出来(这正是上一版认证必然失败的成因)。
* 代价是提权这条没法整组杀,超时只杀直接子进程。 */
if (!elevated) setsid();
int devnull = open("/dev/null", O_RDONLY);
if (devnull >= 0) { dup2(devnull, STDIN_FILENO); close(devnull); }
dup2(fds[1], STDOUT_FILENO);
dup2(fds[1], STDERR_FILENO);
close(fds[0]);
close(fds[1]);
/* argv 形式执行:提权时是 `sudo -u root -- /bin/sh -c <命令>`,
* 命令内容不会被多解析一层。 */
/* 同上:execvp 要 char*[],这里放进去的都不会被改写 */
char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)cmd, NULL };
char **w = elevated ? priv_sudo_argv(NULL, plain) : plain;
execvp(w[0], w);
_exit(127);
}
close(fds[1]);
int fl = fcntl(fds[0], F_GETFL, 0);
if (fl >= 0) fcntl(fds[0], F_SETFL, fl | O_NONBLOCK);
StrBuf out;
sb_init(&out);
int truncated = 0;
int timed_out = 0;
int interrupted = 0;
int64_t t0 = now_ms();
int eof = 0;
while (!eof) {
if (g_ligent_interrupt) {
interrupted = 1;
kill(-pid, SIGKILL);
break;
}
int64_t elapsed = now_ms() - t0;
if (elapsed >= timeout_ms) {
timed_out = 1;
kill(-pid, SIGKILL);
break;
}
int64_t left = timeout_ms - elapsed;
int poll_ms = left > 200 ? 200 : (int)left;
struct pollfd pfd;
pfd.fd = fds[0];
pfd.events = POLLIN;
pfd.revents = 0;
int pr = poll(&pfd, 1, poll_ms);
if (pr < 0) { if (errno == EINTR) continue; break; }
if (pr == 0) continue;
for (;;) {
char buf[8192];
ssize_t n = read(fds[0], buf, sizeof buf);
if (n > 0) { append_capped(&out, buf, (size_t)n, &truncated); continue; }
if (n == 0) { eof = 1; break; }
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
eof = 1;
break;
}
}
close(fds[0]);
int status = 0;
if (timed_out || interrupted)
kill(elevated ? pid : -pid, SIGKILL);
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { /* retry */ }
int exit_code = -1;
int by_signal = 0;
if (WIFEXITED(status)) exit_code = WEXITSTATUS(status);
else if (WIFSIGNALED(status)) { exit_code = 128 + WTERMSIG(status); by_signal = 1; }
StrBuf result;
sb_init(&result);
if (out.len > 0) sb_append(&result, out.data, out.len);
if (truncated)
sb_puts(&result, "\n... [输出超过 100KB,已截断]");
/* 提权失败时,别让模型去猜。sudo 的提示写的是 /dev/tty,多半不在我们
* 捕获到的输出里 —— 所以要如实说"可能是认证没过",而不是编一个原因。 */
if (elevated && exit_code != 0) {
char why[512] = "";
priv_explain(exit_code, out.data, why, sizeof why);
sb_printf(&result, "\n[提权命令失败:%s]", why);
if (!out.data || !*out.data)
sb_puts(&result,
"\n(没有捕获到输出。sudo 的提示是直接写到终端上的,"
"如果你刚看到它在问密码,那就是认证没过。)");
sb_puts(&result, "\n不要反复重试同一个提权命令;"
"如果需要权限才能继续,说明你需要用户亲自做什么。");
}
if (result.len > 0 && result.data[result.len - 1] != '\n')
sb_putc(&result, '\n');
if (interrupted) {
sb_puts(&result, "[已中断]");
*is_error = 1;
} else if (timed_out) {
sb_printf(&result, "[超时 %d 秒,进程组已被终止]", (int)t);
*is_error = 1;
} else if (by_signal) {
sb_printf(&result, "[被信号 %d 终止]", exit_code - 128);
*is_error = 1;
} else {
sb_printf(&result, "[exit code: %d]", exit_code);
if (exit_code != 0) *is_error = 1;
}
sb_free(&out);
return sb_detach(&result);
}
/* ------------------------------------------------------------------ */
/* read_file */
/* ------------------------------------------------------------------ */
static char *tool_read_file(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *path = arg_str(args, "path");
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
double off = json_get_number(args, "offset", 1);
double lim = json_get_number(args, "limit", 2000);
long offset = off > 0 ? (long)off : 1;
long limit = lim > 0 ? (long)lim : 2000;
if (limit > 20000) limit = 20000;
FILE *f = fopen(path, "rb");
if (!f) { *is_error = 1; return err_result("无法打开 %s:%s", path, strerror(errno)); }
/* 二进制文件按文本读出来是乱码,而且会在 NUL 处被截断 —— 直接拒绝。
* 文件已经打开了,就地探一遍前 8 KB(窗口与判据见 util.h),不必再开一次。 */
{
char probe[LIGENT_BIN_PROBE];
size_t pn = fread(probe, 1, sizeof probe, f);
rewind(f);
long at = -1;
if (looks_binary(probe, pn, &at)) {
fclose(f);
*is_error = 1;
return binary_err(path, at,
"read_file 只按文本读(读出来会是乱码,还会在 NUL 处被截断)。"
"看二进制内容用 run_command(file / xxd / strings / od);"
"看图片用 read_image。");
}
}
/* 按 '\n' 切行,而且行号口径必须与 edit_file 的 count_lines/line_range
* **完全一致**(末尾那个换行不算新的一行)—— 工具描述和 README 都写着
* "read_file 打出来的行号可以直接喂给 edit_file 的 line"。
*
* 以前这里是 fgets + 固定 64 KB 缓冲,于是"一行比缓冲长"时它会被切成好几块、
* 每块各带一个行号:一个「70000 字节的单行 + 两行」的文件,read_file 说第 3 行
* 是 "second",而 edit_file 认为第 3 行是 "third"。模型照 read_file 的行号去
* 改,要么改错地方、要么报越界 —— 而它没有任何办法察觉。
*
* 单行的字节数靠"上一块是不是以换行结尾"(in_line)来跟踪,所以仍然**不**
* 需要把整个文件读进内存:一行再长也只占 line[] 这么多,超出输出上限就如实
* 说明这一行读不出来,而不是把它伪装成若干行。 */
StrBuf sb;
sb_init(&sb);
char line[65536];
long lineno = 0; /* 已读到的行号(只在真正换行时递增) */
long emitted = 0; /* 已输出的行数 */
long partial_line = 0; /* 这一行只给了开头(它自己超过输出上限) */
long empty_line = 0; /* 这一行一个字节都没能进去 */
size_t line_bytes = 0; /* 当前这一行已经进了缓冲多少字节 */
int cut_by_bytes = 0; /* 截断的原因是字节上限,而不是行数上限 */
int truncated = 0;
int in_line = 0; /* 上一块不以 '\n' 结尾 => 这一块还是同一行 */
while (fgets(line, sizeof line, f)) {
size_t n = strlen(line);
int has_nl = (n > 0 && line[n - 1] == '\n');
if (!in_line) { /* 新的一行从这里开始 */
lineno++;
line_bytes = 0;
if (lineno < offset) { in_line = !has_nl; continue; }
if (emitted >= limit) { truncated = 1; break; }
char prefix[32];
int pl = snprintf(prefix, sizeof prefix, "%6ld\t", lineno);
if (sb.len + (size_t)pl >= LIGENT_TOOL_OUTPUT_LIMIT) {
if (emitted == 0) empty_line = lineno;
cut_by_bytes = 1;
truncated = 1;
break;
}
sb_append(&sb, prefix, (size_t)pl);
emitted++;
}
in_line = !has_nl;
if (sb.len + n > LIGENT_TOOL_OUTPUT_LIMIT) {
/* 撞的是**字节**上限,而且撞在"正在写的这一行"上(前面那些行都已经
* 完整给出)。分两种:这一行连一个字节都没进去(前缀已写、内容为 0),
* 还是只给了它开头。两种情况都要如实说,不能让调用方以为拿到的是
* 一份完整的行 —— 也不能把剩下的部分伪装成"下一行"。 */
cut_by_bytes = 1;
if (line_bytes == 0) {
empty_line = lineno;
sb_clear(&sb);
emitted = 0;
} else {
partial_line = lineno;
}
truncated = 1;
break;
}
sb_append(&sb, line, n);
line_bytes += n;
/* 文件最后一行没有换行符时补一个,否则行号前缀会跟下一行粘连 */
if (!has_nl) sb_putc(&sb, '\n');
}
fclose(f);
if (lineno == 0) {
sb_free(&sb);
return xstrdup("(空文件)");
}
if (emitted == 0) {
sb_free(&sb);
if (empty_line > 0)
return xasprintf(
"(第 %ld 行本身就超过 %u 字节的输出上限,读不出它的内容。"
"可以用 run_command 只取它的一部分,例如 "
"sed -n '%ldp' <文件> | cut -c1-4000)",
empty_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, empty_line);
return xasprintf("(文件只有 %ld 行,offset=%ld 超出范围)", lineno, offset);
}
if (truncated) {
if (cut_by_bytes && partial_line > 0)
/* 撞的是字节上限:给出的是**半行**,所以不能再说"从下一行继续" */
sb_printf(&sb,
"... [第 %ld 行本身就超过 %u 字节的输出上限,这里只给了它的开头;"
"要看后面的部分,用 run_command(例如 sed -n '%ldp' <文件> | cut -c1-4000)]",
partial_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, partial_line);
else
sb_printf(&sb, "... [已截断,从第 %ld 行继续读取]", offset + emitted);
}
return sb_detach(&sb);
}
/* ------------------------------------------------------------------ */
/* write_file */
/* ------------------------------------------------------------------ */
static char *tool_write_file(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *path = arg_str(args, "path");
JsonValue *cv = json_object_get(args, "content");
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
if (!cv || cv->type != JSON_STRING) {
*is_error = 1;
return xstrdup("错误:缺少必填参数 content(必须是字符串)");
}
const char *content = cv->u.string;
/* 自动创建父目录 */
char *dir = xstrdup(path);
char *slash = strrchr(dir, '/');
if (slash && slash != dir) {
*slash = '\0';
mkdir_p(dir);
}
free(dir);
/* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉,而模型多半没
* 意识到(它只是想"写个文件")。要真的换掉它,先删或用 run_command。 */
{
long at = 0;
if (file_is_binary(path, &at)) {
*is_error = 1;
return binary_err(path, at,
"write_file 只写文本,整体覆盖它等于把原来的内容毁掉。"
"确实要换掉这个文件,先用 run_command 删掉它,或者换一个路径。");
}
}
/* 落盘前再看一眼中断标志。确认环节已经会拦下 Ctrl-C,这里是纵深防御:
* "任何改盘的动作之前都检查中断"应当是一条无例外的约定,
* 而不是依赖"恰好上一层拦住了"。 */
if (g_ligent_interrupt) {
*is_error = 1;
return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。");
}
FILE *f = fopen(path, "wb");
if (!f) { *is_error = 1; return err_result("无法写入 %s:%s", path, strerror(errno)); }
size_t n = strlen(content);
size_t written = fwrite(content, 1, n, f);
if (fclose(f) != 0 && written == n) written = (size_t)-1;
if (written != n) {
*is_error = 1;
return err_result("写入 %s 失败:%s", path, strerror(errno));
}
return xasprintf("已写入 %zu 字节到 %s", n, path);
}
/* ------------------------------------------------------------------ */
/* edit_file */
/* ------------------------------------------------------------------ */
/*
* 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里,且在
* 这些行里只有一处匹配。
*
* line 是**集合型,而且可以整个省略**:省略 = 不设行范围,等于**全文件替换所有
* 匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数(等价于
* 只有一个元素的集合)或整数数组都收,`"line": 12` 和 `"line": [12]` 是同一件事。
* 集合里的行号就是 read_file 打出来的那些(1 起)。
*
* 为什么是集合而不是单个行号:模型看到的常是一整块内容("第 10~13 行是这段
* 函数"),但决定命中与否的是 old_string 的**起点**落在哪一行;只给一个行号时
* 它得先数清第一行到底是 10 还是 11,数错一次就白烧一轮。给了集合之后,照
* read_file 的显示把覆盖到的那几行都填进来就行。集合只是**搜索范围**,不是
* "每行都改一遍":真改动的仍是唯一那一处(replace_all 或省略 line 时才动全文件)。
*
* 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标(read_file 给的),
* 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立
* (同一个 `return 0;` 到处都是),模型只能不断加长上下文去凑唯一性;凑不成
* 就只能放弃,或者赌一把改错地方。把行号钉住之后,"改哪一处"由坐标决定,
* 不再是个概率问题;old_string 也不必再为"唯一"而写得很长。
*
* 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效(比如统一改个
* 名字),但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件
* 替换。要"改一处"仍得给行号,模型没法靠省略来回避看文件。
*
* replace_all 仍在,含义不变:改文件里**所有**匹配。但 line 集合必须圈住其中
* 真实的一处 —— 这等于强制模型先看过文件,而不是凭印象做全文替换。
*/
/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行。
* (read_file 用 fgets 读,末尾那个"空行"不会产生输出,两边口径必须一致,
* 否则 read_file 显示的行号和这里的越界判断会互相矛盾。) */
static long count_lines(const char *s, size_t len)
{
long n = 0;
for (size_t i = 0; i < len; i++)
if (s[i] == '\n') n++;
if (len > 0 && s[len - 1] != '\n') n++;
return n;
}
/* 第 n 行(1 起)的字节范围 [*start, *end),不含行尾换行。越界返回 0 */
static int line_range(const char *s, size_t len, long n, size_t *start, size_t *end)
{
if (n < 1 || n > count_lines(s, len)) return 0;
size_t i = 0;
for (long line = 1; line < n; line++) {
const char *nl = memchr(s + i, '\n', len - i);
if (!nl) return 0;
i = (size_t)(nl - s) + 1;
}
*start = i;
const char *nl = memchr(s + i, '\n', len - i);
*end = nl ? (size_t)(nl - s) : len;
return 1;
}
/* line 集合里最多几个行号。它是用来圈住 old_string 覆盖的那几行的,
* 给到几十个以上就说明模型在瞎猜行号 —— 那时该做的是重新 read_file 看一遍。 */
#define EDIT_LINE_MAX 64
static int cmp_long(const void *a, const void *b)
{
long x = *(const long *)a, y = *(const long *)b;
return x < y ? -1 : (x > y ? 1 : 0);
}
/* 把 line 参数解析成升序去重后的行号数组:写进 out(容量 cap),返回元素个数
* (>=1)。失败返回 -1,并把给模型看的说法写进 *err(调用方 free)。
*
* 两种写法都收:整数(历史写法)和整数数组(集合)。所以老调用和老录制里的
* `"line": 41` 仍然照常工作,不必区分。
*
* 去重不是洁癖:同一个行号处理两遍,同一处匹配会被数成两处,
* 于是"行内只有一处"的判断会莫名其妙地失败。 */
static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err)
{
*err = NULL;
int is_arr = (lv->type == JSON_ARRAY);
size_t n = is_arr ? json_array_size(lv) : 1;
if (is_arr && n == 0) {
*err = xstrdup("错误:line 是空数组 —— 要么至少给一个行号(read_file "
"打出来的那个,从 1 开始),要么把 line 整个省略"
"(省略 = 全文件替换所有匹配)。");
return -1;
}
if (n > cap) {
*err = xasprintf("错误:line 里有 %zu 个行号,一次最多 %zu 个。"
"集合是用来圈住 old_string 覆盖的那几行的 —— "
"要改的地方太多,先 read_file 看清再分几次改。", n, cap);
return -1;
}
for (size_t i = 0; i < n; i++) {
const JsonValue *e = is_arr ? json_array_get(lv, i) : lv;
/* 先把范围夹住再判断是不是整数:JSON 里什么都可能来,而把 1e300
* 直接转成 long 是 UB(UBsan 会当场报出来)。 */
double d = (e->type == JSON_NUMBER) ? e->u.number : 0.0;
if (e->type != JSON_NUMBER || !(d >= 1.0) || d > 9.0e18
|| d != (double)(long)d) {
if (is_arr)
*err = xasprintf("错误:line 里的第 %zu 个不是合法的行号 —— "
"要的是 read_file 打出来的 >=1 的整数行号。", i + 1);
else
*err = xasprintf("错误:line=%g 不合法 —— 要的是 read_file "
"打出来的 >=1 的整数行号。", d);
return -1;
}
out[i] = (long)d;
}
qsort(out, n, sizeof *out, cmp_long);
size_t k = 0;
for (size_t i = 0; i < n; i++)
if (k == 0 || out[i] != out[k - 1]) out[k++] = out[i];
return (long)k;
}
/* 把行号集合渲染成 "3、4、5"(报错与回执里用,给模型看)。
* 超过 8 个就只列前 8 个:再往下只是噪声,源文件的行号本来就在 read_file 里。 */
static void lines_text(const long *v, long n, char *buf, size_t cap)
{
size_t off = 0;
buf[0] = '\0';
for (long i = 0; i < n; i++) {
if (i >= 8) { snprintf(buf + off, cap - off, "…"); break; }
int w = snprintf(buf + off, cap - off, "%s%ld", i ? "、" : "", v[i]);
if (w < 0 || (size_t)w >= cap - off) break;
off += (size_t)w;
}
}
static char *tool_edit_file(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *path = arg_str(args, "path");
const char *old_s = arg_str(args, "old_string");
JsonValue *nv = json_object_get(args, "new_string");
JsonValue *lv = json_object_get(args, "line");
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
if (!old_s) { *is_error = 1; return xstrdup("错误:缺少必填参数 old_string"); }
if (!nv || nv->type != JSON_STRING) {
*is_error = 1;
return xstrdup("错误:缺少必填参数 new_string(必须是字符串)");
}
/* line 可以**整个省略**:省略 = 不设行范围 = 全文件替换所有匹配("全局替换")。
* 类型给错了(字符串之类)仍然当场报错 —— 那是手滑,不是"想全局"。 */
int global = (lv == NULL);
if (!global && lv->type != JSON_NUMBER && lv->type != JSON_ARRAY) {
*is_error = 1;
return xstrdup("错误:line 必须是整数或整数数组(集合型)——"
"行号就是 read_file 打出来的那些,从 1 开始。"
"要全文件替换所有匹配,就把 line 整个省略。");
}
const char *new_s = nv->u.string;
int replace_all = json_get_bool(args, "replace_all", 0);
if (!*old_s) { *is_error = 1; return xstrdup("错误:old_string 不能为空"); }
/* line 是集合:解析成升序去重的行号数组。上界(超过文件总行数)要读过文件
* 才知道,放在后面查;这里只管写法本身对不对。省略 line 时集合是空的,
* 下面所有"按行"的检查都跳过,直接当全文件替换。 */
long lines[EDIT_LINE_MAX];
long nlines = 0;
char req[128];
req[0] = '\0';
if (!global) {
char *perr = NULL;
nlines = parse_line_set(lv, lines, EDIT_LINE_MAX, &perr);
if (nlines < 0) { *is_error = 1; return perr; }
/* 报错与回执里要说清"是哪几行":模型据此判断行号有没有被理解对 */
lines_text(lines, nlines, req, sizeof req);
}
/* 先看大小再读:edit_file 需要把整个文件放进内存,
* 对超大文件直接拒绝,避免把内存吃干。
* 用 stat 而不是读完再看,否则拒绝之前就已经分配了。 */
struct stat st;
if (stat(path, &st) == 0 && (unsigned long long)st.st_size > LIGENT_MAX_EDIT_FILE) {
*is_error = 1;
return err_result(
"%s 有 %lld 字节,超过 edit_file 的 %u 字节上限。"
"请改用 run_command(如 sed/awk)做定点修改,或先拆分文件",
path, (long long)st.st_size, (unsigned)LIGENT_MAX_EDIT_FILE);
}
size_t flen = 0;
char *src = read_entire_file(path, &flen);
if (!src) { *is_error = 1; return err_result("无法读取 %s:%s", path, strerror(errno)); }
/* 二进制拒绝。这里看**全文件**(不像读类工具只看前 8 KB):edit_file 在内存里
* 是按 C 字符串处理的(strstr/strlen),文件里任何一个 NUL 都会让后面的内容
* 被当成结束 —— 改下去就是把原文件毁掉。 */
{
long at = buf_nul_at(src, flen);
if (at >= 0) {
free(src);
*is_error = 1;
return binary_err(path, at,
"edit_file 是按字符串做替换的,NUL 会让结果从那里被截断 —— "
"改下去等于把原文件毁了。要改二进制内容,用 run_command"
"(printf / dd / perl -pi 这类)。");
}
}
/* 上界检查:下界(>=1)和写法在 parse_line_set 里已经查过,这里要文件总行数。
* 省略 line 时没有行号可查。 */
if (!global) {
long total_lines = count_lines(src, flen);
for (long i = 0; i < nlines; i++) {
if (lines[i] <= total_lines) continue;
free(src);
*is_error = 1;
if (total_lines == 0)
return err_result("%s 是空文件,没有第 %ld 行。", path, lines[i]);
if (nlines == 1)
return err_result("%s 只有 %ld 行,line=%ld 越界(行号从 1 开始,"
"用 read_file 看当前行号)。", path, total_lines, lines[i]);
return err_result("%s 只有 %ld 行,line 里的 %ld 越界(行号从 1 开始,"
"用 read_file 看当前行号)。", path, total_lines, lines[i]);
}
}
size_t olen = strlen(old_s);
/* 搜索范围:给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配
* (old_string 可以跨行),集合已去重所以同一处不会被数两遍;省略 line 就是
* 整个文件,那时命中几处换几处(见下面的替换分支)。 */
size_t hits = 0;
const char *at = NULL;
if (global) {
for (const char *p = src; ; ) {
const char *hit = strstr(p, old_s);
if (!hit) break;
if (hits == 0) at = hit;
hits++;
p = hit + olen;
}
} else {
for (long i = 0; i < nlines; i++) {
size_t ls = 0, le = 0;
if (!line_range(src, flen, lines[i], &ls, &le)) continue; /* 上面查过,不会越界 */
for (const char *p = src + ls; ; ) {
const char *hit = strstr(p, old_s);
if (!hit || (size_t)(hit - src) >= le) break;
if (hits == 0) at = hit;
hits++;
p = hit + olen;
}
}
}
if (hits == 0 && global) {
/* 省略 line 时没有"给的哪几行"可说,只说整个文件里没有 */
free(src);
*is_error = 1;
return err_result("%s 整个文件里没有 old_string(注意缩进与换行必须逐字符"
"一致)。要给行范围就填 line:整数或整数数组。", path);
}
if (hits == 0) {
/* 顺手说清它到底在哪几行 —— 不然模型只能一遍遍猜行号,白烧轮次 */
StrBuf w;
sb_init(&w);
const char *q = src, *scan = src;
long ln = 1, found = 0;
while ((q = strstr(q, old_s)) != NULL) {
while (scan < q) { if (*scan == '\n') ln++; scan++; }
if (found < 4) sb_printf(&w, "%s%ld", found ? "、" : "", ln);
found++;
q += olen;
}
char *where = sb_detach(&w);
free(src);
*is_error = 1;
char *msg;
if (found == 0)
msg = err_result("%s 第 %s 行没有 old_string,整个文件里也没有"
"(注意缩进与换行必须逐字符一致)。", path, req);
else if (found <= 4)
msg = err_result("%s 第 %s 行没有 old_string,它在第 %s 行。"
"要改那里,就把那一行的行号填进 line。",
path, req, where);
else
msg = err_result("%s 第 %s 行没有 old_string。它在第 %s 行等共 %ld 处,"
"要改哪一处就把那一行的行号填进 line。",
path, req, where, found);
free(where);
return msg;
}
if (hits > 1 && !replace_all && !global) {
free(src);
*is_error = 1;
return err_result("%s 第 %s 行里有 %zu 处 old_string,定不下来是哪一处。"
"把 old_string 写长一点(带上这几行里的上下文),"
"或者设 replace_all=true 把文件里所有匹配都换掉。",
path, req, hits);
}
StrBuf out;
sb_init(&out);
long replaced = 0;
if (replace_all || global) {
const char *p = src;
for (;;) {
const char *hit = strstr(p, old_s);
if (!hit) { sb_puts(&out, p); break; }
sb_append(&out, p, (size_t)(hit - p));
sb_puts(&out, new_s);
p = hit + olen;
replaced++;
}
} else {
/* 只替换集合里的那一处(hits==1,上面已经确认过) */
size_t off = (size_t)(at - src);
sb_append(&out, src, off);
sb_puts(&out, new_s);
sb_append(&out, src + off + olen, flen - off - olen);
replaced = 1;
}
free(src);
if (g_ligent_interrupt) { /* 同 write_file:改盘前一律先看中断 */
sb_free(&out);
*is_error = 1;
return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。");
}
FILE *f = fopen(path, "wb");
if (!f) {
sb_free(&out);
*is_error = 1;
return err_result("无法写入 %s:%s", path, strerror(errno));
}
size_t w = fwrite(out.data, 1, out.len, f);
int wok = (w == out.len);
if (fclose(f) != 0) wok = 0;
size_t total = out.len;
sb_free(&out);
if (!wok) {
*is_error = 1;
return err_result("写入 %s 失败", path);
}
/* 回执里必须带上行号:模型据此确认"改的就是我以为的那一处"。省略 line 是
* 全文件替换,回执也得直说 —— 否则它会以为只改了一处。 */
if (global)
return xasprintf("已在 %s 全文件替换 %ld 处(写入 %zu 字节;line 省略 = "
"没设行范围)", path, replaced, total);
if (replace_all)
return xasprintf("已在 %s 替换 %ld 处(写入 %zu 字节;line=%s 圈住的"
"那几行里至少有一处)", path, replaced, total, req);
return xasprintf("已在 %s 第 %s 行替换 1 处(写入 %zu 字节)",
path, req, total);
}
/* ------------------------------------------------------------------ */
/* list_dir */
/* ------------------------------------------------------------------ */
static int cmp_names(const void *a, const void *b)
{
return strcmp(*(const char *const *)a, *(const char *const *)b);
}
static char *tool_list_dir(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *path = arg_str(args, "path");
if (!path || !*path) path = ".";
DIR *d = opendir(path);
if (!d) { *is_error = 1; return err_result("无法打开目录 %s:%s", path, strerror(errno)); }
char **list = NULL;
size_t count = 0, cap = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
if (count == cap) {
cap = cap ? cap * 2 : 64;
list = xrealloc(list, cap * sizeof(char *));
}
list[count++] = xstrdup(de->d_name);
}
closedir(d);
/* 空目录时 list 还是 NULL,而 qsort 的第一个参数声明为不可为空 ——
* 传 NULL 进去是 UB(UBSan 会当场报 "null pointer passed as argument 1")。
* attach.c 的 list_dir_sorted 用的是同一条 count > 1 守卫,两边保持一致。 */
if (count > 1) qsort(list, count, sizeof(char *), cmp_names);
StrBuf out;
sb_init(&out);
sb_printf(&out, "目录 %s(%zu 个条目):\n", path, count);
for (size_t i = 0; i < count; i++) {
char full[PATH_MAX];
snprintf(full, sizeof full, "%s/%s", path, list[i]);
struct stat st;
if (lstat(full, &st) == 0) {
if (S_ISDIR(st.st_mode))
sb_printf(&out, " %s/\n", list[i]);
else if (S_ISLNK(st.st_mode))
sb_printf(&out, " %s -> (符号链接)\n", list[i]);
else
sb_printf(&out, " %s (%lld 字节)\n", list[i], (long long)st.st_size);
} else {
sb_printf(&out, " %s\n", list[i]);
}
free(list[i]);
}
free(list);
return sb_detach(&out);
}
/* read_image:把本地图片交给模型看。
*
* 协议里图片只有两条路:外部 URL,或者 base64 内联。ligent 不替用户去网上抓图,
* 所以只收本地文件、产出 base64 内联。
*
* 图片字节不进历史:这里只把图片存成本地备份,历史里挂一个对备份的引用,
* 真正构造请求时才读出来编码(见 image.c 的 image_expand_for_request)。
*
* 尺寸在**本地**就判掉。理由是图片会留在历史里:一次超限不是一次失败,
* 而是从此每一轮都超限。与其发出去等一个 400,不如当场告诉模型"太大了",
* 并且**不备份** —— 一张永远发不出去的图,留着备份没有意义。
*
* 工具结果只能是字符串(协议要求),所以图片挂在"待发附件"里,由
* agent_run 补一条 user 消息带上去 —— 图片只能出现在 user 消息中。 */
static char *tool_read_image(const JsonValue *args, int *is_error)
{
REQUIRE_ERR_PTR(is_error);
const char *path = arg_str(args, "path");
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
size_t len = 0;
const char *mime = NULL;
char *why = NULL;
char *backup = image_backup_from_path(path, &len, &mime, &why);
if (!backup) {
*is_error = 1;
return why ? why : xstrdup("读不了这张图片");
}
image_pending_add(backup);
char *msg = xasprintf("已读入图片 %s(%s,%zu 字节,base64 内联后约 %.1f MiB)。"
"对话历史里只有对它的引用,不发图片数据;"
"本机备份 %s,发请求时展开成 base64。",
path, mime, len,
(double)base64_encoded_len(len) / 1048576.0, backup);
free(backup);
return msg;
}
/* ------------------------------------------------------------------ */
/* 工具表:所有工具的**唯一真相** */
/* ------------------------------------------------------------------ */
/*
* 命名约定(改名前先读这里;README §5 有同一份,两处必须同步)
*
* 1. 形式:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单,
* 而白名单**按域划分** —— 要用新动词,先把它加进对应域的白名单:
* 文件与执行域:read / write / edit / list / run / view
* 历史编辑域: list / read / replace / delete / insert / undo(meta.c)
* 两域的动词不混用(run 不会出现在历史编辑域,undo 也不会出现在文件域)。
* 禁用 do / handle / process / get / exec 这类不说明对象的空动词。
* 2. 名字必须能推出副作用:会改盘的名字里不会出现 read / list。
* 模型靠名字判断该不该先征求同意(.mutating 决定要不要问),
* 含糊的名字等于让它猜。
* 3. 不带 ligent / ai / tool 这类冗余词 —— 工具表本身就在工具命名空间里。
* 4. 不与 REPL 命令同名(/shell、/cd、/model、/log… 都已经被占了),
* 否则人和模型两边会串。这是 run_command 不叫 shell 的原因之一。
* 5. 不与别的工具只在词序、单复数或大小写上不同(read_file / file_read 这种)。
* 6. 长度 <= 24 字符:工具表**每个请求都要重发**,名字长度是重复成本。
* 7. 参数名与工具名同源:路径一律 path,内容 content,正则 pattern,
* 文件名过滤 include。同一个概念在两个工具里必须同名。
*
* 这张表同时生成四样东西:请求里的 schema(tools_schema)、执行分发
* (tool_execute)、要不要走确认(tool_mutating)、/tools 的输出
* (tools_print_list)。所以**改名只需要改 .name 一列** —— 以前这四处各有
* 一份 strcmp 链,改名必定漏一处,而且漏了不报错(只是静默地少认一个名字)。
*
* 两条约束写在这里,因为从表里看不出来:
* - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉
* (agent.c 的 tools_for_request 会调 tools_drop)。
*
* 顺序 = 请求里工具表的顺序,也是 /tools 的打印顺序。改顺序不影响行为,
* 但会让 tests/fixtures 里录制的请求对不上,所以顺序变动要连着 fixtures 一起改。
*
* 为什么**没有**搜索内容的工具(曾经有过 search_files,后来改名 grep,最后删了):
* 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、
* 上下文行、大小写、多模式,我们这个包装每一样都要自己重写一遍,还得跟着
* 需求慢慢长(第一个版本就是"搜内容"却叫 search_files,让人以为按文件名搜)。
* 少一个非必需的工具,工具表更短、模型的误用面更小。
*/
typedef struct {
const char *name;
const char *type; /* JSON-Schema 类型:string / integer / boolean;
* 数组写成 "元素类型[]"(如 "integer[]"),
* 交给 tools_schema 展开成 {type:array, items:{...}} */
const char *desc;
int required;
} ToolParam;
/* 参数表以它结尾 —— 比单独维护一个"参数个数"少一处可以写错的地方 */
#define PARAM_END { NULL, NULL, NULL, 0 }
typedef struct {
const char *name;
const char *desc; /* 给模型看的完整说明 */
const char *ui_key; /* /tools 那一行文案的 i18n 键(纯 UI) */
const ToolParam *params; /* 以 PARAM_END 结尾;可以为 NULL */
int mutating; /* 会改盘或执行命令 → 先问用户 */
int needs_vision; /* 模型不收图时摘掉 */
char *(*fn)(const JsonValue *args, int *is_error);
} ToolDef;
static const ToolParam P_RUN_COMMAND[] = {
{ "command", "string",
"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)", 1 },
{ "elevated", "boolean",
"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,"
"不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),"
"再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。"
"所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。", 0 },
{ "timeout", "integer", "超时秒数,默认 60,最大 600", 0 },
PARAM_END
};
static const ToolParam P_READ_FILE[] = {
{ "path", "string", "文件路径,相对于工作目录或绝对路径", 1 },
{ "offset", "integer", "起始行号(从 1 开始),默认 1", 0 },
{ "limit", "integer", "最多读取的行数,默认 2000", 0 },
PARAM_END
};
static const ToolParam P_WRITE_FILE[] = {
{ "path", "string",
"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**", 1 },
{ "content", "string", "要写入的完整文件内容", 1 },
PARAM_END
};
static const ToolParam P_EDIT_FILE[] = {
{ "path", "string", "要修改的文件路径", 1 },
{ "line", "integer[]",
"old_string **起点**所在的行号,集合型,**可以整个省略**:单个整数(`12`)"
"或整数数组(`[12,13]`)都收,行号就是 read_file 打出来的那些(从 1 开始)。"
"数组的用法:old_string 跨几行、又不想赌起点是第几行时,把覆盖到的那几行"
"都填进来;替换只发生在这些行里。**省略 line = 全文件替换所有匹配**,", 0 },
{ "old_string", "string",
"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 圈住的行里**"
"只有一处匹配(匹配可以跨行,起点落在集合里即可)", 1 },
{ "new_string", "string", "替换后的新文本", 1 },
{ "replace_all", "boolean",
"为 true 时替换**全文件**里所有匹配(line 仍必须圈住其中真实的一处),"
"默认 false(只替换 line 圈住的那一处)", 0 },
PARAM_END
};
static const ToolParam P_LIST_DIR[] = {
{ "path", "string", "目录路径,默认当前目录", 0 },
PARAM_END
};
static const ToolParam P_READ_IMAGE[] = {
{ "path", "string",
"本地图片路径。支持 PNG / JPEG / GIF / WebP(按文件内容判断,不看扩展名)", 1 },
PARAM_END
};
static const ToolDef TOOLS[] = {
{
TOOL_RUN_COMMAND,
"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。"
"适合编译、运行测试、git 操作等。",
"tools.ui.run_command",
P_RUN_COMMAND, 1, 0, tool_run_command
},
{
TOOL_READ_FILE,
"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。"
"大文件请用 offset/limit 分段读取。"
"**二进制文件会被拒绝**(它只处理文本)。",
"tools.ui.read_file",
P_READ_FILE, 0, 0, tool_read_file
},
{
TOOL_WRITE_FILE,
"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。"
"目标已经是二进制文件时会被拒绝覆盖。",
"tools.ui.write_file",
P_WRITE_FILE, 1, 0, tool_write_file
},
{
TOOL_EDIT_FILE,
"在文件中做精确字符串替换。line 是集合型且**可省略**:给一个整数(`12`)"
"或整数数组(`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来,起点到底"
"是第几行就不必赌;替换只发生在这些行里,old_string 也只需在这些行里唯一。"
"**省略 line = 全文件替换所有匹配**(就是编辑器的\"全局替换\"),只在你确实"
"要改全文件时才省。行号对不上会报出 old_string 真正在哪几行,照着改 line。",
"tools.ui.edit_file",
P_EDIT_FILE, 1, 0, tool_edit_file
},
{
TOOL_LIST_DIR,
"列出目录内容,含文件类型与大小。",
"tools.ui.list_dir",
P_LIST_DIR, 0, 0, tool_list_dir
},
{
TOOL_READ_IMAGE,
"读取一张本地图片并把它交给你看(截图、设计稿、图表、报错弹窗都行)。"
"图片会以 base64 内联进请求,本机留一份备份、历史里只记对备份的引用。"
"需要看图片内容时用它,不要试着用 read_file 去读二进制。"
"图片超过内联体积上限时会被直接拒绝,这时请让用户先缩小或压缩。",
"tools.ui.read_image",
P_READ_IMAGE, 0, 1, tool_read_image
},
};
#define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0])
/* 按名字找定义,找不到返回 NULL。
*
* **故意不保留旧名别名**:schema 里只发新名,dispatch 再认旧名只会掩盖
* "调用方没跟着改"这件事(旧存档、用户自写的提示词、测试夹具都一样)。
* 名字即协议,认不出来的就明确报"未知工具",比静默兼容更容易发现漏改。 */
static const ToolDef *find_tool(const char *name)
{
if (!name || !*name) return NULL;
for (size_t i = 0; i < TOOL_COUNT; i++)
if (strcmp(TOOLS[i].name, name) == 0) return &TOOLS[i];
return NULL;
}
/* 从表生成 chat/completions 的 tools 数组(调用者释放) */
JsonValue *tools_schema(void)
{
JsonValue *tools = json_new_array();
for (size_t i = 0; i < TOOL_COUNT; i++) {
const ToolDef *d = &TOOLS[i];
JsonValue *t = json_new_object();
json_object_set_string(t, "type", "function");
JsonValue *fn = json_new_object();
json_object_set_string(fn, "name", d->name);
json_object_set_string(fn, "description", d->desc);
JsonValue *params = json_new_object();
json_object_set_string(params, "type", "object");
JsonValue *props = json_new_object();
JsonValue *req = json_new_array();
for (const ToolParam *p = d->params; p && p->name; p++) {
JsonValue *one = json_new_object();
/* "整数[]" 这种写法表示数组:展开成 {"type":"array","items":{"type":"整数"}}。
* 后缀只认结尾的 "[]",元素类型照抄前面那段。 */
const char *br = strstr(p->type, "[]");
if (br && br[2] == '\0') {
char elem[16];
snprintf(elem, sizeof elem, "%.*s", (int)(br - p->type), p->type);
JsonValue *items = json_new_object();
json_object_set_string(items, "type", elem);
json_object_set_string(one, "type", "array");
json_object_set(one, "items", items);
} else {
json_object_set_string(one, "type", p->type);
}
json_object_set_string(one, "description", p->desc);
json_object_set(props, p->name, one);
if (p->required) json_array_push(req, json_new_string(p->name));
}
json_object_set(params, "properties", props);
json_object_set(params, "required", req);
json_object_set(fn, "parameters", params);
json_object_set(t, "function", fn);
json_array_push(tools, t);
}
return tools;
}
void tools_drop(JsonValue *tools, const char *name)
{
if (!tools || tools->type != JSON_ARRAY || !name) return;
for (size_t i = 0; i < json_array_size(tools); i++) {
JsonValue *fn = json_object_get(json_array_get(tools, i), "function");
const char *n = fn ? json_get_string(fn, "name") : NULL;
if (n && strcmp(n, name) == 0) {
json_array_remove(tools, i);
return;
}
}
}
/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具,
* 否则用户看到的是谎话。列表本身从工具表生成,所以它和真正发出去的工具表
* 天然一致(以前这里是手写的一串 puts,就漏过一个工具)。 */
void tools_print_list(int hide_image)
{
puts(t("tools.ui.header"));
for (size_t i = 0; i < TOOL_COUNT; i++) {
const ToolDef *d = &TOOLS[i];
if (hide_image && d->needs_vision) {
printf("%s\n", tf("tools.ui.hidden", "name", d->name, NULL));
continue;
}
/* 文案在 i18n 表里(键就在上面那一列):/tools 是纯给人看的东西,
* 而表里的 desc 是发给模型的,两者本来就该分开 */
printf(" %-18s%s\n", d->name, t(d->ui_key));
}
}
int tool_mutating(const char *name)
{
const ToolDef *d = find_tool(name);
return d ? d->mutating : 0;
}
/* line 是集合型参数:整数或整数数组。渲染成 "3" / "3,4,5"(超过 8 个就省略
* 尾部)—— 用户/模型要确认的第一件事就是"改的是哪几行",但也不能把终端刷满。
* 一个合法行号都没渲染出来时留空串,调用方据此走"没给行号"那条文案。 */
void tool_lines_label(const JsonValue *args, char *out, size_t cap)
{
JsonValue *lv = json_object_get(args, "line");
int is_arr = (lv && lv->type == JSON_ARRAY);
size_t n = is_arr ? json_array_size(lv) : (lv ? 1 : 0);
size_t off = 0, wrote = 0;
out[0] = '\0';
for (size_t i = 0; i < n; i++) {
JsonValue *e = is_arr ? json_array_get(lv, i) : lv;
if (e->type != JSON_NUMBER) continue;
if (wrote >= 8) { snprintf(out + off, cap - off, "…"); break; }
int w = snprintf(out + off, cap - off, "%s%ld",
wrote ? ", " : "", (long)e->u.number);
if (w < 0 || (size_t)w >= cap - off) break;
off += (size_t)w;
wrote++;
}
}
/* ---- edit_file 的新旧对照(带行号)----
*
* 行号取自 line 参数:替换就发生在那里,所以两侧的起始行号相同 —— 新内容落在
* 旧内容原来的位置上。line 省略(全文件替换)时没有确定的起点,就**不标**行号:
* 编一个出来比不标更糟。
*/
#define DIFF_MAX_LINES 8
#define DIFF_MAX_COLS 200
#define DIFF_RED "\033[31m"
#define DIFF_GREEN "\033[32m"
#define DIFF_DIM "\033[2m"
#define DIFF_OFF "\033[0m"
/* 起始行号:line 是集合型,取最小的那个;没给 line 则返回 0(= 不标行号) */
static long diff_base_line(const JsonValue *args)
{
JsonValue *lv = json_object_get(args, "line");
if (!lv) return 0;
if (lv->type == JSON_NUMBER) return (long)lv->u.number;
if (lv->type != JSON_ARRAY) return 0;
long base = 0;
for (size_t i = 0; i < json_array_size(lv); i++) {
JsonValue *e = json_array_get(lv, i);
if (!e || e->type != JSON_NUMBER) continue;
long v = (long)e->u.number;
if (base == 0 || v < base) base = v;
}
return base;
}
static void diff_side(FILE *out, const char *prefix, const char *sign,
const char *color, int use_color, const char *text, long base)
{
int total = 0, shown = 0;
long lineno = base;
for (const char *p = text; ; ) {
const char *nl = strchr(p, '\n');
size_t n = nl ? (size_t)(nl - p) : strlen(p);
total++;
if (shown < DIFF_MAX_LINES) {
size_t w = n > DIFF_MAX_COLS ? DIFF_MAX_COLS : n;
if (base > 0)
fprintf(out, "%s%s%s%s %4ld %.*s%s\n", prefix,
use_color ? color : "", sign, use_color ? DIFF_OFF : "",
lineno, (int)w, p, n > w ? "…" : "");
else
fprintf(out, "%s%s%s%s %.*s%s\n", prefix,
use_color ? color : "", sign, use_color ? DIFF_OFF : "",
(int)w, p, n > w ? "…" : "");
shown++;
}
lineno++;
if (!nl || !nl[1]) break; /* 末尾那个换行不算多一行 */
p = nl + 1;
}
if (total > shown)
fprintf(out, "%s%s%s%s\n", prefix, use_color ? DIFF_DIM : "",
tf("tool.diff.more", "n", i18n_num(total - shown), NULL),
use_color ? DIFF_OFF : "");
}
int tool_render_diff(FILE *out, const JsonValue *args,
const char *prefix, int use_color)
{
if (!out || !args) return 0;
const char *old_s = json_string_value(json_object_get(args, "old_string"));
const char *new_s = json_string_value(json_object_get(args, "new_string"));
if (!old_s || !new_s) return 0;
long base = diff_base_line(args);
diff_side(out, prefix, "-", DIFF_RED, use_color, old_s, base);
diff_side(out, prefix, "+", DIFF_GREEN, use_color, new_s, base);
return 1;
}
char *tool_execute(const char *name, const JsonValue *args, int *is_error)
{
if (is_error) *is_error = 0;
if (!name) return xstrdup("错误:工具名为空");
if (!args || args->type != JSON_OBJECT) {
if (is_error) *is_error = 1;
return xstrdup("错误:工具参数必须是 JSON 对象");
}
const ToolDef *d = find_tool(name);
if (!d) {
if (is_error) *is_error = 1;
return err_result("未知工具 \"%s\"", name);
}
/* fn == NULL 是一道守卫:表里目前没有这种工具,但留着这条检查 ——
* 将来若加一个"由上层执行"的工具,模型瞎编参数时也能得到明确回答,
* 而不是静默什么都不干。 */
if (!d->fn) {
if (is_error) *is_error = 1;
return err_result("工具 \"%s\" 不能在这里直接执行(它只在主对话里可用,"
"由 agent.c 处理)", name);
}
return d->fn(args, is_error);
}