diff options
Diffstat (limited to 'src/tools.cpp')
| -rw-r--r-- | src/tools.cpp | 1341 |
1 files changed, 1341 insertions, 0 deletions
diff --git a/src/tools.cpp b/src/tools.cpp new file mode 100644 index 0000000..7656b23 --- /dev/null +++ b/src/tools.cpp @@ -0,0 +1,1341 @@ +#include "tools.h" +#include "escape.h" + +#include <cjson/cJSON.h> +#include <curl/curl.h> +#include <ctype.h> +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <limits.h> +#include <math.h> +#include <poll.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <unistd.h> + +#include "limits.h" +#include "llm.h" +#include "verbose.h" + +/* Cap on bytes retained per output stream (mirrors tools.rs). */ +#define MAX_SHELL_OUTPUT_BYTES 1000000u +#define MAX_MEMORY_HITS 10u +#define MAX_MEMORY_BODY_CHARS 2000u +#define MAX_TOOL_HTTP_BYTES (4u * 1024u * 1024u) + +typedef struct { + xbuf *buf; + size_t cap; +} tool_http_write_ctx; + +static size_t tool_http_write(char *data, size_t size, size_t nmemb, void *ud) { + tool_http_write_ctx *ctx = (tool_http_write_ctx *)ud; + if (size && nmemb > SIZE_MAX / size) + return 0; + size_t n = size * nmemb; + if (ctx->buf->len > ctx->cap || n > ctx->cap - ctx->buf->len) + return 0; + xbuf_putn(ctx->buf, data, n); + return n; +} + +/* ------------------------------------------------------------------ */ +/* Small helpers */ +/* ------------------------------------------------------------------ */ + +/* ASCII lowercase copy (Rust to_lowercase for ASCII input). */ +static char *lower_dup(const char *s) { + xbuf b; + xbuf_init(&b); + for (const unsigned char *p = (const unsigned char *)s; *p; p++) + xbuf_push(&b, (char)((*p >= 'A' && *p <= 'Z') ? *p + 32 : *p)); + return xbuf_steal(&b); +} + +/* ------------------------------------------------------------------ */ +/* read_file */ +/* ------------------------------------------------------------------ */ + +/* Total number of lines in `data` (a single trailing newline does not count + * as an extra line; an empty buffer is 0). */ +static unsigned long count_lines(const char *data, size_t len) { + if (len == 0) + return 0; + unsigned long nl = 0; + for (size_t i = 0; i < len; i++) + if (data[i] == '\n') + nl++; + return (data[len - 1] == '\n') ? nl : nl + 1; +} + +/* Newly-allocated copy of the line range [start_line, start_line + max_lines) + * of `data`. start_line is 1-indexed (values < 1 mean "from the start"); + * max_lines 0 means "no limit". A start_line past the last line yields "". */ +static char *slice_lines(const char *data, size_t len, unsigned long start_line, + unsigned long max_lines) { + const char *end = data + len; + const char *p = data; + unsigned long line = 1; + while (line < start_line) { + const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p)); + if (!nl) { + p = end; + break; /* start_line is past the last line */ + } + p = nl + 1; + line++; + } + const char *sel = p; + if (max_lines > 0) { + unsigned long taken = 0; + while (taken < max_lines && p < end) { + const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p)); + if (!nl) { + p = end; + break; + } + p = nl + 1; + taken++; + } + } + size_t out_len = (size_t)(p - sel); + char *out = (char *) xmalloc(out_len + 1); + memcpy(out, sel, out_len); + out[out_len] = '\0'; + return out; +} + +char *tool_read_file(const char *path, unsigned long offset, unsigned long limit, + char **err) { + if (!path || !path[0]) { + if (err) + *err = xstrdup("missing `path` argument (expected a non-empty " + "string)"); + return NULL; + } + verbose_log("tool_call", + (const vlog_kv[]){VLOG_STR("name", "read_file"), + VLOG_STR("path", path)}, + 2); + const tool_limits *lim = tool_limits_current(); + char *data = NULL; + size_t len = 0; + struct stat st; + if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) { + if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): " + "Is a directory (os error 21)", path); + return NULL; + } + size_t max_bytes = lim->max_file_bytes > SIZE_MAX + ? SIZE_MAX + : (size_t)lim->max_file_bytes; + char *read_err = NULL; + if (read_file_to_buf_limited(path, max_bytes, &data, &len, &read_err) != 0) { + if (err && read_err && xstreq(read_err, "file exceeds configured size limit")) + *err = xstrfmt("`%s` is too large to read (limit %llu bytes)", + path, lim->max_file_bytes); + else if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): %s", + path, read_err ? read_err : strerror(errno)); + free(read_err); + return NULL; + } + free(read_err); + if (memchr(data, '\0', len) || !utf8_is_valid(data, len)) { + free(data); + if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): " + "stream did not contain valid UTF-8", + path); + return NULL; + } + /* Apply offset/limit: return only the requested line range. offset is a + * 1-indexed start line (0/1 = from the top); limit is a max line count + * (0 = all). Note how many lines remain when a limit truncated the file. */ + if (offset > 0 || limit > 0) { + unsigned long start = (offset < 1) ? 1 : offset; + unsigned long total = count_lines(data, len); /* lines in the whole file */ + char *sl = slice_lines(data, len, start, limit); + free(data); + if (limit > 0) { + unsigned long last_shown = start + limit - 1; + if (last_shown > total) + last_shown = total; + if (total > last_shown) { + unsigned long remaining = total - last_shown; + char *note = xstrfmt("%s\n[... %lu more %s; use offset=%lu to " + "continue ...]", + sl, remaining, + remaining == 1 ? "line" : "lines", + last_shown + 1); + free(sl); + sl = note; + } + } + data = sl; + } + verbose_log("tool_result", + (const vlog_kv[]){VLOG_STR("name", "read_file"), + VLOG_BOOL("ok", true)}, + 2); + return data; +} + +/* ------------------------------------------------------------------ */ +/* write_file */ +/* ------------------------------------------------------------------ */ + +char *tool_write_file(const char *path, const char *content, char **err) { + if (!path || !path[0] || !content) { + if (err) + *err = xstrdup("write_file requires non-empty `path` and string `content`"); + return NULL; + } + const char *slash = strrchr(path, '/'); + if (slash && slash != path) { + char *parent = xstrndup(path, (size_t)(slash - path)); + if (mkdir_p(parent) != 0) { + if (err) + *err = xstrfmt("creating parent dirs for `%s`: %s", path, + strerror(errno)); + free(parent); + return NULL; + } + free(parent); + } + size_t clen = strlen(content); + if (write_file_from_buf(path, content, clen, NULL) != 0) { + if (err) + *err = xstrfmt("failed to write `%s`: %s", path, strerror(errno)); + return NULL; + } + return xstrfmt("wrote %zu bytes to %s", clen, path); +} + +/* ------------------------------------------------------------------ */ +/* run_shell */ +/* ------------------------------------------------------------------ */ + +/* Lossy UTF-8: copy valid code points verbatim and replace each invalid + * byte with one U+FFFD, so the result is always well-formed UTF-8. */ +static char *utf8_lossy(const char *s, size_t len) { + xbuf b; + xbuf_init(&b); + const char *p = s; + const char *end = s + len; + while (p < end) { + int n = 0; + unsigned char c = (unsigned char)*p; + if (c < 0x80) + n = 1; + else if ((c & 0xE0) == 0xC0 && c >= 0xC2 && end - p >= 2 && (p[1] & 0xC0) == 0x80) + n = 2; + else if ((c & 0xF0) == 0xE0 && end - p >= 3 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && !(c == 0xE0 && (unsigned char)p[1] < 0xA0) && + !(c == 0xED && (unsigned char)p[1] >= 0xA0)) + n = 3; + else if ((c & 0xF8) == 0xF0 && end - p >= 4 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80 && + c <= 0xF4 && !(c == 0xF0 && (unsigned char)p[1] < 0x90) && + !(c == 0xF4 && (unsigned char)p[1] >= 0x90)) + n = 4; + if (n > 0) { + xbuf_putn(&b, p, (size_t)n); + p += (size_t)n; + } else { + xbuf_put(&b, "\xEF\xBF\xBD"); + p++; + } + } + return xbuf_steal(&b); +} + +static bool drain_fd(int fd, xbuf *b, size_t cap, bool *eof) { + char tmp[8192]; + for (;;) { + /* Always keep draining so a child writing more than `cap` does not + * block on a full pipe; bytes past the cap are read and discarded. */ + ssize_t r = read(fd, tmp, sizeof tmp); + if (r > 0) { + if (b->len < cap) { + size_t take = (size_t)r; + if (take > cap - b->len) + take = cap - b->len; + xbuf_putn(b, tmp, take); + } + continue; + } + if (r == 0) { + *eof = true; + } else if (errno == EAGAIN || errno == EWOULDBLOCK) { + *eof = false; + } else { + *eof = true; /* treat I/O errors as end-of-stream */ + } + return *eof; + } +} + +static void set_nonblock(int fd) { + int fl = fcntl(fd, F_GETFL, 0); + if (fl >= 0) + fcntl(fd, F_SETFL, fl | O_NONBLOCK); +} + +char *tool_run_shell_timed(const char *command, unsigned long timeout_secs, + char **err) { + const char *label = command; + int out_fd[2] = {-1, -1}; + int err_fd[2] = {-1, -1}; + if (pipe(out_fd) != 0 || pipe(err_fd) != 0) { + close(out_fd[0]); + close(out_fd[1]); + close(err_fd[0]); + close(err_fd[1]); + if (err) + *err = xstrfmt("failed to run `%s`: %s (os error %d)", label, + strerror(errno), errno); + return NULL; + } + pid_t pid = fork(); + if (pid < 0) { + close(out_fd[0]); + close(out_fd[1]); + close(err_fd[0]); + close(err_fd[1]); + if (err) + *err = xstrfmt("failed to run `%s`: %s (os error %d)", label, + strerror(errno), errno); + return NULL; + } + if (pid == 0) { + /* Own process group so a timeout kills the whole tree. */ + if (setpgid(0, 0) != 0) + _exit(125); + if (dup2(out_fd[1], 1) < 0) + _exit(126); + if (dup2(err_fd[1], 2) < 0) + _exit(126); + int dn = open("/dev/null", O_RDONLY); + if (dn >= 0) { + if (dup2(dn, 0) < 0) + _exit(126); + close(dn); + } + char *argv[] = {"sh", "-c", (char *)command, NULL}; + execvp("sh", argv); + _exit(127); + } + close(out_fd[1]); + close(err_fd[1]); + int out_r = out_fd[0]; + int err_r = err_fd[0]; + set_nonblock(out_r); + set_nonblock(err_r); + xbuf ob; + xbuf eb; + xbuf_init(&ob); + xbuf_init(&eb); + bool out_eof = false; + bool err_eof = false; + int status = 0; + bool done = false; + bool timed_out = false; + double deadline = now_secs() + (double)timeout_secs; + while (!done) { + pid_t r = waitpid(pid, &status, WNOHANG); + if (r == pid) { + done = true; + break; + } + if (r < 0 && errno == ECHILD) { + done = true; + break; + } + if (now_secs() >= deadline) { + kill(-pid, SIGKILL); + for (;;) { + r = waitpid(pid, &status, WNOHANG); + if (r == pid) + break; + sleep_ms(20); + } + timed_out = true; + break; + } + if (!out_eof) + drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof); + if (!err_eof) + drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof); + sleep_ms(20); + } + if (!timed_out) { + /* Bounded final drain: pick up buffered data after the child exits + * without spinning forever if a grandchild keeps the pipe open. */ + double drain_deadline = now_secs() + 2.0; + while (!out_eof && now_secs() < drain_deadline) { + drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof); + if (!out_eof) + sleep_ms(5); + } + while (!err_eof && now_secs() < drain_deadline) { + drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof); + if (!err_eof) + sleep_ms(5); + } + } + close(out_r); + close(err_r); + char *stdout_s = utf8_lossy(ob.data ? ob.data : "", ob.len); + char *stderr_s = utf8_lossy(eb.data ? eb.data : "", eb.len); + xbuf_free(&ob); + xbuf_free(&eb); + if (timed_out) { + free(stdout_s); + free(stderr_s); + if (err) + *err = xstrfmt("command timed out after %lus: %s", + timeout_secs, label); + return NULL; + } + char *so = xstr_rtrim_dup(stdout_s); + char *se = xstr_rtrim_dup(stderr_s); + free(stdout_s); + free(stderr_s); + xbuf t; + xbuf_init(&t); + if (so[0]) + xbuf_put(&t, so); + if (se[0]) { + if (t.len) + xbuf_push(&t, '\n'); + xbuf_put(&t, "[stderr]\n"); + xbuf_put(&t, se); + } + if (t.len == 0) + xbuf_put(&t, "(no output)"); + if (WIFEXITED(status)) { + int code = WEXITSTATUS(status); + if (code != 0) + xbuf_fmt(&t, "\n[exit code %d]", code); + } else if (WIFSIGNALED(status)) { + xbuf_put(&t, "\n[terminated by signal]"); + } + char *text = xbuf_steal(&t); + char *out = tool_truncate_output(text); + free(text); + free(so); + free(se); + return out; +} + +char *tool_run_shell(const char *command, char **err) { + if (!command || !command[0]) { + if (err) + *err = xstrdup("run_shell requires a non-empty `command`"); + return NULL; + } + const tool_limits *lim = tool_limits_current(); + if (shell_gate(command, lim, err) != 0) + return NULL; + verbose_log("tool_call", + (const vlog_kv[]){VLOG_STR("name", "run_shell"), + VLOG_STR("command", command)}, + 2); + unsigned long timeout = lim->shell_timeout_secs; + char *out = tool_run_shell_timed(command, timeout, err); + verbose_log("tool_result", + (const vlog_kv[]){VLOG_STR("name", "run_shell"), + VLOG_BOOL("ok", out != NULL)}, + 2); + return out; +} + +char *tool_truncate_output(const char *text) { + const tool_limits *lim = tool_limits_current(); + unsigned long limit = lim->max_output_chars > 0 ? lim->max_output_chars : 1; + size_t len = strlen(text); + if (len <= limit) + return xstrdup(text); + size_t cut = utf8_byte_len(text, limit); + size_t max_printf_precision = (size_t)(~0u >> 1); + if (cut > max_printf_precision) + cut = max_printf_precision; + char *out = xstrfmt("%.*s", (int)cut, text); + char *res = xstrfmt("%s\u2026\n[output truncated at %lu chars]", out, + limit); + free(out); + return res; +} + +/* ------------------------------------------------------------------ */ +/* use_skill */ +/* ------------------------------------------------------------------ */ + +char *tool_use_skill(const dotagents *dot, const char *id, char **err) { + char *out = NULL; + const skill *sk = dot ? dotagents_find_skill(dot, id) : NULL; + if (sk) { + if (sk->enabled) { + char *body = xstr_rtrim_dup(sk->body ? sk->body : ""); + out = xstrfmt("%s\n\n%s", sk->name, body); + free(body); + } else { + if (err) + *err = xstrfmt("skill `%s` is disabled (enabled: false)", id); + } + } else { + if (!dot || dot->skill_count == 0) { + if (err) + *err = xstrdup( + "no skills defined (expected .agents/skills/<id>/skill.md)"); + } else { + xbuf ids; + xbuf_init(&ids); + for (size_t i = 0; i < dot->skill_count; i++) { + if (i) + xbuf_put(&ids, ", "); + xbuf_put(&ids, dot->skills[i].id); + } + if (err) + *err = xstrfmt("no skill `%s`; available: %s", id, + ids.data ? ids.data : ""); + xbuf_free(&ids); + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* search_memories */ +/* ------------------------------------------------------------------ */ + +void tool_tokenize(const char *query, strlist *out) { + char *low = lower_dup(query); + size_t n = strlen(low); + size_t start = 0; + for (size_t i = 0; i <= n; i++) { + char c = i < n ? low[i] : '\0'; + bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (alnum) { + continue; + } + if (i > start) + sl_push(out, xstrndup(low + start, i - start)); + start = i + 1; + } + free(low); +} + +bool tool_is_subsequence(const char *needle, const char *haystack) { + const char *h = haystack; + for (const char *n = needle; *n; n++) { + const char *found = NULL; + while (*h) { + if (*h == *n) { + found = h; + break; + } + h++; + } + if (!found) + return false; + h = found + 1; + } + return true; +} + +double tool_field_match_strength(const char *field, const char *term) { + if (!field[0] || !term[0]) + return 0.0; + if (strcmp(field, term) == 0) + return 1.0; + size_t start = 0; + for (size_t i = 0;; i++) { + char c = field[i]; + bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (alnum) + continue; + if (i > start) { + char *tok = xstrndup(field + start, i - start); + bool eq = xstreq(tok, term); + free(tok); + if (eq) + return 0.9; + } + start = i + 1; + if (!c) + break; + } + if (strstr(field, term)) + return 0.7; + if (strlen(term) >= 3 && tool_is_subsequence(term, field)) + return 0.4; + return 0.0; +} + +double tool_memory_relevance(const memory *m, const strlist *terms) { + if (terms->len == 0) + return 0.0; + char *title = lower_dup(m->title ? m->title : ""); + xbuf tagsb; + xbuf_init(&tagsb); + for (size_t i = 0; i < m->tag_count; i++) { + if (i) + xbuf_put(&tagsb, " "); + xbuf_put(&tagsb, m->tags[i]); + } + char *tags_raw = xbuf_steal(&tagsb); + char *tags = lower_dup(tags_raw); + free(tags_raw); + char *summary = lower_dup(m->summary ? m->summary : ""); + char *body = lower_dup(m->body ? m->body : ""); + const char *fields[4] = {title, tags, summary, body}; + const double weights[4] = {4.0, 3.0, 2.0, 1.0}; + double points = 0.0; + size_t matched = 0; + for (size_t t = 0; t < terms->len; t++) { + double best = 0.0; + for (int f = 0; f < 4; f++) { + double s = weights[f] * tool_field_match_strength(fields[f], + terms->items[t]); + if (s > best) + best = s; + } + if (best > 0.0) { + matched++; + points += best; + } + } + free(title); + free(tags); + free(summary); + free(body); + if (matched == 0) + return 0.0; + double coverage = (double)matched / (double)terms->len; + return points * (1.0 + coverage); +} + +typedef struct { + double score; + const memory *m; +} scored; + +static int cmp_scored(const void *a, const void *b) { + double sa = ((const scored *)a)->score; + double sb = ((const scored *)b)->score; + if (sa != sb) + return sa < sb ? 1 : -1; + int ra = memory_importance_rank(((const scored *)a)->m); + int rb = memory_importance_rank(((const scored *)b)->m); + if (ra != rb) + return ra - rb; + return strcmp(((const scored *)a)->m->id, ((const scored *)b)->m->id); +} + +char *tool_search_memories(const dotagents *dot, const char *query, + char **err) { + char *needle = xstr_trim_dup(query); + if (!needle[0]) { + if (err) + *err = xstrdup("query must not be empty"); + free(needle); + return NULL; + } + char *out = NULL; + if (!dot || dot->memory_count == 0) { + out = xstrdup("no memories defined (expected .agents/memories/*.md)"); + free(needle); + return out; + } + { + strlist terms; + sl_init(&terms); + tool_tokenize(needle, &terms); + scored *arr = (scored *) xmalloc(dot->memory_count * sizeof(scored)); + size_t n = 0; + for (size_t i = 0; i < dot->memory_count; i++) { + double s = tool_memory_relevance(&dot->memories[i], &terms); + if (s > 0.0) + arr[n++] = (scored){s, &dot->memories[i]}; + } + sl_free(&terms); + if (n == 0) { + out = xstrfmt("no memories matching `%s`", needle); + } else { + qsort(arr, n, sizeof(scored), cmp_scored); + xbuf b; + xbuf_init(&b); + size_t shown = n < MAX_MEMORY_HITS ? n : MAX_MEMORY_HITS; + for (size_t i = 0; i < shown; i++) { + const memory *m = arr[i].m; + xbuf_fmt(&b, "## %s [importance: %s]\n", m->title, + m->importance ? m->importance : ""); + if (m->tag_count > 0) { + xbuf tags; + xbuf_init(&tags); + for (size_t j = 0; j < m->tag_count; j++) { + if (j) + xbuf_put(&tags, ", "); + xbuf_put(&tags, m->tags[j]); + } + xbuf_fmt(&b, "tags: %s\n", tags.data ? tags.data : ""); + xbuf_free(&tags); + } + if (m->summary && m->summary[0]) + xbuf_fmt(&b, "%s (summary)\n", m->summary); + char *body = xstr_trim_dup(m->body ? m->body : ""); + if (body[0]) { + size_t blen = strlen(body); + size_t cut = utf8_byte_len(body, MAX_MEMORY_BODY_CHARS); + xbuf_putn(&b, body, cut); + if (cut < blen) + xbuf_put(&b, "\xE2\x80\xA6"); + xbuf_put(&b, "\n"); + } + free(body); + xbuf_put(&b, "\n"); + } + if (n > MAX_MEMORY_HITS) + xbuf_fmt(&b, "\xE2\x80\xA6" + "and %zu more matches\n", + n - MAX_MEMORY_HITS); + out = xbuf_steal(&b); + } + free(arr); + free(needle); + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Embedding tool */ +/* ------------------------------------------------------------------ */ + +void embed_target_free(embed_target *t) { + free(t->model); + free(t->base_url); + free(t->api_key); + t->model = t->base_url = t->api_key = NULL; +} + +int tool_embed_resolve(const config *cfg, const dotagents *dot, + embed_target *out, char **err) { + memset(out, 0, sizeof *out); + if (err) + *err = NULL; + if (!dot || !dot->has_models) { + if (err) + *err = xstrdup("embeddings not configured: add a preset to " + ".agents/models.json"); + return -1; + } + const char *name = dot->models.default_name; + if (!name || !*name) { + if (err) + *err = xstrdup("embeddings not configured: no `default` preset " + "in .agents/models.json"); + return -1; + } + const model_preset *p = + (dot && dot->has_models) ? models_file_get(&dot->models, name) : NULL; + if (!p) { + if (err) + *err = xstrfmt("embeddings preset `%s` not found in " + ".agents/models.json", + name); + return -1; + } + out->model = (p->model && p->model[0]) ? xstrdup(p->model) + : (cfg->model ? xstrdup(cfg->model) + : NULL); + out->base_url = (p->base_url && p->base_url[0]) + ? xstrdup(p->base_url) + : (cfg->base_url ? xstrdup(cfg->base_url) : NULL); + out->api_key = (p->api_key && p->api_key[0]) + ? xstrdup(p->api_key) + : (cfg->api_key ? xstrdup(cfg->api_key) : NULL); + if (!out->model || !out->model[0] || !out->api_key || !out->api_key[0]) { + char *m = xstrfmt("embeddings preset `%s` needs a `model` and an " + "`api_key`", + name); + embed_target_free(out); + if (err) + *err = m; + else + free(m); + return -1; + } + return 0; +} + +char *tool_embed_request(const char *model, const char *text, char **err) { + if (err) + *err = NULL; + if (!model || !model[0]) { + if (err) + *err = xstrdup("embeddings: empty model name"); + return NULL; + } + if (!text || !text[0]) { + if (err) + *err = xstrdup("embed: empty text"); + return NULL; + } + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "model", model); + cJSON_AddStringToObject(o, "input", text); + char *s = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + return s; +} + +char *tool_embed_url(const char *base_url) { + const char *base = + base_url && base_url[0] ? base_url : "https://api.openai.com/v1"; + size_t n = strlen(base); + while (n && base[n - 1] == '/') + n--; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, base, n); + xbuf_put(&b, "/embeddings"); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +int tool_embed_parse(const char *body, char **vector_json, char **err) { + if (vector_json) + *vector_json = NULL; + if (err) + *err = NULL; + if (!body || !body[0]) { + if (err) + *err = xstrdup("embeddings: empty response body"); + return -1; + } + cJSON *o = cJSON_Parse(body); + if (!o) { + if (err) + *err = xstrdup("embeddings: invalid JSON in response"); + return -1; + } + const cJSON *data = cJSON_GetObjectItemCaseSensitive(o, "data"); + const cJSON *first = + (data && cJSON_IsArray(data) && cJSON_GetArraySize(data) > 0) + ? cJSON_GetArrayItem(data, 0) + : NULL; + const cJSON *emb = first + ? cJSON_GetObjectItemCaseSensitive(first, "embedding") + : NULL; + if (!emb || !cJSON_IsArray(emb) || cJSON_GetArraySize(emb) == 0) { + if (err) + *err = xstrdup("embeddings: no `data[0].embedding` array in " + "response"); + cJSON_Delete(o); + return -1; + } + const cJSON *v = NULL; + cJSON_ArrayForEach(v, emb) { + if (!cJSON_IsNumber(v) || !isfinite(v->valuedouble)) { + if (err) + *err = xstrdup("embeddings: non-numeric element in vector"); + cJSON_Delete(o); + return -1; + } + } + cJSON *arr = cJSON_Duplicate(emb, 1); + cJSON_Delete(o); + if (!arr) { + if (err) + *err = xstrdup("embeddings: out of memory"); + return -1; + } + char *s = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + if (vector_json) + *vector_json = s; + else + free(s); + return s ? 0 : -1; +} + +/* One-shot JSON POST (mirrors agent_http_get). Returns 0 + *body owned + * (may be empty), or -1 with *err owned. */ +static int embed_post(const char *url, const char *api_key, const char *req, + long *status, char **body, char **err) { + if (body) + *body = NULL; + if (err) + *err = NULL; + CURL *c = curl_easy_init(); + if (!c) { + if (err) + *err = xstrdup("could not initialize curl"); + return -1; + } + xbuf b; + xbuf_init(&b); + struct curl_slist *h = NULL; + if (api_key && api_key[0]) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "Authorization: Bearer %s", api_key); + h = curl_slist_append(h, hb.data); + xbuf_free(&hb); + } + h = curl_slist_append(h, "Content-Type: application/json"); + curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); + curl_easy_setopt(c, CURLOPT_URL, url); + curl_easy_setopt(c, CURLOPT_POST, 1L); + curl_easy_setopt(c, CURLOPT_POSTFIELDS, req); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(c, CURLOPT_CAINFO, ca); + tool_http_write_ctx write_ctx = {&b, MAX_TOOL_HTTP_BYTES}; + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, tool_http_write); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(c); + curl_slist_free_all(h); + if (rc != CURLE_OK) { + char *e = xstrdup(curl_easy_strerror(rc)); + free(b.data); + curl_easy_cleanup(c); + if (err) + *err = e; + return -1; + } + long code = 0; + curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code); + if (status) + *status = code; + if (body) + *body = xbuf_steal(&b); + else + free(b.data); + curl_easy_cleanup(c); + return 0; +} + +char *tool_embed(const char *text, const config *cfg, const dotagents *dot, + char **err) { + if (err) + *err = NULL; + embed_target t; + if (tool_embed_resolve(cfg, dot, &t, err) != 0) + return NULL; + char *req = tool_embed_request(t.model, text, NULL); + char *url = tool_embed_url(t.base_url); + long status = 0; + char *body = NULL; + int rc = embed_post(url, t.api_key, req, &status, &body, err); + free(req); + free(url); + if (rc != 0) { + free(body); + return NULL; + } + char *vec = NULL; + if (status < 200 || status >= 300 || tool_embed_parse(body, &vec, err) != 0) { + if (!err || !*err) { + char *m = xstrfmt("embeddings request failed (HTTP %ld): %s", + status, body ? body : "(no body)"); + if (err) + *err = m; + else + free(m); + } + free(body); + embed_target_free(&t); + return NULL; + } + free(body); + cJSON *v = cJSON_Parse(vec); + int dims = v ? cJSON_GetArraySize(v) : 0; + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "{\"dimensions\":%d,\"vector\":%s}", dims, vec); + char *out = xbuf_steal(&b); + xbuf_free(&b); + free(vec); + embed_target_free(&t); + if (v) + cJSON_Delete(v); + return out; +} + +/* ------------------------------------------------------------------ */ +/* llm (one-shot completion / embed) */ +/* ------------------------------------------------------------------ */ + +char *tool_llm(const llm_tool_call *call, const config *cfg, + const dotagents *dot, char **err) { + if (err) + *err = NULL; + if (!call || !call->prompt || !call->prompt[0]) { + if (err) + *err = xstrdup("llm: missing required `prompt`"); + return NULL; + } + if (call->embed) + return tool_embed(call->prompt, cfg, dot, err); + + if (call->response_schema && call->response_schema[0]) { + cJSON *s = cJSON_Parse(call->response_schema); + if (!s) { + if (err) + *err = xstrdup("llm: `response_schema` is not valid JSON"); + return NULL; + } + cJSON_Delete(s); + } + + llm_conn conn; + switch (cfg->provider) { + case PROV_ANTHROPIC: + conn.provider = LLM_ANTHROPIC; + break; + case PROV_OLLAMA: + conn.provider = LLM_OLLAMA; + break; + default: + conn.provider = LLM_OPENAI; + break; + } + conn.model = + (call->model && call->model[0]) ? call->model : cfg->model; + if (!conn.model || !conn.model[0]) { + if (err) + *err = xstrdup("llm: no model configured"); + return NULL; + } + conn.base_url = cfg->base_url; + conn.api_key = cfg->api_key; + conn.ca_bundle = llsh_ca_bundle(); + + llm_params p; + llm_params_init(&p); + p.temperature = call->temperature; + p.max_tokens = call->max_tokens; + p.reasoning_effort = call->reasoning_effort; + p.thinking_budget = call->thinking_budget; + p.response_schema = call->response_schema; + + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = call->prompt; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + char *req = llm_build_request_ex(&conn, NULL, &one, 1, NULL, false, &p); + char *url = llm_url(&conn); + long status = 0; + char *body = NULL, *headers = NULL, *herr = NULL; + int hrc = llm_post(&conn, url, req, &status, &body, &headers, &herr, + NULL, NULL); + free(req); + free(url); + if (hrc != 0) { + if (err) + *err = herr ? herr : xstrdup("llm: transport error"); + free(body); + free(headers); + return NULL; + } + char *result = NULL; + llm_completion c; + memset(&c, 0, sizeof c); + if (llm_parse_body(&conn, body, &c) == 0) { + result = (c.text && c.text[0]) ? c.text : xstrdup(""); + c.text = NULL; + if (c.reasoning && c.reasoning[0]) { + char *full = xstrfmt("[reasoning]\n%s\n\n[answer]\n%s", + c.reasoning, result); + free(result); + result = full; + } + c.reasoning = NULL; + llm_completion_free(&c); + } + if (status < 200 || status >= 300 || !result) { + char *em = llm_parse_error(body ? body : "", status); + if (err) + *err = em; + free(result); + result = NULL; + } + free(body); + free(headers); + return result; +} + +/* ------------------------------------------------------------------ */ +/* Display vtable */ +/* ------------------------------------------------------------------ */ + +/* Owned copy of a string argument from an invocation's args JSON, or + * NULL when the JSON is missing / not an object / the key is absent or + * not a string. */ +static char *display_arg(const char *args_json, const char *key) { + if (!args_json || !args_json[0]) + return NULL; + cJSON *a = cJSON_Parse(args_json); + if (!a) + return NULL; + char *out = NULL; + const cJSON *v = cJSON_GetObjectItem(a, key); + if (cJSON_IsString(v) && v->valuestring) + out = xstrdup(v->valuestring); + cJSON_Delete(a); + return out; +} + +/* run_shell: present the invocation as a plain shell line, `$ <command>` + * (prompt in the accent color, the command muted like other tool args). + * Returns NULL (generic fallback) when no command string is present. */ +static char *display_run_shell(const char *args_json) { + char *cmd = display_arg(args_json, "command"); + if (!cmd || !cmd[0]) { + free(cmd); + return NULL; + } + char *line = xstrfmt("\n" ESC_COLOR_GREEN "$ " ESC_RESET ESC_DIM "%s" + ESC_RESET "\n", + cmd); + free(cmd); + return line; +} + +/* ------------------------------------------------------------------ */ +/* Registry -- the single source of truth for the built-in tools */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *name; + const char *wire; /* description sent to the model (schema) */ + const char *display; /* one-liner for /tools */ + const char *preamble; /* one-liner for the default system preamble */ + tool_render_call render_call; /* transcript display hook (or NULL) */ + bool dim_result; /* result rendered muted (shell-style) */ +} tool_def; + +static const tool_def TOOL_DEFS[] = { + {"read_file", + "Read a file and return its text content. Optional `offset` (1-indexed " + "start line) and `limit` (max lines) read a line range; a trailing note " + "reports how many lines remain.", + "Read a file's contents, optionally a line range via `offset`/`limit`.", + "read a file's contents (optionally a line range)", + NULL, true}, + {"write_file", + "Write content to a path, creating parent directories as needed. " + "Overwrites the file if it exists.", + "Create or overwrite a file, creating parent dirs.", + "create or overwrite a file", + NULL, false}, + {"run_shell", + "Run a shell command via sh -c and return stdout/stderr and a " + "status note on failure. Use it for listing and searching too " + "(ls, rg, grep, find).", + "Run a shell command via sh -c (120s timeout, kills the process " + "tree).", + "run a shell command (`sh -c`) in the working directory", + display_run_shell, true}, + {"use_skill", + "Load the full instructions of a .agents skill by its id.", + "Load a .agents skill's full instructions by id.", + "load a .agents skill's full instructions by id", + NULL, false}, + {"search_memories", + "Search .agents memories by keyword or tag (ranked).", + "Search .agents memories (ranked, fuzzy) by keyword or tag.", + "search .agents memories by keyword or tag (ranked)", + NULL, false}, + {"embed", + "Embed a text into a vector via the configured embeddings endpoint. " + "Returns `{\"dimensions\":N,\"vector\":[...]}`. Use it for semantic " + "comparison: embed a query and candidates, then rank by cosine " + "similarity.", + "Embed text into a vector (configured `embedding` profile).", + "embed text into a vector for semantic comparison", + NULL, true}, + {"llm", + "One-shot LLM call: send `prompt` as a single user message to the " + "session's model and return the reply. Optional generation " + "parameters: `model` override, `temperature` (0..2), `max_tokens`, " + "`reasoning_effort` (low/medium/high), `thinking_budget` (tokens), " + "and `response_schema` (a JSON Schema the reply must match; the " + "model is instructed to answer with one conforming JSON object). " + "`embed: true` embeds the prompt instead and returns " + "`{\"dimensions\":N,\"vector\":[...]}`.", + "One-shot LLM call (JSON schema, reasoning params, or embed mode).", + "make a one-shot LLM call (JSON schema, reasoning params, embed mode)", + NULL, false}, + {"compact", + "Compress the conversation context now: summarize the earlier turns " + "into a compact summary and re-inject recent file reads. Use it when " + "the context is large and mostly settled, or before a long new task.", + "Summarize earlier context to free tokens (model-invoked compaction).", + "compact the conversation context (summarize the earlier turns)", + NULL, true}, +}; + +size_t tool_builtin_count(void) { + return sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]; +} + +const char *tool_builtin_name(size_t i) { + return i < tool_builtin_count() ? TOOL_DEFS[i].name : NULL; +} + +const tool_display *tool_display_for(const char *name) { + if (!name) + return NULL; + static tool_display table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]]; + static bool built = false; + if (!built) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + table[i].name = TOOL_DEFS[i].name; + table[i].render_call = TOOL_DEFS[i].render_call; + table[i].dim_result = TOOL_DEFS[i].dim_result; + } + built = true; + } + for (size_t i = 0; i < tool_builtin_count(); i++) + if (xstreq(table[i].name, name)) + return &table[i]; + return NULL; +} + +const char *tool_desc(const char *name) { + for (size_t i = 0; i < tool_builtin_count(); i++) + if (xstreq(TOOL_DEFS[i].name, name)) + return TOOL_DEFS[i].wire; + return ""; +} + +/* The JSON schema for a built-in tool's parameters (fresh object). */ +cJSON *tool_params_json(const char *name) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "type", "object"); + cJSON *properties = cJSON_AddObjectToObject(o, "properties"); + cJSON *required = cJSON_AddArrayToObject(o, "required"); + if (xstreq(name, "read_file")) { + cJSON *p = cJSON_AddObjectToObject(properties, "path"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", + "Path to read (relative to the working directory, or absolute)."); + cJSON *offset = cJSON_AddObjectToObject(properties, "offset"); + cJSON_AddStringToObject(offset, "type", "integer"); + cJSON_AddNumberToObject(offset, "minimum", 1); + cJSON_AddStringToObject(offset, "description", + "Optional 1-indexed line to start from (defaults to the first line)."); + cJSON *limit = cJSON_AddObjectToObject(properties, "limit"); + cJSON_AddStringToObject(limit, "type", "integer"); + cJSON_AddNumberToObject(limit, "minimum", 1); + cJSON_AddStringToObject(limit, "description", + "Optional maximum number of lines to read (defaults to the whole file)."); + cJSON_AddItemToArray(required, cJSON_CreateString("path")); + } else if (xstreq(name, "write_file")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "path"), + "type", "string"); + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "content"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("path")); + cJSON_AddItemToArray(required, cJSON_CreateString("content")); + } else if (xstreq(name, "run_shell")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "command"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("command")); + } else if (xstreq(name, "use_skill")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "id"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("id")); + } else if (xstreq(name, "search_memories")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "query"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("query")); + } else if (xstreq(name, "embed")) { + cJSON *p = cJSON_AddObjectToObject(properties, "text"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", + "The text to embed (a sentence, paragraph, or document excerpt)."); + cJSON_AddItemToArray(required, cJSON_CreateString("text")); + } else if (xstreq(name, "llm")) { + cJSON *p = cJSON_AddObjectToObject(properties, "prompt"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", "The user message to send to the model in the one-shot call."); + p = cJSON_AddObjectToObject(properties, "model"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", "Optional model name override (defaults to the session model)."); + p = cJSON_AddObjectToObject(properties, "temperature"); + cJSON_AddStringToObject(p, "type", "number"); + cJSON_AddNumberToObject(p, "minimum", 0); + cJSON_AddNumberToObject(p, "maximum", 2); + cJSON_AddStringToObject(p, "description", "Optional sampling temperature (0..2)."); + p = cJSON_AddObjectToObject(properties, "max_tokens"); + cJSON_AddStringToObject(p, "type", "integer"); + cJSON_AddNumberToObject(p, "minimum", 1); + cJSON_AddStringToObject(p, "description", "Optional cap on generated tokens."); + p = cJSON_AddObjectToObject(properties, "reasoning_effort"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON *effort = cJSON_CreateArray(); + cJSON_AddItemToArray(effort, cJSON_CreateString("low")); + cJSON_AddItemToArray(effort, cJSON_CreateString("medium")); + cJSON_AddItemToArray(effort, cJSON_CreateString("high")); + cJSON_AddItemToObject(p, "enum", effort); + cJSON_AddStringToObject(p, "description", "Optional reasoning effort: low, medium, or high."); + p = cJSON_AddObjectToObject(properties, "thinking_budget"); + cJSON_AddStringToObject(p, "type", "integer"); + cJSON_AddNumberToObject(p, "minimum", 1); + cJSON_AddStringToObject(p, "description", "Optional extended-thinking budget in tokens."); + p = cJSON_AddObjectToObject(properties, "response_schema"); + cJSON_AddStringToObject(p, "type", "object"); + cJSON_AddStringToObject(p, "description", "Optional JSON Schema the reply must match; the model answers with a single conforming JSON object."); + p = cJSON_AddObjectToObject(properties, "embed"); + cJSON_AddStringToObject(p, "type", "boolean"); + cJSON_AddStringToObject(p, "description", "Embed the prompt instead of calling the model (returns a vector)."); + cJSON_AddItemToArray(required, cJSON_CreateString("prompt")); + } + return o; +} + +/* The "Available tools:" section of the default system preamble. */ +char *tool_preamble_list(void) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "Available tools:\n"); + for (size_t i = 0; i < tool_builtin_count(); i++) + xbuf_fmt(&b, "- %s: %s\n", TOOL_DEFS[i].name, TOOL_DEFS[i].preamble); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +size_t tool_list_entries(const tool_entry **out) { + static tool_entry table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]]; + static bool built = false; + if (!built) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + table[i].name = TOOL_DEFS[i].name; + table[i].desc = TOOL_DEFS[i].display; + } + built = true; + } + *out = table; + return tool_builtin_count(); +} |