summaryrefslogtreecommitdiffstats
path: root/src/tools.cpp
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /src/tools.cpp
initial commitHEADmaster
Diffstat (limited to 'src/tools.cpp')
-rw-r--r--src/tools.cpp1341
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 = &part;
+ 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();
+}