1301 lines
56 KiB
C
1301 lines
56 KiB
C
/* 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);
|
||
}
|