#include "tools.h" #include "escape.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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//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, `$ ` * (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(); }