diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c | |
64 files changed, 29432 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56c9003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Build outputs +/llsh +/src/*.o +/tests/test_* +!/tests/test_*.c +!/tests/test_*.cpp +/tests/*.o +/build-asan/ +/vg.log + +# Generated vendor build trees (sources are kept in vendor/src/*.tar.gz) +/vendor/build/ +/vendor/src/_extract/ +/vendor/bin/ +/vendor/include/ +/vendor/lib/ +/vendor/libstdc++/ +/vendor/musl-1.2.5/ +/vendor/musl/ + +# Local editor and debug artifacts +/.debug.json.swp +/.ruff_cache/ +/debug.json @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 llsh contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..04edb8c --- /dev/null +++ b/Makefile @@ -0,0 +1,250 @@ +# llsh — host-toolchain build with locally built static library archives. +# +# Supported target: x86_64 Linux. The bundled source archives are compiled +# with the host GCC toolchain and linked against the system glibc/static C++ +# runtime. No musl toolchain or musl shim is used. + +SHELL ?= /bin/sh + +ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +VENDOR := $(ROOT)/vendor +VEXT := $(VENDOR)/src/_extract +BUILD := $(VENDOR)/build +ZLIB_D := $(BUILD)/zlib +OPENSSL_D := $(BUILD)/openssl +CURL_D := $(BUILD)/curl +CJSON_D := $(BUILD)/cjson +NLOHMANN_D := $(BUILD)/nlohmann +SYSRT := $(BUILD)/system-runtime +SYSRT_LINKS := $(SYSRT)/linker-scripts +GCC_TRIPLE := $(shell $(CXX) -dumpmachine) +GCC_MAJOR := $(shell $(CXX) -dumpversion | cut -d. -f1) +SYS_GCC_LIBDIR := $(SYSRT)/usr/lib/gcc/$(GCC_TRIPLE)/$(GCC_MAJOR) + +CC ?= gcc +CXX ?= g++ +AR ?= ar +RANLIB ?= ranlib +STATIC ?= 1 + +CFLAGS ?= -D_GNU_SOURCE -std=gnu11 -Os -g -Wall -Wextra \ + -Wno-unused-parameter -ffunction-sections -fdata-sections +CXXFLAGS ?= -D_GNU_SOURCE -std=gnu++20 -Os -g -Wall -Wextra \ + -Wno-unused-parameter -Wno-write-strings \ + -Wno-missing-field-initializers \ + -ffunction-sections -fdata-sections + +CPPFLAGS += -I. -Isrc -Iinclude \ + -I$(ZLIB_D)/include \ + -I$(OPENSSL_D)/include \ + -I$(CURL_D)/include \ + -I$(CJSON_D)/include \ + -I$(NLOHMANN_D)/include + +VLIBS := \ + $(CURL_D)/lib/libcurl.a \ + $(OPENSSL_D)/lib64/libssl.a \ + $(OPENSSL_D)/lib64/libcrypto.a \ + $(ZLIB_D)/lib/libz.a \ + $(CJSON_D)/lib/libcjson.a + +LIBS := -lm -ldl -pthread +ifeq ($(STATIC),1) +LINK_MODE := -static +RUNTIME_TARGET := static-runtime +STATIC_LIB_DIRS := -L$(SYSRT_LINKS) -L$(SYSRT)/usr/lib64 \ + -L$(SYS_GCC_LIBDIR) +else +LINK_MODE := +RUNTIME_TARGET := +STATIC_LIB_DIRS := +endif + +BIN := llsh +MAIN := src/main.cpp +MAIN_OBJ := src/main.o +LIB_SRC_CPP := $(wildcard src/*.cpp) +LIB_SRC_C := src/escape.c +LIB_SRC := $(LIB_SRC_CPP) $(LIB_SRC_C) +LIB_OBJ := $(LIB_SRC_CPP:.cpp=.o) $(LIB_SRC_C:.c=.o) +LIB_OBJ_NO_MAIN := $(filter-out $(MAIN_OBJ),$(LIB_OBJ)) + +TEST_SRC_CPP := $(wildcard tests/test_*.cpp) +TEST_SRC_C := tests/test_frontmatter.c tests/test_dotagents.c \ + tests/test_quine.c tests/test_clio.c +TEST_SRC := $(TEST_SRC_CPP) $(TEST_SRC_C) +TEST_BIN := $(TEST_SRC_CPP:.cpp=) $(TEST_SRC_C:.c=) +LINKED_TESTS := $(TEST_SRC_CPP:.cpp=) + +HEADERS := $(wildcard src/*.h) $(wildcard include/*.h) + +ASAN_CC ?= clang +ASAN_CXX ?= clang++ +ASAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer +ASANDIR := build-asan +LIB_SRC_NO_MAIN := $(filter-out $(MAIN),$(LIB_SRC)) + +.PHONY: all test valgrind asan clean vendor vendor-deps vendor-clean static-runtime + +all: $(BIN) $(TEST_BIN) + +# --------------------------------------------------------------- dependencies +# Builds the bundled upstream sources with the host compiler. These are +# ordinary glibc-targeting static archives; the final link uses host glibc. +vendor-deps: + @set -e; \ + mkdir -p "$(VEXT)"; \ + for t in $(VENDOR)/src/*.tar.gz; do \ + b=$$(basename $$t .tar.gz); \ + [ -d "$(VEXT)/$$b" ] || tar xzf "$$t" -C "$(VEXT)"; \ + done; \ + if [ ! -f "$(NLOHMANN_D)/include/nlohmann/json.hpp" ]; then \ + mkdir -p "$(NLOHMANN_D)"; \ + cp -r "$(VEXT)/nlohmann-json-3.12.0/include" "$(NLOHMANN_D)/"; \ + fi; \ + if [ ! -f "$(ZLIB_D)/lib/libz.a" ]; then \ + cd "$(VEXT)/zlib-1.3.1"; \ + make distclean >/dev/null 2>&1 || true; \ + CC="$(CC)" CFLAGS="-Os -ffunction-sections -fdata-sections" \ + ./configure --prefix="$(ZLIB_D)" --static >/dev/null; \ + make -j4 >/dev/null; make install >/dev/null; \ + fi; \ + if [ ! -f "$(OPENSSL_D)/lib64/libcrypto.a" ]; then \ + cd "$(VEXT)/openssl-3.3.2"; \ + CC="$(CC)" CFLAGS="-Os -fPIC" AR="$(AR)" RANLIB="$(RANLIB)" \ + ./Configure linux-x86_64 no-shared no-tests no-dso \ + --prefix="$(OPENSSL_D)" >/dev/null; \ + make -j4 >/dev/null; make install_sw >/dev/null; \ + fi; \ + if [ ! -f "$(CJSON_D)/lib/libcjson.a" ]; then \ + mkdir -p "$(CJSON_D)/include/cjson" "$(CJSON_D)/lib"; \ + "$(CC)" -Os -ffunction-sections -fdata-sections -fPIC \ + -c "$(VEXT)/cJSON-1.7.19/cJSON.c" -o "$(CJSON_D)/lib/cJSON.o"; \ + "$(AR)" rcs "$(CJSON_D)/lib/libcjson.a" "$(CJSON_D)/lib/cJSON.o"; \ + "$(RANLIB)" "$(CJSON_D)/lib/libcjson.a"; \ + cp "$(VEXT)/cJSON-1.7.19/cJSON.h" "$(CJSON_D)/include/cjson/"; \ + fi; \ + if [ ! -f "$(CURL_D)/lib/libcurl.a" ]; then \ + cd "$(VEXT)/curl-8.10.1"; \ + CC="$(CC)" CFLAGS="-Os -ffunction-sections -fdata-sections" \ + LDFLAGS="-Wl,--gc-sections" \ + ./configure --host=x86_64-linux --prefix="$(CURL_D)" \ + --disable-shared --enable-static \ + --with-ssl="$(OPENSSL_D)" --with-zlib="$(ZLIB_D)" \ + --disable-ftp --disable-rtsp --disable-dict --disable-telnet \ + --disable-imap --disable-pop3 --disable-smtp --disable-ldap \ + --without-proxy --without-metalink --without-libpsl \ + --without-fish --without-gssapi --without-brotli --without-zstd \ + --without-libssh2 --without-nghttp2 --without-ngtcp2 \ + --without-libidn2 --without-libgsasl >/dev/null; \ + make -j4 >/dev/null; make install >/dev/null; \ + fi + +# Fedora does not install the static system C/C++ runtimes by default. Fetch +# the matching distro packages into the ignored build tree (no system install +# or sudo required) and link against those package archives. +static-runtime: + @set -e; \ + mkdir -p "$(SYSRT)"; \ + if [ ! -f "$(SYSRT)/usr/lib64/libc.a" ] || \ + [ ! -f "$(SYS_GCC_LIBDIR)/libstdc++.a" ]; then \ + glibc_ver=$$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}' glibc); \ + gcc_ver=$$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}' libstdc++); \ + dnf download --destdir="$(SYSRT)" \ + "glibc-static-$$glibc_ver" "libstdc++-static-$$gcc_ver" >/dev/null; \ + for r in "$(SYSRT)"/*.rpm; do \ + rpm2cpio "$$r" | (cd "$(SYSRT)" && cpio -idm --quiet 2>/dev/null); \ + done; \ + fi; \ + mkdir -p "$(SYSRT_LINKS)"; \ + sed "s#/usr/lib64/#$(SYSRT)/usr/lib64/#g" \ + "$(SYSRT)/usr/lib64/libm.a" > "$(SYSRT_LINKS)/libm.a"; \ + test -f "$(SYSRT)/usr/lib64/libc.a"; \ + test -f "$(SYS_GCC_LIBDIR)/libstdc++.a" + +vendor: vendor-deps + +# ------------------------------------------------------------------- app +$(BIN): $(MAIN_OBJ) $(LIB_OBJ_NO_MAIN) | vendor-deps $(RUNTIME_TARGET) + $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LINK_MODE) $(STATIC_LIB_DIRS) -o $@ $^ \ + $(VLIBS) $(LIBS) -Wl,--gc-sections -s -Wl,--build-id=none + +src/%.o: src/%.cpp $(HEADERS) | vendor-deps + $(CXX) $(CXXFLAGS) $(CPPFLAGS) -c $< -o $@ + +src/%.o: src/%.c $(HEADERS) | vendor-deps + $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@ + +tests/%.o: tests/%.cpp $(HEADERS) | vendor-deps + $(CXX) $(CXXFLAGS) $(CPPFLAGS) -c $< -o $@ + +tests/%.o: tests/%.c $(HEADERS) | vendor-deps + $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@ + +# Header-only portability tests. +tests/test_frontmatter: tests/test_frontmatter.o | vendor-deps $(RUNTIME_TARGET) + $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE) + +tests/test_dotagents: tests/test_dotagents.o | vendor-deps $(RUNTIME_TARGET) + $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE) + +tests/test_clio: tests/test_clio.o | vendor-deps $(RUNTIME_TARGET) + $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE) + +tests/test_quine: tests/test_quine.c | vendor-deps $(RUNTIME_TARGET) + $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE) + +$(LINKED_TESTS): %: %.o $(LIB_OBJ_NO_MAIN) | vendor-deps $(RUNTIME_TARGET) + $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LINK_MODE) $(STATIC_LIB_DIRS) -o $@ \ + $< $(LIB_OBJ_NO_MAIN) $(VLIBS) $(LIBS) -Wl,--gc-sections -s + +test: all + @set -e; for t in $(TEST_BIN); do echo "== $$t"; ./$$t; done; \ + echo "ALL TESTS PASSED" + +valgrind: all + @set -e; \ + for t in $(TEST_BIN); do \ + echo "== $$t"; \ + valgrind --quiet --leak-check=full --error-exitcode=9 \ + --suppressions=valgrind.supp ./$$t >/dev/null; \ + done; \ + echo "VALGRIND CLEAN" + +# The sanitizer executable uses dynamic host runtimes by design; application +# and dependency code are instrumented independently in a scratch directory. +asan: vendor-deps + @rm -rf $(ASANDIR); mkdir -p $(ASANDIR) + @set -e; \ + libs=""; \ + for f in $(LIB_SRC_NO_MAIN); do \ + b=$$(basename $$f); b=$${b%.c}; b=$${b%.cpp}; \ + case $$f in \ + *.cpp) $(ASAN_CXX) $(CXXFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.o ;; \ + *) $(ASAN_CC) $(CFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.o ;; \ + esac; \ + libs="$$libs $(ASANDIR)/$$b.o"; \ + done; \ + for f in $(TEST_SRC); do \ + b=$$(basename $$f); b=$${b%.c}; b=$${b%.cpp}; \ + case $$f in \ + *.cpp) $(ASAN_CXX) $(CXXFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.test.o ;; \ + *) $(ASAN_CC) $(CFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.test.o ;; \ + esac; \ + case $$b in \ + test_clio) $(ASAN_CXX) $(ASAN_FLAGS) -o $(ASANDIR)/$$b.test $(ASANDIR)/$$b.test.o $(LIBS) ;; \ + *) $(ASAN_CXX) $(ASAN_FLAGS) -o $(ASANDIR)/$$b.test $(ASANDIR)/$$b.test.o $$libs $(VLIBS) $(LIBS) ;; \ + esac; \ + echo "== $$b"; \ + ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \ + $(ASANDIR)/$$b.test >/dev/null; \ + done; \ + echo "ASAN CLEAN" + +clean: + rm -f $(LIB_OBJ) $(TEST_BIN) tests/*.o $(BIN) vg.log + rm -rf $(ASANDIR) + +vendor-clean: + rm -rf $(BUILD) $(VEXT) diff --git a/README.md b/README.md new file mode 100644 index 0000000..08a24ec --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# llsh + +`llsh` is a statically linked terminal LLM agent for Linux. It supports +OpenAI-compatible APIs, Anthropic, Ollama, shell and file tools, session +history, skills and memories from `.agents/`, and optional MCP servers. + +## Build + +The repository includes source archives for curl, OpenSSL, zlib, cJSON, and +nlohmann/json. The host GCC toolchain builds their static archives. On x86_64 +Fedora, install GNU Make, GCC/G++, Perl, `rpm`, `dnf`, `rpm2cpio`, and `cpio`, +and allow access to Fedora repositories. The build downloads matching +`glibc-static` and `libstdc++-static` RPMs into the ignored build directory; +it does not install packages system-wide. Then run: + +```sh +make +``` + +The default build creates a statically linked `./llsh` executable. To build +and run the test suite: + +```sh +make test +``` + +Optional checks are `make asan` (requires Clang) and `make valgrind` (requires +Valgrind). If static glibc/libstdc++ packages are unavailable, run tests with +the host shared runtimes: + +```sh +make STATIC=0 test +``` + +`make clean` removes application/test outputs; `make vendor-clean` removes +generated third-party archives so they can be rebuilt from the bundled +sources. + +## Configure a model + +The simplest invocation supplies a provider and its credentials: + +```sh +./llsh --provider openai --api-key "$OPENAI_API_KEY" "Explain this project" +``` + +The CLI supports `openai`, `anthropic`, and `ollama`. OpenAI-compatible +endpoints can be selected with `--base-url`; Ollama defaults to +`http://localhost:11434` and does not require an API key. + +For repeat use, configure `.agents/models.json` with a default preset: + +```json +{ + "default": "work", + "presets": { + "work": { + "provider": "openai", + "model": "gpt-5.2", + "api_key": "replace-with-a-key" + } + } +} +``` + +Keep credentials out of committed project files. CLI options override preset +values. See `./llsh --help` for the complete list of options. + +When started without a prompt in a terminal, `llsh` opens an interactive REPL. +With a prompt argument, it runs one turn and exits: + +```sh +./llsh "Summarize the files in this directory" +``` + +The interactive commands include `/help`, `/model`, `/agent`, `/save`, +`/export`, `/compact`, and `/exit`. The shell can be entered with Backtab. + +## Tools and workspace configuration + +The built-in tools are `read_file`, `write_file`, `run_shell`, `use_skill`, +`search_memories`, `embed`, `llm`, and `compact`. File and shell operations +use the process working directory; `-C, --cwd` selects it. + +Model presets in `.agents/models.json` configure model connections and model +limits; they do not configure shell policy. Put llsh runtime tool settings in +`.agents/speakmcp-settings.json` instead: + +```json +{ + "shell_allowlist": ["git", "rg", "cat"], + "shell_denylist": ["git push", "rm"], + "confirm_destructive": true, + "shell_timeout_secs": 45, + "max_output_chars": 9000, + "max_file_bytes": 700000 +} +``` + +The global `~/.agents/speakmcp-settings.json` values are shallow-merged with +workspace settings; workspace keys override global keys. Explicit CLI shell +policy and limit flags override settings. + +llsh is not a sandbox: tool calls run with the current user's permissions, +and absolute file paths are accepted. Shell command allow/deny policies can +be supplied with repeatable CLI options. Destructive-command confirmation is +available in the interactive REPL: + +```sh +./llsh --shell-allow git --shell-allow rg --shell-deny git\ push \ + --confirm-destructive "Inspect the repository" +``` + +The destructive-command detector is heuristic; use an operating-system +sandbox when commands must be restricted to a security boundary. + +Workspace and global agent configuration can be loaded from `.agents/` and +`~/.agents/`. Supported content includes `agents.md`, `system-prompt.md`, +`models.json`, MCP server configuration, skills, sub-agent profiles, tasks, +and memories. MCP server commands are not started unless explicitly trusted: + +```sh +./llsh --allow-mcp "Use the configured project tools" +``` + +MCP tools are exposed with qualified names of the form +`mcp__<server-name-length>_<server-name>__<tool-name>`. Use that name in a +sub-agent's tool allowlist. + +## Platform and release scope + +The build targets x86_64 Linux and uses the host glibc ABI. Although linked +statically, glibc's name-service lookups can load NSS shared libraries at +runtime; use the matching glibc runtime on the target system. HTTPS requests +also require a usable system CA certificate bundle. Other platforms and +architectures are not currently supported by the build. +Third-party components and their license families are listed in +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). + +## License + +The llsh project is licensed under the MIT License; see [`LICENSE`](LICENSE). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..fa06b05 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,34 @@ +# Release notes + +## 0.1.0 + +Initial release of `llsh`, a static Linux terminal LLM agent. + +### Included + +* Interactive and one-shot CLI modes with OpenAI-compatible, Anthropic, and + Ollama providers. +* Built-in file, shell, embedding, one-shot LLM, skill, memory-search, and + context-compaction tools. +* `.agents/` support for model presets, system prompts, skills, sub-agents, + tasks, memories, and MCP configuration. +* Session save/resume, transcript export, context management, and goal loops. +* Bundled third-party sources, host GCC build support, and a static + x86_64 Linux executable. + +### Security and compatibility notes + +* MCP servers from `.agents/mcp.json` require explicit opt-in with + `--allow-mcp`. +* MCP tools use qualified names of the form + `mcp__<server-name-length>_<server-name>__<tool-name>`. +* Model tool execution enforces the active profile's tool allowlist and the + shell policy gate. +* The executable targets x86_64 Linux and uses the host glibc ABI. Glibc name + service lookups may use NSS shared libraries at runtime; HTTPS requests also + require a usable system CA certificate bundle. + +### Verification + +A clean vendor-cache rebuild completed with `make test`. `make asan` also +passed in the development environment. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..1c8fff9 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +llsh bundles source archives and build inputs for the following components. +Refer to the license files included in each upstream source archive for the +full license terms. + +Component | Version | License +--------- | ------- | ------- +musl libc | 1.2.5 | MIT +zlib | 1.3.1 | zlib License +cJSON | 1.7.19 | MIT +OpenSSL | 3.3.2 | Apache-2.0 +curl | 8.10.1 | curl License (MIT-like) +nlohmann/json | 3.12.0 | MIT +GNU glibc static runtime | host-matched Fedora package | LGPL-2.1-or-later and additional package component licenses +GNU libstdc++ static runtime | host-matched Fedora package | GPL-3.0-or-later with GCC Runtime Library Exception + +The build downloads matching Fedora `glibc-static` and `libstdc++-static` +packages into the ignored `vendor/build/system-runtime/` directory; these RPMs +are not bundled as source-tree inputs. Package metadata lists the complete +license set. Source RPM NEVRAs are host-version-specific; obtain the matching +`glibc` and `gcc` source RPMs from the configured Fedora source repositories +for corresponding source and license texts. + +Older vendored musl and libstdc++ artifacts remain in `vendor/` for reference; +the current Makefile does not use them. diff --git a/include/clio.h b/include/clio.h new file mode 100644 index 0000000..8899197 --- /dev/null +++ b/include/clio.h @@ -0,0 +1,1493 @@ +/* clio.h — minimal pseudo-TUI layout layer over escape.h. + * + * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns + * layout: where text goes, how it wraps and clips, and how a composed + * frame is blitted to the terminal with the minimal diff of escape + * sequences. Apps never emit raw escape codes themselves. + * + * Model: + * clio_term the real terminal: size, capabilities, raw mode, alt + * screen, cursor, bracketed-paste/kitty state, key queue, + * and the last flushed frame (for diffing). + * clio_buf an in-memory cell grid (the canvas). Apps compose frames + * here; nothing is written until clio_flush. + * clio_flush diff the canvas against the previous frame and blit only + * changed rows through escape.h. Non-tty (pipe, CI) falls + * back to plain text lines so piped output stays clean. + * input a pure, chunk-fed key decoder (testable without a tty), + * raw-mode key reading with a bounded queue, and a + * single-line editor (clio_readline) that renders into the + * canvas. + * + * Conventions: C99, header-only (static inline), no global state, + * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a + * documented limitation, as in escape.h. + * + * Usage: #include "clio.h". The header is complete on its own; escape.h + * is pulled in for the styling layer. The esc_* function symbols need one + * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the + * test TU itself for zero-link builds). + * + * Layout is column-based: one cell per column, one code point per cell. + * Wide characters (width 2) occupy one cell; a wide character landing + * in the last column may be clipped by the terminal (v1 limitation). + * + * API: + * style: clio_style, clio_style_default, clio_style_eq + * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width, + * clio_text_width, clio_char_start, clio_char_end, + * clio_word_back + * canvas: clio_buf, clio_cell, clio_buf_init/free/clear, + * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region, + * clio_buf_put, clio_buf_box, clio_buf_scroll_up + * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init, + * clio_keydec_feed, clio_keydec_flush + * terminal: clio_term, clio_color_policy, clio_term_init, + * clio_term_resize, clio_term_raw_on/off, + * clio_term_alt_on/off, clio_term_cursor_hide/show, + * clio_term_paste_on/off, clio_term_kitty_on/off, + * clio_term_bell, clio_term_shutdown, clio_term_free + * output: clio_fstyle, clio_flush, clio_cursor + * input: clio_next_key, clio_next_key_stdin, clio_read_key, + * clio_readline + * + * Concurrency: no shared state; each clio_term is independent. + */ +#ifndef CLIO_H +#define CLIO_H + +#include "escape.h" +#include "type.h" + +#include <ctype.h> +#include <limits.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <unistd.h> +#include <wchar.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Style */ +/* ------------------------------------------------------------------ */ + +/* Attribute bits (OR-combined into clio_style.attr). */ +enum { + CLIO_BOLD = 1u, + CLIO_DIM = 2u, + CLIO_ITALIC = 4u, + CLIO_UNDERLINE = 8u, + CLIO_REVERSE = 16u, + CLIO_STRIKE = 32u, +}; + +/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index. + * attr: OR of CLIO_* bits. */ +typedef struct clio_style { + int fg; + int bg; + unsigned attr; +} clio_style; + +static inline clio_style clio_style_default(void) { + clio_style s; + s.fg = -1; + s.bg = -1; + s.attr = 0; + return s; +} + +static inline int clio_style_eq(const clio_style *a, const clio_style *b) { + return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr; +} + +/* ------------------------------------------------------------------ */ +/* Text (UTF-8 + display width) */ +/* ------------------------------------------------------------------ */ + +/* Decode the next code point in [p, end); returns the byte length used. + * Truncated or invalid sequences still consume at least one byte. */ +static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) { + unsigned char b = (unsigned char) p[0]; + size_t n; + if (b < 0x80) + n = 1; + else if ((b & 0xE0) == 0xC0) + n = 2; + else if ((b & 0xF0) == 0xE0) + n = 3; + else if ((b & 0xF8) == 0xF0) + n = 4; + else { + *cp = 0; + return 1; + } + if ((size_t)(end - p) < n) + n = (size_t)(end - p); + u32 c = 0; + for (size_t i = 0; i < n; i++) { + unsigned char ch = (unsigned char) p[i]; + unsigned int mask = + (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u) + : 0x3Fu; + c = (c << 6) | (u32)(ch & mask); + } + *cp = c; + return n; +} + +static inline size_t clio_utf8_next(const char *s, u32 *cp) { + return clio_utf8_next_n(s, s + strlen(s), cp); +} + +/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */ +static inline int clio_utf8_enc(u32 cp, char *out) { + size_t n; + if (cp < 0x80) + n = 1; + else if (cp < 0x800) + n = 2; + else if (cp < 0x10000) + n = 3; + else if (cp <= 0x10FFFF) + n = 4; + else + return -1; + if (cp >= 0xD800 && cp <= 0xDFFF) + return -1; + unsigned char b[4]; + switch (n) { + case 1: + b[0] = (unsigned char) cp; + break; + case 2: + b[0] = (unsigned char)(0xC0 | (cp >> 6)); + b[1] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + case 3: + b[0] = (unsigned char)(0xE0 | (cp >> 12)); + b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + b[2] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + default: + b[0] = (unsigned char)(0xF0 | (cp >> 18)); + b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F)); + b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + b[3] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + } + memcpy(out, b, n); + out[n] = '\0'; + return (int) n; +} + +/* Display width of a code point, locale-independent (wcwidth is not: + * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the + * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth + * + common emoji), 1 = everything else. Simplified W=|F table. */ +static inline size_t clio_cp_width(u32 cp) { + if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F)) + return 0; + if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D || + cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F)) + return 0; + if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) || + (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) || + (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) || + (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) || + (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) || + (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) || + (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD)) + return 2; + return 1; +} + +static inline size_t clio_text_width_n(const char *s, size_t n) { + size_t w = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + u32 cp; + size_t len = clio_utf8_next_n(p, end, &cp); + if (cp != 0) + w += clio_cp_width(cp); + p += len; + } + return w; +} + +static inline size_t clio_text_width(const char *s) { + return clio_text_width_n(s, strlen(s)); +} + +/* Byte offset of the start of the code point containing pos. */ +static inline size_t clio_char_start(const char *s, size_t pos) { + if (pos == 0) + return 0; + size_t i = pos; + while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80) + i--; + return i; +} + +/* Byte offset of the start of the code point immediately before pos + * (pos is a cursor position, i.e. a char boundary). */ +static inline size_t clio_char_before(const char *s, size_t pos) { + size_t i = pos; + if (i == 0) + return 0; + i--; /* last byte belongs to the previous code point */ + while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80) + i--; + return i; +} + +/* Byte offset just past the code point at pos. */ +static inline size_t clio_char_end(const char *s, size_t len, size_t pos) { + if (pos >= len) + return len; + u32 cp; + size_t n = clio_utf8_next_n(s + pos, s + len, &cp); + return pos + n; +} + +/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */ +static inline size_t clio_word_back(const char *s, size_t pos) { + size_t i = pos; + while (i > 0 && s[i - 1] == ' ') + i--; + while (i > 0 && (isalnum((unsigned char) s[i - 1]) || s[i - 1] == '_')) + i--; + while (i > 0 && s[i - 1] == ' ') + i--; + return i; +} + +/* ------------------------------------------------------------------ */ +/* Canvas (cell grid) */ +/* ------------------------------------------------------------------ */ + +/* One column of a row. ch is a code point (0 = blank slot; bg/attr still + * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell + * is {0, 0xFFFF, 0xFFFF, 0}. */ +typedef struct clio_cell { + u32 ch; + u16 fg; + u16 bg; + u16 attr; +} clio_cell; + +typedef struct clio_buf { + clio_cell *cells; + size_t rows; + size_t cols; +} clio_buf; + +static inline u16 clio_style_u16(int v) { + return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v)); +} + +static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) { + c->ch = ch; + c->fg = clio_style_u16(st.fg); + c->bg = clio_style_u16(st.bg); + c->attr = (u16) st.attr; +} + +static inline clio_style clio_cell_style(const clio_cell *c) { + clio_style s; + s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg; + s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg; + s.attr = c->attr; + return s; +} + +static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) { + return memcmp(a, b, sizeof(*a)) == 0; +} + +static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) { + if (rows == 0) + rows = 1; + if (cols == 0) + cols = 1; + b->rows = rows; + b->cols = cols; + b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell)); + if (!b->cells) { + b->rows = b->cols = 0; + return -1; + } + for (size_t i = 0; i < rows * cols; i++) { + b->cells[i].fg = 0xFFFF; + b->cells[i].bg = 0xFFFF; + } + return 0; +} + +static inline void clio_buf_free(clio_buf *b) { + free(b->cells); + b->cells = NULL; + b->rows = b->cols = 0; +} + +static inline void clio_buf_clear(clio_buf *b) { + for (size_t i = 0; i < b->rows * b->cols; i++) { + b->cells[i].ch = 0; + b->cells[i].attr = 0; + b->cells[i].fg = 0xFFFF; + b->cells[i].bg = 0xFFFF; + } +} + +static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) { + if (row >= b->rows || col >= b->cols) + return NULL; + return &b->cells[row * b->cols + col]; +} + +static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1) { + if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows - 1) + r1 = b->rows - 1; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r <= r1; r++) + for (size_t c = c0; c <= c1; c++) { + clio_cell *cell = &b->cells[r * b->cols + c]; + cell->ch = 0; + cell->attr = 0; + cell->fg = 0xFFFF; + cell->bg = 0xFFFF; + } +} + +static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1, u32 ch, + clio_style st) { + if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows - 1) + r1 = b->rows - 1; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r <= r1; r++) + for (size_t c = c0; c <= c1; c++) + clio_cell_set(&b->cells[r * b->cols + c], ch, st); +} + +/* Write text at (row, col); wraps onto the next row (when wrap != 0 and + * rows remain) and clips at the buffer edges. '\n' moves to the next row + * at column 0. Zero-width code points occupy no cell. Returns the number + * of bytes written (including newlines). */ +static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col, + const char *text, clio_style st, int wrap) { + size_t written = 0; + size_t len = strlen(text); + const char *p = text; + while (p < text + len) { + u32 cp; + size_t n = clio_utf8_next_n(p, text + len, &cp); + p += n; + if (cp == '\n') { + row++; + col = 0; + if (row >= b->rows) + break; + written += n; + continue; + } + size_t w = clio_cp_width(cp); + if (w == 0) { + written += n; + continue; + } + if (col + w > b->cols) { + if (!wrap || row + 1 >= b->rows) + break; + row++; + col = 0; + } + for (size_t i = 0; i < w; i++) { + if (col + i >= b->cols) + break; + clio_cell *cell = &b->cells[row * b->cols + (col + i)]; + cell->ch = (i == 0) ? cp : 0; + cell->fg = clio_style_u16(st.fg); + cell->bg = clio_style_u16(st.bg); + cell->attr = (u16) st.attr; + } + col += w; + written += n; + } + return written; +} + +/* Draw a single-character border (ch, e.g. U+2502) around the rectangle + * (r0, c0) with width w and height h. */ +static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w, + size_t h, u32 ch, clio_style st) { + if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0) + return; + if (w > b->cols - c0) + w = b->cols - c0; + if (h > b->rows - r0) + h = b->rows - r0; + for (size_t x = 0; x < w; x++) { + clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st); + clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st); + } + for (size_t y = 1; y + 1 < h; y++) { + clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st); + clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st); + } +} + +/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom + * row becomes blank. */ +static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1) { + if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows) + r1 = b->rows; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r + 1 <= r1; r++) + memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0], + (c1 - c0 + 1) * sizeof(clio_cell)); + for (size_t c = c0; c <= c1; c++) { + clio_cell *cell = &b->cells[r1 * b->cols + c]; + cell->ch = 0; + cell->attr = 0; + cell->fg = 0xFFFF; + cell->bg = 0xFFFF; + } +} + +/* ------------------------------------------------------------------ */ +/* Key types + pure decoder */ +/* ------------------------------------------------------------------ */ + +typedef struct clio_key { + int kind; + u32 cp; /* CLIOK_CHAR: the code point */ +} clio_key; + +enum { + CLIOK_UNKNOWN = 0, + CLIOK_CHAR, + CLIOK_ENTER, + CLIOK_TAB, + CLIOK_BACKTAB, + CLIOK_BACKSPACE, + CLIOK_DELETE, + CLIOK_LEFT, + CLIOK_RIGHT, + CLIOK_UP, + CLIOK_DOWN, + CLIOK_HOME, + CLIOK_END, + CLIOK_PAGEUP, + CLIOK_PAGEDOWN, + CLIOK_ESC, + CLIOK_CTRL_A, + CLIOK_CTRL_C, + CLIOK_CTRL_D, + CLIOK_CTRL_E, + CLIOK_CTRL_K, + CLIOK_CTRL_L, + CLIOK_CTRL_U, + CLIOK_CTRL_W, + CLIOK_CTRL_Y, + CLIOK_CTRL_Z, + CLIOK_CTRL_G, +}; + +/* State machine for decoding raw terminal input into clio_key values. + * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries + * may split UTF-8 or escape sequences); state persists across feeds. */ +typedef struct clio_keydec { + int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */ + int utf8_n; + int utf8_need; + u32 utf8[4]; + int csi_n; + unsigned char csi[32]; + int pasting; +} clio_keydec; + +static inline void clio_keydec_init(clio_keydec *d) { + memset(d, 0, sizeof *d); +} + +static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) { + if (b == 0x1B) { + d->state = 1; + return 0; + } + if (b == 0x0D) { + k->kind = CLIOK_ENTER; + k->cp = 0; + return 1; + } + if (b == 0x0A) { + if (d->pasting) { + k->kind = CLIOK_CHAR; + k->cp = '\n'; + return 1; + } + return 0; /* bare LF outside paste: dead key */ + } + if (b == 0x7F || b == 0x08) { + k->kind = CLIOK_BACKSPACE; + k->cp = 0; + return 1; + } + if (b == 0x09) { + k->kind = CLIOK_TAB; + k->cp = 0; + return 1; + } + if (b < 0x20) { + switch (b) { + case 0x01u: + k->kind = CLIOK_CTRL_A; + break; + case 0x03u: + k->kind = CLIOK_CTRL_C; + d->pasting = 0; /* Ctrl+C interrupts a dangling paste */ + break; + case 0x04u: + k->kind = CLIOK_CTRL_D; + break; + case 0x05u: + k->kind = CLIOK_CTRL_E; + break; + case 0x0Bu: + k->kind = CLIOK_CTRL_K; + break; + case 0x0Cu: + k->kind = CLIOK_CTRL_L; + break; + case 0x15u: + k->kind = CLIOK_CTRL_U; + break; + case 0x17u: + k->kind = CLIOK_CTRL_W; + break; + case 0x19u: + k->kind = CLIOK_CTRL_Y; + break; + case 0x1Au: + k->kind = CLIOK_CTRL_Z; + break; + case 0x07u: + k->kind = CLIOK_CTRL_G; + break; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; + } + if (b < 0x80) { + k->kind = CLIOK_CHAR; + k->cp = b; + return 1; + } + if (b >= 0xC0 && b < 0xF8) { + d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2; + d->utf8[0] = b; + d->utf8_n = 1; + return 0; + } + if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) { + d->utf8[d->utf8_n++] = b; + if (d->utf8_n < d->utf8_need) + return 0; + int n = d->utf8_n; + d->utf8_n = 0; + u32 cp = + d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u); + for (int i = 1; i < n; i++) + cp = (cp << 6) | (d->utf8[i] & 0x3Fu); + k->kind = CLIOK_CHAR; + k->cp = cp; + return 1; + } + d->utf8_n = 0; + k->kind = CLIOK_UNKNOWN; + k->cp = b; + return 1; +} + +static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) { + d->state = 0; + if (b == '[') { + d->state = 2; + d->csi_n = 0; + return 0; + } + if (b == 'O') { + d->state = 3; + return 0; + } + if (b >= 0x20 && b <= 0x7E) { + k->kind = CLIOK_CHAR; + k->cp = b; + return 1; + } + k->kind = CLIOK_ESC; + k->cp = 0; + return 1; +} + +static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) { + if (d->csi_n < (int) sizeof d->csi) + d->csi[d->csi_n++] = b; + if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' || + b == '>') + return 0; + d->state = 0; + unsigned long num = 0; + int any = 0; + for (int i = 0; i < d->csi_n - 1; i++) { + unsigned char p = d->csi[i]; + if (p < '0' || p > '9') + break; + if (any) + num = num * 10 + (unsigned long)(p - '0'); + else + num = (unsigned long)(p - '0'); + any = 1; + if (num > 0xFFFFu) + break; + } + d->csi_n = 0; + switch (b) { + case 'A': + k->kind = CLIOK_UP; + k->cp = 0; + return 1; + case 'B': + k->kind = CLIOK_DOWN; + k->cp = 0; + return 1; + case 'C': + k->kind = CLIOK_RIGHT; + k->cp = 0; + return 1; + case 'D': + k->kind = CLIOK_LEFT; + k->cp = 0; + return 1; + case 'H': + k->kind = CLIOK_HOME; + k->cp = 0; + return 1; + case 'F': + k->kind = CLIOK_END; + k->cp = 0; + return 1; + case 'Z': + k->kind = CLIOK_BACKTAB; + k->cp = 0; + return 1; + case '~': + switch (num) { + case 1u: + case 7u: + k->kind = CLIOK_HOME; + break; + case 3u: + k->kind = CLIOK_DELETE; + break; + case 4u: + case 8u: + k->kind = CLIOK_END; + break; + case 5u: + k->kind = CLIOK_PAGEUP; + break; + case 6u: + k->kind = CLIOK_PAGEDOWN; + break; + case 200u: + d->pasting = 1; + return 0; + case 201u: + d->pasting = 0; + return 0; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; + case 'u': /* kitty keyboard protocol */ + switch (num) { + case 9u: + k->kind = CLIOK_TAB; + break; + case 12u: + k->kind = CLIOK_BACKSPACE; + break; + case 13u: + k->kind = CLIOK_ENTER; + break; + case 27u: + k->kind = CLIOK_ESC; + break; + case 127u: + k->kind = CLIOK_DELETE; + break; + case 0x2190u: + k->kind = CLIOK_LEFT; + break; + case 0x2191u: + k->kind = CLIOK_UP; + break; + case 0x2192u: + k->kind = CLIOK_RIGHT; + break; + case 0x2193u: + k->kind = CLIOK_DOWN; + break; + default: + k->cp = 0; + return 0; + } + k->cp = 0; + return 1; + case 'R': /* cursor position report: swallow */ + case 'M': + case 'h': + case 'l': + case 'm': + return 0; + default: + k->kind = CLIOK_UNKNOWN; + k->cp = 0; + return 1; + } +} + +static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) { + d->state = 0; + switch (b) { + case 'A': + k->kind = CLIOK_UP; + break; + case 'B': + k->kind = CLIOK_DOWN; + break; + case 'C': + k->kind = CLIOK_RIGHT; + break; + case 'D': + k->kind = CLIOK_LEFT; + break; + case 'H': + k->kind = CLIOK_HOME; + break; + case 'F': + k->kind = CLIOK_END; + break; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; +} + +static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) { + switch (d->state) { + case 1: + return clio_kd_escape(d, b, k); + case 2: + return clio_kd_csi(d, b, k); + case 3: + return clio_kd_ss3(d, b, k); + default: + return clio_kd_ground(d, b, k); + } +} + +/* Feed raw bytes; emits up to cap decoded keys into out; returns the + * number emitted. */ +static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in, + size_t n, clio_key *out, size_t cap) { + size_t emitted = 0; + for (size_t i = 0; i < n; i++) { + clio_key k; + if (clio_kd_step(d, in[i], &k) && emitted < cap) + out[emitted++] = k; + } + return emitted; +} + +/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */ +static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) { + if (d->state != 1) + return 0; + d->state = 0; + k->kind = CLIOK_ESC; + k->cp = 0; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* Terminal state */ +/* ------------------------------------------------------------------ */ + +#define CLIO_KEYQ 32 + +typedef struct clio_term { + int is_tty; + int color; /* clio_color_policy() result */ + size_t rows; + size_t cols; + int raw; + struct termios saved; + int alt; + int cursor_hidden; + int paste; + int kitty; + clio_keydec dec; + clio_key q[CLIO_KEYQ]; + size_t qh; + size_t qt; + clio_buf last; + int has_last; +} clio_term; + +/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty + * without NO_COLOR, TERM=dumb, or CI. */ +static inline int clio_color_policy(int tty) { + const char *force = getenv("CLICOLOR_FORCE"); + if (force && force[0]) { + if (strcmp(force, "0") == 0) + return 0; + return 1; + } + if (!tty) + return 0; + if (getenv("NO_COLOR")) + return 0; + const char *term = getenv("TERM"); + if (term && strcmp(term, "dumb") == 0) + return 0; + if (getenv("CI")) + return 0; + return 1; +} + +static inline int clio_term_init(clio_term *t) { + memset(t, 0, sizeof *t); + t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0; + t->color = clio_color_policy(t->is_tty); + t->rows = 24; + t->cols = 80; + struct winsize ws; + if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && + ws.ws_row > 0 && ws.ws_col > 0) { + t->rows = (size_t) ws.ws_row; + t->cols = (size_t) ws.ws_col; + } + clio_keydec_init(&t->dec); + return 0; +} + +/* Re-read the window size; invalidates the flushed-frame cache. -1 on + * failure (previous size kept). */ +static inline int clio_term_resize(clio_term *t) { + struct winsize ws; + if (!t->is_tty) + return -1; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0) + return -1; + if (ws.ws_row == 0 || ws.ws_col == 0) + return -1; + t->rows = (size_t) ws.ws_row; + t->cols = (size_t) ws.ws_col; + clio_buf_free(&t->last); + t->has_last = 0; + return 0; +} + +static inline int clio_term_raw_on(clio_term *t) { + if (t->raw || !t->is_tty) + return -1; + if (tcgetattr(STDIN_FILENO, &t->saved) != 0) + return -1; + struct termios raw = t->saved; + raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON)); + raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG)); + raw.c_oflag &= ~((tcflag_t) OPOST); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) + return -1; + t->raw = 1; + return 0; +} + +static inline void clio_term_raw_off(clio_term *t) { + if (!t->raw) + return; + tcsetattr(STDIN_FILENO, TCSANOW, &t->saved); + t->raw = 0; +} + +static inline void clio_term_alt_on(clio_term *t) { + if (t->alt) + return; + esc_falt_screen_on(stdout); + fflush(stdout); + t->alt = 1; + clio_buf_free(&t->last); + t->has_last = 0; +} + +static inline void clio_term_alt_off(clio_term *t) { + if (!t->alt) + return; + esc_falt_screen_off(stdout); + fflush(stdout); + t->alt = 0; + clio_buf_free(&t->last); + t->has_last = 0; +} + +static inline void clio_term_cursor_hide(clio_term *t) { + if (t->cursor_hidden) + return; + esc_fcursor_hide(stdout); + fflush(stdout); + t->cursor_hidden = 1; +} + +static inline void clio_term_cursor_show(clio_term *t) { + if (!t->cursor_hidden) + return; + esc_fcursor_show(stdout); + fflush(stdout); + t->cursor_hidden = 0; +} + +static inline void clio_term_paste_on(clio_term *t) { + if (t->paste) + return; + esc_fbracketed_paste_on(stdout); + fflush(stdout); + t->paste = 1; + t->dec.pasting = 1; +} + +static inline void clio_term_paste_off(clio_term *t) { + if (!t->paste) + return; + esc_fbracketed_paste_off(stdout); + fflush(stdout); + t->paste = 0; + t->dec.pasting = 0; +} + +static inline void clio_term_kitty_on(clio_term *t) { + if (t->kitty) + return; + esc_fkitty_on(stdout); + fflush(stdout); + t->kitty = 1; +} + +static inline void clio_term_kitty_off(clio_term *t) { + if (!t->kitty) + return; + esc_fkitty_off(stdout); + fflush(stdout); + t->kitty = 0; +} + +static inline void clio_term_bell(clio_term *t) { + (void) t; + esc_fbell(stdout); + fflush(stdout); +} + +/* Restore everything clio_term turned on (idempotent). */ +static inline void clio_term_shutdown(clio_term *t) { + if (t->kitty) + clio_term_kitty_off(t); + if (t->paste) + clio_term_paste_off(t); + if (t->cursor_hidden) + clio_term_cursor_show(t); + if (t->alt) + clio_term_alt_off(t); + clio_term_raw_off(t); +} + +static inline void clio_term_free(clio_term *t) { + clio_term_shutdown(t); + clio_buf_free(&t->last); + memset(t, 0, sizeof *t); +} + +/* ------------------------------------------------------------------ */ +/* Output (diff flush) */ +/* ------------------------------------------------------------------ */ + +/* Map a style onto the stream: reset, then attributes, then 256-color + * fg/bg. escape.h emits the codes; clio.h only decides what goes. */ +static inline void clio_fstyle(FILE *f, clio_style st) { + if (!f) + return; + fputs(ESC_RESET, f); + if (st.attr & CLIO_BOLD) + fputs(ESC_BOLD, f); + if (st.attr & CLIO_DIM) + fputs(ESC_DIM, f); + if (st.attr & CLIO_ITALIC) + fputs(ESC_ITALIC, f); + if (st.attr & CLIO_UNDERLINE) + fputs(ESC_UNDERLINE, f); + if (st.attr & CLIO_REVERSE) + fputs(ESC_REVERSE, f); + if (st.attr & CLIO_STRIKE) + fputs(ESC_STRIKETHROUGH, f); + if (st.fg >= 0) + esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF)); + if (st.bg >= 0) + esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF)); +} + +/* Render one row: style runs + text. Blank cells print as spaces. */ +static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) { + clio_style cur = clio_style_default(); + for (size_t c = 0; c < b->cols; c++) { + const clio_cell *cell = &b->cells[row * b->cols + c]; + clio_style st = clio_cell_style(cell); + if (!clio_style_eq(&st, &cur)) { + clio_fstyle(f, st); + cur = st; + } + if (cell->ch == 0) { + fputc(' ', f); + } else if (cell->ch < 0x80) { + fputc((int)(cell->ch & 0xFF), f); + } else { + char tmp[8]; + if (clio_utf8_enc(cell->ch, tmp) > 0) + fputs(tmp, f); + else + fputc(' ', f); + } + } +} + +/* Non-tty fallback: plain text lines, trailing blanks trimmed. */ +static inline void clio_flush_plain(const clio_buf *b) { + size_t cap = b->cols * 4 + 1; + char *row = (char *) malloc(cap); + if (!row) + return; + for (size_t r = 0; r < b->rows; r++) { + size_t n = 0; + for (size_t c = 0; c < b->cols; c++) { + const clio_cell *cell = &b->cells[r * b->cols + c]; + if (cell->ch == 0) { + row[n++] = ' '; + } else if (cell->ch < 0x80) { + row[n++] = (char)(cell->ch & 0xFF); + } else { + int e = clio_utf8_enc(cell->ch, row + n); + if (e > 0) + n += (size_t) e; + else + row[n++] = ' '; + } + } + while (n > 0 && row[n - 1] == ' ') + n--; + fwrite(row, 1, n, stdout); + fputc('\n', stdout); + } + free(row); + fflush(stdout); +} + +/* Blit the canvas to the terminal. TTY: diff against the previous frame, + * clear screen + full repaint on first flush or size change, otherwise + * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */ +static inline void clio_flush(clio_term *t, const clio_buf *b) { + if (!b || !b->cells) + return; + if (!t->is_tty) { + clio_flush_plain(b); + return; + } + int full = 1; + if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols) + full = 0; + if (full) { + clio_buf_free(&t->last); + if (clio_buf_init(&t->last, b->rows, b->cols) != 0) { + t->has_last = 0; + return; + } + t->has_last = 1; + esc_fclear_screen(stdout); + for (size_t r = 0; r < b->rows; r++) { + esc_fcursor_position(stdout, 1, (u32)(r + 1)); + clio_emit_row(stdout, b, r); + } + } else { + for (size_t r = 0; r < b->rows; r++) { + int same = 1; + for (size_t c = 0; c < b->cols; c++) { + if (!clio_cell_eq(&t->last.cells[r * b->cols + c], + &b->cells[r * b->cols + c])) { + same = 0; + break; + } + } + if (same) + continue; + esc_fcursor_position(stdout, 1, (u32)(r + 1)); + clio_emit_row(stdout, b, r); + } + } + memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell)); + fflush(stdout); +} + +/* Move the visible cursor (0-based row/col, clamped to the terminal). */ +static inline void clio_cursor(clio_term *t, size_t row, size_t col) { + if (!t->is_tty) + return; + if (row > t->rows) + row = t->rows; + if (col > t->cols) + col = t->cols; + esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1)); + fflush(stdout); +} + +/* ------------------------------------------------------------------ */ +/* Key input */ +/* ------------------------------------------------------------------ */ + +/* Key source: 1 + *k set on success, 0 on timeout/EOF. */ +typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms); + +static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) { + for (;;) { + if (t->qt != t->qh) { + *out = t->q[t->qh]; + t->qh++; + if (t->qh == CLIO_KEYQ) + t->qh = 0; + return 1; + } + if (timeout_ms == 0) + return clio_keydec_flush(&t->dec, out); + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + pfd.revents = 0; + int pr = poll(&pfd, 1, timeout_ms); + if (pr <= 0) + return clio_keydec_flush(&t->dec, out); + unsigned char in[16]; + ssize_t n = read(STDIN_FILENO, in, sizeof in); + if (n <= 0) + return clio_keydec_flush(&t->dec, out); + clio_key tmp[16]; + size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16); + for (size_t i = 0; i < got; i++) { + if (t->qt == t->qh) + return clio_keydec_flush(&t->dec, out); + size_t slot = t->qt; + if (slot == CLIO_KEYQ) + slot = 0; + if (slot == t->qh) { + t->qh++; + if (t->qh == CLIO_KEYQ) + t->qh = 0; + } /* queue full: drop the oldest */ + t->q[slot] = tmp[i]; + t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1; + } + } +} + +static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) { + clio_term *t = (clio_term *) ud; + return clio_read_key(t, k, timeout_ms); +} + +/* ------------------------------------------------------------------ */ +/* Single-line editor */ +/* ------------------------------------------------------------------ */ + +static inline void *clio_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "clio: out of memory\n"); + abort(); + } + return q; +} + +typedef struct clio_str { + char *s; + size_t len; + size_t cap; +} clio_str; + +static inline void clio_str_init(clio_str *x) { + x->s = NULL; + x->len = 0; + x->cap = 0; +} + +static inline void clio_str_free(clio_str *x) { + free(x->s); + x->s = NULL; + x->len = x->cap = 0; +} + +static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) { + if (x->len + n + 1 > x->cap) { + size_t nc = x->cap ? x->cap : 16; + while (x->len + n + 1 > nc) + nc *= 2; + x->s = (char *) clio_xrealloc(x->s, nc); + x->cap = nc; + } + memmove(x->s + at + n, x->s + at, x->len - at); + memcpy(x->s + at, s, n); + x->len += n; + x->s[x->len] = '\0'; +} + +static inline void clio_str_del(clio_str *x, size_t at, size_t n) { + if (at > x->len) + at = x->len; + if (at + n > x->len) + n = x->len - at; + memmove(x->s + at, x->s + at + n, x->len - at - n); + x->len -= n; + x->s[x->len] = '\0'; +} + +/* Count visible (non-zero-width) code points in the first n bytes. */ +static inline size_t clio_visible_n(const char *s, size_t n) { + size_t c = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + u32 cp; + size_t len = clio_utf8_next_n(p, end, &cp); + if (cp != 0 && clio_cp_width(cp) > 0) + c++; + p += len; + } + return c; +} + +/* Paint the prompt + text (echo, or one '*' per visible code point) into + * the canvas at row; returns the caret column. + * + * The input is ONE visual line: it never wraps. When the caret passes the + * right edge the window slides (bash/readline style) so the caret always + * stays visible, so backspace/left-arrow work across the "wrap". */ +static inline size_t clio_readline_paint(clio_buf *b, size_t row, + const char *prompt, clio_style pstyle, + const clio_str *txt, size_t caret, + clio_style tstyle, int echo) { + clio_buf_clear_region(b, row, 0, row, b->cols - 1); + size_t pw = prompt ? clio_text_width(prompt) : 0; + if (prompt && prompt[0]) + clio_buf_put(b, row, 0, prompt, pstyle, 0); + size_t area = (pw < b->cols) ? b->cols - pw : 0; + if (txt->len == 0 || area == 0) + return pw; + if (echo) { + size_t cw = clio_text_width_n(txt->s, caret); + /* Window left edge (display cells): 0 unless the caret does not + * fit, in which case pin the caret to the last column. */ + size_t L = (cw + 1 > area) ? cw - (area - 1) : 0; + /* Largest char boundary <= L (a wide char straddling L is kept + * whole, window starts at it). */ + size_t off = 0, start = 0; + const char *p = txt->s; + const char *end = txt->s + txt->len; + while (p < end) { + u32 cp; + size_t n = clio_utf8_next_n(p, end, &cp); + size_t w = (cp != 0) ? clio_cp_width(cp) : 0; + if (off + w > L) { + start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */ + break; + } + off += w; + p += n; + } + clio_buf_put(b, row, pw, txt->s + start, tstyle, 0); + size_t c = pw + (cw - off); + if (c >= b->cols) /* wide char straddled the left edge */ + c = b->cols - 1; + return c; + } + size_t total = clio_visible_n(txt->s, txt->len); + size_t cw = clio_visible_n(txt->s, caret); + size_t first = (cw + 1 > area) ? cw - (area - 1) : 0; + for (size_t i = first; i < total; i++) + clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0); + return pw + (cw - first); +} + +/* Single-line editor. Renders into ONE canvas row (no wrapping; long + * input scrolls to keep the caret visible); reads keys from nk (NULL + * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab, + * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC + * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints, + * Enter submits. Returns the input (caller frees) or NULL. */ +static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row, + const char *prompt, clio_style pstyle, + clio_style tstyle, int echo, + clio_next_key nk, void *ud, int timeout_ms) { + if (!nk) { + nk = clio_next_key_stdin; + ud = t; + } + clio_str txt; + clio_str_init(&txt); + size_t caret = 0; + + size_t col = + clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo); + if (t->is_tty) + clio_cursor(t, row, col); + + int quit = 0; + for (;;) { + clio_key k; + if (!nk(ud, &k, timeout_ms)) + break; /* timeout / EOF / no key */ + int done = 0; + switch (k.kind) { + case CLIOK_CHAR: { + char tmp[8]; + int e = clio_utf8_enc(k.cp, tmp); + if (e > 0) { + clio_str_ins(&txt, caret, tmp, (size_t) e); + caret += (size_t) e; + } + break; + } + case CLIOK_TAB: + clio_str_ins(&txt, caret, "\t", 1); + caret++; + break; + case CLIOK_ENTER: + done = 1; + break; + case CLIOK_BACKSPACE: + if (caret > 0) { + size_t s = clio_char_before(txt.s, caret); + clio_str_del(&txt, s, caret - s); + caret = s; + } + break; + case CLIOK_DELETE: + if (caret < txt.len) + clio_str_del(&txt, caret, + clio_char_end(txt.s, txt.len, caret) - caret); + break; + case CLIOK_LEFT: + if (caret > 0) + caret = clio_char_before(txt.s, caret); + break; + case CLIOK_RIGHT: + if (caret < txt.len) + caret = clio_char_end(txt.s, txt.len, caret); + break; + case CLIOK_HOME: + case CLIOK_CTRL_A: + caret = 0; + break; + case CLIOK_END: + case CLIOK_CTRL_E: + caret = txt.len; + break; + case CLIOK_CTRL_U: + clio_str_del(&txt, 0, caret); + caret = 0; + break; + case CLIOK_CTRL_K: + clio_str_del(&txt, caret, txt.len - caret); + break; + case CLIOK_CTRL_W: { + size_t s = clio_word_back(txt.s, caret); + clio_str_del(&txt, s, caret - s); + caret = s; + break; + } + case CLIOK_CTRL_C: + quit = 1; + break; + case CLIOK_CTRL_D: + if (txt.len == 0) + done = 1; + else if (caret < txt.len) + clio_str_del(&txt, caret, + clio_char_end(txt.s, txt.len, caret) - caret); + break; + case CLIOK_CTRL_L: + if (t->is_tty) { + esc_fclear_screen(stdout); + esc_fcursor_position(stdout, 1, 1); + fflush(stdout); + } + break; + case CLIOK_ESC: + quit = 1; + break; + default: + break; + } + col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo); + if (t->is_tty) + clio_cursor(t, row, col); + if (done || quit) + break; + } + + if (quit || txt.len == 0) { + clio_str_free(&txt); + return NULL; /* cancelled, or submitted empty */ + } + return txt.s; /* caller frees */ +} + +#ifdef __cplusplus +} +#endif + +#endif /* CLIO_H */ diff --git a/include/dotagents.h b/include/dotagents.h new file mode 100644 index 0000000..659e156 --- /dev/null +++ b/include/dotagents.h @@ -0,0 +1,1282 @@ +/* dotagents.h — portable, header-only implementation of the .agents protocol. + * + * The .agents protocol (dotagentsprotocol.com) is an open directory + * convention for AI agent configuration: one `.agents/` directory holds + * MCP server config, agent guidelines (AGENTS.md-compatible), skills, + * sub-agent profiles, repeat tasks, persistent memories, and model presets — + * in plain, version-controllable files. + * + * This library is the read-only side: it parses and resolves two layers — + * global `~/.agents/` and workspace `./.agents/` — with workspace winning on + * conflicts. It does not write. + * + * Directory schema (per layer): + * agents.md instructions (AGENTS.md-compatible) + * system-prompt.md system prompt + * mcp.json MCP server configuration + * models.json model presets & provider keys + * speakmcp-settings.json general settings + * layouts/ui.json UI/layout prefs + * skills/<id>/skill.md skill definition + * agents/<id>/agent.md sub-agent profile (+ optional config.json) + * tasks/<id>/task.md repeat task definition + * memories/ persistent memory entries (markdown files) + * .backups/ ignored (write-side artifact) + * + * Merge rules (final = global + workspace, workspace wins): + * - agents.md / system-prompt.md: whole-file replacement (workspace wins) + * - models.json: `default` replaced; `presets` merged by preset name + * (workspace wins per name) + * - mcp.json: `mcpServers` merged by server name (workspace wins per name) + * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level + * key (workspace wins per key) + * - skills / agents / tasks / memories: merged by id (workspace wins per id) + * - unknown files and malformed files: warned about, skipped (never fatal) + * + * Conformance (read-only): + * [x] agents.md, system-prompt.md, mcp.json, models.json + * [x] speakmcp-settings.json, layouts/ui.json + * [x] skills/, agents/ (+ config.json), tasks/, memories/ + * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int + * [x] task `profileId`, agent `connection-type` + * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...) + * [x] deterministic id-sorted output for stable diffs + * [ ] write side (atomic writes, .backups/ rotation) — out of scope + * [ ] .dotagents bundles / Hub — out of scope + * + * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the + * same directory (or on the include path). Zero dependencies beyond + * frontmatter.h and libc. All functions are static inline; safe to include + * in any number of translation units. + * + * API: + * dotagents_load(out) load ~/.agents + ./.agents (cwd) + * dotagents_load_from(out, ws, global) load two explicit layer dirs + * dotagents_free(out) free everything + * dotagents_project_sections(d, cap) guidelines + skill index + memory note + * dotagents_compose_preamble(d, base, cap) base + sections + * dotagents_find_skill(d, id) skill or NULL + * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL + * dotagents_mcp_config(d, name) raw per-server JSON or NULL + * models_file_get(models, name) preset or NULL + * models_file_free(models) free a models_file + * memory_importance_rank(m) 0 high, 1 medium, 2 else + * parse_frontmatter / doc_get / doc_free / fm_list_items ... + * from frontmatter.h (re-exported) + * + * Memory: all strings are owned by the `dotagents` struct until + * dotagents_free. Load never fails: missing layers and malformed files are + * recorded in `out->warnings` (or silently skipped when absent). + */ +#ifndef DOTAGENTS_H +#define DOTAGENTS_H + +#include "frontmatter.h" + +#include <dirent.h> +#include <strings.h> +#include <sys/stat.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LAYER_GLOBAL, + LAYER_WORKSPACE, +} layer; + +typedef struct { + char *id; + char *name; + char *description; + bool enabled; + char *body; + layer layer; +} skill; + +typedef struct { + char *id; + char *name; + char *description; + char *role; + char *connection_type; /* `connection-type` frontmatter, or NULL */ + bool enabled; + char *body; + char *config; /* raw JSON text, or NULL */ + layer layer; +} sub_agent; + +typedef struct { + char *id; + char *name; + char *profile_id; /* `profileId` frontmatter, or NULL */ + unsigned long interval_minutes; + bool has_interval; + bool enabled; + bool run_on_startup; + char *body; + layer layer; +} task_entry; + +typedef struct { + char *id; + char *title; + char *importance; + char **tags; + size_t tag_count; + char *summary; + char *body; + layer layer; +} memory; + +typedef struct { + char *name; + char *provider; + char *model; + char *base_url; + char *api_key; + unsigned long max_turns; + bool has_max_turns; + unsigned long context_limit; + bool has_context_limit; +} model_preset; + +typedef struct { + char *default_name; + model_preset *presets; + size_t preset_count; +} models_file; + +typedef struct { + layer *found_layers; + char **found_paths; + size_t found_count; + fm_strlist warnings; + char *system_prompt; + char *agents_md; + char *settings_json; /* merged speakmcp-settings.json, or NULL */ + char *layouts_json; /* merged layouts/ui.json, or NULL */ + fm_json *settings; /* accumulated merge tree (transient) */ + fm_json *layouts; /* accumulated merge tree (transient) */ + models_file models; + bool has_models; + char **mcp_names; + char **mcp_configs; /* raw JSON per server */ + size_t mcp_count; + skill *skills; + size_t skill_count; + sub_agent *sub_agents; + size_t sub_agent_count; + task_entry *tasks; + size_t task_count; + memory *memories; + size_t memory_count; +} dotagents; + +/* qsort comparators (replace the old O(n^2) inline sorts) */ +typedef struct { char *name; char *path; } da_dirent; +static int da_cmp_dirent(const void *a, const void *b) { + return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name); +} +static int da_cmp_skill_id(const void *a, const void *b) { + return strcmp(((const skill *)a)->id, ((const skill *)b)->id); +} +static int da_cmp_subagent_id(const void *a, const void *b) { + return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id); +} +static int da_cmp_task_id(const void *a, const void *b) { + return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id); +} +static int da_cmp_memory_id(const void *a, const void *b) { + return strcmp(((const memory *)a)->id, ((const memory *)b)->id); +} +static int da_cmp_cstr(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* ------------------------------------------------------------------ */ +/* Layer helpers */ +/* ------------------------------------------------------------------ */ + +static inline const char *layer_short(layer l) { + return l == LAYER_GLOBAL ? "global" : "workspace"; +} + +static inline const char *layer_display(layer l) { + return l == LAYER_GLOBAL ? "~/.agents" : ".agents"; +} + +/* ------------------------------------------------------------------ */ +/* File I/O */ +/* ------------------------------------------------------------------ */ + +static inline bool da_is_file(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISREG(st.st_mode); +} + +static inline bool da_is_dir(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISDIR(st.st_mode); +} + +/* Read a whole file. 0 on success, -1 on any failure. */ +static inline int da_read_file(const char *path, char **out, size_t *out_len) { + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + fm_buf b; + fm_buf_init(&b); + char chunk[8192]; + size_t r; + while ((r = fread(chunk, 1, sizeof chunk, f)) > 0) + fm_buf_putn(&b, chunk, r); + int fe = ferror(f); + fclose(f); + if (fe) { + fm_buf_free(&b); + return -1; + } + size_t total = b.len; + if (out) + *out = fm_buf_steal(&b); + if (out_len) + *out_len = total; + return 0; +} + +static inline bool da_dir_has_entry(const char *dir) { + DIR *d = opendir(dir); + if (!d) + return false; + bool has = readdir(d) != NULL; + closedir(d); + return has; +} + +/* Find `{stem}.md` case-insensitively directly inside dir. */ +static inline char *da_find_md(const char *dir, const char *stem) { + char *want = fm_strfmt("%s.md", stem); + for (char *p = want; *p; p++) + *p = (char)tolower((unsigned char)*p); + DIR *d = opendir(dir); + if (!d) { + free(want); + return NULL; + } + char *result = NULL; + struct dirent *e; + while ((e = readdir(d))) { + if (strcasecmp(e->d_name, want) == 0) { + result = fm_strfmt("%s/%s", dir, e->d_name); + break; + } + } + closedir(d); + free(want); + return result; +} + +/* Sorted subdirectories of `dir`: returns name+path arrays. */ +static inline void da_sub_dirs(const char *dir, char ***out_names, + char ***out_paths, size_t *out_count) { + *out_count = 0; + DIR *d = opendir(dir); + if (!d) + return; + size_t cap = 8, n = 0; + char **names = (char **)fm_xmalloc(cap * sizeof(char *)); + char **paths = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) + continue; + char *path = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_dir(path)) { + free(path); + continue; + } + if (n + 1 > cap) { + cap *= 2; + names = (char **)fm_xrealloc(names, cap * sizeof(char *)); + paths = (char **)fm_xrealloc(paths, cap * sizeof(char *)); + } + names[n] = fm_strdup(e->d_name); + paths[n] = path; + n++; + } + closedir(d); + /* sort by name (keep the parallel paths array in lockstep) */ + if (n > 1) { + da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents); + for (size_t i = 0; i < n; i++) { + ents[i].name = names[i]; + ents[i].path = paths[i]; + } + qsort(ents, n, sizeof *ents, da_cmp_dirent); + for (size_t i = 0; i < n; i++) { + names[i] = ents[i].name; + paths[i] = ents[i].path; + } + free(ents); + } + *out_names = names; + *out_paths = paths; + *out_count = n; +} + +/* Does this root hold any recognizable protocol content? */ +static inline bool da_has_protocol_content(const char *root) { + const char *stems[2] = {"agents", "system-prompt"}; + for (size_t i = 0; i < 2; i++) { + char *md = da_find_md(root, stems[i]); + if (md) { + free(md); + return true; + } + } + const char *files[4] = {"models.json", "mcp.json", + "speakmcp-settings.json", "layouts/ui.json"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, files[i]); + bool has = da_is_file(p); + free(p); + if (has) + return true; + } + const char *subs[4] = {"skills", "agents", "tasks", "memories"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, subs[i]); + bool has = da_is_dir(p) && da_dir_has_entry(p); + free(p); + if (has) + return true; + } + return false; +} + +/* ------------------------------------------------------------------ */ +/* Accumulator helpers */ +/* ------------------------------------------------------------------ */ + +static inline void da_warn(dotagents *out, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *msg = fm_vsnprintf(fmt, ap); + va_end(ap); + fm_sl_push(&out->warnings, msg); +} + +static inline char *da_read_optional(const char *path, layer layer, + dotagents *out) { + char *raw = NULL; + size_t len = 0; + if (da_read_file(path, &raw, &len) != 0) { + struct stat st; + if (stat(path, &st) == 0) { + /* exists but unreadable */ + const char *base = strrchr(path, '/'); + da_warn(out, "%s: %s: read error", layer_display(layer), + base ? base + 1 : path); + } + free(raw); + return NULL; + } + /* whitespace-only: treat as absent */ + size_t i = 0; + while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' || + raw[i] == '\r')) + i++; + if (i == len) { + free(raw); + return NULL; + } + return raw; +} + +/* ------------------------------------------------------------------ */ +/* Ingest */ +/* ------------------------------------------------------------------ */ + +static inline void da_ingest_models_json(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/models.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + fm_json_free(o); + return; + } + /* unknown field check (deny_unknown_fields) */ + bool ok = true; + for (size_t i = 0; i < o->count; i++) { + const char *k = o->items[i] ? o->items[i]->key : NULL; + if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) { + ok = false; + break; + } + } + if (ok) { + const fm_json *def = fm_json_get(o, "default"); + if (def && def->type == FMJ_STRING) { + free(out->models.default_name); + out->models.default_name = fm_strdup(def->str); + } + const fm_json *pres = fm_json_get(o, "presets"); + if (pres && pres->type == FMJ_OBJECT) { + for (size_t pi = 0; pi < pres->count; pi++) { + const fm_json *pv = pres->items[pi]; + if (!pv || pv->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: preset `%s` is not an " + "object; skipped", + layer_short(layer), pv && pv->key ? pv->key : "?"); + continue; + } + const fm_json *bad = NULL; + for (size_t fi = 0; fi < pv->count; fi++) { + const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL; + if (!fk || + (strcmp(fk, "provider") != 0 && + strcmp(fk, "model") != 0 && + strcmp(fk, "base_url") != 0 && + strcmp(fk, "api_key") != 0 && + strcmp(fk, "max_turns") != 0 && + strcmp(fk, "context_limit") != 0)) { + bad = pv->items[fi]; + break; + } + } + if (bad) { + da_warn(out, "%s: models.json: preset `%s` has unknown " + "field `%s`; skipped", + layer_short(layer), pv->key ? pv->key : "?", + bad->key ? bad->key : "?"); + continue; + } + model_preset mp; + memset(&mp, 0, sizeof mp); + mp.name = fm_strdup(pv->key ? pv->key : ""); + const fm_json *s; + if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING) + mp.provider = fm_strdup(s->str); + if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING) + mp.model = fm_strdup(s->str); + if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING) + mp.base_url = fm_strdup(s->str); + if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING) + mp.api_key = fm_strdup(s->str); + if ((s = fm_json_get(pv, "max_turns")) && + s->type == FMJ_NUMBER) { + mp.max_turns = (unsigned long)s->num; + mp.has_max_turns = true; + } + if ((s = fm_json_get(pv, "context_limit")) && + s->type == FMJ_NUMBER) { + mp.context_limit = (unsigned long)s->num; + mp.has_context_limit = true; + } + /* replace by name or append */ + bool replaced = false; + for (size_t i = 0; i < out->models.preset_count; i++) { + if (strcmp(out->models.presets[i].name, pv->key) == 0) { + free(out->models.presets[i].name); + free(out->models.presets[i].provider); + free(out->models.presets[i].model); + free(out->models.presets[i].base_url); + free(out->models.presets[i].api_key); + out->models.presets[i] = mp; + replaced = true; + break; + } + } + if (!replaced) { + out->models.presets = (model_preset *)fm_xrealloc( + out->models.presets, + (out->models.preset_count + 1) * + sizeof(model_preset)); + out->models.presets[out->models.preset_count++] = mp; + } + out->has_models = true; + } + } + } else { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + } + fm_json_free(o); +} + +static inline void da_ingest_mcp(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/mcp.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o) { + da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer)); + return; + } + const fm_json *servers = fm_json_get(o, "mcpServers"); + if (!servers || servers->type != FMJ_OBJECT) { + da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + for (size_t i = 0; i < servers->count; i++) { + const fm_json *sv = servers->items[i]; + if (!sv) + continue; + char *cfg = fm_json_serialize(sv); + bool replaced = false; + for (size_t j = 0; j < out->mcp_count; j++) { + if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) { + free(out->mcp_configs[j]); + out->mcp_configs[j] = cfg; + replaced = true; + break; + } + } + if (!replaced) { + out->mcp_names = + (char **)fm_xrealloc(out->mcp_names, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_configs = + (char **)fm_xrealloc(out->mcp_configs, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_names[out->mcp_count] = + fm_strdup(sv->key ? sv->key : ""); + out->mcp_configs[out->mcp_count] = cfg; + out->mcp_count++; + } + } + fm_json_free(o); +} + +static inline void da_ingest_settings(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/speakmcp-settings.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->settings = fm_json_merge_owned(out->settings, o); +} + +static inline void da_ingest_layouts(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/layouts/ui.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->layouts = fm_json_merge_owned(out->layouts, o); +} + +static inline void da_ingest_skills(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/skills", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "skill"); + if (!md) { + da_warn(out, "%s: skills/%s: missing skill.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: skills/%s: skill.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + skill s; + s.id = fm_strdup(id); + s.name = fm_strdup(name); + s.description = fm_strdup(desc); + s.enabled = enabled; + s.body = fm_strdup(d.body); + s.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->skill_count; j++) { + if (strcmp(out->skills[j].id, s.id) == 0) { + free(out->skills[j].id); + free(out->skills[j].name); + free(out->skills[j].description); + free(out->skills[j].body); + out->skills[j] = s; + replaced = true; + break; + } + } + if (!replaced) { + out->skills = (skill *)fm_xrealloc( + out->skills, (out->skill_count + 1) * sizeof(skill)); + out->skills[out->skill_count++] = s; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + /* keep sorted by id */ + if (out->skill_count > 1) + qsort(out->skills, out->skill_count, sizeof *out->skills, + da_cmp_skill_id); +} + +static inline void da_ingest_sub_agents(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/agents", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "agent"); + if (!md) { + da_warn(out, "%s: agents/%s: missing agent.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: agents/%s: agent.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + /* optional config.json next to agent.md */ + char *config = NULL; + char *cp = fm_strfmt("%s/config.json", paths[i]); + char *craw = NULL; + if (da_read_file(cp, &craw, NULL) == 0) { + size_t cl = strlen(craw); + size_t ci = 0; + while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' || + craw[ci] == '\n' || craw[ci] == '\r')) + ci++; + if (ci < cl) { + fm_json *cv = fm_json_parse(craw); + if (cv) { + config = fm_json_serialize(cv); + fm_json_free(cv); + } else { + da_warn(out, "%s: agents/%s/config.json: invalid JSON; " + "skipped", + layer_short(layer), names[i]); + } + } + free(craw); + } + free(cp); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + char *role = "delegation-target"; + if ((v = doc_get(&d, "role")) && v->kind == FM_STR) + role = v->s; + char *ctype = NULL; + if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR) + ctype = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + sub_agent a; + a.id = fm_strdup(id); + a.name = fm_strdup(name); + a.description = fm_strdup(desc); + a.role = fm_strdup(role); + a.connection_type = ctype ? fm_strdup(ctype) : NULL; + a.enabled = enabled; + a.body = fm_strdup(d.body); + a.config = config; + a.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->sub_agent_count; j++) { + if (strcmp(out->sub_agents[j].id, a.id) == 0) { + free(out->sub_agents[j].id); + free(out->sub_agents[j].name); + free(out->sub_agents[j].description); + free(out->sub_agents[j].role); + free(out->sub_agents[j].connection_type); + free(out->sub_agents[j].body); + free(out->sub_agents[j].config); + out->sub_agents[j] = a; + replaced = true; + break; + } + } + if (!replaced) { + out->sub_agents = + (sub_agent *)fm_xrealloc(out->sub_agents, + (out->sub_agent_count + 1) * + sizeof(sub_agent)); + out->sub_agents[out->sub_agent_count++] = a; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->sub_agent_count > 1) + qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents, + da_cmp_subagent_id); +} + +static inline void da_ingest_tasks(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/tasks", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "task"); + if (!md) { + da_warn(out, "%s: tasks/%s: missing task.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: tasks/%s: task.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *pid = NULL; + if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s && + *v->s) + pid = v->s; + + task_entry t; + memset(&t, 0, sizeof t); + t.id = fm_strdup(id); + t.name = fm_strdup(name); + t.profile_id = pid ? fm_strdup(pid) : NULL; + if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT && + v->i >= 0) { + t.interval_minutes = (unsigned long)v->i; + t.has_interval = true; + } + t.enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + t.enabled = v->b; + t.run_on_startup = false; + if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL) + t.run_on_startup = v->b; + t.body = fm_strdup(d.body); + t.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->task_count; j++) { + if (strcmp(out->tasks[j].id, t.id) == 0) { + free(out->tasks[j].id); + free(out->tasks[j].name); + free(out->tasks[j].profile_id); + free(out->tasks[j].body); + out->tasks[j] = t; + replaced = true; + break; + } + } + if (!replaced) { + out->tasks = + (task_entry *)fm_xrealloc(out->tasks, + (out->task_count + 1) * + sizeof(task_entry)); + out->tasks[out->task_count++] = t; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->task_count > 1) + qsort(out->tasks, out->task_count, sizeof *out->tasks, + da_cmp_task_id); +} + +static inline void da_ingest_memories(const char *root, layer layer, + dotagents *out) { + char *dir = fm_strfmt("%s/memories", root); + DIR *d = opendir(dir); + if (!d) { + free(dir); + return; + } + /* collect .md files */ + size_t cap = 8, n = 0; + char **files = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + char *p = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_file(p)) { + free(p); + continue; + } + const char *dot = strrchr(e->d_name, '.'); + if (!dot || strcasecmp(dot, ".md") != 0) { + free(p); + continue; + } + if (n + 1 > cap) { + cap *= 2; + files = (char **)fm_xrealloc(files, cap * sizeof(char *)); + } + files[n++] = p; + } + closedir(d); + if (n > 1) + qsort(files, n, sizeof *files, da_cmp_cstr); + for (size_t i = 0; i < n; i++) { + const char *base = strrchr(files[i], '/'); + base = base ? base + 1 : files[i]; + const char *dot = strrchr(base, '.'); + char *stem = fm_strndup( + base, dot ? (size_t)(dot - base) : strlen(base)); + char *text = NULL; + if (da_read_file(files[i], &text, NULL) != 0) { + da_warn(out, "%s: memories/%s.md: unreadable; skipped", + layer_short(layer), stem); + free(stem); + continue; + } + doc d2; + parse_frontmatter(text, &d2); + free(text); + + char *id = stem; + const fm_value *v; + if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *title = stem; + if ((v = doc_get(&d2, "title")) && v->kind == FM_STR) + title = v->s; + char *importance = "medium"; + if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR) + importance = v->s; + char *summary = ""; + if ((v = doc_get(&d2, "content")) && v->kind == FM_STR) + summary = v->s; + + memory m; + memset(&m, 0, sizeof m); + m.id = fm_strdup(id); + m.title = fm_strdup(title); + m.importance = fm_strdup(importance); + m.summary = fm_strdup(summary); + m.body = fm_strdup(d2.body); + m.layer = layer; + if ((v = doc_get(&d2, "tags"))) { + fm_strlist items; + fm_list_items(v, &items); + m.tags = (char **)fm_xmalloc( + (items.len ? items.len : 1) * sizeof(char *)); + for (size_t k = 0; k < items.len; k++) + m.tags[k] = fm_strdup(items.items[k]); + m.tag_count = items.len; + fm_sl_free(&items); + } + + bool replaced = false; + for (size_t j = 0; j < out->memory_count; j++) { + if (strcmp(out->memories[j].id, m.id) == 0) { + free(out->memories[j].id); + free(out->memories[j].title); + free(out->memories[j].importance); + free(out->memories[j].summary); + free(out->memories[j].body); + for (size_t k = 0; k < out->memories[j].tag_count; k++) + free(out->memories[j].tags[k]); + free(out->memories[j].tags); + out->memories[j] = m; + replaced = true; + break; + } + } + if (!replaced) { + out->memories = + (memory *)fm_xrealloc(out->memories, + (out->memory_count + 1) * + sizeof(memory)); + out->memories[out->memory_count++] = m; + } + doc_free(&d2); + free(stem); + } + for (size_t i = 0; i < n; i++) + free(files[i]); + free(files); + free(dir); + if (out->memory_count > 1) + qsort(out->memories, out->memory_count, sizeof *out->memories, + da_cmp_memory_id); +} + +static inline void da_ingest(const char *root, layer layer, dotagents *out) { + char *agents_md = da_find_md(root, "agents"); + if (agents_md) { + char *text = da_read_optional(agents_md, layer, out); + if (text) { + free(out->agents_md); + out->agents_md = text; + } + free(agents_md); + } + char *sp = da_find_md(root, "system-prompt"); + if (sp) { + char *text = da_read_optional(sp, layer, out); + if (text) { + free(out->system_prompt); + out->system_prompt = text; + } + free(sp); + } + da_ingest_models_json(root, layer, out); + da_ingest_mcp(root, layer, out); + da_ingest_settings(root, layer, out); + da_ingest_layouts(root, layer, out); + da_ingest_skills(root, layer, out); + da_ingest_sub_agents(root, layer, out); + da_ingest_tasks(root, layer, out); + da_ingest_memories(root, layer, out); +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +static inline void dotagents_free(dotagents *d) { + free(d->found_layers); + for (size_t i = 0; i < d->found_count; i++) + free(d->found_paths[i]); + free(d->found_paths); + fm_sl_free(&d->warnings); + free(d->system_prompt); + free(d->agents_md); + free(d->settings_json); + free(d->layouts_json); + fm_json_free(d->settings); + fm_json_free(d->layouts); + free(d->models.default_name); + for (size_t i = 0; i < d->models.preset_count; i++) { + free(d->models.presets[i].name); + free(d->models.presets[i].provider); + free(d->models.presets[i].model); + free(d->models.presets[i].base_url); + free(d->models.presets[i].api_key); + } + free(d->models.presets); + for (size_t i = 0; i < d->mcp_count; i++) { + free(d->mcp_names[i]); + free(d->mcp_configs[i]); + } + free(d->mcp_names); + free(d->mcp_configs); + for (size_t i = 0; i < d->skill_count; i++) { + free(d->skills[i].id); + free(d->skills[i].name); + free(d->skills[i].description); + free(d->skills[i].body); + } + free(d->skills); + for (size_t i = 0; i < d->sub_agent_count; i++) { + free(d->sub_agents[i].id); + free(d->sub_agents[i].name); + free(d->sub_agents[i].description); + free(d->sub_agents[i].role); + free(d->sub_agents[i].connection_type); + free(d->sub_agents[i].body); + free(d->sub_agents[i].config); + } + free(d->sub_agents); + for (size_t i = 0; i < d->task_count; i++) { + free(d->tasks[i].id); + free(d->tasks[i].name); + free(d->tasks[i].profile_id); + free(d->tasks[i].body); + } + free(d->tasks); + for (size_t i = 0; i < d->memory_count; i++) { + free(d->memories[i].id); + free(d->memories[i].title); + free(d->memories[i].importance); + for (size_t k = 0; k < d->memories[i].tag_count; k++) + free(d->memories[i].tags[k]); + free(d->memories[i].tags); + free(d->memories[i].summary); + free(d->memories[i].body); + } + free(d->memories); +} + +static inline void dotagents_load_from(dotagents *out, const char *workspace, + const char *global) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + if (global && da_is_dir(global) && da_has_protocol_content(global)) { + da_ingest(global, LAYER_GLOBAL, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_GLOBAL; + out->found_paths[out->found_count] = fm_strdup(global); + out->found_count++; + } + if (da_is_dir(workspace) && da_has_protocol_content(workspace)) { + da_ingest(workspace, LAYER_WORKSPACE, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_WORKSPACE; + out->found_paths[out->found_count] = fm_strdup(workspace); + out->found_count++; + } + /* materialize the transient merge trees as stable JSON text */ + if (out->settings) { + out->settings_json = fm_json_serialize(out->settings); + fm_json_free(out->settings); + out->settings = NULL; + } + if (out->layouts) { + out->layouts_json = fm_json_serialize(out->layouts); + fm_json_free(out->layouts); + out->layouts = NULL; + } +} + +static inline void dotagents_load(dotagents *out) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + const char *home = getenv("HOME"); + char *global = NULL; + if (home) + global = fm_strfmt("%s/.agents", home); + dotagents_load_from(out, ".agents", global); + free(global); +} + +/* Sections appended to the preamble: guidelines + skill index + memories. */ +static inline char *dotagents_project_sections(const dotagents *d, + unsigned long cap) { + if (cap < 1) + cap = 1; + fm_buf out; + fm_buf_init(&out); + if (d->agents_md && d->agents_md[0] && + d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) { + fm_buf_push(&out, '\n'); + /* trim end */ + size_t l = strlen(d->agents_md); + while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' || + d->agents_md[l - 1] == ' ')) + l--; + fm_buf_putn(&out, d->agents_md, l); + fm_buf_push(&out, '\n'); + } + /* enabled skills */ + size_t enabled_count = 0; + for (size_t i = 0; i < d->skill_count; i++) + if (d->skills[i].enabled) + enabled_count++; + if (enabled_count > 0) { + fm_buf_put(&out, "\n# Skills\n\n"); + fm_buf_put( + &out, + "Skills available for this project. Load a skill's full " + "instructions with the `use_skill` tool (pass its id):\n"); + size_t listed = 0; + for (size_t i = 0; i < d->skill_count && listed < cap; i++) { + if (!d->skills[i].enabled) + continue; + if (d->skills[i].description[0]) + fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id, + d->skills[i].description); + else + fm_buf_fmt(&out, "- %s\n", d->skills[i].id); + listed++; + } + if (enabled_count > cap) + fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap); + } + if (d->memory_count > 0) { + fm_buf_fmt( + &out, + "\n# Memories\n\n%lu persistent memory entries are available " + "across sessions. Retrieve them with the `search_memories` tool " + "(keyword or tag query).\n", + d->memory_count); + } + return fm_buf_steal(&out); +} + +static inline char *dotagents_compose_preamble(const dotagents *d, + const char *base, + unsigned long cap) { + char *sections = dotagents_project_sections(d, cap); + fm_buf b; + fm_buf_init(&b); + fm_buf_put(&b, base); + fm_buf_put(&b, sections); + free(sections); + return fm_buf_steal(&b); +} + +static inline const sub_agent *dotagents_enabled_sub_agent( + const dotagents *d, const char *id) { + for (size_t i = 0; i < d->sub_agent_count; i++) + if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled) + return &d->sub_agents[i]; + return NULL; +} + +static inline const skill *dotagents_find_skill(const dotagents *d, + const char *id) { + for (size_t i = 0; i < d->skill_count; i++) + if (strcmp(d->skills[i].id, id) == 0) + return &d->skills[i]; + return NULL; +} + +static inline const char *dotagents_mcp_config(const dotagents *d, + const char *name) { + for (size_t i = 0; i < d->mcp_count; i++) + if (strcmp(d->mcp_names[i], name) == 0) + return d->mcp_configs[i]; + return NULL; +} + +static inline const model_preset *models_file_get(const models_file *m, + const char *name) { + for (size_t i = 0; i < m->preset_count; i++) + if (strcmp(m->presets[i].name, name) == 0) + return &m->presets[i]; + return NULL; +} + +static inline void models_file_free(models_file *m) { + free(m->default_name); + for (size_t i = 0; i < m->preset_count; i++) { + free(m->presets[i].name); + free(m->presets[i].provider); + free(m->presets[i].model); + free(m->presets[i].base_url); + free(m->presets[i].api_key); + } + free(m->presets); + m->presets = NULL; + m->preset_count = 0; +} + +static inline int memory_importance_rank(const memory *m) { + fm_buf l; + fm_buf_init(&l); + for (const char *p = m->importance; *p; p++) + fm_buf_push(&l, (char)tolower((unsigned char)*p)); + l.data[l.len] = '\0'; + int r = 2; + if (strcmp(l.data, "high") == 0) + r = 0; + else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0) + r = 1; + fm_buf_free(&l); + return r; +} + +#ifdef __cplusplus +} +#endif + +#endif /* DOTAGENTS_H */ diff --git a/include/escape.h b/include/escape.h new file mode 100755 index 0000000..af616b3 --- /dev/null +++ b/include/escape.h @@ -0,0 +1,272 @@ +// escape.h - ESC escape code macros and functions +// +// Thread safety: Safe for concurrent use (no shared state). The macros +// produce string literals; the functions write to the provided FILE*. +// Portability: ANSI escape codes are not supported on Windows Console API +// (use Windows 10+ VT mode or a translation layer). + +#ifndef ESCAPE_H +#define ESCAPE_H + +#include "type.h" +#include <stdio.h> + +#ifdef __cplusplus +extern "C" { +#endif + +#define CSI "\x1B[" + +#define ESC_CURSOR_UP(n) CSI #n "A" +void esc_cursor_up(u32 n); +void esc_fcursor_up(FILE *, u32 n); + +#define ESC_CURSOR_DOWN(n) CSI #n "B" +void esc_cursor_down(u32 n); +void esc_fcursor_down(FILE *, u32 n); + +#define ESC_CURSOR_LEFT(n) CSI #n "D" +void esc_cursor_left(u32 n); +void esc_fcursor_left(FILE *, u32 n); + +#define ESC_CURSOR_RIGHT(n) CSI #n "C" +void esc_cursor_right(u32 n); +void esc_fcursor_right(FILE *, u32 n); + +#define ESC_CURSOR_LINE_NEXT(n) CSI #n "E" +void esc_cursor_line_next(u32 n); +void esc_fcursor_line_next(FILE *, u32 n); + +#define ESC_CURSOR_PREVIOUS(n) CSI #n "F" +void esc_cursor_previous(u32 n); +void esc_fcursor_previous(FILE *, u32 n); + +#define ESC_CURSOR_POSITION(col, row) CSI #row ";" #col "H" +void esc_cursor_position(u32 col, u32 row); +void esc_fcursor_position(FILE *, u32 col, u32 row); + +#define ESC_CURSOR_COLUMN_HOME CSI "G" + +#define ESC_CLEAR_SCREEN CSI "2J" +void esc_clear_screen(void); +void esc_fclear_screen(FILE *); + +#define ESC_CLEAR_SCREEN_START CSI "1J" +void esc_clear_screen_start(void); +void esc_fclear_screen_start(FILE *); + +#define ESC_CLEAR_SCREEN_END CSI "0J" +void esc_clear_screen_end(void); +void esc_fclear_screen_end(FILE *); + +#define ESC_CLEAR_LINE CSI "2K" +void esc_clear_line(void); +void esc_fclear_line(FILE *); + +#define ESC_CLEAR_LINE_START CSI "1K" +void esc_clear_line_start(void); +void esc_fclear_line_start(FILE *); + +#define ESC_CLEAR_LINE_END CSI "0K" +void esc_clear_line_end(void); +void esc_fclear_line_end(FILE *); + +#define ESC_SCROLL_UP(n) CSI #n "S" +void esc_scroll_up(u32 n); +void esc_fscroll_up(FILE *, u32 n); + +#define ESC_SCROLL_DOWN(n) CSI #n "T" +void esc_scroll_down(u32 n); +void esc_fscroll_down(FILE *, u32 n); + +#define ESC_SGR "m" + +#define ESC_RESET CSI "0" ESC_SGR +#define ESC_BOLD CSI "1" ESC_SGR +#define ESC_DIM CSI "2" ESC_SGR +#define ESC_ITALIC CSI "3" ESC_SGR +#define ESC_UNDERLINE CSI "4" ESC_SGR +#define ESC_BLINK CSI "5" ESC_SGR +#define ESC_REVERSE CSI "7" ESC_SGR +#define ESC_CONCEAL CSI "8" ESC_SGR +#define ESC_REVEAL CSI "28" ESC_SGR +#define ESC_STRIKETHROUGH CSI "9" ESC_SGR +#define ESC_OVERLINE CSI "53" ESC_SGR + +#define ESC_COLOR_FG_RGB(r, g, b) CSI "38;2;" #r ";" #g ";" #b ESC_SGR +void esc_color_fg_rgb(u32 r, u32 g, u32 b); +void esc_fcolor_fg_rgb(FILE *, u32 r, u32 g, u32 b); + +#define ESC_COLOR_BG_RGB(r, g, b) CSI "48;2;" #r ";" #g ";" #b ESC_SGR +void esc_color_bg_rgb(u32 r, u32 g, u32 b); +void esc_fcolor_bg_rgb(FILE *, u32 r, u32 g, u32 b); + +#define ESC_COLOR_FG_256(n) CSI "38;5;" #n ESC_SGR +void esc_color_fg_256(u32 n); +void esc_fcolor_fg_256(FILE *, u32 n); + +#define ESC_COLOR_BG_256(n) CSI "48;5;" #n ESC_SGR +void esc_color_bg_256(u32 n); +void esc_fcolor_bg_256(FILE *, u32 n); + +#define ESC_COLOR_FG(n) CSI "3" #n ESC_SGR +#define ESC_COLOR_BG(n) CSI "4" #n ESC_SGR +#define ESC_COLOR_FG_BRIGHT(n) CSI "9" #n ESC_SGR +#define ESC_COLOR_BG_BRIGHT(n) CSI "10" #n ESC_SGR + +/* Named standard colors (ANSI 0-7). */ +#define ESC_COLOR_BLACK ESC_COLOR_FG(0) +#define ESC_COLOR_RED ESC_COLOR_FG(1) +#define ESC_COLOR_GREEN ESC_COLOR_FG(2) +#define ESC_COLOR_YELLOW ESC_COLOR_FG(3) +#define ESC_COLOR_BLUE ESC_COLOR_FG(4) +#define ESC_COLOR_MAGENTA ESC_COLOR_FG(5) +#define ESC_COLOR_CYAN ESC_COLOR_FG(6) +#define ESC_COLOR_WHITE ESC_COLOR_FG(7) + +#define ESC_CURSOR_HIDE CSI "?25l" +void esc_cursor_hide(void); +void esc_fcursor_hide(FILE *); + +#define ESC_CURSOR_SHOW CSI "?25h" +void esc_cursor_show(void); +void esc_fcursor_show(FILE *); + +#define ESC_CURSOR_SAVE CSI "s" +void esc_cursor_save(void); +void esc_fcursor_save(FILE *); + +#define ESC_CURSOR_RESTORE CSI "u" +void esc_cursor_restore(void); +void esc_fcursor_restore(FILE *); + +#define ESC_BELL "\x07" +void esc_bell(void); +void esc_fbell(FILE *); + +#define ESC_ALT_SCREEN_ON CSI "?1049h" +void esc_alt_screen_on(void); +void esc_falt_screen_on(FILE *); + +#define ESC_ALT_SCREEN_OFF CSI "?1049l" +void esc_alt_screen_off(void); +void esc_falt_screen_off(FILE *); + +#define ESC_BRACKETED_PASTE_ON CSI "?2004h" +void esc_bracketed_paste_on(void); +void esc_fbracketed_paste_on(FILE *); + +#define ESC_BRACKETED_PASTE_OFF CSI "?2004l" +void esc_bracketed_paste_off(void); +void esc_fbracketed_paste_off(FILE *); + +#define ESC_KITTY_ON CSI ">1u" +void esc_kitty_on(void); +void esc_fkitty_on(FILE *); + +#define ESC_KITTY_OFF CSI ">0u" +void esc_kitty_off(void); +void esc_fkitty_off(FILE *); + +#ifdef __cplusplus +} +#endif + +#endif // ESCAPE_H + +#ifdef ESCAPE_IMPLEMENTATION + +#include <stdio.h> + +void esc_fcursor_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uA", (unsigned)n); } +void esc_fcursor_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uB", (unsigned)n); } +void esc_fcursor_left(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uD", (unsigned)n); } +void esc_fcursor_right(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uC", (unsigned)n); } +void esc_fcursor_line_next(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uE", (unsigned)n); } +void esc_fcursor_previous(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uF", (unsigned)n); } +void esc_fcursor_position(FILE *f, u32 col, u32 row) { + if (!f) return; + fprintf(f, CSI "%u;%uH", (unsigned)row, (unsigned)col); +} +void esc_fclear_screen(FILE *f) { if (!f) return; fputs(CSI "2J", f); } +void esc_fclear_screen_start(FILE *f) { if (!f) return; fputs(CSI "1J", f); } +void esc_fclear_screen_end(FILE *f) { if (!f) return; fputs(CSI "0J", f); } +void esc_fclear_line(FILE *f) { if (!f) return; fputs(CSI "2K", f); } +void esc_fclear_line_start(FILE *f) { if (!f) return; fputs(CSI "1K", f); } +void esc_fclear_line_end(FILE *f) { if (!f) return; fputs(CSI "0K", f); } +void esc_fscroll_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uS", (unsigned)n); } +void esc_fscroll_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uT", (unsigned)n); } + +void esc_fcolor_fg_rgb(FILE *f, u32 r, u32 g, u32 b) { + if (!f) return; + fprintf(f, CSI "38;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b); +} +void esc_fcolor_bg_rgb(FILE *f, u32 r, u32 g, u32 b) { + if (!f) return; + fprintf(f, CSI "48;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b); +} +void esc_fcolor_fg_256(FILE *f, u32 n) { + if (!f) return; + fprintf(f, CSI "38;5;%u" ESC_SGR, (unsigned)n); +} +void esc_fcolor_bg_256(FILE *f, u32 n) { + if (!f) return; + fprintf(f, CSI "48;5;%u" ESC_SGR, (unsigned)n); +} + +void esc_fcursor_hide(FILE *f) { if (!f) return; fputs(CSI "?25l", f); } +void esc_fcursor_show(FILE *f) { if (!f) return; fputs(CSI "?25h", f); } +void esc_fcursor_save(FILE *f) { if (!f) return; fputs(CSI "s", f); } +void esc_fcursor_restore(FILE *f) { if (!f) return; fputs(CSI "u", f); } +void esc_fbell(FILE *f) { if (!f) return; fputc('\a', f); } +void esc_falt_screen_on(FILE *f) { if (!f) return; fputs(CSI "?1049h", f); } +void esc_falt_screen_off(FILE *f) { if (!f) return; fputs(CSI "?1049l", f); } +void esc_fbracketed_paste_on(FILE *f) { if (!f) return; fputs(CSI "?2004h", f); } +void esc_fbracketed_paste_off(FILE *f) { if (!f) return; fputs(CSI "?2004l", f); } +void esc_fkitty_on(FILE *f) { if (!f) return; fputs(CSI ">1u", f); } +void esc_fkitty_off(FILE *f) { if (!f) return; fputs(CSI ">0u", f); } + +void esc_cursor_up(u32 n) { esc_fcursor_up(stdout, n); } +void esc_cursor_down(u32 n) { esc_fcursor_down(stdout, n); } +void esc_cursor_left(u32 n) { esc_fcursor_left(stdout, n); } +void esc_cursor_right(u32 n) { esc_fcursor_right(stdout, n); } +void esc_cursor_line_next(u32 n) { esc_fcursor_line_next(stdout, n); } +void esc_cursor_previous(u32 n) { esc_fcursor_previous(stdout, n); } +void esc_cursor_position(u32 col, u32 row) { + esc_fcursor_position(stdout, col, row); +} +void esc_clear_screen(void) { esc_fclear_screen(stdout); } +void esc_clear_screen_start(void) { esc_fclear_screen_start(stdout); } +void esc_clear_screen_end(void) { esc_fclear_screen_end(stdout); } +void esc_clear_line(void) { esc_fclear_line(stdout); } +void esc_clear_line_start(void) { esc_fclear_line_start(stdout); } +void esc_clear_line_end(void) { esc_fclear_line_end(stdout); } +void esc_scroll_up(u32 n) { esc_fscroll_up(stdout, n); } +void esc_scroll_down(u32 n) { esc_fscroll_down(stdout, n); } + +void esc_color_fg_rgb(u32 r, u32 g, u32 b) { + esc_fcolor_fg_rgb(stdout, r, g, b); +} +void esc_color_bg_rgb(u32 r, u32 g, u32 b) { + esc_fcolor_bg_rgb(stdout, r, g, b); +} +void esc_color_fg_256(u32 n) { + esc_fcolor_fg_256(stdout, n); +} +void esc_color_bg_256(u32 n) { + esc_fcolor_bg_256(stdout, n); +} + +void esc_cursor_hide(void) { esc_fcursor_hide(stdout); } +void esc_cursor_show(void) { esc_fcursor_show(stdout); } +void esc_cursor_save(void) { esc_fcursor_save(stdout); } +void esc_cursor_restore(void) { esc_fcursor_restore(stdout); } +void esc_bell(void) { esc_fbell(stdout); } +void esc_alt_screen_on(void) { esc_falt_screen_on(stdout); } +void esc_alt_screen_off(void) { esc_falt_screen_off(stdout); } +void esc_bracketed_paste_on(void) { esc_fbracketed_paste_on(stdout); } +void esc_bracketed_paste_off(void) { esc_fbracketed_paste_off(stdout); } +void esc_kitty_on(void) { esc_fkitty_on(stdout); } +void esc_kitty_off(void) { esc_fkitty_off(stdout); } + +#endif // ESCAPE_IMPLEMENTATION
\ No newline at end of file diff --git a/include/frontmatter.h b/include/frontmatter.h new file mode 100644 index 0000000..56c6935 --- /dev/null +++ b/include/frontmatter.h @@ -0,0 +1,1058 @@ +/* frontmatter.h — portable, header-only markdown frontmatter library. + * + * Parses a markdown document with an optional `---` fenced frontmatter block + * into typed values. Zero dependencies: libc only. Not full YAML: simple + * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields + * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`). + * + * Format: + * - Frontmatter is delimited by a leading `---` line and a closing `---` + * line. An unterminated fence (no closing `---`) means the whole text is + * body — no metadata. + * - Lines are `key: value`. Blank lines, `#` comments, and lines without a + * colon are skipped. Keys and values are trimmed. + * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list + * (JSON array; non-string elements are serialized); anything else → + * string (quotes stripped if present). + * - The body is everything after the closing fence, trailing whitespace + * trimmed. Documents without frontmatter: whole text, trailing + * whitespace trimmed. + * + * Usage: #include "frontmatter.h" in your code. The header is complete on + * its own; all functions are static inline, safe to include in any number + * of translation units. + * + * API: + * parse_frontmatter(text, &doc) split frontmatter + body + * doc_get(&doc, "key") value or NULL + * doc_free(&doc) free the document + * fm_value_free(&val) free one value + * fm_list_items(&val, &items) list-valued field as string items + * fm_json_parse(text) embedded JSON value or NULL (invalid) + * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v) + * fm_json_merge_owned(base, over) shallow object merge; owns both inputs + * + * Concurrency: no shared state. Each call is self-contained. + */ +#ifndef FRONTMATTER_H +#define FRONTMATTER_H + +#include <ctype.h> +#include <errno.h> +#include <stdarg.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------ */ + +static inline void *fm_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "frontmatter: out of memory\n"); + abort(); + } + return p; +} + +static inline void *fm_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "frontmatter: out of memory\n"); + abort(); + } + return q; +} + +static inline char *fm_strdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *)fm_xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *fm_strndup(const char *s, size_t n) { + char *p = (char *)fm_xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *fm_vsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return fm_strdup(""); + char *p = (char *)fm_xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +static inline char *fm_strfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = fm_vsnprintf(fmt, ap); + va_end(ap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* Growable byte buffer (NUL-terminated on steal) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *data; + size_t len; + size_t cap; +} fm_buf; + +static inline void fm_buf_init(fm_buf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void fm_buf_free(fm_buf *b) { + free(b->data); + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void fm_buf_reset(fm_buf *b) { + b->len = 0; +} + +static inline void fm_buf_reserve(fm_buf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *)fm_xrealloc(b->data, cap); + b->cap = cap; + } +} + +static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) { + fm_buf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static inline void fm_buf_put(fm_buf *b, const char *s) { + if (s) + fm_buf_putn(b, s, strlen(s)); +} + +static inline void fm_buf_push(fm_buf *b, char c) { + fm_buf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +static inline int fm_buf_fmt(fm_buf *b, const char *fmt, ...) { + va_list ap, ap2; + if (!fmt) + return 0; + va_start(ap, fmt); + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) { + va_end(ap); + return -1; + } + fm_buf_reserve(b, (size_t)n); + vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap); + va_end(ap); + b->len += (size_t)n; + return n; +} + +static inline char *fm_buf_steal(fm_buf *b) { + if (!b->data) + b->data = (char *)fm_xmalloc(1); + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = 0; + b->cap = 0; + return s; +} + +/* ------------------------------------------------------------------ */ +/* String list */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char **items; + size_t len; + size_t cap; +} fm_strlist; + +static inline void fm_sl_init(fm_strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +static inline void fm_sl_free(fm_strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + fm_sl_init(l); +} + +static inline void fm_sl_push(fm_strlist *l, char *owned) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = owned; +} + +static inline void fm_sl_push_copy(fm_strlist *l, const char *s) { + fm_sl_push(l, fm_strdup(s)); +} + +static inline void fm_sl_pop(fm_strlist *l) { + if (!l->len) + return; + free(l->items[--l->len]); +} + +/* ------------------------------------------------------------------ */ +/* Embedded JSON values */ +/* Object, array, string, number, bool, null. Parse, serialize, */ +/* lookup, shallow-merge. */ +/* ------------------------------------------------------------------ */ + +enum { + FMJ_OBJECT, + FMJ_ARRAY, + FMJ_STRING, + FMJ_NUMBER, + FMJ_BOOL, + FMJ_NULL, +}; + +typedef struct fm_json { + int type; + char *key; /* OBJECT member key (owned), else NULL */ + char *str; /* FMJ_STRING value (owned), else NULL */ + double num; /* FMJ_NUMBER */ + int is_int; /* FMJ_NUMBER was an integer literal in range */ + long long i; /* FMJ_NUMBER integral value */ + int b; /* FMJ_BOOL */ + struct fm_json **items; /* OBJECT/ARRAY members (owned) */ + size_t count; +} fm_json; + +static inline fm_json *fm_json_new(int type) { + fm_json *v = (fm_json *)fm_xmalloc(sizeof *v); + memset(v, 0, sizeof *v); + v->type = type; + return v; +} + +static inline void fm_json_free(fm_json *v) { + if (!v) + return; + for (size_t i = 0; i < v->count; i++) + fm_json_free(v->items[i]); + free(v->items); + free(v->key); + free(v->str); + free(v); +} + +static inline const fm_json *fm_json_get(const fm_json *o, const char *key) { + if (!o || o->type != FMJ_OBJECT || !key) + return NULL; + for (size_t i = 0; i < o->count; i++) + if (o->items[i] && o->items[i]->key && + strcmp(o->items[i]->key, key) == 0) + return o->items[i]; + return NULL; +} + +/* Max nesting depth for the recursive-descent JSON parser; bounds stack + * growth so a pathologically deep input (e.g. 100k nested arrays) is + * rejected with an error instead of overflowing the C stack. */ +#define FM_JSON_MAX_DEPTH 256 + +typedef struct { + const char *p; + const char *end; + int err; + size_t depth; +} fm_jp; + +static inline void fm_jskip(fm_jp *s) { + while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' || + *s->p == '\r')) + s->p++; +} + +/* String at s->p (opening quote). Returns owned unescaped text. */ +static inline char *fm_jstring_raw(fm_jp *s) { + if (s->p >= s->end || *s->p != '"') { + s->err = 1; + return NULL; + } + s->p++; + fm_buf b; + fm_buf_init(&b); + while (s->p < s->end && *s->p != '"') { + char c = *s->p++; + if (c == '\\' && s->p < s->end) { + char e = *s->p++; + switch (e) { + case '"': + fm_buf_push(&b, '"'); + break; + case '\\': + fm_buf_push(&b, '\\'); + break; + case '/': + fm_buf_push(&b, '/'); + break; + case 'b': + fm_buf_push(&b, '\b'); + break; + case 'f': + fm_buf_push(&b, '\f'); + break; + case 'n': + fm_buf_push(&b, '\n'); + break; + case 'r': + fm_buf_push(&b, '\r'); + break; + case 't': + fm_buf_push(&b, '\t'); + break; + case 'u': { + if (s->end - s->p < 4) + goto fail; + unsigned h = 0; + bool ok = true; + for (int k = 0; k < 4; k++) { + char hc = s->p[k]; + int d; + if (hc >= '0' && hc <= '9') + d = hc - '0'; + else if (hc >= 'a' && hc <= 'f') + d = hc - 'a' + 10; + else if (hc >= 'A' && hc <= 'F') + d = hc - 'A' + 10; + else { + ok = false; + break; + } + h = h * 16 + (unsigned)d; + } + if (!ok) + goto fail; + s->p += 4; + /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by + * low \uDC00-\uDFFF) into a single codepoint so astral chars + * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than + * two CESU-8 3-byte sequences. Lone surrogates fall through to + * the 3-byte (lenient) encoding below. */ + unsigned cp = h; + if (h >= 0xD800u && h <= 0xDBFFu && + s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') { + unsigned lo = 0; + bool lok = true; + for (int k = 0; k < 4; k++) { + char hc = s->p[2 + k]; + int d; + if (hc >= '0' && hc <= '9') + d = hc - '0'; + else if (hc >= 'a' && hc <= 'f') + d = hc - 'a' + 10; + else if (hc >= 'A' && hc <= 'F') + d = hc - 'A' + 10; + else { + lok = false; + break; + } + lo = lo * 16 + (unsigned)d; + } + if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) { + cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u); + s->p += 6; + } + } + if (cp < 0x80) { + fm_buf_push(&b, (char)cp); + } else if (cp < 0x800) { + fm_buf_push(&b, (char)(0xC0 | (cp >> 6))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + fm_buf_push(&b, (char)(0xE0 | (cp >> 12))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } else { + fm_buf_push(&b, (char)(0xF0 | (cp >> 18))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } + break; + } + default: + goto fail; + } + } else if (c < 0x20) { + goto fail; /* raw control char: invalid JSON */ + } else { + fm_buf_push(&b, c); + } + } + if (s->p >= s->end || *s->p != '"') + goto fail; + s->p++; + return fm_buf_steal(&b); +fail: + s->err = 1; + fm_buf_free(&b); + return NULL; +} + +static inline fm_json *fm_jvalue(fm_jp *s); + +static inline fm_json *fm_jobject(fm_jp *s) { + if (s->depth >= FM_JSON_MAX_DEPTH) { + s->err = 1; + return NULL; + } + s->depth++; + s->p++; /* { */ + fm_json *o = fm_json_new(FMJ_OBJECT); + for (;;) { + fm_jskip(s); + if (s->p < s->end && *s->p == '}') { + s->p++; + s->depth--; + return o; + } + char *key = fm_jstring_raw(s); + if (!key) { + fm_json_free(o); + return NULL; + } + fm_jskip(s); + if (s->p >= s->end || *s->p != ':') { + free(key); + fm_json_free(o); + s->err = 1; + return NULL; + } + s->p++; + fm_json *v = fm_jvalue(s); + if (!v) { + free(key); + fm_json_free(o); + return NULL; + } + v->key = key; + o->items = + (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *)); + o->items[o->count++] = v; + fm_jskip(s); + if (s->p < s->end && *s->p == ',') { + s->p++; + continue; + } + if (s->p < s->end && *s->p == '}') { + s->p++; + s->depth--; + return o; + } + fm_json_free(o); + s->err = 1; + return NULL; + } +} + +static inline fm_json *fm_jarray(fm_jp *s) { + if (s->depth >= FM_JSON_MAX_DEPTH) { + s->err = 1; + return NULL; + } + s->depth++; + s->p++; /* [ */ + fm_json *a = fm_json_new(FMJ_ARRAY); + for (;;) { + fm_jskip(s); + if (s->p < s->end && *s->p == ']') { + s->p++; + s->depth--; + return a; + } + fm_json *v = fm_jvalue(s); + if (!v) { + fm_json_free(a); + return NULL; + } + a->items = + (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *)); + a->items[a->count++] = v; + fm_jskip(s); + if (s->p < s->end && *s->p == ',') { + s->p++; + continue; + } + if (s->p < s->end && *s->p == ']') { + s->p++; + s->depth--; + return a; + } + fm_json_free(a); + s->err = 1; + return NULL; + } +} + +static inline fm_json *fm_jvalue(fm_jp *s) { + fm_jskip(s); + if (s->p >= s->end) { + s->err = 1; + return NULL; + } + char c = *s->p; + if (c == '{') + return fm_jobject(s); + if (c == '[') + return fm_jarray(s); + if (c == '"') { + char *t = fm_jstring_raw(s); + if (!t) + return NULL; + fm_json *v = fm_json_new(FMJ_STRING); + v->str = t; + return v; + } + if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) { + s->p += 4; + fm_json *v = fm_json_new(FMJ_BOOL); + v->b = 1; + return v; + } + if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) { + s->p += 5; + fm_json *v = fm_json_new(FMJ_BOOL); + v->b = 0; + return v; + } + if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) { + s->p += 4; + return fm_json_new(FMJ_NULL); + } + if (c == '-' || (c >= '0' && c <= '9')) { + const char *start = s->p; + if (c == '-') + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' && + s->p[1] <= '9') { + s->err = 1; /* leading zero */ + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + int is_int = 1; + if (s->p < s->end && *s->p == '.') { + is_int = 0; + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + } + if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) { + is_int = 0; + s->p++; + if (s->p < s->end && (*s->p == '+' || *s->p == '-')) + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + } + char *endp = NULL; + errno = 0; + double d = strtod(start, &endp); + if (errno != 0 || endp == start) { + s->err = 1; + return NULL; + } + fm_json *v = fm_json_new(FMJ_NUMBER); + v->num = d; + if (is_int && d >= -9.0e18 && d <= 9.0e18) { + v->is_int = 1; + v->i = (long long)d; + } + return v; + } + s->err = 1; + return NULL; +} + +static inline fm_json *fm_json_parse(const char *text) { + if (!text) + return NULL; + fm_jp s = {text, text + strlen(text), 0, 0}; + fm_json *v = fm_jvalue(&s); + if (!v) + return NULL; + fm_jskip(&s); + if (s.p != s.end) { /* trailing garbage */ + fm_json_free(v); + return NULL; + } + return v; +} + +static inline void fm_jstring_out(fm_buf *b, const char *s) { + fm_buf_push(b, '"'); + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + unsigned char c = *p; + switch (c) { + case '"': + fm_buf_put(b, "\\\""); + break; + case '\\': + fm_buf_put(b, "\\\\"); + break; + case '\b': + fm_buf_put(b, "\\b"); + break; + case '\f': + fm_buf_put(b, "\\f"); + break; + case '\n': + fm_buf_put(b, "\\n"); + break; + case '\r': + fm_buf_put(b, "\\r"); + break; + case '\t': + fm_buf_put(b, "\\t"); + break; + default: + if (c < 0x20) + fm_buf_fmt(b, "\\u%04x", c); + else + fm_buf_push(b, (char)c); + } + } + fm_buf_push(b, '"'); +} + +static inline void fm_json_out(const fm_json *v, fm_buf *b) { + switch (v->type) { + case FMJ_NULL: + fm_buf_put(b, "null"); + break; + case FMJ_BOOL: + fm_buf_put(b, v->b ? "true" : "false"); + break; + case FMJ_STRING: + fm_jstring_out(b, v->str ? v->str : ""); + break; + case FMJ_NUMBER: + if (v->is_int) + fm_buf_fmt(b, "%lld", v->i); + else + fm_buf_fmt(b, "%.15g", v->num); + break; + case FMJ_ARRAY: + fm_buf_push(b, '['); + for (size_t i = 0; i < v->count; i++) { + if (i) + fm_buf_put(b, ","); + fm_json_out(v->items[i], b); + } + fm_buf_push(b, ']'); + break; + case FMJ_OBJECT: + fm_buf_push(b, '{'); + for (size_t i = 0; i < v->count; i++) { + if (i) + fm_buf_put(b, ","); + fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : ""); + fm_buf_put(b, ":"); + fm_json_out(v->items[i], b); + } + fm_buf_push(b, '}'); + break; + } +} + +static inline char *fm_json_serialize(const fm_json *v) { + fm_buf b; + fm_buf_init(&b); + if (v) + fm_json_out(v, &b); + return fm_buf_steal(&b); +} + +/* Shallow merge of two objects: union of keys, `over` wins per key. + * Takes ownership of both inputs (either may be NULL or non-object, in + * which case it is dropped). Returns a new object; free with fm_json_free. + * Member order: over's keys first, then base-only keys. */ +static inline fm_json *fm_json_merge_owned(const fm_json *base, + const fm_json *over) { + fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL; + fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL; + if (base && base->type != FMJ_OBJECT) + fm_json_free((fm_json *)base); + if (over && over->type != FMJ_OBJECT) + fm_json_free((fm_json *)over); + fm_json *r = fm_json_new(FMJ_OBJECT); + if (O) { + for (size_t i = 0; i < O->count; i++) + if (O->items[i]) { + r->items = + (fm_json **)fm_xrealloc(r->items, + (r->count + 1) * sizeof(fm_json *)); + r->items[r->count++] = O->items[i]; + } + } + if (B) { + for (size_t i = 0; i < B->count; i++) { + fm_json *m = B->items[i]; + if (!m) + continue; + if (O && fm_json_get(O, m->key)) + fm_json_free(m); /* superseded */ + else { + r->items = + (fm_json **)fm_xrealloc(r->items, + (r->count + 1) * sizeof(fm_json *)); + r->items[r->count++] = m; + } + } + } + if (B) { + free(B->items); + free(B->key); + free(B->str); + free(B); + } + if (O) { + free(O->items); + free(O->key); + free(O->str); + free(O); + } + return r; +} + +/* ------------------------------------------------------------------ */ +/* Frontmatter */ +/* ------------------------------------------------------------------ */ + +/* Frontmatter value */ +typedef enum { + FM_STR, + FM_LIST, + FM_BOOL, + FM_INT, +} fm_kind; + +typedef struct { + fm_kind kind; + char *s; + char **items; + size_t item_count; + bool b; + long long i; +} fm_value; + +static inline void fm_value_free(fm_value *v) { + free(v->s); + for (size_t i = 0; i < v->item_count; i++) + free(v->items[i]); + free(v->items); + memset(v, 0, sizeof *v); +} + +/* List-valued field as items: JSON array as-is, CSV split on commas, + * single string as one item. */ +static inline void fm_list_items(const fm_value *v, fm_strlist *out) { + fm_sl_init(out); + if (!v) + return; + if (v->kind == FM_LIST) { + for (size_t i = 0; i < v->item_count; i++) + fm_sl_push_copy(out, v->items[i]); + return; + } + if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) { + if (v->s) { + size_t a = 0, n = strlen(v->s); + while (a < n && isspace((unsigned char)v->s[a])) + a++; + while (n > a && isspace((unsigned char)v->s[n - 1])) + n--; + if (n > a) + fm_sl_push(out, fm_strndup(v->s + a, n - a)); + } + return; + } + const char *s = v->s; + size_t n = strlen(s); + size_t start = 0; + for (;;) { + while (start < n && isspace((unsigned char)s[start])) + start++; + if (start >= n) + break; + const char *comma = (const char *)memchr(s + start, ',', n - start); + size_t seg_end = comma ? (size_t)(comma - s) : n; + size_t a = start, b = seg_end; + while (b > a && isspace((unsigned char)s[b - 1])) + b--; + if (b > a) + fm_sl_push(out, fm_strndup(s + a, b - a)); + if (!comma) + break; + start = seg_end + 1; + } +} + +/* A frontmatter entry */ +typedef struct { + char *key; + fm_value val; +} fm_entry; + +/* A markdown document with optional `---` frontmatter */ +typedef struct { + fm_entry *entries; + size_t count; + size_t cap; + char *body; +} doc; + +static inline const fm_value *doc_get(const doc *d, const char *key) { + for (size_t i = 0; i < d->count; i++) + if (strcmp(d->entries[i].key, key) == 0) + return &d->entries[i].val; + return NULL; +} + +static inline void doc_free(doc *d) { + for (size_t i = 0; i < d->count; i++) { + free(d->entries[i].key); + fm_value_free(&d->entries[i].val); + } + free(d->entries); + free(d->body); + memset(d, 0, sizeof *d); +} + +static inline void fm_parse_value(const char *raw, fm_value *v) { + memset(v, 0, sizeof *v); + if (raw[0] == '[') { + fm_json *arr = fm_json_parse(raw); + if (arr && arr->type == FMJ_ARRAY) { + v->kind = FM_LIST; + v->items = (char **)fm_xmalloc(arr->count * sizeof(char *)); + size_t i = 0; + for (size_t k = 0; k < arr->count; k++) { + const fm_json *it = arr->items[k]; + if (it && it->type == FMJ_STRING) + v->items[i++] = fm_strdup(it->str); + else + v->items[i++] = it ? fm_json_serialize(it) + : fm_strdup(""); + } + v->item_count = arr->count; + fm_json_free(arr); + return; + } + fm_json_free(arr); + v->kind = FM_STR; + v->s = fm_strdup(raw); + return; + } + if (strcmp(raw, "true") == 0) { + v->kind = FM_BOOL; + v->b = true; + return; + } + if (strcmp(raw, "false") == 0) { + v->kind = FM_BOOL; + v->b = false; + return; + } + size_t len = strlen(raw); + if (len >= 2 && + ((raw[0] == '"' && raw[len - 1] == '"') || + (raw[0] == '\'' && raw[len - 1] == '\''))) { + v->kind = FM_STR; + v->s = fm_strndup(raw + 1, len - 2); + return; + } + /* integer? */ + char *end = NULL; + errno = 0; + long long n = strtoll(raw, &end, 10); + if (errno == 0 && end && *end == '\0' && raw[0] != '\0') { + v->kind = FM_INT; + v->i = n; + return; + } + v->kind = FM_STR; + v->s = fm_strdup(raw); +} + +/* Split text into lines (no trailing empty). Returns line count. */ +static inline char **fm_split_lines(const char *text, size_t *out_count) { + size_t cap = 16, n = 0; + char **lines = (char **)fm_xmalloc(cap * sizeof(char *)); + const char *p = text; + while (*p) { + const char *nl = (const char *)strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (n + 1 > cap) { + cap *= 2; + lines = (char **)fm_xrealloc(lines, cap * sizeof(char *)); + } + lines[n++] = fm_strndup(p, len); + if (!nl) + break; + p = nl + 1; + } + *out_count = n; + return lines; +} + +static inline void parse_frontmatter(const char *text, doc *out) { + memset(out, 0, sizeof *out); + size_t nlines = 0; + char **lines = fm_split_lines(text, &nlines); + + char *body = fm_strdup(text); /* default: whole text */ + if (nlines == 0) { + /* keep as-is */ + } else if (strcmp(lines[0], "---") != 0) { + /* trim end */ + size_t blen = strlen(body); + while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' || + body[blen - 1] == ' ')) + body[--blen] = '\0'; + } else { + size_t close = 0; + bool found = false; + for (size_t i = 1; i < nlines; i++) { + if (strcmp(lines[i], "---") == 0) { + close = i - 1; /* index within lines[1..] */ + found = true; + break; + } + } + if (!found) { + /* whole text is body, trimmed */ + size_t blen = strlen(body); + while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' || + body[blen - 1] == ' ')) + body[--blen] = '\0'; + } else { + for (size_t i = 1; i <= close; i++) { + const char *line = lines[i]; + /* trim */ + size_t start = 0, len = strlen(line); + while (start < len && isspace((unsigned char)line[start])) + start++; + while (len > start && + isspace((unsigned char)line[len - 1])) + len--; + if (len == 0 || line[start] == '#') + continue; + const char *colon = + (const char *)memchr(line + start, ':', len - start); + if (!colon) + continue; + size_t klen = (size_t)(colon - (line + start)); + if (klen == 0) + continue; + char *key = fm_strndup(line + start, klen); + const char *vraw = colon + 1; + /* trim value */ + size_t vlen = strlen(vraw); + size_t vstart = 0; + while (vstart < vlen && + isspace((unsigned char)vraw[vstart])) + vstart++; + while (vlen > vstart && + isspace((unsigned char)vraw[vlen - 1])) + vlen--; + fm_value val; + char *vtmp = fm_strndup(vraw + vstart, vlen - vstart); + fm_parse_value(vtmp, &val); + free(vtmp); + if (out->count + 1 > out->cap) { + out->cap = out->cap ? out->cap * 2 : 8; + out->entries = + (fm_entry *)fm_xrealloc(out->entries, + out->cap * + sizeof(out->entries[0])); + } + out->entries[out->count].key = key; + out->entries[out->count].val = val; + out->count++; + } + /* body = lines[2+close..] joined by \n, trimmed end */ + fm_buf b; + fm_buf_init(&b); + for (size_t i = 2 + close; i < nlines; i++) { + if (i > 2 + close) + fm_buf_push(&b, '\n'); + fm_buf_put(&b, lines[i]); + } + char *nb = fm_buf_steal(&b); + size_t blen = strlen(nb); + while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' || + nb[blen - 1] == ' ')) + nb[--blen] = '\0'; + free(body); + body = nb; + } + } + for (size_t i = 0; i < nlines; i++) + free(lines[i]); + free(lines); + out->body = body; +} + +#ifdef __cplusplus +} +#endif + +#endif /* FRONTMATTER_H */ diff --git a/include/type.h b/include/type.h new file mode 100644 index 0000000..1106832 --- /dev/null +++ b/include/type.h @@ -0,0 +1,24 @@ +// type.h - Common type aliases +// +// Used across header libraries to provide consistent, concise type names +// for fixed-width integers and floating point types. + +#ifndef TYPE_H +#define TYPE_H + +#include <stdint.h> + +typedef double f64; +typedef float f32; + +typedef int8_t i8; +typedef int16_t i16; +typedef int32_t i32; +typedef int64_t i64; + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; + +#endif diff --git a/src/agent.cpp b/src/agent.cpp new file mode 100644 index 0000000..d1357b7 --- /dev/null +++ b/src/agent.cpp @@ -0,0 +1,435 @@ +#include "agent.h" + +#include <curl/curl.h> +#include <cmath> +#include <limits> +#include <stdlib.h> +#include <string.h> + +static char *trim_slashes(char *s) { + size_t n = strlen(s); + while (n && s[n - 1] == '/') + s[--n] = '\0'; + return s; +} + +void provider_probe_free(provider_probe *p) { + free(p->detail); + p->detail = NULL; +} + +const char *agent_default_base_url(provider_kind p) { + switch (p) { + case PROV_OPENAI: + return "https://api.openai.com/v1"; + case PROV_ANTHROPIC: + return "https://api.anthropic.com"; + case PROV_OLLAMA: + return "http://localhost:11434"; + } + return "https://api.openai.com/v1"; +} + +char *agent_models_url(const config *cfg) { + char *base = xstrdup(cfg->base_url ? cfg->base_url + : agent_default_base_url(cfg->provider)); + trim_slashes(base); + xbuf u; + xbuf_init(&u); + xbuf_put(&u, base); + switch (cfg->provider) { + case PROV_OPENAI: + xbuf_push(&u, '/'); + xbuf_put(&u, "models"); + break; + case PROV_ANTHROPIC: { + /* The canonical base is api.anthropic.com; the /v1 prefix belongs to + * the path. If the caller already included /v1 in base_url, don't + * duplicate it (avoids .../v1/v1/models). */ + size_t blen = strlen(base); + if (blen >= 3 && memcmp(base + blen - 3, "/v1", 3) == 0) + xbuf_put(&u, "/models?limit=100"); + else + xbuf_put(&u, "/v1/models?limit=100"); + break; + } + case PROV_OLLAMA: { + xbuf_put(&u, "/api/show?name="); + char *enc = cfg->model ? curl_easy_escape(NULL, cfg->model, 0) : NULL; + xbuf_put(&u, enc ? enc : ""); + free(enc); + break; + } + } + char *out = xbuf_steal(&u); + free(base); + return out; +} + +void agent_auth(const config *cfg, char **header, char **value) { + if (header) + *header = NULL; + if (value) + *value = NULL; + switch (cfg->provider) { + case PROV_OPENAI: + if (cfg->api_key) { + if (header) + *header = xstrdup("Authorization"); + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "Bearer %s", cfg->api_key); + if (value) + *value = xbuf_steal(&b); + else + xbuf_free(&b); + } + break; + case PROV_ANTHROPIC: + if (cfg->api_key) { + if (header) + *header = xstrdup("x-api-key"); + if (value) + *value = xstrdup(cfg->api_key); + } + break; + case PROV_OLLAMA: + break; + } +} + +int agent_http_get(const char *url, const char *auth_header, + const char *auth_value, 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 (auth_header && auth_value) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", auth_header, auth_value); + h = curl_slist_append(h, hb.data); + xbuf_free(&hb); + curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); + } + curl_easy_setopt(c, CURLOPT_URL, url); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(c, CURLOPT_CAINFO, ca); + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, xbuf_curl_write); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &b); + 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; +} + +static bool str_eq(const cJSON *v, const char *s) { + return cJSON_IsString(v) && v->valuestring && + strcmp(v->valuestring, s) == 0; +} + +static unsigned long num_u64(const cJSON *v) { + if (cJSON_IsNumber(v) && std::isfinite(v->valuedouble) && + v->valuedouble >= 0 && std::floor(v->valuedouble) == v->valuedouble && + v->valuedouble < (double)std::numeric_limits<unsigned long>::max()) + return (unsigned long)v->valuedouble; + return 0; +} + +static bool openai_model_matches_entry(const cJSON *entry, const char *model) { + if (str_eq(cJSON_GetObjectItem(entry, "id"), model)) + return true; + if (str_eq(cJSON_GetObjectItem(entry, "name"), model)) + return true; + const cJSON *al = cJSON_GetObjectItem(entry, "aliases"); + if (cJSON_IsArray(al)) { + const cJSON *x = NULL; + cJSON_ArrayForEach(x, al) + if (str_eq(x, model)) + return true; + } + return false; +} + +bool openai_model_present(const char *body, const char *model) { + cJSON *v = cJSON_Parse(body); + if (!v) + return false; + bool found = false; + cJSON *arr = cJSON_GetObjectItem(v, "data"); + if (!arr) + arr = cJSON_GetObjectItem(v, "models"); + if (cJSON_IsArray(arr)) { + const cJSON *m = NULL; + cJSON_ArrayForEach(m, arr) + if (openai_model_matches_entry(m, model)) { + found = true; + break; + } + } + cJSON_Delete(v); + return found; +} + +bool anthropic_model_present(const char *body, const char *model) { + cJSON *v = cJSON_Parse(body); + if (!v) + return false; + bool found = false; + const cJSON *arr = cJSON_GetObjectItem(v, "data"); + if (cJSON_IsArray(arr)) { + const cJSON *m = NULL; + cJSON_ArrayForEach(m, arr) + if (str_eq(cJSON_GetObjectItem(m, "id"), model)) { + found = true; + break; + } + } + cJSON_Delete(v); + return found; +} + +unsigned long extract_context_limit(const cJSON *json, const config *cfg) { + if (!json) + return 0; + switch (cfg->provider) { + case PROV_OPENAI: { + unsigned long v = num_u64(cJSON_GetObjectItem(json, "context_length")); + if (v) + return v; + const cJSON *data = cJSON_GetObjectItem(json, "data"); + if (!cJSON_IsArray(data)) + return 0; + const cJSON *want = NULL; + const cJSON *m = NULL; + cJSON_ArrayForEach(m, data) + if (openai_model_matches_entry(m, cfg->model)) { + want = m; + break; + } + if (!want && data->child) + want = data->child; + if (!want) + return 0; + v = num_u64(cJSON_GetObjectItem(want, "context_length")); + if (v) + return v; + v = num_u64(cJSON_GetObjectItem(cJSON_GetObjectItem(want, "meta"), + "n_ctx")); + if (v) + return v; + return num_u64(cJSON_GetObjectItem(want, "context_window")); + } + case PROV_OLLAMA: { + unsigned long v = num_u64(cJSON_GetObjectItem( + cJSON_GetObjectItem(json, "model_details"), "context_length")); + if (v) + return v; + const cJSON *mf = cJSON_GetObjectItem(json, "modelfiles"); + if (cJSON_IsArray(mf) && mf->child) + v = num_u64(cJSON_GetObjectItem( + cJSON_GetObjectItem(mf->child, "parameters"), + "context_length")); + return v; + } + case PROV_ANTHROPIC: { + const cJSON *data = cJSON_GetObjectItem(json, "data"); + if (!cJSON_IsArray(data) || !data->child) + return 0; + const cJSON *want = NULL; + const cJSON *m = NULL; + cJSON_ArrayForEach(m, data) + if (str_eq(cJSON_GetObjectItem(m, "id"), cfg->model)) { + want = m; + break; + } + if (!want) + want = data->child; + return num_u64(cJSON_GetObjectItem(want, "max_tokens")); + } + } + return 0; +} + +unsigned long agent_fetch_context_limit(const config *cfg) { + char *url = agent_models_url(cfg); + char *h = NULL, *v = NULL; + agent_auth(cfg, &h, &v); + long status = 0; + char *body = NULL; + char *err = NULL; + int rc = agent_http_get(url, h, v, &status, &body, &err); + free(h); + free(v); + free(url); + free(err); + if (rc != 0) { + free(body); + return 0; + } + unsigned long limit = 0; + /* No status gate (parity): a non-2xx body just has to parse and carry + * the field. */ + cJSON *j = cJSON_Parse(body); + if (j) { + limit = extract_context_limit(j, cfg); + cJSON_Delete(j); + } + free(body); + (void)status; + return limit; +} + +static const char *status_phrase(long code) { + switch (code) { + case 200: return "200 OK"; + case 201: return "201 Created"; + case 204: return "204 No Content"; + case 400: return "400 Bad Request"; + case 401: return "401 Unauthorized"; + case 403: return "403 Forbidden"; + case 404: return "404 Not Found"; + case 405: return "405 Method Not Allowed"; + case 408: return "408 Request Timeout"; + case 429: return "429 Too Many Requests"; + case 500: return "500 Internal Server Error"; + case 502: return "502 Bad Gateway"; + case 503: return "503 Service Unavailable"; + case 504: return "504 Gateway Timeout"; + default: { + static char buf[32]; + snprintf(buf, sizeof buf, "%ld", code); + return buf; + } + } +} + +provider_probe agent_probe_provider(const config *cfg) { + provider_probe p = {false, NULL}; + char *base = xstrdup(cfg->base_url ? cfg->base_url + : agent_default_base_url(cfg->provider)); + trim_slashes(base); + char *url = agent_models_url(cfg); + char *h = NULL, *v = NULL; + agent_auth(cfg, &h, &v); + long status = 0; + char *body = NULL; + char *err = NULL; + if (agent_http_get(url, h, v, &status, &body, &err) != 0) { + xbuf fb; + xbuf_init(&fb); + xbuf_fmt(&fb, "could not reach %s (connection: %s); is the endpoint " + "up and reachable?", + base, err ? err : "unknown"); + free(err); + free(h); + free(v); + free(url); + free(base); + free(body); + p.detail = xbuf_steal(&fb); + return p; + } + free(h); + free(v); + free(url); + bool present = false; + switch (cfg->provider) { + case PROV_OPENAI: + present = openai_model_present(body ? body : "", cfg->model); + break; + case PROV_ANTHROPIC: + present = anthropic_model_present(body ? body : "", cfg->model); + break; + case PROV_OLLAMA: + present = (status >= 200 && status < 300); + break; + } + bool success = status >= 200 && status < 300; + bool client_err = status >= 400 && status < 500; + xbuf fb; + xbuf_init(&fb); + switch (cfg->provider) { + case PROV_OPENAI: + if (success) { + if (present) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found in the provider list at %s", + cfg->model, base); + } else { + xbuf_fmt(&fb, "model `%s` not in the provider's model list " + "at %s", + cfg->model, base); + } + } else if (client_err) { + xbuf_fmt(&fb, "provider returned %s for %s/models (check api_key)", + status_phrase(status), base); + } else { + xbuf_fmt(&fb, "provider returned %s for %s/models", + status_phrase(status), base); + } + break; + case PROV_ANTHROPIC: + if (success) { + if (present) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found in the provider list", + cfg->model); + } else { + xbuf_fmt(&fb, "model `%s` not in the provider's model list " + "at %s", + cfg->model, base); + } + } else if (client_err) { + xbuf_fmt(&fb, "provider returned %s for %s/v1/models (check " + "api_key)", + status_phrase(status), base); + } else { + xbuf_fmt(&fb, "provider returned %s for %s/v1/models", + status_phrase(status), base); + } + break; + case PROV_OLLAMA: + if (success) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found via Ollama", cfg->model); + } else { + xbuf_fmt(&fb, "Ollama returned %s at %s (model `%s`)", + status_phrase(status), base, cfg->model); + } + break; + } + free(body); + p.detail = xbuf_steal(&fb); + free(base); + return p; +} diff --git a/src/agent.h b/src/agent.h new file mode 100644 index 0000000..a18f55e --- /dev/null +++ b/src/agent.h @@ -0,0 +1,41 @@ +#ifndef LLSH_AGENT_H +#define LLSH_AGENT_H + +#include "config.h" + +#include <cjson/cJSON.h> + +/* Mirrors agent.rs: provider probe + context-limit fetch (libcurl). */ + +typedef struct { + bool ok; + char *detail; /* owned; release with provider_probe_free */ +} provider_probe; + +void provider_probe_free(provider_probe *p); + +const char *agent_default_base_url(provider_kind p); + +/* Model-list endpoint for the probe/fetch. Caller frees. */ +char *agent_models_url(const config *cfg); +/* Auth header for the model-list endpoint. Caller frees each non-NULL. + * Ollama sends no auth: both come back NULL. */ +void agent_auth(const config *cfg, char **header, char **value); + +/* GET `url` with a 5s timeout. 0 = HTTP response received (*status, *body + * owned); -1 = transport failure (*err owned, body NULL). */ +int agent_http_get(const char *url, const char *auth_header, + const char *auth_value, long *status, char **body, + char **err); + +/* Pull the window size out of a provider's model-info response. 0 = absent. */ +unsigned long extract_context_limit(const cJSON *json, const config *cfg); + +bool openai_model_present(const char *body, const char *model); +bool anthropic_model_present(const char *body, const char *model); + +/* Best effort: network failures / non-2xx / missing fields -> 0. */ +unsigned long agent_fetch_context_limit(const config *cfg); +provider_probe agent_probe_provider(const config *cfg); + +#endif diff --git a/src/compact.cpp b/src/compact.cpp new file mode 100644 index 0000000..65e2210 --- /dev/null +++ b/src/compact.cpp @@ -0,0 +1,596 @@ +#include "compact.h" + +#include <ctype.h> +#include <stdlib.h> +#include <string.h> + +/* ------------------------------------------------------------------ */ +/* Preamble */ +/* ------------------------------------------------------------------ */ + +const char *compact_summarizer_preamble(void) { + return "You are a context compactor for a terminal coding agent. You are " + "given the transcript of the earlier part of that agent's " + "conversation, which no longer fits in the context window. Compress " + "it into a faithful, dense summary that lets the agent continue " + "seamlessly.\n" + "\n" + "Write ONLY the summary, in Markdown, with these exact sections in " + "this order:\n" + "\n" + "## Goal\n" + "What the user is trying to accomplish.\n" + "\n" + "## Decisions\n" + "Choices made and the reasons that still matter.\n" + "\n" + "## Work done\n" + "Concrete steps taken: files touched, commands run, and their " + "results.\n" + "\n" + "## Open issues\n" + "Errors, edge cases, or problems that remain unresolved.\n" + "\n" + "## Next steps\n" + "What should happen next.\n" + "\n" + "## Files\n" + "One bullet per file the agent read or edited, as `- <path>`.\n" + "\n" + "Rules:\n" + "- Recall over precision: keep every fact the agent would otherwise " + "have to rediscover; drop nothing load-bearing.\n" + "- Be factual and terse; never invent details that are not in the " + "transcript.\n" + "- Discard raw tool output and verbatim file contents; summarize " + "their substance instead.\n" + "- Keep it under 800 words."; +} + +/* ------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------ */ + +void compact_config_from(unsigned long context_limit, double compact_threshold, + double clear_threshold, compact_config *out) { + out->context_limit = context_limit; + out->compact_threshold = compact_threshold; + out->clear_threshold = clear_threshold; +} + +bool compact_at(const compact_config *c, unsigned long *out) { + if (c->compact_threshold <= 0.0) + return false; + *out = (unsigned long)(c->compact_threshold * (double)c->context_limit); + return true; +} + +bool clear_at(const compact_config *c, unsigned long *out) { + if (c->clear_threshold <= 0.0) + return false; + *out = (unsigned long)(c->clear_threshold * (double)c->context_limit); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Token estimation */ +/* ------------------------------------------------------------------ */ + +static size_t trc_bytes(const tool_result_content &c) { + if (std::holds_alternative<trc_image>(c)) + return 8; + if (const auto *t = std::get_if<trc_text>(&c)) + return t->text.size(); + if (const auto *s = std::get_if<trc_json>(&c)) + return s->value.size(); + return 0; +} + +static size_t uc_bytes(const user_part &p) { + if (const auto *t = std::get_if<user_text_part>(&p)) + return t->text.size(); + const auto &r = std::get<user_tool_result_part>(p); + size_t total = r.name.size(); + for (const auto &c : r.content) + total += trc_bytes(c); + return total; +} + +static size_t ac_bytes(const assistant_part &p) { + if (std::holds_alternative<asst_image_part>(p)) + return 0; + if (const auto *c = std::get_if<asst_tool_call_part>(&p)) + return c->name.size() + c->args.size(); + if (const auto *t = std::get_if<asst_text_part>(&p)) + return t->text.size(); + if (const auto *r = std::get_if<asst_reasoning_part>(&p)) + return r->text.size(); + return 0; +} + +static size_t message_bytes(const message *m) { + if (m->is_system()) + return m->system().text.size(); + size_t total = 0; + if (m->is_user()) { + for (const auto &p : m->user().parts) + total += uc_bytes(p); + } else if (m->is_assistant()) { + for (const auto &p : m->assistant().parts) + total += ac_bytes(p); + } + return total; +} + +unsigned long compact_estimate_tokens(const message *msgs, size_t n) { + unsigned long long bytes = 0; + for (size_t i = 0; i < n; i++) + bytes += message_bytes(&msgs[i]); + return (unsigned long)((bytes + 3) / 4); +} + +/* ------------------------------------------------------------------ */ +/* Boundary + clearing */ +/* ------------------------------------------------------------------ */ + +static bool compact_is_pure_text_user(const message *m) { + return m->is_user() && m->user().parts.size() == 1 && + std::holds_alternative<user_text_part>(m->user().parts[0]); +} + +size_t compact_keep_boundary(const message *msgs, size_t n) { + for (size_t i = n; i-- > 0;) { + if (compact_is_pure_text_user(&msgs[i])) + return i; + } + return n; +} + +size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary) { + size_t cleared = 0; + for (size_t i = 0; i < boundary && i < n; i++) { + message *m = &msgs[i]; + if (!m->is_user()) + continue; + for (auto &p : m->user().parts) { + user_tool_result_part *r = std::get_if<user_tool_result_part>(&p); + if (!r) + continue; + bool already = false; + for (const auto &c : r->content) { + const trc_text *t = std::get_if<trc_text>(&c); + if (t && t->text == CLEARED_PLACEHOLDER) + already = true; + } + if (already) + continue; + r->content.clear(); + trc_text t; + t.text = CLEARED_PLACEHOLDER; + r->content.push_back(std::move(t)); + cleared++; + } + } + return cleared; +} + +/* ------------------------------------------------------------------ */ +/* Transcripts */ +/* ------------------------------------------------------------------ */ + +static void put_trc(xbuf *b, const tool_result_content &c) { + if (std::holds_alternative<trc_image>(c)) { + xbuf_put(b, "[image]"); + return; + } + if (const auto *t = std::get_if<trc_text>(&c)) { + if (!t->text.empty()) + xbuf_put(b, t->text.c_str()); + return; + } + if (const auto *s = std::get_if<trc_json>(&c)) + if (!s->value.empty()) + xbuf_put(b, s->value.c_str()); +} + +char *compact_transcript(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "System: "); + xbuf_put(&b, m->system().text.c_str()); + xbuf_push(&b, '\n'); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if<user_text_part>(&p)) { + xbuf_put(&b, "User: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else { + const auto &r = std::get<user_tool_result_part>(p); + xbuf_fmt(&b, "ToolResult(%s): ", r.name.c_str()); + for (const auto &c : r.content) + put_trc(&b, c); + xbuf_push(&b, '\n'); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if<asst_text_part>(&p)) { + xbuf_put(&b, "Assistant: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) { + xbuf_fmt(&b, "Assistant tool call %s: %s", + c->name.c_str(), c->args.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) { + xbuf_put(&b, "Assistant reasoning: "); + xbuf_put(&b, r->text.c_str()); + xbuf_push(&b, '\n'); + } + } + } + } + return xbuf_steal(&b); +} + +static void push_md_tool_result(xbuf *b, const tool_result_content &c) { + if (const auto *t = std::get_if<trc_text>(&c)) { + xbuf_put(b, t->text.c_str()); + xbuf_put(b, "\n\n"); + } else if (const auto *s = std::get_if<trc_json>(&c)) { + xbuf_put(b, "```json\n"); + xbuf_put(b, s->value.c_str()); + xbuf_put(b, "\n```\n\n"); + } else { + xbuf_put(b, "[image]\n\n"); + } +} + +char *compact_transcript_markdown(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "# llsh transcript\n\n"); + if (n == 0) { + xbuf_put(&b, "_No messages yet._\n"); + return xbuf_steal(&b); + } + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "## System\n\n"); + xbuf_put(&b, m->system().text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if<user_text_part>(&p)) { + xbuf_put(&b, "## User\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else { + const auto &r = std::get<user_tool_result_part>(p); + xbuf_fmt(&b, "### Tool result: `%s`\n\n", + r.name.c_str()); + for (const auto &c : r.content) + push_md_tool_result(&b, c); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if<asst_text_part>(&p)) { + xbuf_put(&b, "## Assistant\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) { + xbuf_fmt(&b, "### Tool call: `%s`\n\n", + c->name.c_str()); + if (c->args.empty() || c->args == "null") { + xbuf_put(&b, "_no arguments_\n\n"); + } else { + xbuf_put(&b, "```json\n"); + xbuf_put(&b, c->args.c_str()); + xbuf_put(&b, "\n```\n\n"); + } + } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) { + if (!r->text.empty()) { + xbuf_put(&b, "> **reasoning** "); + xbuf_put(&b, r->text.c_str()); + xbuf_put(&b, "\n\n"); + } + } + } + } + } + return xbuf_steal(&b); +} + +/* ------------------------------------------------------------------ */ +/* File-path extraction */ +/* ------------------------------------------------------------------ */ + +static bool starts_with_i(const char *s, size_t n, const char *prefix) { + size_t pn = strlen(prefix); + if (n < pn) + return false; + for (size_t i = 0; i < pn; i++) { + char a = s[i]; + if (a >= 'A' && a <= 'Z') + a = (char)(a - 'A' + 'a'); + if (a != prefix[i]) + return false; + } + return true; +} + +void compact_recent_file_paths(const char *summary, strlist *out) { + sl_init(out); + if (!summary) + return; + bool in_files = false; + const char *p = summary; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + const char *line_end = p + linelen; + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 0 && line[0] == '#') { + const char *h = line; + while (*h == '#') + h++; + size_t hl; + h = xtrim_span(h, (size_t)(line_end - h), &hl); + in_files = starts_with_i(h, hl, "files"); + } else if (in_files && tlen > 0 && (line[0] == '-' || line[0] == '*')) { + const char *rest = line + 1; + size_t rl; + rest = xtrim_span(rest, (size_t)(line_end - rest), &rl); + if (rl > 0) + sl_push(out, xstrndup(rest, rl)); + } + p = nl ? nl + 1 : p + linelen; + } +} + +/* ------------------------------------------------------------------ */ +/* Compose history */ +/* ------------------------------------------------------------------ */ + +history *compact_compose_history(const char *summary, + const char **file_paths, + const char **file_contents, size_t nfiles, + const message *tail, size_t ntail) { + xbuf body; + xbuf_init(&body); + xbuf_put(&body, + "The earlier conversation was summarized to fit the context " + "window. Continue from this summary.\n\n"); + if (summary) { + size_t tlen; + const char *s = xtrim_span(summary, strlen(summary), &tlen); + xbuf_putn(&body, s, tlen); + xbuf_push(&body, '\n'); + } + + unsigned long total = 0; + size_t taken = 0; + for (size_t i = 0; i < nfiles && i < MAX_FILES; i++) { + if (total >= FILES_CAP) + break; + const char *content = file_contents[i] ? file_contents[i] : ""; + size_t cpoints = utf8_len(content); + unsigned long take = PER_FILE_CAP; + if (take > FILES_CAP - total) + take = FILES_CAP - total; + if (take > cpoints) + take = cpoints; + if (take == 0) + break; + size_t take_bytes = utf8_byte_len(content, take); + total += take; + taken++; + xbuf_fmt(&body, "\n### %s\n```\n", file_paths[i] ? file_paths[i] : ""); + xbuf_putn(&body, content, take_bytes); + xbuf_put(&body, "\n```\n"); + } + + history *h = (history *) xmalloc(sizeof(*h)); + new (h) history{}; + char *body_text = xbuf_steal(&body); + h->msgs.push_back(msg_user_text(body_text)); + free(body_text); + for (size_t i = 0; i < ntail; i++) + h->msgs.push_back(tail[i]); + (void)taken; + return h; +} + +/* ------------------------------------------------------------------ */ +/* SelfCompact-style rubric probe (arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +const char *compact_rubric_preamble(void) { + return "You are a careful judge for a terminal coding agent. You are " + "given the agent's trajectory and a search rubric of four yes/no " + "questions (C1, C2, C3, N1). Answer each question exactly in the " + "requested line format, with the evidence it asks for. Do not " + "summarize the trajectory, do not restate the questions, and add " + "no text beyond the four verdict lines."; +} + +const char *compact_rubric_prompt(void) { + return "You are about to decide whether to compress your conversation " + "history into a summary that REPLACES the full history above. " + "After compression, you continue from only [system, original " + "question, assistant summary, user continue]. Compression is " + "irreversible: anything not preserved in the summary is gone.\n" + "\n" + "Compression is safe ONLY when ALL FOUR of the following hold: " + "(C1) the trajectory has reached a closed unit (not mid-thought), " + "(C2) the essential information is reducible to 3-5 citable " + "facts without loss, (C3) something has progressed since the last " + "compression, (N1) you are NOT currently stuck in a way " + "summarization would mask. Answer C1, C2, C3, N1 honestly. Each " + "Y answer requires verbatim evidence quoted from the trajectory " + "above; answers without evidence default to N.\n" + "\n" + "C1 CLOSED-UNIT: The most recent assistant message is a closed " + "unit - a completed tool call whose result is now visible, or a " + "completed sub-analysis with a clear stopping point. It is NOT " + "mid-sentence reasoning (\"Let me now check...\", \"I should " + "next look at...\"), and not a half-formulated query. *If Y*, " + "quote the closing fragment of the last assistant message. *If " + "N*, quote the open fragment that shows the trajectory is " + "mid-thought.\n" + "C2 SUMMARIZABLE: You can write 3-5 essential facts (with " + "verbatim citations from the trajectory) that future-you needs " + "to continue after compression. Each fact must be a single " + "concrete statement: a name, date, URL, quoted claim, or " + "resolved sub-question. Answer N if the trajectory's value is " + "dispersed across many small inferences (e.g., a list of dead-" + "end queries needed to avoid retries, negative results that " + "constrain the hypothesis space) that would be lost without the " + "dispersal. *If Y*, list the 3-5 facts numbered, each with a " + "verbatim citation in quotes. *If N*, name in one sentence the " + "class of information that would be lost.\n" + "C3 PROGRESS: Since the most recent compression (or since the " + "start of the conversation if none), you have either obtained a " + "new concrete fact (name, date, URL, or quoted claim) OR " + "refined the sub-question being pursued. *If Y*, name the new " + "fact or refined sub-question. *If N*, state that you are " + "returning the same state you compressed from.\n" + "N1 STUCK: At least 3 of your last 4 tool calls returned no new " + "information (i.e., were duplicates or returned already-known " + "content). If you have made fewer than 4 tool calls total, " + "answer N. *If Y*, name 1 distinct strategy you have NOT yet " + "tried (different tool, different query type, different angle " + "on the question). *If N*, name one new fact obtained " + "recently.\n" + "\n" + "Output: exactly 4 lines, no preamble or trailing text:\n" + "C1: Y/N -- <evidence>\n" + "C2: Y/N -- <evidence>\n" + "C3: Y/N -- <evidence>\n" + "N1: Y/N -- <evidence>"; +} + +/* First line whose start matches `label` (case-insensitive, followed by + * whitespace or ':'), or NULL. */ +static const char *rubric_line(const char *answer, const char *label) { + size_t ll = strlen(label); + const char *p = answer; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= ll && strncasecmp(p, label, ll) == 0 && + (len == ll || p[ll] == ' ' || p[ll] == '\t' || p[ll] == ':')) + return p; + if (!nl) + break; + p = nl + 1; + } + return NULL; +} + +/* Verdict at `line` (just past the label). Sets *evidence to the + * remainder of the line after an optional `--` separator. Returns 1 = Y, + * 0 = N, -1 = no verdict token. A Y with empty evidence is returned as N + * (the paper's "answers without evidence default to N"). */ +static int rubric_verdict(const char *line, const char **evidence) { + while (*line == ' ' || *line == '\t') + line++; + if (*line == ':') { + line++; + while (*line == ' ' || *line == '\t') + line++; + } + char v = (char)toupper((unsigned char)*line); + if (v != 'Y' && v != 'N') + return -1; + int verdict = (v == 'Y'); + line++; + while (*line == ' ' || *line == '\t') + line++; + if (line[0] == '-' && line[1] == '-') + line += 2; + while (*line == ' ' || *line == '\t') + line++; + *evidence = line; + if (verdict) { + /* A Y needs real evidence on the same line (the paper's rule). */ + const char *p = line; + while (*p && *p != '\n') + p++; + if (p == line) + verdict = 0; + } + return verdict; +} + +int compact_rubric_parse(const char *answer, compact_rubric *out, + char **err) { + if (err) + *err = NULL; + if (!out) + return -1; + out->c1 = out->c2 = out->c3 = out->n1 = 0; + if (!answer || !*answer) { + if (err) + *err = xstrdup("rubric: empty verdict"); + return -1; + } + struct { + const char *label; + int *slot; + } items[4] = { + {"C1", &out->c1}, + {"C2", &out->c2}, + {"C3", &out->c3}, + {"N1", &out->n1}, + }; + for (int i = 0; i < 4; i++) { + const char *line = rubric_line(answer, items[i].label); + if (!line) { + if (err) + *err = xstrfmt("rubric: missing `%s` verdict line", + items[i].label); + return -1; + } + const char *ev = NULL; + int verdict = rubric_verdict(line + strlen(items[i].label), &ev); + if (verdict < 0) { + if (err) + *err = xstrfmt("rubric: malformed `%s` verdict line", + items[i].label); + return -1; + } + *items[i].slot = verdict; + } + return 0; +} + +bool compact_rubric_fire(const compact_rubric *r) { + return r && r->c1 && r->c2 && r->c3 && !r->n1; +} + +bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens, + unsigned long summaries_so_far, + unsigned long max_summaries, + unsigned long rounds_since_probe) { + if (iteration < RUBRIC_MIN_ITERATION) + return false; + if (prompt_tokens < RUBRIC_MIN_TOKENS) + return false; + if (max_summaries > 0 && summaries_so_far >= max_summaries) + return false; + if (rounds_since_probe < RUBRIC_MIN_ROUNDS_SINCE_PROBE) + return false; + return true; +} diff --git a/src/compact.h b/src/compact.h new file mode 100644 index 0000000..c34a17d --- /dev/null +++ b/src/compact.h @@ -0,0 +1,124 @@ +#ifndef LLSH_COMPACT_H +#define LLSH_COMPACT_H + +#include "message.h" +#include "util.h" + +/* Mirrors compact.rs: pure helpers for the two-tier context compaction. + * Side-effect free (no I/O) so it is unit-testable; the file reads and the + * model call live in the session. */ + +#define CLEARED_PLACEHOLDER \ + "[tool output cleared to save context; re-run the tool if you need it]" + +/* Re-injected file caps: per-file chars, number of files, total budget. */ +#define PER_FILE_CAP 50000UL +#define MAX_FILES 5UL +#define FILES_CAP 100000UL + +/* System preamble for the tool-less one-shot summarizer. */ +const char *compact_summarizer_preamble(void); + +/* The resolved compaction knobs, carried by a session. */ +typedef struct { + unsigned long context_limit; /* 0 disables compaction entirely */ + double compact_threshold; /* fraction; 0.0 disables the tier */ + double clear_threshold; /* fraction; 0.0 disables the tier */ +} compact_config; + +/* Populate from a config (see config.h). */ +void compact_config_from(unsigned long context_limit, double compact_threshold, + double clear_threshold, compact_config *out); + +/* Token budget at which the summarization tier fires. + * Returns true (and sets *out) when enabled, false when disabled. */ +bool compact_at(const compact_config *c, unsigned long *out); +bool clear_at(const compact_config *c, unsigned long *out); + +/* Which compaction action actually ran. */ +typedef enum { + TIER_CLEARED, /* tier 1: stale tool results replaced in place */ + TIER_SUMMARIZED, /* tier 2: prefix summarized, recent files re-injected */ + TIER_FALLBACK_CLEAR /* tier 2 requested but summarization failed */ +} compact_tier; + +/* What a compaction pass did, for a status line and tests. */ +typedef struct { + compact_tier tier; + unsigned long tokens_before; + unsigned long tokens_after; + size_t cleared_tool_results; /* 0 when a summarization ran */ + size_t files_reinjected; /* 0 unless a summarization ran */ +} compact_report; + +/* Rough token count: total bytes / 4 (div_ceil). */ +unsigned long compact_estimate_tokens(const message *msgs, size_t n); + +/* Index of the last pure-text user message, or n when there is none. */ +size_t compact_keep_boundary(const message *msgs, size_t n); + +/* Replace content of stale tool results (strictly before boundary) with the + * placeholder. Idempotent. Returns results cleared on this pass. */ +size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary); + +/* Flatten a message slice into a plain-text transcript. Caller frees. */ +char *compact_transcript(const message *msgs, size_t n); + +/* Render the conversation as a Markdown transcript. Caller frees. */ +char *compact_transcript_markdown(const message *msgs, size_t n); + +/* Extract file paths listed under a `## Files` heading (case-insensitive; + * accepts `-` or `*` bullets). Caller frees the strlist. */ +void compact_recent_file_paths(const char *summary, strlist *out); + +/* Build the post-compaction history: one user message carrying the summary + * and any re-injected files, followed by the kept tail (copied). + * Heap-allocated: caller frees with history_free(h); free(h); */ +history *compact_compose_history(const char *summary, + const char **file_paths, + const char **file_contents, size_t nfiles, + const message *tail, size_t ntail); + +/* ------------------------------------------------------------------ */ +/* SelfCompact-style rubric probe (arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +/* Fire-gate thresholds from the paper. max_summaries 0 = unlimited. */ +#define RUBRIC_MIN_ITERATION 3UL +#define RUBRIC_MIN_TOKENS 40000UL +#define RUBRIC_MIN_ROUNDS_SINCE_PROBE 2UL +#define RUBRIC_DEFAULT_MAX_SUMMARIES 1UL + +/* Verdict of a rubric probe. Each field: 1 = Y, 0 = N. */ +typedef struct { + int c1; /* CLOSED-UNIT: last assistant message is a closed unit */ + int c2; /* SUMMARIZABLE: value fits 3-5 citable facts */ + int c3; /* PROGRESS: something advanced since last compression */ + int n1; /* STUCK: recent tool calls yield nothing new */ +} compact_rubric; + +/* System preamble for the rubric judge (kept out of the summarizer's + * "write a summary" role). */ +const char *compact_rubric_preamble(void); + +/* The probe prompt (the paper's search rubric, adapted to tool calls). */ +const char *compact_rubric_prompt(void); + +/* Parse the four `C1/C2/C3/N1: Y|N -- <evidence>` lines. A Y without + * evidence on its line is downgraded to N (the paper's rule). `out` must + * be non-NULL; 0 on success, -1 with owned *err when a verdict is missing + * or malformed. */ +int compact_rubric_parse(const char *answer, compact_rubric *out, char **err); + +/* Fire only when C1 and C2 and C3 are all Y and N1 is N. */ +bool compact_rubric_fire(const compact_rubric *r); + +/* Gate check: iteration (tool rounds) >= 3, prompt >= 40k tokens, + * summaries so far < cap (0 = unlimited), and >= 2 tool rounds since the + * last probe. */ +bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens, + unsigned long summaries_so_far, + unsigned long max_summaries, + unsigned long rounds_since_probe); + +#endif diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..4f3d00a --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,732 @@ +#include "config.h" + +#include <cjson/cJSON.h> +#include <ctype.h> +#include <errno.h> +#include <getopt.h> +#include <limits> +#include <sys/stat.h> +#include <math.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#include "compact.h" +#include "dotagents.h" +#include "tools.h" + +#define DEFAULT_COMPACT_THRESHOLD 0.75 +#define DEFAULT_CLEAR_THRESHOLD 0.5 +#define DEFAULT_MAX_RETRIES 3UL +#define DEFAULT_SKILL_INDEX_CAP 50UL +#define DEFAULT_SHELL_TIMEOUT_SECS 120UL +#define DEFAULT_MAX_OUTPUT_CHARS 16000UL +#define DEFAULT_MAX_FILE_BYTES 2000000UL + +/* The default system preamble. The "Available tools" section is generated + * from the tool registry (tools.c) so it can't drift from the actual + * tool set. */ +static const char *PREAMBLE_HEAD = + "You are an agent operating inside a terminal. You accomplish tasks by " + "calling tools.\n" + "\n"; +static const char *PREAMBLE_TAIL = + "\n" + "Guidelines:\n" + "- Paths may be relative to the working directory or absolute.\n" + "- Inspect before you modify: use read_file, or run_shell with ls / " + "rg / grep, to understand existing state.\n" + "- Prefer small, verifiable steps. After making changes, verify them " + "(e.g. run the build or tests with run_shell).\n" + "- Keep run_shell commands safe and scoped to the task; avoid " + "destructive commands unless explicitly asked.\n" + "- When a task is complete, stop calling tools and reply with a short " + "summary of what you did."; + +char *default_preamble(void) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, PREAMBLE_HEAD); + char *tools = tool_preamble_list(); + xbuf_put(&b, tools); + free(tools); + xbuf_put(&b, PREAMBLE_TAIL); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +/* ------------------------------------------------------------------ */ +/* Providers */ +/* ------------------------------------------------------------------ */ + +provider_kind provider_parse(const char *raw, char **err) { + xbuf l; + xbuf_init(&l); + for (const char *p = raw; *p; p++) + xbuf_push(&l, (char)tolower((unsigned char)*p)); + l.data[l.len] = '\0'; + const char *s = l.data; + if (strcmp(s, "openai") == 0 || strcmp(s, "openai-compatible") == 0 || + strcmp(s, "openai_compatible") == 0) { + xbuf_free(&l); + return PROV_OPENAI; + } + if (strcmp(s, "anthropic") == 0 || strcmp(s, "claude") == 0) { + xbuf_free(&l); + return PROV_ANTHROPIC; + } + if (strcmp(s, "ollama") == 0) { + xbuf_free(&l); + return PROV_OLLAMA; + } + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "unknown provider `%s` (expected openai, anthropic, or " + "ollama)", + raw); + xbuf_free(&l); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return PROV_OPENAI; +} + +const char *provider_name(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "openai"; + case PROV_ANTHROPIC: + return "anthropic"; + case PROV_OLLAMA: + return "ollama"; + } + return "openai"; +} + +static const char *provider_debug(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "OpenAI"; + case PROV_ANTHROPIC: + return "Anthropic"; + case PROV_OLLAMA: + return "Ollama"; + } + return "OpenAI"; +} + +const char *provider_default_model(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "gpt-5.2"; + case PROV_ANTHROPIC: + return "claude-sonnet-4-6"; + case PROV_OLLAMA: + return "llama3.2"; + } + return "gpt-5.2"; +} + +/* ------------------------------------------------------------------ */ +/* CLI parsing */ +/* ------------------------------------------------------------------ */ + +static void print_usage(FILE *f) { + fprintf( + f, + "llsh %s\n" + "llsh — a terminal LLM agent harness built on rig\n" + "\n" + "Usage: llsh [OPTIONS] [PROMPT]\n" + "\n" + "Arguments:\n" + " [PROMPT] One-shot prompt. If omitted (and stdin is a TTY), " + "launches the interactive REPL.\n" + "\n" + "Options:\n" + " --model <MODEL> Model name, e.g. `gpt-5.2`, " + "`claude-sonnet-4-6`, `llama3.2`; or a `.agents/models.json` " + "preset name (which also brings that preset's " + "provider/base_url/api_key). Overrides the preset's `model`; " + "defaults per provider when unset\n" + " --provider <PROVIDER> Model provider: `openai`, " + "`anthropic`, or `ollama`. `openai` also works against any " + "OpenAI-compatible endpoint via `--base-url`\n" + " --base-url <BASE_URL> Base URL override. For `openai` " + "this targets any OpenAI-compatible endpoint (LM Studio, vLLM, " + "Ollama's /v1, llama.cpp, ...); for `ollama` this is the Ollama " + "server address (default http://localhost:11434)\n" + " --api-key <API_KEY> API key. Overrides the preset's " + "`api_key`\n" + " -C, --cwd <CWD> Working directory the agent's file and shell " + "tools operate in\n" + " --preamble <PREAMBLE> System preamble for the agent\n" + " --max-turns <MAX_TURNS> Maximum model calls (including " + "tool-call continuations) per user turn. `0` (the default) is " + "unbounded\n" + " --max-retries <MAX_RETRIES> Number of times a transient " + "provider failure (timeout, 429, 5xx) is retried with exponential " + "backoff before the turn fails. `0` disables retry\n" + " --context-limit <CONTEXT_LIMIT> Model context window in " + "tokens. `0` (the default) disables context compaction; a window " + "reported by the provider's endpoint is adopted when available, " + "otherwise this value is used\n" + " --compact-threshold <COMPACT_THRESHOLD> Fraction of " + "`context_limit` at which earlier context is summarized. `0` " + "disables the tier; otherwise the value must be in `(0, 1]`\n" + " --clear-threshold <CLEAR_THRESHOLD> Fraction of " + "`context_limit` at which stale tool results are cleared. `0` " + "disables the tier; otherwise the value must be in `(0, 1]`\n" + " --no-rubric-compaction Disable the SelfCompact rubric " + "probe that compacts the trajectory at a judged-safe point " + "(independent of the token thresholds above)\n" + " --rubric-max-summaries <N> Cap on rubric-triggered " + "compactions per session. The default is `1` (the paper's cap); " + "`0` means unlimited\n" + " --verbose Verbose, structured logging to stderr (one JSON " + "object per event: turn lifecycle, tool calls, compaction, " + "retries, and errors)\n" + " --debug Write the whole conversation history to " + "`debug.json` (in the working directory) after every turn and " + "again on exit\n" + " --skill-index-cap <SKILL_INDEX_CAP> Maximum number of " + "skills listed in the preamble's skills index\n" + " --shell-timeout-secs <SHELL_TIMEOUT_SECS> `run_shell` " + "timeout in seconds (the whole process tree is killed on expiry)\n" + " --max-output-chars <MAX_OUTPUT_CHARS> Cap on tool/shell " + "output text, in characters (longer is truncated)\n" + " --max-file-bytes <MAX_FILE_BYTES> Cap on the size of a " + "file `read_file` will read, in bytes\n" + " --resume <PATH> Resume a session saved by `/save`: restore " + "its conversation, model, preset, sub-agent, and working " + "directory. The saved model/config values are authoritative, so " + "`--model` / `--provider` / `--base-url` / `--api-key` are " + "ignored when this is set\n" + " --allow-mcp Trust and start MCP servers from .agents/mcp.json\n" + " --shell-allow <COMMAND> Allow this run_shell command (repeatable)\n" + " --shell-deny <COMMAND> Deny this run_shell command (repeatable)\n" + " --confirm-destructive Prompt before recognized destructive shell commands\n" + " --no-confirm-destructive Disable the destructive-command prompt\n" + " -h, --help Print help\n" + " -V, --version Print version\n", + LLSH_VERSION); +} + +static int parse_ulong(const char *s, unsigned long *out, char **err, + const char *flag) { + /* strtoull silently accepts a leading '-' and wraps on overflow; reject + * both explicitly so `--max-turns -1` does not become ULONG_MAX. */ + errno = 0; + char *endp = NULL; + unsigned long v = strtoull(s, &endp, 10); + if (s[0] == '-' || endp == s || *endp != '\0' || errno == ERANGE) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "invalid value for %s: `%s`", flag, s); + *err = xbuf_steal(&b); + } + return -1; + } + *out = v; + return 0; +} + +static int parse_fraction_arg(const char *s, double *out, char **err, + const char *flag) { + errno = 0; + char *endp = NULL; + double value = strtod(s, &endp); + if (endp == s || *endp != '\0' || errno == ERANGE || !isfinite(value) || + value < 0.0 || value > 1.0) { + if (err) + *err = xstrfmt("invalid value for %s: `%s` (expected 0 or a finite value in (0, 1])", + flag, s); + return -1; + } + *out = value; + return 0; +} + +int cli_parse(int argc, char **argv, cli *out, char **err) { + memset(out, 0, sizeof *out); + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + + enum { + OPT_MODEL = 256, + OPT_PROVIDER, + OPT_BASE_URL, + OPT_API_KEY, + OPT_CWD, + OPT_PREAMBLE, + OPT_MAX_TURNS, + OPT_MAX_RETRIES, + OPT_CONTEXT_LIMIT, + OPT_COMPACT_THRESHOLD, + OPT_CLEAR_THRESHOLD, + OPT_VERBOSE, + OPT_DEBUG, + OPT_NO_RUBRIC, + OPT_RUBRIC_MAX, + OPT_SKILL_INDEX_CAP, + OPT_SHELL_TIMEOUT, + OPT_MAX_OUTPUT, + OPT_MAX_FILE, + OPT_RESUME, + OPT_ALLOW_MCP, + OPT_SHELL_ALLOW, + OPT_SHELL_DENY, + OPT_CONFIRM_DESTRUCTIVE, + OPT_NO_CONFIRM_DESTRUCTIVE, + }; + static const struct option long_opts[] = { + {"model", required_argument, 0, OPT_MODEL}, + {"provider", required_argument, 0, OPT_PROVIDER}, + {"base-url", required_argument, 0, OPT_BASE_URL}, + {"api-key", required_argument, 0, OPT_API_KEY}, + {"cwd", required_argument, 0, OPT_CWD}, + {"preamble", required_argument, 0, OPT_PREAMBLE}, + {"max-turns", required_argument, 0, OPT_MAX_TURNS}, + {"max-retries", required_argument, 0, OPT_MAX_RETRIES}, + {"context-limit", required_argument, 0, OPT_CONTEXT_LIMIT}, + {"compact-threshold", required_argument, 0, OPT_COMPACT_THRESHOLD}, + {"clear-threshold", required_argument, 0, OPT_CLEAR_THRESHOLD}, + {"verbose", no_argument, 0, OPT_VERBOSE}, + {"debug", no_argument, 0, OPT_DEBUG}, + {"no-rubric-compaction", no_argument, 0, OPT_NO_RUBRIC}, + {"rubric-max-summaries", required_argument, 0, OPT_RUBRIC_MAX}, + {"skill-index-cap", required_argument, 0, OPT_SKILL_INDEX_CAP}, + {"shell-timeout-secs", required_argument, 0, OPT_SHELL_TIMEOUT}, + {"max-output-chars", required_argument, 0, OPT_MAX_OUTPUT}, + {"max-file-bytes", required_argument, 0, OPT_MAX_FILE}, + {"resume", required_argument, 0, OPT_RESUME}, + {"allow-mcp", no_argument, 0, OPT_ALLOW_MCP}, + {"shell-allow", required_argument, 0, OPT_SHELL_ALLOW}, + {"shell-deny", required_argument, 0, OPT_SHELL_DENY}, + {"confirm-destructive", no_argument, 0, OPT_CONFIRM_DESTRUCTIVE}, + {"no-confirm-destructive", no_argument, 0, OPT_NO_CONFIRM_DESTRUCTIVE}, + {"help", no_argument, 0, 'h'}, + {"version", no_argument, 0, 'V'}, + {0, 0, 0, 0}, + }; + + int c; + int prompt_set = 0; + optind = 1; + while ((c = getopt_long(argc, argv, "C:hV", long_opts, NULL)) != -1) { + switch (c) { + case OPT_MODEL: + out->model = xstrdup(optarg); + break; + case OPT_PROVIDER: + out->provider = xstrdup(optarg); + break; + case OPT_BASE_URL: + out->base_url = xstrdup(optarg); + break; + case OPT_API_KEY: + out->api_key = xstrdup(optarg); + break; + case OPT_CWD: + case 'C': + out->cwd = xstrdup(optarg); + break; + case OPT_PREAMBLE: + out->preamble = xstrdup(optarg); + break; + case OPT_MAX_TURNS: + if (parse_ulong(optarg, &out->max_turns, err, "--max-turns") != 0) + return -1; + out->has_max_turns = true; + break; + case OPT_MAX_RETRIES: + if (parse_ulong(optarg, &out->max_retries, err, + "--max-retries") != 0) + return -1; + out->has_max_retries = true; + break; + case OPT_CONTEXT_LIMIT: + if (parse_ulong(optarg, &out->context_limit, err, + "--context-limit") != 0) + return -1; + out->has_context_limit = true; + break; + case OPT_COMPACT_THRESHOLD: + if (parse_fraction_arg(optarg, &out->compact_threshold, err, + "--compact-threshold") != 0) + return -1; + out->has_compact_threshold = true; + break; + case OPT_CLEAR_THRESHOLD: + if (parse_fraction_arg(optarg, &out->clear_threshold, err, + "--clear-threshold") != 0) + return -1; + out->has_clear_threshold = true; + break; + case OPT_VERBOSE: + out->verbose = true; + break; + case OPT_DEBUG: + out->debug = true; + break; + case OPT_NO_RUBRIC: + out->no_rubric = true; + break; + case OPT_RUBRIC_MAX: + if (parse_ulong(optarg, &out->rubric_max_summaries, err, + "--rubric-max-summaries") != 0) + return -1; + out->has_rubric_max_summaries = true; + break; + case OPT_SKILL_INDEX_CAP: + if (parse_ulong(optarg, &out->skill_index_cap, err, + "--skill-index-cap") != 0) + return -1; + out->has_skill_index_cap = true; + break; + case OPT_SHELL_TIMEOUT: + if (parse_ulong(optarg, &out->shell_timeout_secs, err, + "--shell-timeout-secs") != 0) + return -1; + out->has_shell_timeout_secs = true; + break; + case OPT_MAX_OUTPUT: + if (parse_ulong(optarg, &out->max_output_chars, err, + "--max-output-chars") != 0) + return -1; + out->has_max_output_chars = true; + break; + case OPT_MAX_FILE: + if (parse_ulong(optarg, &out->max_file_bytes, err, + "--max-file-bytes") != 0) + return -1; + out->has_max_file_bytes = true; + break; + case OPT_RESUME: + out->resume = xstrdup(optarg); + break; + case OPT_ALLOW_MCP: + out->allow_mcp = true; + break; + case OPT_SHELL_ALLOW: + if (!optarg[0]) { + if (err) + *err = xstrdup("--shell-allow requires a non-empty command"); + return -1; + } + sl_push_copy(&out->shell_allowlist, optarg); + break; + case OPT_SHELL_DENY: + if (!optarg[0]) { + if (err) + *err = xstrdup("--shell-deny requires a non-empty command"); + return -1; + } + sl_push_copy(&out->shell_denylist, optarg); + break; + case OPT_CONFIRM_DESTRUCTIVE: + out->confirm_destructive = true; + out->has_confirm_destructive = true; + break; + case OPT_NO_CONFIRM_DESTRUCTIVE: + out->confirm_destructive = false; + out->has_confirm_destructive = true; + break; + case 'h': + print_usage(stdout); + return CLI_HELP; + case 'V': + fprintf(stdout, "llsh %s\n", LLSH_VERSION); + return CLI_HELP; + case '?': + print_usage(stderr); + if (err) + *err = xstrdup("try --help"); + return -1; + default: + /* getopt_long returns '?' for unknown options; anything else + * here is a short option we did not register. */ + print_usage(stderr); + if (err) + *err = xstrdup("try --help"); + return -1; + } + } + for (int i = optind; i < argc; i++) { + if (prompt_set) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "unexpected argument `%s`", argv[i]); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return -1; + } + out->prompt = xstrdup(argv[i]); + prompt_set = 1; + } + return 0; +} + +void cli_free(cli *c) { + free(c->prompt); + free(c->model); + free(c->provider); + free(c->base_url); + free(c->api_key); + free(c->cwd); + free(c->preamble); + free(c->resume); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + + +/* ------------------------------------------------------------------ */ +/* Merge + validation */ +/* ------------------------------------------------------------------ */ + +static int validate_fraction(const char *name, double value, char **err) { + if (value == 0.0) + return 0; + if (isnan(value) || isinf(value) || value <= 0.0 || value > 1.0) { + if (err) { + xbuf b; + xbuf_init(&b); + const char *rep; + if (isnan(value)) + rep = "NaN"; + else if (value > 0) + rep = "inf"; + else + rep = "-inf"; + xbuf_fmt(&b, "%s must be 0 or in (0, 1], got %s", name, rep); + *err = xbuf_steal(&b); + } + return -1; + } + return 0; +} + +static int validate_compaction(const config *c, char **err) { + if (validate_fraction("compact_threshold", c->compact_threshold, err) != 0) + return -1; + if (validate_fraction("clear_threshold", c->clear_threshold, err) != 0) + return -1; + if (c->context_limit > 0 && c->compact_threshold > 0.0 && + c->clear_threshold > 0.0 && + c->clear_threshold >= c->compact_threshold) { + if (err) { + char *ct = dtoa_dup(c->clear_threshold); + char *pt = dtoa_dup(c->compact_threshold); + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "clear_threshold (%s) must be less than " + "compact_threshold (%s)", + ct, pt); + *err = xbuf_steal(&b); + free(ct); + free(pt); + } + return -1; + } + return 0; +} + +int config_require_key_or_base(const config *c, char **err) { + if (c->provider != PROV_OLLAMA && !c->base_url && !c->api_key) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "no API key for provider `%s`: set `api_key` in " + "`.agents/models.json` or pass --api-key", + provider_debug(c->provider)); + *err = xbuf_steal(&b); + } + return -1; + } + return 0; +} + +void config_free(config *c) { + free(c->model); + free(c->base_url); + free(c->api_key); + free(c->preamble); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + +int config_merge(const cli *cli, config *out, char **err) { + memset(out, 0, sizeof *out); + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + out->confirm_destructive = cli->confirm_destructive; + for (size_t i = 0; i < cli->shell_allowlist.len; i++) + sl_push_copy(&out->shell_allowlist, cli->shell_allowlist.items[i]); + for (size_t i = 0; i < cli->shell_denylist.len; i++) + sl_push_copy(&out->shell_denylist, cli->shell_denylist.items[i]); + + out->provider = provider_parse(cli->provider ? cli->provider : "openai", + err); + if (err && *err) + return -1; + + out->model = xstrdup(cli->model ? cli->model + : provider_default_model(out->provider)); + + out->base_url = cli->base_url ? xstrdup(cli->base_url) : NULL; + out->api_key = cli->api_key ? xstrdup(cli->api_key) : NULL; + + out->preamble = cli->preamble ? xstrdup(cli->preamble) + : default_preamble(); + + out->max_turns = cli->has_max_turns ? cli->max_turns : 0; + out->max_retries = cli->has_max_retries ? cli->max_retries + : DEFAULT_MAX_RETRIES; + out->context_limit = + cli->has_context_limit ? cli->context_limit : 0; + out->compact_threshold = cli->has_compact_threshold ? cli->compact_threshold + : DEFAULT_COMPACT_THRESHOLD; + out->clear_threshold = cli->has_clear_threshold ? cli->clear_threshold + : DEFAULT_CLEAR_THRESHOLD; + + out->rubric_enabled = !cli->no_rubric; + out->rubric_max_summaries = cli->has_rubric_max_summaries + ? cli->rubric_max_summaries + : RUBRIC_DEFAULT_MAX_SUMMARIES; + + out->verbose = cli->verbose; + + out->skill_index_cap = + cli->has_skill_index_cap ? cli->skill_index_cap + : DEFAULT_SKILL_INDEX_CAP; + if (out->skill_index_cap < 1) + out->skill_index_cap = 1; + out->shell_timeout_secs = + cli->has_shell_timeout_secs ? cli->shell_timeout_secs + : DEFAULT_SHELL_TIMEOUT_SECS; + if (out->shell_timeout_secs < 1) + out->shell_timeout_secs = 1; + out->max_output_chars = + cli->has_max_output_chars ? cli->max_output_chars + : DEFAULT_MAX_OUTPUT_CHARS; + if (out->max_output_chars < 1) + out->max_output_chars = 1; + out->max_file_bytes = + cli->has_max_file_bytes ? cli->max_file_bytes + : DEFAULT_MAX_FILE_BYTES; + if (out->max_file_bytes < 1) + out->max_file_bytes = 1; + + if (validate_compaction(out, err) != 0) + return -1; + /* The api_key/base_url requirement is checked by the caller AFTER a + * models.json preset may have supplied them (precedence: CLI flag > + * models.json preset > provider defaults). */ + return 0; +} + +int config_from_cli(const cli *cli, config *out, char **err) { + const char *cwd = cli->cwd ? cli->cwd : "."; + + if (config_merge(cli, out, err) != 0) { + config_free(out); + return -1; + } + + char *cwd_exp = expand_tilde(cwd); + if (chdir(cwd_exp) != 0) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "failed to enter working directory `%s`: %s", + cwd_exp, strerror(errno)); + *err = xbuf_steal(&b); + } + free(cwd_exp); + config_free(out); + return -1; + } + free(cwd_exp); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* models.json presets */ +/* ------------------------------------------------------------------ */ + +char *config_apply_models_preset(config *cfg, const models_file *models, + const cli *cli, char **err) { + const char *name = models->default_name; + if (!name || !*name) { + if (err) + *err = xstrdup("models.json declares no `default` preset"); + return NULL; + } + const model_preset *preset = models_file_get(models, name); + if (!preset) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "models.json default preset `"); + xbuf_put(&b, name); + xbuf_put(&b, "` is not in presets ("); + for (size_t i = 0; i < models->preset_count; i++) { + if (i) + xbuf_put(&b, ", "); + xbuf_put(&b, models->presets[i].name ? models->presets[i].name : ""); + } + xbuf_put(&b, ")"); + *err = xbuf_steal(&b); + } + return NULL; + } + if (!cli->provider && preset->provider) { + cfg->provider = provider_parse(preset->provider, err); + if (err && *err) + return NULL; + } + if (!cli->model && preset->model) { + free(cfg->model); + cfg->model = xstrdup(preset->model); + } + if (!cli->base_url && preset->base_url) { + free(cfg->base_url); + cfg->base_url = xstrdup(preset->base_url); + } + if (!cli->api_key && preset->api_key) { + free(cfg->api_key); + cfg->api_key = xstrdup(preset->api_key); + } + if (!cli->has_max_turns && preset->has_max_turns && + preset->max_turns > 0) + cfg->max_turns = preset->max_turns; + if (!cli->has_context_limit && preset->has_context_limit) + cfg->context_limit = preset->context_limit; + + if (cfg->provider != PROV_OLLAMA && !cfg->base_url && !cfg->api_key) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "no API key for provider `%s` after applying preset " + "`%s`: set `api_key` in the preset/config file or " + "pass --api-key", + provider_debug(cfg->provider), name); + *err = xbuf_steal(&b); + } + return NULL; + } + if (validate_compaction(cfg, err) != 0) + return NULL; + return xstrdup(name); +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..d71a78b --- /dev/null +++ b/src/config.h @@ -0,0 +1,106 @@ +#ifndef LLSH_CONFIG_H +#define LLSH_CONFIG_H + +#include "dotagents.h" +#include "util.h" + +/* Mirrors config.rs: CLI + file config + merged Config. */ + +typedef enum { + PROV_OPENAI, + PROV_ANTHROPIC, + PROV_OLLAMA, +} provider_kind; + +#define LLSH_VERSION "0.1.0" + +typedef struct { + char *prompt; + char *model; + char *provider; + char *base_url; + char *api_key; + char *cwd; + char *preamble; + unsigned long max_turns; + bool has_max_turns; + unsigned long max_retries; + bool has_max_retries; + unsigned long context_limit; + bool has_context_limit; + double compact_threshold; + bool has_compact_threshold; + double clear_threshold; + bool has_clear_threshold; + bool verbose; + bool allow_mcp; + bool confirm_destructive; + bool has_confirm_destructive; + strlist shell_allowlist; + strlist shell_denylist; + bool debug; + bool no_rubric; /* --no-rubric-compaction */ + unsigned long rubric_max_summaries; + bool has_rubric_max_summaries; + unsigned long skill_index_cap; + bool has_skill_index_cap; + unsigned long shell_timeout_secs; + bool has_shell_timeout_secs; + unsigned long max_output_chars; + bool has_max_output_chars; + unsigned long max_file_bytes; + bool has_max_file_bytes; + char *resume; +} cli; + +typedef struct { + provider_kind provider; + char *model; + char *base_url; /* may be NULL */ + char *api_key; /* may be NULL */ + char *preamble; + unsigned long max_turns; + unsigned long max_retries; + unsigned long context_limit; + double compact_threshold; + double clear_threshold; + bool rubric_enabled; + unsigned long rubric_max_summaries; /* 0 = unlimited */ + bool verbose; + unsigned long skill_index_cap; + unsigned long shell_timeout_secs; + unsigned long max_output_chars; + unsigned long max_file_bytes; + strlist shell_allowlist; + strlist shell_denylist; + bool confirm_destructive; +} config; + +/* Parse argv (without program name). 0 on success; -2 prints help and returns + * CLI_HELP (call should exit); -1 error. */ +#define CLI_HELP (-2) +int cli_parse(int argc, char **argv, cli *out, char **err); +void cli_free(cli *c); + +/* Merge CLI + file with precedence CLI > file > defaults. Changes CWD. */ +/* Build the merged config directly from CLI arguments (the former + * config-file layer was removed; connection presets live in + * `.agents/models.json`). */ +int config_from_cli(const cli *cli, config *out, char **err); +int config_merge(const cli *cli, config *out, char **err); +void config_free(config *c); + +/* Reject key-requiring providers with neither api_key nor base_url. Run + * after a models.json preset may have supplied them (the merge itself no + * longer checks). */ +int config_require_key_or_base(const config *c, char **err); +provider_kind provider_parse(const char *raw, char **err); +const char *provider_name(provider_kind k); +const char *provider_default_model(provider_kind k); + +/* Apply a models.json `default` preset (see dotagents). Returns preset name + * (caller frees) or NULL + *err. */ +char *config_apply_models_preset(config *cfg, const models_file *models, + const cli *cli, char **err); + +#endif diff --git a/src/escape.c b/src/escape.c new file mode 100644 index 0000000..de6e1bd --- /dev/null +++ b/src/escape.c @@ -0,0 +1,5 @@ +/* escape.c — translation unit for the escape.h ANSI escape library. + * escape.h is a hardlink into ../header-libraries; the macro interface is + * header-only, this file provides the esc_* functions. */ +#define ESCAPE_IMPLEMENTATION +#include "escape.h" diff --git a/src/goal.cpp b/src/goal.cpp new file mode 100644 index 0000000..36aa271 --- /dev/null +++ b/src/goal.cpp @@ -0,0 +1,241 @@ +#include "goal.h" + +#include <ctype.h> +#include <stdlib.h> +#include <string.h> + +static const char *const GOAL_PREAMBLE = + "You are the reviewer for a terminal coding agent that is working toward a " + "goal. You are given the goal — a success condition — and the agent's most " + "recent reply. Decide whether the goal is fully met.\n" + "\n" + "Reply with exactly two lines:\n" + "1. Either `MET` or `NOT MET` — your verdict.\n" + "2. One short sentence: if met, what was accomplished; if not, the single " + "most important gap to fix next.\n" + "\n" + "Be strict: the goal is met only when the stated success condition is " + "clearly satisfied — not merely started, in progress, or partially done. " + "Do not give the agent the benefit of the doubt, and do not add commentary " + "beyond the two lines."; + +void goal_spec_free(goal_spec *s) +{ + if (!s) + return; + free(s->criteria); + s->criteria = NULL; +} + +void goal_report_free(goal_report *r) +{ + if (!r) + return; + free(r->criteria); + free(r); +} + +const char *goal_evaluator_preamble(void) +{ + return GOAL_PREAMBLE; +} + +static bool str_eq_i(const char *s, size_t n, const char *word) +{ + size_t wn = strlen(word); + if (n != wn) + return false; + for (size_t i = 0; i < wn; i++) { + char a = s[i], b = word[i]; + if (a >= 'a' && a <= 'z') + a = (char)(a - 'a' + 'A'); + if (b >= 'a' && b <= 'z') + b = (char)(b - 'a' + 'A'); + if (a != b) + return false; + } + return true; +} + +static int parse_tries(const char *s, size_t n, unsigned long *out, char **err) +{ + size_t tlen; + const char *t = xtrim_span(s, n, &tlen); + size_t digits = 0; + while (digits < tlen && t[digits] >= '0' && t[digits] <= '9') + digits++; + if (digits == 0) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + /* After the digits: end of input, or the word `tries` as a whole word. + * "5xyz" is rejected; "5 tries" is the canonical form. */ + size_t rest = digits; + while (rest < tlen && isspace((unsigned char)t[rest])) + rest++; + if (rest != tlen) { + size_t wn = 5; + bool word_ok = (tlen - rest) >= wn; + if (word_ok) { + const char *w = "tries"; + for (size_t i = 0; i < wn; i++) { + char a = t[rest + i], b = w[i]; + if (a >= 'a' && a <= 'z') + a = (char)(a - 'a' + 'A'); + if (b >= 'a' && b <= 'z') + b = (char)(b - 'a' + 'A'); + if (a != b) { + word_ok = false; + break; + } + } + } + if (!word_ok || ((tlen - rest) > wn && + isalpha((unsigned char)t[rest + wn]))) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + } + unsigned long value = 0; + for (size_t i = 0; i < digits; i++) { + value = value * 10UL + (unsigned long)(t[i] - '0'); + if (value > 4294967295UL) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + } + if (value == 0) { + *err = xstrdup("the goal needs at least 1 try"); + return 0; + } + *out = value; + return 1; +} + +int goal_parse(const char *input, goal_spec *out, char **err) +{ + /* Zero on entry so a failed parse leaves a freeable, empty spec. */ + memset(out, 0, sizeof *out); + const char *src = input ? input : ""; + size_t slen = strlen(src); + size_t tlen; + const char *t = xtrim_span(src, slen, &tlen); + if (tlen == 0) { + *err = xstrdup( + "give the goal its success criteria, e.g. " + "`/goal all tests pass, stop after 5 tries`"); + return 0; + } + + const char marker[] = "stop after"; + size_t mlen = sizeof(marker) - 1; + /* rfind: last occurrence of marker in [t, t+tlen). */ + const char *hit = NULL; + const char *p = t; + while (tlen - (size_t)(p - t) >= mlen) { + void *m = memmem(p, tlen - (size_t)(p - t), marker, mlen); + if (!m) + break; + hit = (const char* ) m; + p = (const char *)m + 1; + } + + if (!hit) { + out->criteria = xstrndup(t, tlen); + out->max_tries = GOAL_DEFAULT_MAX_TRIES; + return 1; + } + + size_t pre_len = (size_t)(hit - t); + size_t clen; + const char *crit = xtrim_span(t, pre_len, &clen); + while (clen > 0 && crit[clen - 1] == ',') + clen--; + crit = xtrim_span(crit, clen, &clen); + if (clen == 0) { + *err = xstrdup( + "give the goal its success criteria before `stop after`, e.g. " + "`/goal all tests pass, stop after 5 tries`"); + return 0; + } + const char *tail = hit + mlen; + if (!parse_tries(tail, tlen - (size_t)(tail - t), &out->max_tries, err)) + return 0; + out->criteria = xstrndup(crit, clen); + return 1; +} + +char *goal_prompt(const goal_spec *spec, unsigned long attempt, + const char *feedback) +{ + const char *crit = spec->criteria ? spec->criteria : ""; + if (attempt <= 1) + return xstrfmt( + "Work toward this goal until it is fully met:\n\n%s\n\n" + "Judge your own progress, verify it, and keep going if it is not " + "done. When you are confident the goal is fully met, stop and say " + "what you did.", + crit); + const char *fb = feedback ? feedback : "no specific feedback was given"; + return xstrfmt( + "You are on attempt %lu of %lu toward this goal:\n\n%s\n\n" + "A reviewer judged the goal NOT yet met: %s\n\n" + "Address that gap and keep working. When you are confident the goal is " + "fully met, stop and say what you did.", + attempt, spec->max_tries, crit, fb); +} + +/* True if `needle` occurs in `hay` as a whole word (A-Z only; `hay` is + * already upper-cased). Prevents "MET" matching inside "UNMET". */ +static bool has_word(const char *hay, const char *needle) { + size_t nl = strlen(needle); + const char *p = hay; + while ((p = strstr(p, needle)) != NULL) { + bool left_ok = (p == hay) || (p[-1] < 'A' || p[-1] > 'Z'); + bool right_ok = (p[nl] < 'A' || p[nl] > 'Z'); + if (left_ok && right_ok) + return true; + p += (nl > 0 ? nl : 1); + } + return false; +} + +void goal_parse_verdict(const char *reply, goal_verdict *out) +{ + const char *src = reply ? reply : ""; + char *upper = xstrdup(src); + for (char *c = upper; *c; c++) + if (*c >= 'a' && *c <= 'z') + *c = (char)(*c - 'a' + 'A'); + out->met = has_word(upper, "MET") && !has_word(upper, "NOT MET") && + !has_word(upper, "UNMET"); + free(upper); + + const char *chosen = NULL; + size_t chosen_len = 0; + const char *p = src; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 0 && !str_eq_i(line, tlen, "MET") && + !str_eq_i(line, tlen, "NOT MET")) { + chosen = line; + chosen_len = tlen; + break; + } + if (!nl) + break; + p = nl + 1; + } + out->reason = chosen + ? xstrndup(chosen, chosen_len) + : xstrdup("the reviewer did not give a reason"); +} diff --git a/src/goal.h b/src/goal.h new file mode 100644 index 0000000..6ef823d --- /dev/null +++ b/src/goal.h @@ -0,0 +1,53 @@ +#ifndef LLSH_GOAL_H +#define LLSH_GOAL_H + +#include "util.h" + +/* Pure helpers for the /goal goal-based loop (mirrors goal.rs): parse the + * command, build each attempt's prompt, parse the evaluator's verdict. + * Side-effect free; the evaluator's model call lives in the session and the + * iteration loop in main. */ + +#define GOAL_DEFAULT_MAX_TRIES 5UL + +/* Parsed /goal command: success criteria + attempt cap. */ +typedef struct { + char *criteria; /* owned */ + unsigned long max_tries; +} goal_spec; + +/* Evaluator's judgment of a single attempt. reason is owned. */ +typedef struct { + bool met; + char *reason; +} goal_verdict; + +/* Summary of a finished /goal run. criteria is owned. */ +typedef struct { + char *criteria; + unsigned long max_tries; + unsigned long turns_done; + unsigned long long tokens_used; + bool met; +} goal_report; + +void goal_spec_free(goal_spec *s); +void goal_report_free(goal_report *r); + +/* System preamble for the tool-less goal evaluator. */ +const char *goal_evaluator_preamble(void); + +/* Parse "<criteria>[, stop after <N> tries]". + * Returns 1 on success (fills *out), 0 on failure (*err set, caller frees). */ +int goal_parse(const char *input, goal_spec *out, char **err); + +/* Prompt for a goal attempt (attempt starts at 1; feedback may be NULL). + * Caller frees. */ +char *goal_prompt(const goal_spec *spec, unsigned long attempt, + const char *feedback); + +/* Parse the evaluator's reply into a verdict (conservative: met only when + * MET appears and NOT MET does not). Caller frees out->reason. */ +void goal_parse_verdict(const char *reply, goal_verdict *out); + +#endif diff --git a/src/input.cpp b/src/input.cpp new file mode 100644 index 0000000..935f998 --- /dev/null +++ b/src/input.cpp @@ -0,0 +1,1949 @@ +#include "input.h" + +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <stdint.h> +#include <pthread.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#include <poll.h> +#include <sys/stat.h> +#include <termios.h> + +#include <cjson/cJSON.h> + +/* ================================================================== */ +/* Actions / events */ +/* ================================================================== */ + +void input_action_free(input_action *a) { + if (!a) return; + free(a->text); + a->text = NULL; + a->kind = ACT_NONE; +} + +void evlist_init(evlist *l) { + memset(l, 0, sizeof(*l)); +} + +void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len) { + if (l->len == l->cap) { + size_t new_cap = l->cap ? l->cap * 2 : 8; + input_key *ni = (input_key *) xmalloc(new_cap * sizeof(*ni)); + unsigned char* *nr = (unsigned char* * ) xmalloc(new_cap * sizeof(*nr)); + size_t *nl = (size_t *) xmalloc(new_cap * sizeof(*nl)); + if (l->cap > 0) { + for (size_t i = 0; i < l->len; i++) { + size_t src = (l->head + i) % l->cap; + ni[i] = l->items[src]; + nr[i] = l->raws[src]; + nl[i] = l->lens[src]; + } + free(l->items); + free(l->raws); + free(l->lens); + } + l->items = ni; + l->raws = nr; + l->lens = nl; + l->head = 0; + l->cap = new_cap; + } + size_t i = (l->head + l->len) % l->cap; + l->items[i] = k; + l->raws[i] = (unsigned char* ) xmalloc(raw_len + 1); + memcpy(l->raws[i], raw, raw_len); + l->raws[i][raw_len] = '\0'; + l->lens[i] = raw_len; + l->len++; +} + +void evlist_pop(evlist *l) { + if (l->len == 0) return; + free(l->raws[l->head]); + l->head = (l->head + 1 == l->cap) ? 0 : l->head + 1; + l->len--; +} + +void evlist_free(evlist *l) { + if (l->cap > 0) + for (size_t i = 0; i < l->len; i++) + free(l->raws[(l->head + i) % l->cap]); + free(l->items); + free(l->raws); + free(l->lens); + memset(l, 0, sizeof(*l)); +} + +/* ================================================================== */ +/* Display width */ +/* ================================================================== */ + +/* Unicode width tables generated from `unicode-width` 0.2 (the oracle's + * source of truth), covering U+0000..U+2FFFF. Zero-width (control chars, + * combining marks) and wide/fullwidth codepoint ranges. */ +static const uint32_t zw[][2] = { + {0x0u, 0x1fu}, + {0x7fu, 0x9fu}, + {0xadu, 0xadu}, + {0x300u, 0x36fu}, + {0x483u, 0x489u}, + {0x591u, 0x5bdu}, + {0x5bfu, 0x5bfu}, + {0x5c1u, 0x5c2u}, + {0x5c4u, 0x5c5u}, + {0x5c7u, 0x5c7u}, + {0x605u, 0x605u}, + {0x610u, 0x61au}, + {0x61cu, 0x61cu}, + {0x64bu, 0x65fu}, + {0x670u, 0x670u}, + {0x6d6u, 0x6dcu}, + {0x6dfu, 0x6e4u}, + {0x6e7u, 0x6e8u}, + {0x6eau, 0x6edu}, + {0x70fu, 0x70fu}, + {0x711u, 0x711u}, + {0x730u, 0x74au}, + {0x7a6u, 0x7b0u}, + {0x7ebu, 0x7f3u}, + {0x7fdu, 0x7fdu}, + {0x816u, 0x819u}, + {0x81bu, 0x823u}, + {0x825u, 0x827u}, + {0x829u, 0x82du}, + {0x859u, 0x85bu}, + {0x890u, 0x891u}, + {0x897u, 0x89fu}, + {0x8cau, 0x902u}, + {0x93au, 0x93au}, + {0x93cu, 0x93cu}, + {0x941u, 0x948u}, + {0x94du, 0x94du}, + {0x951u, 0x957u}, + {0x962u, 0x963u}, + {0x981u, 0x981u}, + {0x9bcu, 0x9bcu}, + {0x9beu, 0x9beu}, + {0x9c1u, 0x9c4u}, + {0x9cdu, 0x9cdu}, + {0x9d7u, 0x9d7u}, + {0x9e2u, 0x9e3u}, + {0x9feu, 0x9feu}, + {0xa01u, 0xa02u}, + {0xa3cu, 0xa3cu}, + {0xa41u, 0xa42u}, + {0xa47u, 0xa48u}, + {0xa4bu, 0xa4du}, + {0xa51u, 0xa51u}, + {0xa70u, 0xa71u}, + {0xa75u, 0xa75u}, + {0xa81u, 0xa82u}, + {0xabcu, 0xabcu}, + {0xac1u, 0xac5u}, + {0xac7u, 0xac8u}, + {0xacdu, 0xacdu}, + {0xae2u, 0xae3u}, + {0xafau, 0xaffu}, + {0xb01u, 0xb01u}, + {0xb3cu, 0xb3cu}, + {0xb3eu, 0xb3fu}, + {0xb41u, 0xb44u}, + {0xb4du, 0xb4du}, + {0xb55u, 0xb57u}, + {0xb62u, 0xb63u}, + {0xb82u, 0xb82u}, + {0xbbeu, 0xbbeu}, + {0xbc0u, 0xbc0u}, + {0xbcdu, 0xbcdu}, + {0xbd7u, 0xbd7u}, + {0xc00u, 0xc00u}, + {0xc04u, 0xc04u}, + {0xc3cu, 0xc3cu}, + {0xc3eu, 0xc40u}, + {0xc46u, 0xc48u}, + {0xc4au, 0xc4du}, + {0xc55u, 0xc56u}, + {0xc62u, 0xc63u}, + {0xc81u, 0xc81u}, + {0xcbcu, 0xcbcu}, + {0xcbfu, 0xcc0u}, + {0xcc2u, 0xcc2u}, + {0xcc6u, 0xcc8u}, + {0xccau, 0xccdu}, + {0xcd5u, 0xcd6u}, + {0xce2u, 0xce3u}, + {0xd00u, 0xd01u}, + {0xd3bu, 0xd3cu}, + {0xd3eu, 0xd3eu}, + {0xd41u, 0xd44u}, + {0xd4du, 0xd4eu}, + {0xd57u, 0xd57u}, + {0xd62u, 0xd63u}, + {0xd81u, 0xd81u}, + {0xdcau, 0xdcau}, + {0xdcfu, 0xdcfu}, + {0xdd2u, 0xdd4u}, + {0xdd6u, 0xdd6u}, + {0xddfu, 0xddfu}, + {0xe31u, 0xe31u}, + {0xe34u, 0xe3au}, + {0xe47u, 0xe4eu}, + {0xeb1u, 0xeb1u}, + {0xeb4u, 0xebcu}, + {0xec8u, 0xeceu}, + {0xf18u, 0xf19u}, + {0xf35u, 0xf35u}, + {0xf37u, 0xf37u}, + {0xf39u, 0xf39u}, + {0xf71u, 0xf7eu}, + {0xf80u, 0xf84u}, + {0xf86u, 0xf87u}, + {0xf8du, 0xf97u}, + {0xf99u, 0xfbcu}, + {0xfc6u, 0xfc6u}, + {0x102du, 0x1030u}, + {0x1032u, 0x1037u}, + {0x1039u, 0x103au}, + {0x103du, 0x103eu}, + {0x1058u, 0x1059u}, + {0x105eu, 0x1060u}, + {0x1071u, 0x1074u}, + {0x1082u, 0x1082u}, + {0x1085u, 0x1086u}, + {0x108du, 0x108du}, + {0x109du, 0x109du}, + {0x1160u, 0x11ffu}, + {0x135du, 0x135fu}, + {0x1712u, 0x1715u}, + {0x1732u, 0x1734u}, + {0x1752u, 0x1753u}, + {0x1772u, 0x1773u}, + {0x17b4u, 0x17b5u}, + {0x17b7u, 0x17bdu}, + {0x17c6u, 0x17c6u}, + {0x17c9u, 0x17d3u}, + {0x17ddu, 0x17ddu}, + {0x180bu, 0x180fu}, + {0x1885u, 0x1886u}, + {0x18a9u, 0x18a9u}, + {0x1920u, 0x1922u}, + {0x1927u, 0x1928u}, + {0x1932u, 0x1932u}, + {0x1939u, 0x193bu}, + {0x1a17u, 0x1a18u}, + {0x1a1bu, 0x1a1bu}, + {0x1a56u, 0x1a56u}, + {0x1a58u, 0x1a5eu}, + {0x1a60u, 0x1a60u}, + {0x1a62u, 0x1a62u}, + {0x1a65u, 0x1a6cu}, + {0x1a73u, 0x1a7cu}, + {0x1a7fu, 0x1a7fu}, + {0x1ab0u, 0x1addu}, + {0x1ae0u, 0x1aebu}, + {0x1b00u, 0x1b03u}, + {0x1b34u, 0x1b3du}, + {0x1b42u, 0x1b44u}, + {0x1b6bu, 0x1b73u}, + {0x1b80u, 0x1b81u}, + {0x1ba2u, 0x1ba5u}, + {0x1ba8u, 0x1badu}, + {0x1be6u, 0x1be6u}, + {0x1be8u, 0x1be9u}, + {0x1bedu, 0x1bedu}, + {0x1befu, 0x1bf3u}, + {0x1c2cu, 0x1c33u}, + {0x1c36u, 0x1c37u}, + {0x1cd0u, 0x1cd2u}, + {0x1cd4u, 0x1ce0u}, + {0x1ce2u, 0x1ce8u}, + {0x1cedu, 0x1cedu}, + {0x1cf4u, 0x1cf4u}, + {0x1cf8u, 0x1cf9u}, + {0x1dc0u, 0x1dffu}, + {0x200bu, 0x200fu}, + {0x202au, 0x202eu}, + {0x2060u, 0x206fu}, + {0x20d0u, 0x20f0u}, + {0x2cefu, 0x2cf1u}, + {0x2de0u, 0x2dffu}, + {0x302au, 0x302fu}, + {0x3099u, 0x309au}, + {0x3164u, 0x3164u}, + {0xa66fu, 0xa672u}, + {0xa674u, 0xa67du}, + {0xa69eu, 0xa69fu}, + {0xa6f0u, 0xa6f1u}, + {0xa802u, 0xa802u}, + {0xa806u, 0xa806u}, + {0xa80bu, 0xa80bu}, + {0xa825u, 0xa826u}, + {0xa82cu, 0xa82cu}, + {0xa8c4u, 0xa8c5u}, + {0xa8e0u, 0xa8f1u}, + {0xa8fau, 0xa8fau}, + {0xa8ffu, 0xa8ffu}, + {0xa926u, 0xa92du}, + {0xa947u, 0xa951u}, + {0xa953u, 0xa953u}, + {0xa980u, 0xa982u}, + {0xa9b3u, 0xa9b3u}, + {0xa9b6u, 0xa9b9u}, + {0xa9bcu, 0xa9bdu}, + {0xa9c0u, 0xa9c0u}, + {0xa9e5u, 0xa9e5u}, + {0xaa29u, 0xaa2eu}, + {0xaa31u, 0xaa32u}, + {0xaa35u, 0xaa36u}, + {0xaa43u, 0xaa43u}, + {0xaa4cu, 0xaa4cu}, + {0xaa7cu, 0xaa7cu}, + {0xaab0u, 0xaab0u}, + {0xaab2u, 0xaab4u}, + {0xaab7u, 0xaab8u}, + {0xaabeu, 0xaabfu}, + {0xaac1u, 0xaac1u}, + {0xaaecu, 0xaaedu}, + {0xaaf6u, 0xaaf6u}, + {0xabe5u, 0xabe5u}, + {0xabe8u, 0xabe8u}, + {0xabedu, 0xabedu}, + {0xd7b0u, 0xd7c6u}, + {0xd7cbu, 0xd7fbu}, + {0xfb1eu, 0xfb1eu}, + {0xfe00u, 0xfe0fu}, + {0xfe20u, 0xfe2fu}, + {0xfeffu, 0xfeffu}, + {0xff9eu, 0xffa0u}, + {0xfff0u, 0xfff8u}, + {0x101fdu, 0x101fdu}, + {0x102e0u, 0x102e0u}, + {0x10376u, 0x1037au}, + {0x10a01u, 0x10a03u}, + {0x10a05u, 0x10a06u}, + {0x10a0cu, 0x10a0fu}, + {0x10a38u, 0x10a3au}, + {0x10a3fu, 0x10a3fu}, + {0x10ae5u, 0x10ae6u}, + {0x10d24u, 0x10d27u}, + {0x10d69u, 0x10d6du}, + {0x10eabu, 0x10eacu}, + {0x10efau, 0x10effu}, + {0x10f46u, 0x10f50u}, + {0x10f82u, 0x10f85u}, + {0x11001u, 0x11001u}, + {0x11038u, 0x11046u}, + {0x11070u, 0x11070u}, + {0x11073u, 0x11074u}, + {0x1107fu, 0x11081u}, + {0x110b3u, 0x110b6u}, + {0x110b9u, 0x110bau}, + {0x110c2u, 0x110c2u}, + {0x11100u, 0x11102u}, + {0x11127u, 0x1112bu}, + {0x1112du, 0x11134u}, + {0x11173u, 0x11173u}, + {0x11180u, 0x11181u}, + {0x111b6u, 0x111beu}, + {0x111c0u, 0x111c0u}, + {0x111c2u, 0x111c3u}, + {0x111c9u, 0x111ccu}, + {0x111cfu, 0x111cfu}, + {0x1122fu, 0x11231u}, + {0x11234u, 0x11237u}, + {0x1123eu, 0x1123eu}, + {0x11241u, 0x11241u}, + {0x112dfu, 0x112dfu}, + {0x112e3u, 0x112eau}, + {0x11300u, 0x11301u}, + {0x1133bu, 0x1133cu}, + {0x1133eu, 0x1133eu}, + {0x11340u, 0x11340u}, + {0x1134du, 0x1134du}, + {0x11357u, 0x11357u}, + {0x11366u, 0x1136cu}, + {0x11370u, 0x11374u}, + {0x113b8u, 0x113b8u}, + {0x113bbu, 0x113c0u}, + {0x113c2u, 0x113c2u}, + {0x113c5u, 0x113c5u}, + {0x113c7u, 0x113c9u}, + {0x113ceu, 0x113d2u}, + {0x113e1u, 0x113e2u}, + {0x11438u, 0x1143fu}, + {0x11442u, 0x11444u}, + {0x11446u, 0x11446u}, + {0x1145eu, 0x1145eu}, + {0x114b0u, 0x114b0u}, + {0x114b3u, 0x114b8u}, + {0x114bau, 0x114bau}, + {0x114bdu, 0x114bdu}, + {0x114bfu, 0x114c0u}, + {0x114c2u, 0x114c3u}, + {0x115afu, 0x115afu}, + {0x115b2u, 0x115b5u}, + {0x115bcu, 0x115bdu}, + {0x115bfu, 0x115c0u}, + {0x115dcu, 0x115ddu}, + {0x11633u, 0x1163au}, + {0x1163du, 0x1163du}, + {0x1163fu, 0x11640u}, + {0x116abu, 0x116abu}, + {0x116adu, 0x116adu}, + {0x116b0u, 0x116b7u}, + {0x1171du, 0x1171du}, + {0x1171fu, 0x1171fu}, + {0x11722u, 0x11725u}, + {0x11727u, 0x1172bu}, + {0x1182fu, 0x11837u}, + {0x11839u, 0x1183au}, + {0x11930u, 0x11930u}, + {0x1193bu, 0x1193fu}, + {0x11941u, 0x11941u}, + {0x11943u, 0x11943u}, + {0x119d4u, 0x119d7u}, + {0x119dau, 0x119dbu}, + {0x119e0u, 0x119e0u}, + {0x11a01u, 0x11a0au}, + {0x11a33u, 0x11a38u}, + {0x11a3bu, 0x11a3eu}, + {0x11a47u, 0x11a47u}, + {0x11a51u, 0x11a56u}, + {0x11a59u, 0x11a5bu}, + {0x11a84u, 0x11a96u}, + {0x11a98u, 0x11a99u}, + {0x11b60u, 0x11b60u}, + {0x11b62u, 0x11b64u}, + {0x11b66u, 0x11b66u}, + {0x11c30u, 0x11c36u}, + {0x11c38u, 0x11c3du}, + {0x11c3fu, 0x11c3fu}, + {0x11c92u, 0x11ca7u}, + {0x11caau, 0x11cb0u}, + {0x11cb2u, 0x11cb3u}, + {0x11cb5u, 0x11cb6u}, + {0x11d31u, 0x11d36u}, + {0x11d3au, 0x11d3au}, + {0x11d3cu, 0x11d3du}, + {0x11d3fu, 0x11d47u}, + {0x11d90u, 0x11d91u}, + {0x11d95u, 0x11d95u}, + {0x11d97u, 0x11d97u}, + {0x11ef3u, 0x11ef4u}, + {0x11f00u, 0x11f02u}, + {0x11f36u, 0x11f3au}, + {0x11f40u, 0x11f42u}, + {0x11f5au, 0x11f5au}, + {0x13440u, 0x13440u}, + {0x13447u, 0x13455u}, + {0x1611eu, 0x16129u}, + {0x1612du, 0x1612fu}, + {0x16af0u, 0x16af4u}, + {0x16b30u, 0x16b36u}, + {0x16f4fu, 0x16f4fu}, + {0x16f8fu, 0x16f92u}, + {0x16fe4u, 0x16fe4u}, + {0x16ff0u, 0x16ff1u}, + {0x1bc9du, 0x1bc9eu}, + {0x1bca0u, 0x1bca3u}, + {0x1cf00u, 0x1cf2du}, + {0x1cf30u, 0x1cf46u}, + {0x1d165u, 0x1d169u}, + {0x1d16du, 0x1d182u}, + {0x1d185u, 0x1d18bu}, + {0x1d1aau, 0x1d1adu}, + {0x1d242u, 0x1d244u}, + {0x1da00u, 0x1da36u}, + {0x1da3bu, 0x1da6cu}, + {0x1da75u, 0x1da75u}, + {0x1da84u, 0x1da84u}, + {0x1da9bu, 0x1da9fu}, + {0x1daa1u, 0x1daafu}, + {0x1e000u, 0x1e006u}, + {0x1e008u, 0x1e018u}, + {0x1e01bu, 0x1e021u}, + {0x1e023u, 0x1e024u}, + {0x1e026u, 0x1e02au}, + {0x1e08fu, 0x1e08fu}, + {0x1e130u, 0x1e136u}, + {0x1e2aeu, 0x1e2aeu}, + {0x1e2ecu, 0x1e2efu}, + {0x1e4ecu, 0x1e4efu}, + {0x1e5eeu, 0x1e5efu}, + {0x1e6e3u, 0x1e6e3u}, + {0x1e6e6u, 0x1e6e6u}, + {0x1e6eeu, 0x1e6efu}, + {0x1e6f5u, 0x1e6f5u}, + {0x1e8d0u, 0x1e8d6u}, + {0x1e944u, 0x1e94au}, +}; +static const size_t zw_n = 394; + +static const uint32_t wd[][2] = { + {0x1100u, 0x115fu}, + {0x17a4u, 0x17a4u}, + {0x231au, 0x231bu}, + {0x2329u, 0x232au}, + {0x23e9u, 0x23ecu}, + {0x23f0u, 0x23f0u}, + {0x23f3u, 0x23f3u}, + {0x25fdu, 0x25feu}, + {0x2614u, 0x2615u}, + {0x2630u, 0x2637u}, + {0x2648u, 0x2653u}, + {0x267fu, 0x267fu}, + {0x268au, 0x268fu}, + {0x2693u, 0x2693u}, + {0x26a1u, 0x26a1u}, + {0x26aau, 0x26abu}, + {0x26bdu, 0x26beu}, + {0x26c4u, 0x26c5u}, + {0x26ceu, 0x26ceu}, + {0x26d4u, 0x26d4u}, + {0x26eau, 0x26eau}, + {0x26f2u, 0x26f3u}, + {0x26f5u, 0x26f5u}, + {0x26fau, 0x26fau}, + {0x26fdu, 0x26fdu}, + {0x2705u, 0x2705u}, + {0x270au, 0x270bu}, + {0x2728u, 0x2728u}, + {0x274cu, 0x274cu}, + {0x274eu, 0x274eu}, + {0x2753u, 0x2755u}, + {0x2757u, 0x2757u}, + {0x2795u, 0x2797u}, + {0x27b0u, 0x27b0u}, + {0x27bfu, 0x27bfu}, + {0x2b1bu, 0x2b1cu}, + {0x2b50u, 0x2b50u}, + {0x2b55u, 0x2b55u}, + {0x2e80u, 0x2e99u}, + {0x2e9bu, 0x2ef3u}, + {0x2f00u, 0x2fd5u}, + {0x2ff0u, 0x3029u}, + {0x3030u, 0x303eu}, + {0x3041u, 0x3096u}, + {0x309bu, 0x30ffu}, + {0x3105u, 0x312fu}, + {0x3131u, 0x3163u}, + {0x3165u, 0x318eu}, + {0x3190u, 0x31e5u}, + {0x31efu, 0x321eu}, + {0x3220u, 0x3247u}, + {0x3250u, 0xa48cu}, + {0xa490u, 0xa4c6u}, + {0xa960u, 0xa97cu}, + {0xac00u, 0xd7a3u}, + {0xf900u, 0xfaffu}, + {0xfe10u, 0xfe19u}, + {0xfe30u, 0xfe52u}, + {0xfe54u, 0xfe66u}, + {0xfe68u, 0xfe6bu}, + {0xff01u, 0xff60u}, + {0xffe0u, 0xffe6u}, + {0x16fe0u, 0x16fe3u}, + {0x16ff2u, 0x16ff6u}, + {0x17000u, 0x18cd5u}, + {0x18cffu, 0x18d1eu}, + {0x18d80u, 0x18df2u}, + {0x1aff0u, 0x1aff3u}, + {0x1aff5u, 0x1affbu}, + {0x1affdu, 0x1affeu}, + {0x1b000u, 0x1b122u}, + {0x1b132u, 0x1b132u}, + {0x1b150u, 0x1b152u}, + {0x1b155u, 0x1b155u}, + {0x1b164u, 0x1b167u}, + {0x1b170u, 0x1b2fbu}, + {0x1d300u, 0x1d356u}, + {0x1d360u, 0x1d376u}, + {0x1f004u, 0x1f004u}, + {0x1f0cfu, 0x1f0cfu}, + {0x1f18eu, 0x1f18eu}, + {0x1f191u, 0x1f19au}, + {0x1f200u, 0x1f202u}, + {0x1f210u, 0x1f23bu}, + {0x1f240u, 0x1f248u}, + {0x1f250u, 0x1f251u}, + {0x1f260u, 0x1f265u}, + {0x1f300u, 0x1f320u}, + {0x1f32du, 0x1f335u}, + {0x1f337u, 0x1f37cu}, + {0x1f37eu, 0x1f393u}, + {0x1f3a0u, 0x1f3cau}, + {0x1f3cfu, 0x1f3d3u}, + {0x1f3e0u, 0x1f3f0u}, + {0x1f3f4u, 0x1f3f4u}, + {0x1f3f8u, 0x1f43eu}, + {0x1f440u, 0x1f440u}, + {0x1f442u, 0x1f4fcu}, + {0x1f4ffu, 0x1f53du}, + {0x1f54bu, 0x1f54eu}, + {0x1f550u, 0x1f567u}, + {0x1f57au, 0x1f57au}, + {0x1f595u, 0x1f596u}, + {0x1f5a4u, 0x1f5a4u}, + {0x1f5fbu, 0x1f64fu}, + {0x1f680u, 0x1f6c5u}, + {0x1f6ccu, 0x1f6ccu}, + {0x1f6d0u, 0x1f6d2u}, + {0x1f6d5u, 0x1f6d8u}, + {0x1f6dcu, 0x1f6dfu}, + {0x1f6ebu, 0x1f6ecu}, + {0x1f6f4u, 0x1f6fcu}, + {0x1f7e0u, 0x1f7ebu}, + {0x1f7f0u, 0x1f7f0u}, + {0x1f90cu, 0x1f93au}, + {0x1f93cu, 0x1f945u}, + {0x1f947u, 0x1f9ffu}, + {0x1fa70u, 0x1fa7cu}, + {0x1fa80u, 0x1fa8au}, + {0x1fa8eu, 0x1fac6u}, + {0x1fac8u, 0x1fac8u}, + {0x1facdu, 0x1fadcu}, + {0x1fadfu, 0x1faeau}, + {0x1faefu, 0x1faf8u}, + {0x20000u, 0x2fffdu}, +}; +static const size_t wd_n = 125; + +static int range_has(const uint32_t t[][2], size_t n, uint32_t cp) { + size_t lo = 0, hi = n; + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + if (t[mid][0] > cp) hi = mid; + else if (t[mid][1] < cp) lo = mid + 1; + else return 1; + } + return 0; +} + +static int charw(size_t cp) { + if (cp < 0x20 || (cp >= 0x7F && cp <= 0x9F)) return 0; /* C0, DEL, C1 */ + if (range_has(zw, zw_n, (uint32_t)cp)) return 0; + if (range_has(wd, wd_n, (uint32_t)cp)) return 2; + return 1; +} + +size_t input_display_len_n(const char *s, size_t n) { + size_t width = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + size_t cp; + const char *nxt = utf8_next(p, end, &cp); + if (cp == 0x1B) { + if (nxt >= end) break; /* lone trailing ESC: not rendered */ + unsigned char second = (unsigned char)*nxt; + if (second == '[') { + const char *q = nxt + 1; + while (q < end) { + size_t c2; + const char *q2 = utf8_next(q, end, &c2); + if (c2 >= 0x40 && c2 <= 0x7E) break; + q = q2; + } + if (q >= end) return width; + size_t c3; + p = utf8_next(q, end, &c3); + continue; + } + if (second == ']' || second == 'P' || second == '^' || second == 'X' || + second == '_') { + /* OSC / DCS / SOS / PM / APC: scan to BEL or ESC \ */ + int prev_esc = 0; + const char *q = nxt + 1; + while (q < end) { + unsigned char c = (unsigned char)*q; + if (c == 0x07 || (prev_esc && c == '\\')) break; + prev_esc = (c == 0x1B); + q++; + } + if (q >= end) return width; + p = q + 1; + continue; + } + if (second == 'O') { + /* SS3: `O` plus one more final byte (e.g. ESC O A). */ + if (nxt + 1 >= end) return width; + size_t c3; + p = utf8_next(nxt + 1, end, &c3); + continue; + } + if (second >= 0x20 && second <= 0x7E) { + /* Intermediates 0x20..=0x2F then one final byte; a bare + * final byte ends the sequence on the spot. */ + const char *q = nxt + 1; + while (q < end) { + unsigned char c = (unsigned char)*q; + if (c < 0x20 || c > 0x2F) break; + q++; + } + if (q >= end) return width; + size_t c3; + p = utf8_next(q, end, &c3); + continue; + } + p = nxt; /* other: lone ESC */ + continue; + } + if (cp != 0x7F) width += (size_t)charw(cp); + p = nxt; + } + return width; +} + +size_t input_display_len(const char *s) { + return input_display_len_n(s, strlen(s)); +} + +/* ================================================================== */ +/* Parser */ +/* ================================================================== */ + +void key_parser_init(key_parser *p) { + p->state = KST_GROUND; + xbuf_init(&p->seq); + xbuf_init(&p->csi_params); + p->utf8_len = 0; + p->pasting = false; +} + +void key_parser_free(key_parser *p) { + xbuf_free(&p->seq); + xbuf_free(&p->csi_params); + p->state = KST_GROUND; + p->utf8_len = 0; + p->pasting = false; +} + +static void kp_emit(key_parser *p, evlist *out, int kind, unsigned int cp) { + evlist_push(out, (input_key){kind, cp}, (const unsigned char *)p->seq.data, + p->seq.len); + xbuf_reset(&p->seq); + xbuf_reset(&p->csi_params); +} + +static int kp_digit(unsigned char b) { + return b >= '0' && b <= '9'; +} + +static int first_num(const char *s, unsigned long *out) { + unsigned long d = 0; + int any = 0; + for (const char *p = s; *p >= '0' && *p <= '9'; p++) { + if (any) d = d * 10 + (unsigned long)(*p - '0'); + else + d = (unsigned long)(*p - '0'); + any = 1; + if (d > 0xFFFFFFFFUL) break; + } + if (!any) return 0; + *out = d; + return 1; +} + +static int csi_u(const char *params, int *key) { + const char *semi = strchr(params, ';'); + size_t first_len = semi ? (size_t)(semi - params) : strlen(params); + if (first_len == 0 || first_len > 10) return 0; + unsigned long cp = 0; + for (size_t i = 0; i < first_len; i++) { + cp = cp * 10 + (unsigned long)(params[i] - '0'); + if (cp > 0xFFFFFFFFUL) return 0; + } + unsigned long mods = 1; + if (semi) { + const char *m = semi + 1; + size_t mlen = strlen(m); + if (mlen > 0 && mlen <= 10) { + unsigned long v = 0; + int ok = 1; + for (size_t i = 0; i < mlen; i++) { + if (m[i] < '0' || m[i] > '9') { + ok = 0; + break; + } + v = v * 10 + (unsigned long)(m[i] - '0'); + if (v > 0xFFFFFFFFUL) { + ok = 0; + break; + } + } + if (ok) mods = v; + } + } + int shift = ((mods - 1UL) & 1UL) == 1UL; + int k; + switch (cp) { + case 9u: + k = shift ? KEY_BACKTAB : KEY_TAB; + break; + case 12u: + k = KEY_BACKSPACE; + break; + case 13u: + k = KEY_ENTER; + break; + case 27u: + k = KEY_ESC; + break; + case 127u: + k = KEY_DELETE; + break; + case 0x2190u: + k = KEY_LEFT; + break; + case 0x2191u: + k = KEY_UP; + break; + case 0x2192u: + k = KEY_RIGHT; + break; + case 0x2193u: + k = KEY_DOWN; + break; + default: + return 0; + } + *key = k; + return 1; +} + +static int kp_ground(key_parser *p, unsigned char b, evlist *out); + +static size_t utf8_lead_len(unsigned char lead) { + if (lead >= 0xF0 && lead < 0xF8) return 4; + if (lead >= 0xE0) return 3; + if (lead >= 0xC0) return 2; + return 1; +} + +static int kp_utf8_step(key_parser *p, unsigned char b, evlist *out) { + if (p->utf8_len == 0) { + if (b < 0xC0 || b >= 0xF8) { + p->utf8_len = 0; + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_UNKNOWN, 0); + return 1; + } + p->utf8[p->utf8_len++] = b; + return 0; + } + if (b >= 0x80 && b < 0xC0) { + if (p->utf8_len < 8) p->utf8[p->utf8_len++] = b; + size_t expected = utf8_lead_len(p->utf8[0]); + if (p->utf8_len < expected) return 0; + if (utf8_is_valid((const char *)p->utf8, p->utf8_len)) { + size_t cp = 0; + utf8_next((const char *)p->utf8, (const char *)p->utf8 + p->utf8_len, + &cp); + unsigned char tmp[8]; + size_t n = p->utf8_len; + memcpy(tmp, p->utf8, n); + p->utf8_len = 0; + xbuf_reset(&p->seq); + for (size_t i = 0; i < n; i++) xbuf_push(&p->seq, (char)tmp[i]); + kp_emit(p, out, KEY_CHAR, cp); + return 1; + } + p->utf8_len = 0; + return 0; + } + p->utf8_len = 0; + return kp_ground(p, b, out); +} + +static int kp_ground(key_parser *p, unsigned char b, evlist *out) { + if (b == 0x1B) { + p->state = KST_ESCAPE; + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + return 0; + } + if (b == 0x09) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_TAB, 0); + return 1; + } + if (b == 0x0D || b == 0x0A) { + /* CR (0x0D) submits. During bracketed paste every CR/LF is a newline, + * preserving multi-line pastes. LF (0x0A, Ctrl+J) outside of paste is + * a dead key. Multi-line typing is done with backslash+Enter. */ + if (p->pasting) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_NEWLINE, 0); + return 1; + } + if (b == 0x0D) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_ENTER, 0); + return 1; + } + xbuf_reset(&p->seq); /* discard LF: dead key, no event */ + return 0; + } + if (b == 0x7F || b == 0x08) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_BACKSPACE, 0); + return 1; + } + switch (b) { + case 0x01: + case 0x03: + case 0x04: + case 0x05: + case 0x0B: + case 0x0C: + case 0x15: + case 0x17: + case 0x19: + case 0x1F: { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + int k; + switch (b) { + case 0x01: k = KEY_CTRL_A; break; + case 0x03: k = KEY_CTRL_C; break; + case 0x04: k = KEY_CTRL_D; break; + case 0x05: k = KEY_CTRL_E; break; + case 0x0B: k = KEY_CTRL_K; break; + case 0x0C: k = KEY_CTRL_L; break; + case 0x15: k = KEY_CTRL_U; break; + case 0x19: k = KEY_CTRL_Y; break; + case 0x1F: k = KEY_UNDO; break; + default: k = KEY_CTRL_W; break; + } + /* A bare Ctrl+C is an interrupt, not pasted text: if a bracketed + * paste start (CSI 200~) was received but its 201~ terminator never + * arrived, paste mode would otherwise stick and turn every Enter into + * a newline. Reset it so the user can submit again. */ + if (b == 0x03) + p->pasting = false; + kp_emit(p, out, k, 0); + return 1; + } + default: + break; + } + if (b < 0x20) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_UNKNOWN, 0); + return 1; + } + if (b < 0x80) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_CHAR, b); + return 1; + } + return kp_utf8_step(p, b, out); +} + +static int kp_escape(key_parser *p, unsigned char b, evlist *out) { + p->state = KST_GROUND; + xbuf_push(&p->seq, (char)b); + if (b == '[') { + p->state = KST_CSI; + xbuf_reset(&p->csi_params); + return 0; + } + if (b == 'O') { + p->state = KST_SS3; + return 0; + } + if (b >= 0x20 && b <= 0x7E) { + kp_emit(p, out, KEY_CHAR, b); + return 1; + } + kp_emit(p, out, KEY_ESC, 0); + return 1; +} + +static int kp_csi(key_parser *p, unsigned char b, evlist *out) { + xbuf_push(&p->seq, (char)b); + if (kp_digit(b) || b == ';' || b == '<' || b == '=' || b == '?' || b == '>') { + xbuf_push(&p->csi_params, (char)b); + return 0; + } + p->state = KST_GROUND; + char *params = xbuf_steal(&p->csi_params); + int k = -1; + if (b == 'A') + k = KEY_UP; + else if (b == 'B') + k = KEY_DOWN; + else if (b == 'C') + k = KEY_RIGHT; + else if (b == 'D') + k = KEY_LEFT; + else if (b == 'H') + k = KEY_HOME; + else if (b == 'F') + k = KEY_END; + else if (b == 'Z') + k = KEY_BACKTAB; + else if (b == 'u') + k = csi_u(params, &k) ? k : -1; + else if (b == '~') { + unsigned long n; + if (first_num(params, &n)) { + switch (n) { + case 200u: + free(params); + p->pasting = true; + return 0; + case 201u: + free(params); + p->pasting = false; + return 0; + case 1u: + case 7u: + k = KEY_HOME; + break; + case 3u: + k = KEY_DELETE; + break; + case 4u: + case 8u: + k = KEY_END; + break; + case 5u: + k = KEY_PAGEUP; + break; + case 6u: + k = KEY_PAGEDOWN; + break; + default: + k = KEY_UNKNOWN; + break; + } + } else { + k = KEY_UNKNOWN; + } + } + else if (b == 'R' || b == 'M' || b == 'h' || b == 'l' || b == 'm') + k = -1; + free(params); + if (k < 0) return 0; + kp_emit(p, out, k, 0); + return 1; +} + +static int kp_ss3(key_parser *p, unsigned char b, evlist *out) { + xbuf_push(&p->seq, (char)b); + p->state = KST_GROUND; + int k; + switch (b) { + case 'A': k = KEY_UP; break; + case 'B': k = KEY_DOWN; break; + case 'C': k = KEY_RIGHT; break; + case 'D': k = KEY_LEFT; break; + case 'H': k = KEY_HOME; break; + case 'F': k = KEY_END; break; + default: k = KEY_UNKNOWN; break; + } + kp_emit(p, out, k, 0); + return 1; +} + +size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out) { + size_t emitted = 0; + for (size_t i = 0; i < n; i++) { + unsigned char b = in[i]; + switch (p->state) { + case KST_ESCAPE: + emitted += kp_escape(p, b, out); + break; + case KST_CSI: + emitted += kp_csi(p, b, out); + break; + case KST_SS3: + emitted += kp_ss3(p, b, out); + break; + default: + emitted += kp_ground(p, b, out); + break; + } + } + return emitted; +} + +int key_parser_flush(key_parser *p, evlist *out) { + if (p->state != KST_ESCAPE) return 0; + p->state = KST_GROUND; + kp_emit(p, out, KEY_ESC, 0); + return 1; +} + +/* ================================================================== */ +/* Editor */ +/* ================================================================== */ + +static size_t char_start(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t j = pos; + while (j > 0 && (((unsigned char)s[j - 1]) & 0xC0) == 0x80) j--; + return j == 0 ? 0 : j - 1; +} + +static size_t char_end(const char *s, size_t len, size_t pos) { + if (pos >= len) return len; + size_t cp; + const char *nxt = utf8_next(s + pos, s + len, &cp); + return (size_t)(nxt - s); +} + +/* Byte offset of the first column of the logical line holding `pos`. */ +static size_t line_start_of(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t i = pos; + while (i > 0 && s[i - 1] != '\n') i--; + return i; +} + +/* Byte offset of the line terminator ('\n') for the line holding `pos`, or + * end-of-string when that line is the last one. */ +static size_t line_end_of(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t i = pos; + while (i < len && s[i] != '\n') i++; + return i; +} + +/* Physical (row, col) of a byte offset in the buffer, tracking the *visual* + * cursor position the way a delayed-wrap terminal (xterm/VTE) actually keeps + * it: writing a character into the last column leaves the cursor sitting on + * that last column with a pending wrap, the wrap fires only when the *next* + * character is written, and a newline clears the pending wrap. So the cursor + * never sits on an empty spurious row — a full-width line followed by a + * newline does not gain one. Line 0 is prefixed by the 2-column CHAT_PROMPT. + * The returned (row, col) is exactly where the terminal cursor is after the + * text up to that byte has been printed, so relative cursor moves from it are + * reliable. */ +static void input_wrap_caret_impl(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col, + bool *out_pending) { + if (w == 0) w = 1; + size_t len = text ? strlen(text) : 0; + if (caret > len) caret = len; + size_t r = 0, c = 0; + int pending = 0; +#define ADV_COL() do { \ + if (pending) { pending = 0; r++; c = 0; } \ + c++; \ + if (c == w) { pending = 1; c = w - 1; } \ + } while (0) + /* The 2-column prompt is always printed in front of line 0. */ + ADV_COL(); + ADV_COL(); + size_t byte = 0; + while (byte < caret) { + size_t cp; + const char *nxt = utf8_next(text + byte, text + len, &cp); + size_t cl = (size_t)(nxt - (text + byte)); + if (cl == 0) cl = 1; + if (cp == '\n') { + pending = 0; + c = 0; + r++; + } else { + ADV_COL(); + } + byte += cl; + } +#undef ADV_COL + if (out_row) *out_row = r; + if (out_col) *out_col = c; + if (out_pending) *out_pending = (pending != 0); +} + +void input_wrap_caret(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col) { + input_wrap_caret_impl(text, caret, w, out_row, out_col, NULL); +} + +bool input_wrap_pending(const char *text, size_t caret, size_t w) { + bool pending = false; + input_wrap_caret_impl(text, caret, w, NULL, NULL, &pending); + return pending; +} + +/* Total physical rows the buffer occupies when printed, i.e. the row of the + * visual end-of-text cursor plus one. */ +size_t input_wrap_total(const char *text, size_t w) { + size_t r, c; + const char *t = text ? text : ""; + input_wrap_caret(t, strlen(t), w, &r, &c); + return r + 1; +} + +static char *splice_remove(const char *s, size_t start, size_t end) { + size_t len = strlen(s); + char *out = (char *) xmalloc(len - (end - start) + 1); + memcpy(out, s, start); + memcpy(out + start, s + end, len - end); + out[len - (end - start)] = '\0'; + return out; +} + +static size_t word_backward(const char *s, size_t byte) { + size_t i = byte; + while (i > 0 && s[i - 1] == ' ') i--; + while (i > 0 && + (isalnum((unsigned char)s[i - 1]) || s[i - 1] == '_')) + i--; + while (i > 0 && s[i - 1] == ' ') i--; + return i; +} + +#define EDITOR_UNDO_LIMIT 100 + +void editor_init(editor *e) { + e->text = xstrdup(""); + e->caret = 0; + e->killed = NULL; + e->undo = NULL; + e->undo_len = 0; + e->undo_cap = 0; +} + +void editor_free(editor *e) { + free(e->text); + for (size_t i = 0; i < e->undo_len; i++) + free(e->undo[i].text); + free(e->undo); + free(e->killed); + memset(e, 0, sizeof(*e)); +} + +/* Snapshot the current buffer onto the bounded undo stack. */ +static void editor_record(editor *e) { + if (e->undo_len == e->undo_cap) { + size_t ncap = e->undo_cap ? e->undo_cap * 2 : 16; + if (ncap > EDITOR_UNDO_LIMIT) { + free(e->undo[0].text); + memmove(e->undo, e->undo + 1, (e->undo_len - 1) * sizeof(*e->undo)); + e->undo_len--; + } else { + e->undo = (editor_undo_entry *) xrealloc(e->undo, ncap * sizeof(*e->undo)); + e->undo_cap = ncap; + } + } + e->undo[e->undo_len].text = xstrdup(e->text ? e->text : ""); + e->undo[e->undo_len].caret = e->caret; + e->undo_len++; +} + +/* Restore the most recent snapshot. False when there is nothing to undo. */ +static bool editor_undo_pop(editor *e) { + if (e->undo_len == 0) + return false; + e->undo_len--; + free(e->text); + e->text = e->undo[e->undo_len].text; + e->undo[e->undo_len].text = NULL; + e->caret = e->undo[e->undo_len].caret; + return true; +} + +void editor_clear(editor *e) { + e->text[0] = '\0'; + e->caret = 0; +} + +void editor_set(editor *e, const char *text) { + free(e->text); + e->text = xstrdup(text); + e->caret = strlen(e->text); +} + +char *editor_text(const editor *e) { + return xstrdup(e->text); +} + +bool editor_is_empty(const editor *e) { + return e->text[0] == '\0'; +} + +size_t editor_line_count(const editor *e) { + size_t n = 1; + for (const char *p = e->text; *p; p++) + if (*p == '\n') n++; + return n; +} + +size_t editor_caret_line(const editor *e) { + size_t n = 0; + for (size_t i = 0; i < e->caret && e->text[i]; i++) + if (e->text[i] == '\n') n++; + return n; +} + +static void ed_insert_str(editor *e, const char *s) { + size_t len = strlen(e->text); + if (e->caret > len) e->caret = len; + size_t ins = strlen(s); + char *nt = (char *) xmalloc(len + ins + 1); + memcpy(nt, e->text, e->caret); + memcpy(nt + e->caret, s, ins); + memcpy(nt + e->caret + ins, e->text + e->caret, len - e->caret); + nt[len + ins] = '\0'; + free(e->text); + e->text = nt; + e->caret += ins; +} + +static void ed_insert_char(editor *e, size_t cp) { + char tmp[8]; + size_t i = 0; + if (cp < 0x80) + tmp[i++] = (char)cp; + else if (cp < 0x800) { + tmp[i++] = (char)(0xC0 | (cp >> 6)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + tmp[i++] = (char)(0xE0 | (cp >> 12)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else { + tmp[i++] = (char)(0xF0 | (cp >> 18)); + tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } + tmp[i] = '\0'; + ed_insert_str(e, tmp); +} + +static void ed_backspace(editor *e) { + if (e->caret > 0) { + size_t s = char_start(e->text, e->caret); + char *killed = xstrndup(e->text + s, e->caret - s); + char *nt = splice_remove(e->text, s, e->caret); + free(e->text); + e->text = nt; + e->caret = s; + free(e->killed); + e->killed = killed; + } +} + +static void ed_delete(editor *e) { + size_t len = strlen(e->text); + if (e->caret < len) { + size_t c = char_end(e->text, len, e->caret); + char *killed = xstrndup(e->text + e->caret, c - e->caret); + char *nt = splice_remove(e->text, e->caret, c); + free(e->text); + e->text = nt; + free(e->killed); + e->killed = killed; + } +} + +/* Move the caret to the previous (dir<0) or next (dir>0) line, keeping the + * column and clamping it to the target line's length on a UTF-8 boundary. + * No-op at the top/bottom edge. */ +static void ed_move_line(editor *e, int dir) { + const char *s = e->text; + size_t len = strlen(s); + if (e->caret > len) e->caret = len; + size_t start = e->caret; + while (start > 0 && s[start - 1] != '\n') start--; + size_t col = e->caret - start; + if (dir < 0) { + if (start == 0) return; /* first line */ + size_t pstart = start - 1; + while (pstart > 0 && s[pstart - 1] != '\n') pstart--; + size_t plen = (start - 1) - pstart; + size_t c = col < plen ? col : plen; + while (c > 0 && (((unsigned char)s[pstart + c]) & 0xC0) == 0x80) c--; + e->caret = pstart + c; + } else { + size_t end = e->caret; + while (end < len && s[end] != '\n') end++; + if (end >= len) return; /* last line */ + size_t nstart = end + 1; + size_t nend = nstart; + while (nend < len && s[nend] != '\n') nend++; + size_t nlen = nend - nstart; + size_t c = col < nlen ? col : nlen; + while (c > 0 && (((unsigned char)s[nstart + c]) & 0xC0) == 0x80) c--; + e->caret = nstart + c; + } +} + +void editor_apply(editor *e, input_key key, input_action *a) { + a->kind = ACT_NONE; + a->text = NULL; + switch (key.kind) { + case KEY_CHAR: + editor_record(e); + ed_insert_char(e, key.cp); + break; + case KEY_TAB: + editor_record(e); + ed_insert_str(e, "\t"); + break; + case KEY_NEWLINE: /* pasted CR/LF: insert a real newline */ + editor_record(e); + ed_insert_str(e, "\n"); + break; + case KEY_ENTER: { + /* Backslash + Enter: line continuation, not submit. Universal across + * terminals (works where Shift+Enter is not distinguishable from + * Enter). The backslash is the char immediately before the caret on + * its own line -- so a middle line of a multi-line buffer can be + * split, not just the last line. */ + if (e->caret > 0 && e->text[e->caret - 1] == '\\') { + editor_record(e); + e->text[e->caret - 1] = '\n'; + a->kind = ACT_NEWLINE; + break; + } + a->kind = ACT_SUBMIT; + a->text = editor_text(e); + break; + } + case KEY_BACKTAB: + a->kind = ACT_TOGGLE_MODE; + break; + case KEY_ESC: + a->kind = ACT_STOP; + break; + case KEY_BACKSPACE: + editor_record(e); + ed_backspace(e); + break; + case KEY_DELETE: + editor_record(e); + ed_delete(e); + break; + case KEY_LEFT: + /* Stay on the current line: the caret never crosses a newline to the + * left, so a press at the line start is a no-op. */ + if (e->caret > line_start_of(e->text, e->caret)) + e->caret = char_start(e->text, e->caret); + break; + case KEY_RIGHT: + if (e->caret < line_end_of(e->text, e->caret)) + e->caret = char_end(e->text, strlen(e->text), e->caret); + break; + case KEY_HOME: + case KEY_CTRL_A: + e->caret = line_start_of(e->text, e->caret); + break; + case KEY_END: + case KEY_CTRL_E: + e->caret = line_end_of(e->text, e->caret); + break; + case KEY_CTRL_U: + editor_record(e); + if (e->caret > 0) { + char *killed = xstrndup(e->text, e->caret); + char *keep = xstrdup(e->text + e->caret); + free(e->text); + e->text = keep; + e->caret = 0; + free(e->killed); + e->killed = killed; + } + break; + case KEY_CTRL_K: { + editor_record(e); + size_t len = strlen(e->text); + if (e->caret < len) { + char *killed = xstrdup(e->text + e->caret); + char *keep = xstrndup(e->text, e->caret); + free(e->text); + e->text = keep; + free(e->killed); + e->killed = killed; + } + break; + } + case KEY_CTRL_W: { + editor_record(e); + size_t i = word_backward(e->text, e->caret); + if (i < e->caret) { + char *killed = xstrndup(e->text + i, e->caret - i); + char *nt = splice_remove(e->text, i, e->caret); + free(e->text); + e->text = nt; + e->caret = i; + free(e->killed); + e->killed = killed; + } + break; + } + case KEY_CTRL_Y: + if (e->killed && e->killed[0]) { + editor_record(e); + ed_insert_str(e, e->killed); + } + break; + case KEY_UNDO: + editor_undo_pop(e); + break; + case KEY_CTRL_C: + if (editor_is_empty(e)) + a->kind = ACT_QUIT; + else { + editor_record(e); + editor_clear(e); + } + break; + case KEY_CTRL_D: + if (editor_is_empty(e)) + a->kind = ACT_QUIT; + else + ed_delete(e); + break; + case KEY_CTRL_L: + a->kind = ACT_CLEAR_SCREEN; + break; + case KEY_UP: + ed_move_line(e, -1); + break; + case KEY_DOWN: + ed_move_line(e, +1); + break; + case KEY_PAGEUP: + case KEY_PAGEDOWN: + case KEY_UNKNOWN: + break; + } +} + +/* ================================================================== */ +/* History */ +/* ================================================================== */ + +input_history *input_history_new(void) { + input_history *h = (input_history *) xmalloc(sizeof(*h)); + sl_init(&h->entries); + h->head = 0; + h->active = false; + h->index = 0; + h->snapshot = NULL; + h->path = NULL; + return h; +} + +void input_history_free(input_history *h) { + sl_free(&h->entries); + free(h->snapshot); + free(h->path); + free(h); +} + +void input_history_reset(input_history *h) { + h->active = false; + h->index = 0; + free(h->snapshot); + h->snapshot = NULL; +} + +static size_t hist_live(input_history *h) { + return h->entries.len > h->head ? h->entries.len - h->head : 0; +} + +static void history_evict_head(input_history *h) { + h->head++; + /* Amortized O(1): once half the storage is dead, free the dead prefix + * and slide the live entries to the front (absolute `index` moves with + * them). Evicted-but-uncompacted entries are freed by sl_free. */ + if (h->head * 2 >= h->entries.len) { + size_t live = h->entries.len - h->head; + for (size_t i = 0; i < h->head; i++) + free(h->entries.items[i]); + memmove(h->entries.items, h->entries.items + h->head, + live * sizeof(*h->entries.items)); + h->entries.len = live; + if (h->active) + h->index -= h->head; + h->head = 0; + } +} + +static int make_parent_dirs(const char *path) { + char *copy = xstrdup(path); + for (char *p = copy + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (copy[0] && mkdir(copy, 0755) != 0 && errno != EEXIST) { + free(copy); + return -1; + } + *p = '/'; + } + } + free(copy); + return 0; +} + +bool input_history_persist(input_history *h) { + if (!h->path) return false; + cJSON *arr = cJSON_CreateArray(); + for (size_t i = h->head; i < h->entries.len; i++) + cJSON_AddItemToArray(arr, cJSON_CreateString(h->entries.items[i])); + char *s = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + char *err = NULL; + bool ok = make_parent_dirs(h->path) == 0 && + write_file_from_buf(h->path, s, strlen(s), &err) == 0; + free(err); + free(s); + return ok; +} + +input_history *input_history_open(const char *path) { + input_history *h = input_history_new(); + h->path = xstrdup(path); + char *data = NULL; + size_t len = 0; + char *err = NULL; + int rc = read_file_to_buf(path, &data, &len, &err); + if (rc == 0) { + cJSON *arr = data ? cJSON_Parse(data) : NULL; + free(data); + if (arr && cJSON_IsArray(arr)) { + int n = cJSON_GetArraySize(arr); + for (int i = 0; i < n; i++) { + cJSON *it = cJSON_GetArrayItem(arr, i); + if (cJSON_IsString(it) && it->valuestring) + sl_push(&h->entries, xstrdup(it->valuestring)); + } + } + cJSON_Delete(arr); + } else { + free(err); + if (errno != ENOENT) { + input_history_free(h); + return NULL; + } + } + while (hist_live(h) > LLSH_HISTORY_LIMIT) history_evict_head(h); + return h; +} + +void input_history_push(input_history *h, const char *text) { + char *trimmed = xstr_trim_dup(text); + if (!trimmed[0]) { + free(trimmed); + input_history_reset(h); + return; + } + if (hist_live(h) > 0) { + const char *last = h->entries.items[h->entries.len - 1]; + if (xstreq(last, trimmed)) { + free(trimmed); + input_history_reset(h); + return; + } + } + if (hist_live(h) == LLSH_HISTORY_LIMIT) history_evict_head(h); + sl_push(&h->entries, trimmed); + input_history_reset(h); + input_history_persist(h); +} + +char *input_history_walk(input_history *h, int dir, size_t caret_line, + const char *current) { + if (dir < 0) { + /* Up: walk newest -> oldest. While composing (not active) engage + * only from the first line so a mid-buffer Up still moves the caret; + * the first Up snapshots the line being edited so a Down can restore + * it. Mid-walk (active) the press always advances, regardless of + * where the caret now sits. */ + if (!h->active) { + if (hist_live(h) == 0 || caret_line != 0) + return NULL; + free(h->snapshot); + h->snapshot = xstrdup(current ? current : ""); + h->active = true; + h->index = h->entries.len - 1; + } else if (h->index == h->head) { + return NULL; /* oldest already shown: hand back to the caret */ + } else { + h->index -= 1; + } + return xstrdup(h->entries.items[h->index]); + } + + /* Down: only meaningful mid-walk. Step back toward the newest entry, or, + * past the newest, hand back the pre-recall line and end the walk. + * While composing a Down never recalls — it moves the caret. */ + if (!h->active) + return NULL; + if (h->index + 1 < h->entries.len) { + h->index += 1; + return xstrdup(h->entries.items[h->index]); + } + h->active = false; + char *s = h->snapshot; + h->snapshot = NULL; + return s; +} + +/* ================================================================== */ +/* Terminal */ +/* ================================================================== */ + +int input_isatty(void) { + return isatty(STDIN_FILENO); +} + +int input_setup_terminal(void) { + struct termios orig; + if (tcgetattr(STDIN_FILENO, &orig) != 0) return -1; + struct termios raw = orig; + raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON)); + raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG)); + raw.c_oflag &= ~((tcflag_t)OPOST); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) return -1; + struct termios t; + if (tcgetattr(STDIN_FILENO, &t) == 0) { + t.c_oflag |= (tcflag_t)(OPOST | ONLCR); + (void)tcsetattr(STDIN_FILENO, TCSANOW, &t); + } + const char *init = "\x1b[>1u\x1b[?2004h"; + (void)!write(STDOUT_FILENO, init, strlen(init)); + fflush(stdout); + return 0; +} + +void input_teardown_terminal(void) { + const char *fin = "\x1b[?2004l\x1b[>0u"; + (void)!write(STDOUT_FILENO, fin, strlen(fin)); + fflush(stdout); + struct termios t; + if (tcgetattr(STDIN_FILENO, &t) != 0) return; + t.c_lflag |= (tcflag_t)(ICANON | ECHO | ISIG | IEXTEN); + t.c_iflag |= (tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + t.c_oflag |= (tcflag_t)(OPOST | ONLCR); + t.c_cc[VMIN] = 1; + t.c_cc[VTIME] = 0; + (void)tcsetattr(STDIN_FILENO, TCSANOW, &t); +} + +/* ================================================================== */ +/* Reader */ +/* ================================================================== */ + +struct input_key_reader { + reader_ctx *ctx; + pthread_t tid; + int started; + volatile int stop; + int exit; + pthread_mutex_t qlock; + evlist q; +}; + +input_key_reader *input_key_reader_new(reader_ctx *ctx) { + input_key_reader *r = (input_key_reader *) xmalloc(sizeof(*r)); + memset(r, 0, sizeof(*r)); + r->ctx = ctx; + pthread_mutex_init(&r->qlock, NULL); + evlist_init(&r->q); + return r; +} + +void input_key_reader_send(input_key_reader *r, const input_key *k) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, (const unsigned char *)"", 0); + pthread_mutex_unlock(&r->qlock); +} + +void input_key_reader_send_raw(input_key_reader *r, const input_key *k, + const char *raw) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, (const unsigned char *)raw, strlen(raw)); + pthread_mutex_unlock(&r->qlock); +} + +bool input_key_reader_recv(input_key_reader *r, input_key *out, + const unsigned char **raw, size_t *raw_len) { + pthread_mutex_lock(&r->qlock); + if (r->q.len == 0) { + pthread_mutex_unlock(&r->qlock); + return false; + } + *out = r->q.items[r->q.head]; + size_t n = r->q.lens[r->q.head]; + if (raw) { + /* The queue frees its slot on pop, so hand the caller an owned copy; + * a borrowed pointer here was freed before the caller used it. */ + unsigned char *copy = (unsigned char* ) xmalloc(n + 1); + memcpy(copy, r->q.raws[r->q.head], n); + copy[n] = '\0'; + *raw = copy; + } + if (raw_len) *raw_len = n; + evlist_pop(&r->q); + pthread_mutex_unlock(&r->qlock); + return true; +} + +void input_raw_free(const unsigned char *raw) { + free((void *)raw); +} + +/* Queue the event and, if set, invoke the direct hook. + * Returns non-zero to stop reading (receiver gone). */ +static int reader_dispatch(input_key_reader *r, const input_key *k, + const unsigned char *raw, size_t raw_len) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, raw, raw_len); + pthread_mutex_unlock(&r->qlock); + if (r->ctx && r->ctx->send) return r->ctx->send(r, k, raw, raw_len); + return 0; +} + +static void *reader_thread(void *arg) { + input_key_reader *r = (input_key_reader *) arg; + unsigned char buf[512]; + for (;;) { + if (r->stop) break; + struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN, .revents = 0}; + int pr = poll(&pfd, 1, 50); + if (pr < 0) { + if (errno == EINTR) continue; + break; + } + if (pr == 0) continue; + if ((pfd.revents & (POLLIN | POLLHUP | POLLERR)) == 0) continue; + ssize_t n = 0; + do { + n = read(STDIN_FILENO, buf, sizeof buf); + } while (n < 0 && errno == EINTR); + if (n < 0) break; + if (n == 0) break; /* EOF: no synthetic Ctrl-D */ + evlist evs; + evlist_init(&evs); + key_parser_feed(r->ctx->parser, buf, (size_t)n, &evs); + for (size_t j = 0; j < evs.len; j++) { + if (reader_dispatch(r, &evs.items[j], evs.raws[j], + evs.lens[j]) != 0) { + evlist_free(&evs); + r->exit = 1; + return NULL; + } + } + evlist_free(&evs); + key_parser_flush(r->ctx->parser, &evs); + for (size_t j = 0; j < evs.len; j++) { + if (reader_dispatch(r, &evs.items[j], evs.raws[j], + evs.lens[j]) != 0) { + evlist_free(&evs); + r->exit = 1; + return NULL; + } + } + evlist_free(&evs); + } + r->exit = 1; + return NULL; +} + +int input_key_reader_start(input_key_reader *r) { + r->stop = 0; + r->exit = 0; + if (pthread_create(&r->tid, NULL, reader_thread, r) != 0) return -1; + r->started = 1; + return 0; +} + +void input_key_reader_stop(input_key_reader *r) { + if (!r->started) return; + r->stop = 1; + /* The thread notices stop within one poll timeout (50 ms). */ + pthread_join(r->tid, NULL); + r->started = 0; +} + +void input_key_reader_free(input_key_reader *r) { + input_key_reader_stop(r); + evlist_free(&r->q); + pthread_mutex_destroy(&r->qlock); + if (r->ctx) { + if (r->ctx->parser) { + key_parser_free(r->ctx->parser); + free(r->ctx->parser); + } + free(r->ctx); + } + free(r); +} + +/* ================================================================== */ +/* Shell shadow */ +/* ================================================================== */ + +char *shell_shadow_apply(editor *shadow, input_key key) { + input_action a; + memset(&a, 0, sizeof(a)); + editor_apply(shadow, key, &a); + if (a.kind == ACT_SUBMIT && a.text) { + size_t n; + xtrim_span(a.text, strlen(a.text), &n); + if (n > 0) { + char *text = a.text; + editor_clear(shadow); + a.text = NULL; + return text; + } + } + input_action_free(&a); + return NULL; +} + +char *input_history_path_for(const char *config_dir) { + size_t n = strlen(config_dir); + char *p = (char *) xmalloc(n + 1 + 14); /* "/llsh/history\0" */ + memcpy(p, config_dir, n); + memcpy(p + n, "/llsh/history\0", 15); + return p; +} + +char *input_default_config_dir(void) { + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg && xdg[0] == '/') return xstrdup(xdg); + const char *home = getenv("HOME"); + if (home && home[0]) { + size_t n = strlen(home); + char *p = (char *) xmalloc(n + 1 + 8); /* "/.config\0" */ + memcpy(p, home, n); + memcpy(p + n, "/.config\0", 9); + return p; + } + return xstrdup("/tmp"); +} + +char *input_default_history_path(void) { + char *dir = input_default_config_dir(); + char *p = input_history_path_for(dir); + free(dir); + return p; +} diff --git a/src/input.h b/src/input.h new file mode 100644 index 0000000..2eee9e1 --- /dev/null +++ b/src/input.h @@ -0,0 +1,253 @@ +#ifndef LLSH_INPUT_H +#define LLSH_INPUT_H + +#include "util.h" +#include <pthread.h> +#include <stdbool.h> +#include <stddef.h> + +/* ------------------------------------------------------------------ */ +/* Keys (decoded) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int kind; + unsigned int cp; /* KEY_CHAR: codepoint */ +} input_key; + +enum { + KEY_CHAR, + KEY_BACKSPACE, + KEY_DELETE, + KEY_LEFT, + KEY_RIGHT, + KEY_UP, + KEY_DOWN, + KEY_HOME, + KEY_END, + KEY_PAGEUP, + KEY_PAGEDOWN, + KEY_ENTER, + KEY_NEWLINE, + KEY_TAB, + KEY_BACKTAB, + KEY_ESC, + KEY_CTRL_A, + KEY_CTRL_C, + KEY_CTRL_D, + KEY_CTRL_E, + KEY_CTRL_K, + KEY_CTRL_L, + KEY_CTRL_U, + KEY_CTRL_W, + KEY_CTRL_Y, + KEY_UNDO, + KEY_UNKNOWN, +}; + +/* ------------------------------------------------------------------ */ +/* Actions */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int kind; + char *text; /* ACT_SUBMIT: owned buffer */ +} input_action; + +enum { + ACT_NONE, + ACT_SUBMIT, + ACT_QUIT, + ACT_STOP, + ACT_TOGGLE_MODE, + ACT_CLEAR_SCREEN, + /* Enter on a trailing backslash: line continuation (newline inserted). */ + ACT_NEWLINE, +}; + +void input_action_free(input_action *a); + +/* ------------------------------------------------------------------ */ +/* Event list (owned) */ +/* ------------------------------------------------------------------ */ + +/* True circular FIFO: pushes/pops advance indices modulo `cap` and never + * shift live elements. */ +typedef struct { + input_key *items; + unsigned char **raws; + size_t *lens; + size_t len; + size_t head; + size_t cap; +} evlist; + +void evlist_init(evlist *l); +void evlist_free(evlist *l); +void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len); +/* Remove the oldest event (frees its raw buffer). No-op when empty. */ +void evlist_pop(evlist *l); + +/* ------------------------------------------------------------------ */ +/* Parser */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int state; /* KST_* */ + xbuf seq; /* raw sequence bytes (ESC + rest) */ + xbuf csi_params; + unsigned char utf8[8]; + size_t utf8_len; + bool pasting; /* bracketed paste mode (CSI 200/201) */ +} key_parser; + +enum { KST_GROUND, KST_ESCAPE, KST_CSI, KST_SS3 }; + +void key_parser_init(key_parser *p); +void key_parser_free(key_parser *p); +/* Feed a chunk of terminal bytes; emits events into `out`. */ +size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out); +/* Drain any partial sequence; emits at most one event. Returns 1 if emitted. */ +int key_parser_flush(key_parser *p, evlist *out); + +/* ------------------------------------------------------------------ */ +/* Editor (multi-line) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *text; + size_t caret; +} editor_undo_entry; + +typedef struct { + char *text; /* owned; lines separated by '\n' */ + size_t caret; /* byte offset, always on a UTF-8 boundary */ + char *killed; /* most recent kill/delete, for Ctrl+Y yank */ + editor_undo_entry *undo; /* bounded undo stack (oldest -> newest) */ + size_t undo_len; + size_t undo_cap; +} editor; + +void editor_init(editor *e); +void editor_free(editor *e); +void editor_clear(editor *e); +void editor_set(editor *e, const char *text); +char *editor_text(const editor *e); +bool editor_is_empty(const editor *e); +/* Number of lines in the buffer (1 when empty). */ +size_t editor_line_count(const editor *e); +/* 0-based index of the line holding the caret. */ +size_t editor_caret_line(const editor *e); +void editor_apply(editor *e, input_key key, input_action *a); + +/* Terminal-wrap geometry for the multi-line input. These let the renderer and + * the tests agree on how a buffer of a given width breaks into physical rows + * and where the caret lands, without touching a tty. The model matches the + * delayed-wrap auto-wrapping used by xterm/VTE. */ +void input_wrap_caret(const char *text, size_t caret, size_t w, + size_t *row, size_t *col); +size_t input_wrap_total(const char *text, size_t w); +/* True when the caret sits in the delayed-wrap pending state: on the last + * column of a row immediately after printing that row's final character. */ +bool input_wrap_pending(const char *text, size_t caret, size_t w); + +/* ------------------------------------------------------------------ */ +/* History */ +/* ------------------------------------------------------------------ */ + +#define LLSH_HISTORY_LIMIT 100 + +/* `entries` is oldest -> newest with a ring head: live entries are + * items[head .. len), and `index` is an absolute position in items. */ +typedef struct { + strlist entries; + size_t head; + bool active; + size_t index; + char *snapshot; + char *path; +} input_history; + +input_history *input_history_new(void); +/* Open history at path; loads existing entries. NULL on error. */ +input_history *input_history_open(const char *path); +void input_history_free(input_history *h); +void input_history_push(input_history *h, const char *text); +void input_history_reset(input_history *h); + +/* Consolidated Up/Down history policy. dir: -1 for Up, +1 for Down. + * caret_line describes the editor so the policy can tell a caret move + * (mid-buffer) from a history recall (from the relevant edge, or mid-walk). + * Returns an owned entry to load (caller frees), or NULL when the press + * should move the caret instead / the walk is exhausted. */ +char *input_history_walk(input_history *h, int dir, size_t caret_line, + const char *current); +bool input_history_persist(input_history *h); + +/* ------------------------------------------------------------------ */ +/* Terminal / reader */ +/* ------------------------------------------------------------------ */ + +/* Enable raw mode + bracketed paste + kitty keyboard protocol. */ +int input_setup_terminal(void); +/* Restore terminal; write teardown sequences. */ +void input_teardown_terminal(void); + +typedef struct input_key_reader input_key_reader; + +typedef struct { + key_parser *parser; + /* Optional direct hook, called for every event in the reader thread. */ + int (*send)(input_key_reader *r, const input_key *k, const unsigned char *raw, + size_t raw_len); +} reader_ctx; + +input_key_reader *input_key_reader_new(reader_ctx *ctx); +int input_key_reader_start(input_key_reader *r); +/* Push an event into the reader's queue (used by tests). */ +void input_key_reader_send(input_key_reader *r, const input_key *k); +/* Push an event with a raw payload into the reader's queue (used by tests). */ +void input_key_reader_send_raw(input_key_reader *r, const input_key *k, + const char *raw); +/* Pop a queued event (non-blocking); false when the queue is empty. + * When `raw` is non-NULL the caller receives an owned copy of the event's + * raw bytes (the queue frees its own slot on pop); release it with + * input_raw_free once consumed. Pass NULL for `raw`/`raw_len` when only the + * decoded key is needed. */ +bool input_key_reader_recv(input_key_reader *r, input_key *out, + const unsigned char **raw, size_t *raw_len); +/* Release a raw buffer returned by input_key_reader_recv. NULL-safe. */ +void input_raw_free(const unsigned char *raw); +void input_key_reader_stop(input_key_reader *r); +void input_key_reader_free(input_key_reader *r); + +/* True when stdin is a terminal (used to gate DSR queries). */ +int input_isatty(void); + +/* ------------------------------------------------------------------ */ +/* Shell shadow */ +/* ------------------------------------------------------------------ */ + +/* Feed one key into the shell-mode shadow editor. If the key finished a + * non-empty command, the shadow is cleared and the submitted text is + * returned (caller frees); otherwise NULL. */ +char *shell_shadow_apply(editor *shadow, input_key key); + +/* History file location: `config_dir/llsh/history`. */ +char *input_history_path_for(const char *config_dir); +/* $XDG_CONFIG_HOME when set to an absolute path, else $HOME/.config, + * else /tmp. */ +char *input_default_config_dir(void); +/* default_history_path = history_path_for(default_config_dir). */ +char *input_default_history_path(void); + +/* ------------------------------------------------------------------ */ +/* Display */ +/* ------------------------------------------------------------------ */ + +/* Display width in terminal columns (ANSI escapes skipped). */ +size_t input_display_len(const char *s); +/* Display width of the first n bytes of s. */ +size_t input_display_len_n(const char *s, size_t n); + +#endif diff --git a/src/limits.cpp b/src/limits.cpp new file mode 100644 index 0000000..bd4b773 --- /dev/null +++ b/src/limits.cpp @@ -0,0 +1,275 @@ +#include "limits.h" + +#include <ctype.h> +#include <stdlib.h> +#include <string.h> + +#define DEFAULT_SHELL_TIMEOUT_SECS 120UL +#define DEFAULT_MAX_OUTPUT_CHARS 16000UL +#define DEFAULT_MAX_FILE_BYTES 2000000UL + +static tool_limits g_limits; +static bool g_limits_inited = false; +static bool (*g_confirm_hook)(const char *, void *) = NULL; +static void *g_confirm_ud = NULL; + +/* The global starts zeroed in BSS; make sure it holds defaults before + * any consumer reads it (Rust: lazy static initialized to defaults). */ +static void limits_ensure(void) { + if (!g_limits_inited) { + tool_limits_defaults(&g_limits); + g_limits_inited = true; + } +} + +void tool_limits_defaults(tool_limits *l) { + memset(l, 0, sizeof *l); + l->shell_timeout_secs = DEFAULT_SHELL_TIMEOUT_SECS; + l->max_output_chars = DEFAULT_MAX_OUTPUT_CHARS; + l->max_file_bytes = DEFAULT_MAX_FILE_BYTES; + sl_init(&l->shell_allowlist); + sl_init(&l->shell_denylist); + l->confirm_destructive = false; + l->verbose = false; +} + +void tool_limits_set(const tool_limits *l) { + limits_ensure(); + tool_limits_free(&g_limits); + g_limits = *l; + sl_init(&g_limits.shell_allowlist); + sl_init(&g_limits.shell_denylist); + for (size_t i = 0; i < l->shell_allowlist.len; i++) + sl_push(&g_limits.shell_allowlist, xstrdup(l->shell_allowlist.items[i])); + for (size_t i = 0; i < l->shell_denylist.len; i++) + sl_push(&g_limits.shell_denylist, xstrdup(l->shell_denylist.items[i])); +} + +const tool_limits *tool_limits_current(void) { + limits_ensure(); + return &g_limits; +} + +void tool_limits_get(tool_limits *out) { + limits_ensure(); + *out = g_limits; + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + for (size_t i = 0; i < g_limits.shell_allowlist.len; i++) + sl_push(&out->shell_allowlist, + xstrdup(g_limits.shell_allowlist.items[i])); + for (size_t i = 0; i < g_limits.shell_denylist.len; i++) + sl_push(&out->shell_denylist, xstrdup(g_limits.shell_denylist.items[i])); +} + +void tool_limits_free(tool_limits *l) { + sl_free(&l->shell_allowlist); + sl_free(&l->shell_denylist); +} + +static bool entry_matches(const char *entry_raw, const char *command_raw) { + size_t elen, clen; + const char *entry = xtrim_span(entry_raw, strlen(entry_raw), &elen); + const char *command = xtrim_span(command_raw, strlen(command_raw), &clen); + if (elen == 0) + return true; + if (clen == elen && strncmp(entry, command, elen) == 0) + return true; + /* leading substring on a word boundary */ + if (clen > elen && strncmp(entry, command, elen) == 0) { + char c = command[elen]; + if (c == ' ' || c == '\t') + return true; + } + /* bare first word */ + size_t fw = 0; + while (fw < clen && !isspace((unsigned char)command[fw])) + fw++; + return fw == elen && strncmp(entry, command, elen) == 0; +} + +static bool word_eq(const char *w, size_t n, const char *s); + +/* Return the likely executable after common command wrappers. This is a + * conservative token-level check, not a shell parser. */ +static const char *unwrap_command(const char *command) { + const char *p = command; + for (int depth = 0; depth < 8; depth++) { + while (*p && isspace((unsigned char)*p)) + p++; + const char *word = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t n = (size_t)(p - word); + bool wrapper = word_eq(word, n, "sudo") || word_eq(word, n, "env") || + word_eq(word, n, "command") || word_eq(word, n, "nohup") || + word_eq(word, n, "time") || word_eq(word, n, "nice") || + word_eq(word, n, "busybox"); + if (!wrapper) + return word; + bool skip_next = false; + while (*p) { + while (*p && isspace((unsigned char)*p)) + p++; + if (!*p) + return p; + const char *next = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t nn = (size_t)(p - next); + if (skip_next) { + skip_next = false; + continue; + } + if ((word_eq(word, n, "sudo") || word_eq(word, n, "nice")) && + (word_eq(next, nn, "-u") || word_eq(next, nn, "-g") || + word_eq(next, nn, "-n") || word_eq(next, nn, "-C") || + word_eq(next, nn, "--user") || word_eq(next, nn, "--group"))) { + skip_next = true; + continue; + } + if (next[0] == '-' || + ((word_eq(word, n, "env") || word_eq(word, n, "busybox")) && + strchr(next, '=') != NULL)) + continue; + p = next; + break; + } + } + while (*p && isspace((unsigned char)*p)) + p++; + return p; +} + +int shell_policy(const char *command, const tool_limits *l, char **reason) { + for (size_t i = 0; i < l->shell_denylist.len; i++) { + const char *e = l->shell_denylist.items[i]; + if (entry_matches(e, command) || + entry_matches(e, unwrap_command(command))) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "command matches the denylist entry `%s`", e); + char *r = xbuf_steal(&b); + if (reason) + *reason = r; + else + free(r); + return SHELL_DENIED; + } + } + if (l->shell_allowlist.len > 0) { + bool any = false; + for (size_t i = 0; i < l->shell_allowlist.len; i++) + if (entry_matches(l->shell_allowlist.items[i], command) || + entry_matches(l->shell_allowlist.items[i], + unwrap_command(command))) { + any = true; + break; + } + if (!any) { + xbuf list; + xbuf_init(&list); + for (size_t i = 0; i < l->shell_allowlist.len; i++) { + if (i) + xbuf_put(&list, ", "); + xbuf_put(&list, l->shell_allowlist.items[i]); + } + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "command not in the allowlist (%s)", list.data ? list.data : ""); + xbuf_free(&list); + char *r = xbuf_steal(&b); + if (reason) + *reason = r; + else + free(r); + return SHELL_DENIED; + } + } + return SHELL_ALLOWED; +} + +static bool word_eq(const char *w, size_t n, const char *s) { + size_t sn = strlen(s); + return n == sn && strncmp(w, s, n) == 0; +} + +bool is_destructive(const char *command) { + /* This is deliberately conservative rather than a shell parser: syntax + * that can introduce or expand commands requires confirmation. */ + for (const char *p = command; *p; p++) { + if (*p == ';' || *p == '|' || *p == '&' || *p == '\n' || + *p == '`' || *p == '<' || *p == '>' || *p == '$') + return true; + } + /* split the executable and its first argument after common wrappers */ + const char *p = unwrap_command(command); + while (*p && isspace((unsigned char)*p)) + p++; + const char *w1 = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t w1len = (size_t)(p - w1); + while (*p && isspace((unsigned char)*p)) + p++; + const char *w2 = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t w2len = (size_t)(p - w2); + + if (word_eq(w1, w1len, "rm") || word_eq(w1, w1len, "rmdir") || + word_eq(w1, w1len, "kill") || word_eq(w1, w1len, "killall") || + word_eq(w1, w1len, "pkill") || word_eq(w1, w1len, "truncate") || + word_eq(w1, w1len, "mv") || word_eq(w1, w1len, "chmod") || + word_eq(w1, w1len, "chown") || word_eq(w1, w1len, "mkfs") || + word_eq(w1, w1len, "fdisk") || word_eq(w1, w1len, "parted") || + word_eq(w1, w1len, "mount") || word_eq(w1, w1len, "umount") || + word_eq(w1, w1len, "swapon") || word_eq(w1, w1len, "swapoff") || + word_eq(w1, w1len, "shutdown") || word_eq(w1, w1len, "reboot") || + word_eq(w1, w1len, "poweroff") || word_eq(w1, w1len, "halt")) + return true; + if (word_eq(w1, w1len, "dd")) + return strstr(command, "of=") != NULL; + if (word_eq(w1, w1len, "git")) + return word_eq(w2, w2len, "reset") || word_eq(w2, w2len, "clean") || + word_eq(w2, w2len, "checkout") || word_eq(w2, w2len, "rebase") || + word_eq(w2, w2len, "push"); + return false; +} + +void set_confirm_hook(bool (*hook)(const char *, void *), void *ud) { + g_confirm_hook = hook; + g_confirm_ud = ud; +} + +static bool confirm_command(const char *command) { + if (g_confirm_hook) + return g_confirm_hook(command, g_confirm_ud); + return false; +} + +int shell_gate(const char *command, const tool_limits *l, char **err) { + char *reason = NULL; + if (shell_policy(command, l, &reason) == SHELL_DENIED) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "run_shell blocked: %s", reason); + free(reason); + if (err) + *err = xbuf_steal(&b); + return -1; + } + if (l->confirm_destructive && is_destructive(command) && + !confirm_command(command)) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "user declined to run destructive command: %s", command); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return -1; + } + return 0; +} diff --git a/src/limits.h b/src/limits.h new file mode 100644 index 0000000..14ec0e5 --- /dev/null +++ b/src/limits.h @@ -0,0 +1,40 @@ +#ifndef LLSH_LIMITS_H +#define LLSH_LIMITS_H + +#include "util.h" + +/* Process-global tool limits + run_shell policy (mirrors limits.rs). */ + +typedef struct { + unsigned long shell_timeout_secs; + unsigned long max_output_chars; + unsigned long max_file_bytes; + strlist shell_allowlist; + strlist shell_denylist; + bool confirm_destructive; + bool verbose; +} tool_limits; + +void tool_limits_defaults(tool_limits *l); +void tool_limits_set(const tool_limits *l); +/* Read-only view of the current limits (no copy). The pointer is valid until + * the next tool_limits_set; do not store it across set calls. */ +const tool_limits *tool_limits_current(void); +void tool_limits_get(tool_limits *out); +void tool_limits_free(tool_limits *l); + +typedef enum { + SHELL_ALLOWED, + SHELL_DENIED, +} shell_verdict; + +/* Returns 0 allowed, -1 denied (reason in **err, owned). */ +int shell_policy(const char *command, const tool_limits *l, char **reason); +bool is_destructive(const char *command); + +void set_confirm_hook(bool (*hook)(const char *command, void *ud), void *ud); + +/* Full gate: policy, then destructive confirmation. 0 = proceed. */ +int shell_gate(const char *command, const tool_limits *l, char **err); + +#endif diff --git a/src/llm.h b/src/llm.h new file mode 100644 index 0000000..f0b68f0 --- /dev/null +++ b/src/llm.h @@ -0,0 +1,1722 @@ +/* + * llm.h -- header-only LLM provider protocol layer. + * + * Speaks OpenAI-compatible (any /chat/completions endpoint), Anthropic + * (/v1/messages), and Ollama (/api/chat). Pure protocol: request assembly, + * HTTP POST with stop-cancellation, SSE / NDJSON streaming with live delta + * emission, response and error-body parsing, Retry-After handling. No + * application state: messages arrive as borrowed `llm_message` arrays, tools + * as a pre-built `"tools":[...]` JSON fragment, and tool execution, session + * access, and emit formatting are supplied by the caller. + * + * Header-only: every symbol is `static inline`, so a translation unit that + * `#include`s this file gets a self-contained copy and there is nothing to + * link. Include it from exactly the TUs that drive a provider. Each TU's + * copies are independent. + * + * Dependencies: curl, cJSON, libc. + * + * API: + * llm_url / llm_auth / llm_build_request request side + * llm_post / llm_post_stream transport + * llm_parse_body / llm_parse_error / + * llm_header_retry_after response side + * llm_sse_acc_* / llm_sse_parse_line streaming accumulator + * llm_completion / llm_completion_free parsed result + */ +#ifndef LLM_H +#define LLM_H + +#include <curl/curl.h> +#include <cjson/cJSON.h> +#include <ctype.h> +#include <stdarg.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +#ifdef __cplusplus +#include <atomic> +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Provider + connection */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LLM_OPENAI = 0, + LLM_ANTHROPIC, + LLM_OLLAMA, +} llm_provider; + +/* Default cap on generated tokens for Anthropic requests, which require an + * explicit `max_tokens`. */ +#define LLM_DEFAULT_MAX_TOKENS 8192UL + +/* Total wall-clock budget for a single (possibly streaming) LLM request. + * Generous on purpose: long generations and slow cold-start servers. */ +#define LLM_REQUEST_TIMEOUT_SECS 600L + +/* One provider connection. Strings are borrowed for the call's lifetime. + * base_url NULL = provider default; api_key NULL = unauthenticated; + * ca_bundle NULL = curl's default CA path. */ +typedef struct { + llm_provider provider; + const char *model; + const char *base_url; + const char *api_key; + const char *ca_bundle; +} llm_conn; + +/* Optional generation parameters. Every field has an "unset" value, so a + * zeroed struct (or NULL) produces a plain request: + * temperature < 0 = unset + * max_tokens 0 = unset + * reasoning_effort NULL = unset ("low" / "medium" / "high") + * thinking_budget 0 = unset (tokens; Anthropic extended thinking) + * response_schema NULL = unset (JSON Schema text, caller-validated) */ +typedef struct { + double temperature; + unsigned long max_tokens; + const char *reasoning_effort; + unsigned long thinking_budget; + const char *response_schema; +} llm_params; + +static inline void llm_params_init(llm_params *p) { + memset(p, 0, sizeof *p); + p->temperature = -1.0; +} + +/* Anthropic extended-thinking budget for a reasoning-effort label. */ +#define LLM_EFFORT_BUDGET_LOW 1024UL +#define LLM_EFFORT_BUDGET_MEDIUM 4096UL +#define LLM_EFFORT_BUDGET_HIGH 16384UL + +/* ------------------------------------------------------------------ */ +/* Wire message model (borrowed strings) */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LLM_ROLE_SYSTEM, + LLM_ROLE_USER, + LLM_ROLE_ASSISTANT, +} llm_role; + +typedef enum { + LLM_TEXT, /* user/assistant text */ + LLM_REASONING, /* assistant chain of thought (display only, not re-sent) */ + LLM_TOOL_CALL, /* assistant: name, args (JSON text) */ + LLM_TOOL_RESULT, /* user: call (tool-call id), name, text */ +} llm_part_kind; + +typedef struct { + llm_part_kind kind; + const char *text; /* LLM_TEXT / LLM_REASONING / LLM_TOOL_RESULT */ + const char *name; /* LLM_TOOL_CALL / LLM_TOOL_RESULT */ + const char *args; /* LLM_TOOL_CALL: JSON text */ + const char *call; /* LLM_TOOL_RESULT: tool-call id (may be NULL) */ +} llm_part; + +typedef struct { + llm_role role; + llm_part *parts; /* borrowed; const from the consumer's view */ + size_t part_count; +} llm_message; + +/* Growable byte buffer (NUL-terminated on steal). */ +typedef struct llm_buf { + char *data; + size_t len; + size_t cap; +} llm_buf; + +/* ------------------------------------------------------------------ */ +/* Parsed completion (owned strings) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *text; + char *reasoning; /* thinking / reasoning_content, when the model returns it */ + char **tc_id; + char **tc_name; + char **tc_args; + size_t tc_len; + unsigned long input_tokens; +} llm_completion; + +/* Accumulates one streamed response. Text/reasoning arrive as deltas; + * tool calls may arrive in indexed chunks (OpenAI) or as whole blocks + * (Anthropic) or complete objects (Ollama NDJSON). */ +typedef struct { + struct llm_buf text; + struct llm_buf reasoning; + char **tc_id; + char **tc_name; + struct llm_buf *tc_args; /* growing argument fragments, one per call */ + size_t tc_len; + unsigned long input_tokens; + bool done; + /* Anthropic event state: the `event:` line that the next `data:` + * belongs to, and the kind of the open content block. */ + char *anth_event; + int anth_mode; /* 0 = text, 1 = thinking, 2 = tool */ + size_t anth_tc; +} llm_sse_acc; + +/* Live-delta emitter for streaming. Return 0 to continue, non-zero to + * abort the transfer (a user stop). */ +typedef enum { + LLM_EMIT_TEXT = 0, + LLM_EMIT_REASONING, +} llm_emit_kind; + +typedef int (*llm_emit_cb)(llm_provider prov, llm_emit_kind kind, + const char *delta, void *user); + +/* ================================================================== */ +/* Implementation (static inline; active in every including TU) */ +/* ================================================================== */ + +/* ------------------------------------------------------------------ */ +/* Allocation + small string utilities (self-contained) */ +/* ------------------------------------------------------------------ */ + +static inline void *llm_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "llm: out of memory\n"); + abort(); + } + return p; +} + +static inline void *llm_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "llm: out of memory\n"); + abort(); + } + return q; +} + +static inline char *llm_strdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *)llm_xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *llm_strndup(const char *s, size_t n) { + char *p = (char *)llm_xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *llm_vsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return llm_strdup(""); + char *p = (char *)llm_xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +static inline char *llm_strfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = llm_vsnprintf(fmt, ap); + va_end(ap); + return s; +} + +static inline bool llm_streq(const char *a, const char *b) { + return a && b && strcmp(a, b) == 0; +} + +/* First case-insensitive occurrence of `needle` in `hay` (portable + * strcasestr). */ +static inline const char *llm_strcasestr(const char *hay, const char *needle) { + size_t nn = strlen(needle); + if (nn == 0) + return hay; + for (const char *p = hay; *p; p++) { + bool ok = true; + for (size_t i = 0; i < nn; i++) { + if (tolower((unsigned char)p[i]) != + tolower((unsigned char)needle[i])) { + ok = false; + break; + } + } + if (ok) + return p; + } + return NULL; +} + +static inline void llm_buf_init(llm_buf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void llm_buf_free(llm_buf *b) { + free(b->data); + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void llm_buf_reserve(llm_buf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *)llm_xrealloc(b->data, cap); + b->cap = cap; + } +} + +static inline void llm_buf_putn(llm_buf *b, const char *s, size_t n) { + llm_buf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static inline void llm_buf_put(llm_buf *b, const char *s) { + if (s) + llm_buf_putn(b, s, strlen(s)); +} + +static inline void llm_buf_push(llm_buf *b, char c) { + llm_buf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +static inline char *llm_buf_steal(llm_buf *b) { + if (!b->data) + b->data = (char *)llm_xmalloc(1); + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = 0; + b->cap = 0; + return s; +} + +/* Small owned string list. */ +typedef struct { + char **items; + size_t len; + size_t cap; +} llm_strlist; + +static inline void llm_sl_init(llm_strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +static inline void llm_sl_free(llm_strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + llm_sl_init(l); +} + +static inline void llm_sl_push_copy(llm_strlist *l, const char *s) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = + (char **)llm_xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = llm_strdup(s); +} + +/* ------------------------------------------------------------------ */ +/* Request side */ +/* ------------------------------------------------------------------ */ + +static inline char *llm_url(const llm_conn *c) { + const char *def; + switch (c->provider) { + case LLM_ANTHROPIC: + def = "https://api.anthropic.com"; + break; + case LLM_OLLAMA: + def = "http://localhost:11434"; + break; + case LLM_OPENAI: + default: + def = "https://api.openai.com/v1"; + break; + } + char *base = llm_strdup(c->base_url ? c->base_url : def); + size_t n = strlen(base); + while (n && base[n - 1] == '/') + base[--n] = '\0'; + llm_buf u; + llm_buf_init(&u); + llm_buf_put(&u, base); + free(base); + switch (c->provider) { + case LLM_ANTHROPIC: + llm_buf_put(&u, "/v1/messages"); + break; + case LLM_OLLAMA: + llm_buf_put(&u, "/api/chat"); + break; + case LLM_OPENAI: + default: + llm_buf_put(&u, "/chat/completions"); + break; + } + char *out = llm_buf_steal(&u); + llm_buf_free(&u); + return out; +} + +static inline void llm_auth(const llm_conn *c, char **header, char **value) { + if (header) + *header = NULL; + if (value) + *value = NULL; + if (!c->api_key) + return; + switch (c->provider) { + case LLM_OPENAI: + if (header) + *header = llm_strdup("Authorization"); + if (value) + *value = llm_strfmt("Bearer %s", c->api_key); + break; + case LLM_ANTHROPIC: + if (header) + *header = llm_strdup("x-api-key"); + if (value) + *value = llm_strdup(c->api_key); + break; + case LLM_OLLAMA: + break; + } +} + +/* Shared setup: cached-or-throwaway curl handle, the JSON + auth header + * list, and the common POST options. Returns the handle (or NULL with + * *err set) and the header list in *out_h (the caller frees it with + * curl_slist_free_all). */ +static inline CURL *llm_post_setup(const llm_conn *c, const char *url, + const char *body, + struct curl_slist **out_h, char **err, + CURL **cached) { + CURL *h = cached ? *cached : NULL; + if (!h) { + h = curl_easy_init(); + if (h && cached) + *cached = h; + } + if (!h) { + if (err) + *err = llm_strdup("could not initialize curl"); + return NULL; + } + struct curl_slist *hl = NULL; + hl = curl_slist_append(hl, "Content-Type: application/json"); + char *ah = NULL, *av = NULL; + llm_auth(c, &ah, &av); + if (ah && av) { + llm_buf hb; + llm_buf_init(&hb); + char *combo = llm_strfmt("%s: %s", ah, av); + llm_buf_put(&hb, combo); + free(combo); + hl = curl_slist_append(hl, hb.data); + llm_buf_free(&hb); + free(ah); + free(av); + } + curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl); + curl_easy_setopt(h, CURLOPT_URL, url); + curl_easy_setopt(h, CURLOPT_POST, 1L); + curl_easy_setopt(h, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(body)); + curl_easy_setopt(h, CURLOPT_TIMEOUT, LLM_REQUEST_TIMEOUT_SECS); + curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); + if (c->ca_bundle) + curl_easy_setopt(h, CURLOPT_CAINFO, c->ca_bundle); + *out_h = hl; + return h; +} + +typedef struct { + llm_buf body; + llm_buf headers; + std::atomic<bool> *stop; + bool response_too_large; +} llm_post_ctx; + +static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) { + llm_post_ctx *c = (llm_post_ctx *)ud; + const size_t cap = 8u * 1024u * 1024u; + if ((s && n > SIZE_MAX / s) || c->body.len > cap || + s * n > cap - c->body.len) { + c->response_too_large = true; + return 0; + } + llm_buf_putn(&c->body, d, s * n); + return s * n; +} + +static inline size_t llm_post_header(char *d, size_t s, size_t n, void *ud) { + llm_post_ctx *c = (llm_post_ctx *)ud; + llm_buf_putn(&c->headers, d, s * n); + return s * n; +} + +static inline int llm_post_xfer(void *clientp, curl_off_t t, curl_off_t dn, + curl_off_t ut, curl_off_t un) { + (void)t; + (void)dn; + (void)ut; + (void)un; + llm_post_ctx *c = (llm_post_ctx *)clientp; + if (c->stop && c->stop->load()) + return 1; /* abort the transfer */ + return 0; +} + +static inline int llm_post(const llm_conn *c, const char *url, + const char *body, long *status_out, char **body_out, + char **headers_out, char **err, + std::atomic<bool> *stop, CURL **cached) { + if (body_out) + *body_out = NULL; + if (headers_out) + *headers_out = NULL; + if (err) + *err = NULL; + + llm_post_ctx ctx; + llm_buf_init(&ctx.body); + llm_buf_init(&ctx.headers); + ctx.stop = stop; + ctx.response_too_large = false; + + struct curl_slist *h = NULL; + CURL *curl = + llm_post_setup(c, url, body, &h, err, cached); + if (!curl) { + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + return -1; + } + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_post_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx.body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_post_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_post_xfer); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx); + + CURLcode rc = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(h); + char *body_str = llm_buf_steal(&ctx.body); + char *hdr_str = llm_buf_steal(&ctx.headers); + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + if (!cached || *cached != curl) + curl_easy_cleanup(curl); /* throwaway handle; the cached one outlives us */ + + if (rc != CURLE_OK) { + if (err) + *err = llm_strdup(ctx.response_too_large + ? "LLM response exceeds 8 MiB limit" + : curl_easy_strerror(rc)); + free(body_str); + free(hdr_str); + return -1; + } + if (status_out) + *status_out = code; + if (body_out) + *body_out = body_str; + else + free(body_str); + if (headers_out) + *headers_out = hdr_str; + else + free(hdr_str); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Wire messages -> provider JSON */ +/* ------------------------------------------------------------------ */ + +/* Recover the tool-call ids in order (they live on the tool results). */ +static inline void llm_collect_result_ids(const llm_message *msgs, + size_t n, llm_strlist *out) { + llm_sl_init(out); + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role != LLM_ROLE_USER) + continue; + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TOOL_RESULT) + llm_sl_push_copy(out, p->call ? p->call : ""); + } + } +} + +static inline char *llm_id_or(const llm_strlist *ids, size_t i) { + if (i < ids->len && ids->items[i] && ids->items[i][0]) + return llm_strdup(ids->items[i]); + return llm_strfmt("call-%d", (int)(i + 1)); +} + +static inline cJSON *llm_openai_messages(const llm_message *msgs, size_t n) { + cJSON *arr = cJSON_CreateArray(); + llm_strlist ids; + llm_collect_result_ids(msgs, n, &ids); + size_t call_idx = 0, result_idx = 0; + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role == LLM_ROLE_SYSTEM) { + const char *text = ""; + if (m->part_count && m->parts[0].text) + text = m->parts[0].text; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "system"); + cJSON_AddStringToObject(o, "content", text); + cJSON_AddItemToArray(arr, o); + } else if (m->role == LLM_ROLE_USER) { + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "user"); + cJSON_AddStringToObject(o, "content", + p->text ? p->text : ""); + cJSON_AddItemToArray(arr, o); + } else if (p->kind == LLM_TOOL_RESULT) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "tool"); + char *id = llm_id_or(&ids, result_idx); + result_idx++; + cJSON_AddStringToObject(o, "tool_call_id", id); + free(id); + if (p->name) + cJSON_AddStringToObject(o, "name", p->name); + cJSON_AddStringToObject(o, "content", + p->text ? p->text : ""); + cJSON_AddItemToArray(arr, o); + } + } + } else { /* assistant */ + llm_buf txt; + llm_buf_init(&txt); + bool first = true; + cJSON *tcs = NULL; + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + if (!first) + llm_buf_push(&txt, '\n'); + if (p->text) + llm_buf_put(&txt, p->text); + first = false; + } else if (p->kind == LLM_TOOL_CALL) { + if (!tcs) + tcs = cJSON_CreateArray(); + cJSON *tcobj = cJSON_CreateObject(); + char *id = llm_id_or(&ids, call_idx); + cJSON_AddStringToObject(tcobj, "id", id); + free(id); + cJSON_AddStringToObject(tcobj, "type", "function"); + cJSON *fn = cJSON_AddObjectToObject(tcobj, "function"); + cJSON_AddStringToObject(fn, "name", p->name ? p->name : ""); + cJSON_AddStringToObject(fn, "arguments", p->args ? p->args : "{}"); + cJSON_AddItemToArray(tcs, tcobj); + call_idx++; + } + } + char *t = llm_buf_steal(&txt); + llm_buf_free(&txt); + if (t[0] || tcs) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "assistant"); + if (t[0]) + cJSON_AddStringToObject(o, "content", t); + else + cJSON_AddItemToObject(o, "content", cJSON_CreateNull()); + if (tcs) + cJSON_AddItemToObject(o, "tool_calls", tcs); + cJSON_AddItemToArray(arr, o); + } + free(t); + } + } + llm_sl_free(&ids); + return arr; +} + +static inline cJSON *llm_anthropic_messages(const llm_message *msgs, + size_t n) { + cJSON *arr = cJSON_CreateArray(); + llm_strlist ids; + llm_collect_result_ids(msgs, n, &ids); + size_t call_idx = 0, result_idx = 0; + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role == LLM_ROLE_SYSTEM) + continue; /* folded into the top-level `system` */ + cJSON *o = cJSON_CreateObject(); + cJSON *content = cJSON_CreateArray(); + if (m->role == LLM_ROLE_USER) { + cJSON_AddStringToObject(o, "role", "user"); + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "text"); + cJSON_AddStringToObject(b, "text", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } else if (p->kind == LLM_TOOL_RESULT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "tool_result"); + char *id = llm_id_or(&ids, result_idx); + result_idx++; + cJSON_AddStringToObject(b, "tool_use_id", id); + free(id); + cJSON_AddStringToObject(b, "content", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } + } + } else { /* assistant */ + cJSON_AddStringToObject(o, "role", "assistant"); + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "text"); + cJSON_AddStringToObject(b, "text", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } else if (p->kind == LLM_TOOL_CALL) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "tool_use"); + char *id = llm_id_or(&ids, call_idx); + cJSON_AddStringToObject(b, "id", id); + free(id); + cJSON_AddStringToObject(b, "name", p->name ? p->name : ""); + cJSON *inp = p->args ? cJSON_Parse(p->args) : NULL; + if (!inp) + inp = cJSON_CreateObject(); + cJSON_AddItemToObject(b, "input", inp); + cJSON_AddItemToArray(content, b); + call_idx++; + } + } + } + cJSON_AddItemToObject(o, "content", content); + cJSON_AddItemToArray(arr, o); + } + llm_sl_free(&ids); + return arr; +} + +/* ------------------------------------------------------------------ */ +/* Response parsing */ +/* ------------------------------------------------------------------ */ + +static inline void llm_completion_free(llm_completion *c) { + free(c->text); + free(c->reasoning); + for (size_t i = 0; i < c->tc_len; i++) { + free(c->tc_id[i]); + free(c->tc_name[i]); + free(c->tc_args[i]); + } + free(c->tc_id); + free(c->tc_name); + free(c->tc_args); + c->text = NULL; + c->reasoning = NULL; + c->tc_id = c->tc_name = c->tc_args = NULL; + c->tc_len = 0; + c->input_tokens = 0; +} + +static inline void llm_add_tool_call(llm_completion *c, char *id, char *name, + char *args) { + c->tc_id = (char **)llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id); + c->tc_name = (char **)llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name); + c->tc_args = (char **)llm_xrealloc(c->tc_args, (c->tc_len + 1) * sizeof *c->tc_args); + c->tc_id[c->tc_len] = id; + c->tc_name[c->tc_len] = name; + c->tc_args[c->tc_len] = args; + c->tc_len++; +} + +static inline void llm_parse_openai(const cJSON *resp, llm_completion *c) { + const cJSON *choices = cJSON_GetObjectItem(resp, "choices"); + if (cJSON_IsArray(choices) && choices->child) { + const cJSON *choice = choices->child; + const cJSON *msg = cJSON_GetObjectItem(choice, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring) + c->text = llm_strdup(ct->valuestring); + /* Reasoning models (o1/o3 and compatibles) return the chain of + * thought in `reasoning_content`; some servers use `reasoning`. */ + const cJSON *rc = cJSON_GetObjectItem(msg, "reasoning_content"); + if (!cJSON_IsString(rc) || !rc->valuestring) + rc = cJSON_GetObjectItem(msg, "reasoning"); + if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0]) + c->reasoning = llm_strdup(rc->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + char *id = NULL, *name = NULL, *args = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + args = llm_strdup(ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + args = s ? s : llm_strdup("{}"); + } + } + llm_add_tool_call( + c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)), + name ? name : llm_strdup(""), + args ? args : llm_strdup("{}")); + } + } + } + } + const cJSON *usage = cJSON_GetObjectItem(resp, "usage"); + if (usage) { + const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens"); + if (cJSON_IsNumber(pt)) + c->input_tokens = (unsigned long)pt->valuedouble; + } +} + +static inline void llm_parse_anthropic(const cJSON *resp, llm_completion *c) { + const cJSON *content = cJSON_GetObjectItem(resp, "content"); + if (cJSON_IsArray(content)) { + const cJSON *b = NULL; + cJSON_ArrayForEach(b, content) { + const cJSON *type = cJSON_GetObjectItem(b, "type"); + const char *t = (cJSON_IsString(type) && type->valuestring) + ? type->valuestring + : ""; + if (llm_streq(t, "text")) { + const cJSON *txt = cJSON_GetObjectItem(b, "text"); + if (cJSON_IsString(txt) && txt->valuestring) { + free(c->text); + c->text = llm_strdup(txt->valuestring); + } + } else if (llm_streq(t, "thinking")) { + const cJSON *th = cJSON_GetObjectItem(b, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) { + free(c->reasoning); + c->reasoning = llm_strdup(th->valuestring); + } + } else if (llm_streq(t, "tool_use")) { + char *id = NULL, *name = NULL, *args = NULL; + const cJSON *tid = cJSON_GetObjectItem(b, "id"); + if (cJSON_IsString(tid) && tid->valuestring) + id = llm_strdup(tid->valuestring); + const cJSON *tn = cJSON_GetObjectItem(b, "name"); + if (cJSON_IsString(tn) && tn->valuestring) + name = llm_strdup(tn->valuestring); + const cJSON *tin = cJSON_GetObjectItem(b, "input"); + if (cJSON_IsObject(tin)) { + char *s = cJSON_PrintUnformatted(tin); + args = s ? s : llm_strdup("{}"); + } + llm_add_tool_call( + c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)), + name ? name : llm_strdup(""), + args ? args : llm_strdup("{}")); + } + } + } + const cJSON *usage = cJSON_GetObjectItem(resp, "usage"); + if (usage) { + const cJSON *it = cJSON_GetObjectItem(usage, "input_tokens"); + if (cJSON_IsNumber(it)) + c->input_tokens = (unsigned long)it->valuedouble; + } +} + +static inline void llm_parse_ollama(const cJSON *resp, llm_completion *c) { + const cJSON *msg = cJSON_GetObjectItem(resp, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring) + c->text = llm_strdup(ct->valuestring); + /* Thinking models (qwen3, deepseek-r1, …) return `message.thinking`. */ + const cJSON *th = cJSON_GetObjectItem(msg, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) + c->reasoning = llm_strdup(th->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + const cJSON *id = cJSON_GetObjectItem(tc, "id"); + char *cid = (cJSON_IsString(id) && id->valuestring) + ? llm_strdup(id->valuestring) + : llm_strfmt("call-%d", (int)(c->tc_len + 1)); + char *name = llm_strdup(""), *args = llm_strdup("{}"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) { + free(name); + name = llm_strdup(cn->valuestring); + } + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) { + free(args); + args = llm_strdup(ca->valuestring); + } else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + free(args); + args = s; + } + } + } + llm_add_tool_call(c, cid, name, args); + } + } + } + const cJSON *pe = cJSON_GetObjectItem(resp, "prompt_eval_count"); + if (cJSON_IsNumber(pe)) + c->input_tokens = (unsigned long)pe->valuedouble; +} + +static inline int llm_parse_body(const llm_conn *c, const char *body, + llm_completion *out) { + if (!body || !body[0]) + return -1; + cJSON *resp = cJSON_Parse(body); + if (!resp) + return -1; + if (c->provider == LLM_ANTHROPIC) + llm_parse_anthropic(resp, out); + else if (c->provider == LLM_OLLAMA) + llm_parse_ollama(resp, out); + else + llm_parse_openai(resp, out); + cJSON_Delete(resp); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Error body + Retry-After */ +/* ------------------------------------------------------------------ */ + +static inline char *llm_parse_error(const char *body, long status) { + char *msg = NULL; + if (body && body[0]) { + cJSON *j = cJSON_Parse(body); + if (j) { + const cJSON *e = cJSON_GetObjectItem(j, "error"); + const cJSON *em = cJSON_IsObject(e) + ? cJSON_GetObjectItem(e, "message") + : (cJSON_IsString(e) ? e : NULL); + if (cJSON_IsString(em) && em->valuestring) + msg = llm_strdup(em->valuestring); + if (!msg) + msg = llm_strdup(""); + cJSON_Delete(j); + } else { + size_t cap = strlen(body); + if (cap > 500) + cap = 500; + msg = llm_strndup(body, cap); + } + } + if (!msg || !msg[0]) + msg = llm_strfmt("HTTP %ld", status); + return msg; +} + +static inline unsigned long llm_retry_after(const char *val) { + if (!val) + return 0; + char *end = NULL; + long v = strtol(val, &end, 10); + if (end != val && v >= 0 && v <= 300) + return (unsigned long)v; + /* HTTP-date (RFC 7231 §7.1.1.1): a GMT timestamp. Take the delta from + * now, clamped to the same [0, 300]s window as delta-seconds. */ + struct tm t; + memset(&t, 0, sizeof t); + static const char *const fmts[] = { + "%a, %d %b %Y %H:%M:%S GMT", /* IMF-fixdate */ + "%a, %d %b %Y %H:%M:%S %Z", /* tolerant tz name */ + "%a %b %d %H:%M:%S %Y", /* obs-date / asctime */ + }; + for (size_t i = 0; i < sizeof fmts / sizeof *fmts; i++) { + struct tm tt = t; + if (strptime(val, fmts[i], &tt) != NULL) { + long delta = (long)(timegm(&tt) - time(NULL)); + if (delta < 0) + delta = 0; + if (delta > 300) + delta = 300; + return (unsigned long)delta; + } + } + return 0; +} + +static inline unsigned long llm_header_retry_after(const char *headers) { + if (!headers) + return 0; + const char *p = headers; + while ((p = llm_strcasestr(p, "retry-after")) != NULL) { + p += strlen("retry-after"); + while (*p == ':' || *p == ' ' || *p == '\t') + p++; + /* find the value up to CRLF */ + char *val = llm_strdup(p); + size_t n = strlen(val); + while (n && (val[n - 1] == '\r' || val[n - 1] == '\n')) + val[--n] = '\0'; + unsigned long r = llm_retry_after(val); + free(val); + if (r) + return r; + p += 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* SSE streaming */ +/* ------------------------------------------------------------------ */ + +static inline void llm_sse_acc_init(llm_sse_acc *a, llm_provider prov) { + (void)prov; + memset(a, 0, sizeof *a); + llm_buf_init(&a->text); + llm_buf_init(&a->reasoning); +} + +static inline void llm_sse_acc_free(llm_sse_acc *a) { + llm_buf_free(&a->text); + llm_buf_free(&a->reasoning); + for (size_t i = 0; i < a->tc_len; i++) { + free(a->tc_id[i]); + free(a->tc_name[i]); + llm_buf_free(&a->tc_args[i]); + } + free(a->tc_id); + free(a->tc_name); + free(a->tc_args); + free(a->anth_event); + memset(a, 0, sizeof *a); +} + +static inline void llm_acc_push_tool(llm_sse_acc *a, char *id, char *name) { + a->tc_id = (char **)llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id); + a->tc_name = + (char **)llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name); + a->tc_args = + (llm_buf *)llm_xrealloc(a->tc_args, (a->tc_len + 1) * sizeof *a->tc_args); + a->tc_id[a->tc_len] = id; + a->tc_name[a->tc_len] = name; + llm_buf_init(&a->tc_args[a->tc_len]); + a->tc_len++; +} + +/* The args buffer of the tool call at `idx` (creating the slot when the + * stream has not announced it yet). Fresh slots adopt `id`/`name`. */ +static inline llm_buf *llm_acc_tool_args(llm_sse_acc *a, size_t idx, char *id, + char *name) { + while (a->tc_len <= idx) + llm_acc_push_tool(a, NULL, NULL); + if (id) { + free(a->tc_id[idx]); + a->tc_id[idx] = id; + } + if (name) { + if (a->tc_name[idx] && a->tc_name[idx][0]) + free(name); + else { + free(a->tc_name[idx]); + a->tc_name[idx] = name; + } + } + return &a->tc_args[idx]; +} + +static inline void llm_sse_parse_line(llm_provider prov, const char *line, + llm_sse_acc *a) { + const char *p = line; + while (*p == ' ' || *p == '\t') + p++; + if (!*p) + return; + + if (prov == LLM_ANTHROPIC) { + if (strncmp(p, "event:", 6) == 0) { + const char *v = p + 6; + while (*v == ' ') + v++; + char *ev = llm_strdup(v); + size_t n = strlen(ev); + while (n && (ev[n - 1] == '\r' || ev[n - 1] == '\n')) + ev[--n] = '\0'; + free(a->anth_event); + a->anth_event = ev; + return; + } + if (strncmp(p, "data:", 5) != 0) + return; + const char *v = p + 5; + while (*v == ' ') + v++; + const char *ev = a->anth_event ? a->anth_event : ""; + /* message_stop arrives as `data: [DONE]` (not JSON). */ + if (llm_streq(ev, "message_stop")) { + a->done = true; + return; + } + cJSON *j = cJSON_Parse(v); + if (!j) + return; + if (llm_streq(ev, "message_start")) { + const cJSON *m = cJSON_GetObjectItem(j, "message"); + const cJSON *u = m ? cJSON_GetObjectItem(m, "usage") : NULL; + if (u) { + const cJSON *it = cJSON_GetObjectItem(u, "input_tokens"); + if (cJSON_IsNumber(it)) + a->input_tokens = (unsigned long)it->valuedouble; + } + } else if (llm_streq(ev, "content_block_start")) { + const cJSON *cb = cJSON_GetObjectItem(j, "content_block"); + const cJSON *t = cb ? cJSON_GetObjectItem(cb, "type") : NULL; + if (cJSON_IsString(t) && t->valuestring) { + if (llm_streq(t->valuestring, "thinking")) { + a->anth_mode = 1; + } else if (llm_streq(t->valuestring, "tool_use")) { + char *id = NULL, *name = NULL; + const cJSON *tid = cJSON_GetObjectItem(cb, "id"); + if (cJSON_IsString(tid) && tid->valuestring) + id = llm_strdup(tid->valuestring); + const cJSON *tn = cJSON_GetObjectItem(cb, "name"); + if (cJSON_IsString(tn) && tn->valuestring) + name = llm_strdup(tn->valuestring); + llm_acc_push_tool(a, id, name); + a->anth_mode = 2; + a->anth_tc = a->tc_len - 1; + } else { + a->anth_mode = 0; + } + } + } else if (llm_streq(ev, "content_block_delta")) { + const cJSON *d = cJSON_GetObjectItem(j, "delta"); + const cJSON *t = d ? cJSON_GetObjectItem(d, "type") : NULL; + if (cJSON_IsString(t) && t->valuestring) { + if (llm_streq(t->valuestring, "text_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "text"); + if (cJSON_IsString(s) && s->valuestring) + llm_buf_put(&a->text, s->valuestring); + } else if (llm_streq(t->valuestring, "thinking_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "thinking"); + if (cJSON_IsString(s) && s->valuestring) + llm_buf_put(&a->reasoning, s->valuestring); + } else if (llm_streq(t->valuestring, "input_json_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "partial_json"); + if (cJSON_IsString(s) && s->valuestring && a->anth_mode == 2) + llm_buf_put(&a->tc_args[a->anth_tc], s->valuestring); + } + } + } + cJSON_Delete(j); + return; + } + + if (prov == LLM_OLLAMA) { + cJSON *j = cJSON_Parse(p); + if (!j) + return; + const cJSON *msg = cJSON_GetObjectItem(j, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0]) + llm_buf_put(&a->text, ct->valuestring); + const cJSON *th = cJSON_GetObjectItem(msg, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) + llm_buf_put(&a->reasoning, th->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + /* Ollama carries no per-call index (and often no id), so the + * array position is the only stable key: map the i-th call to + * accumulator slot i so arguments streamed across chunks + * accumulate into one call instead of appending a duplicate + * slot per chunk (the OpenAI path does this via `index`). */ + size_t pos = 0; + const cJSON *tc = tcs->child; + while (tc) { + char *id = NULL, *name = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + } + llm_buf *ab = llm_acc_tool_args(a, pos, id, name); + if (fn) { + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + llm_buf_put(ab, ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + llm_buf_put(ab, s); + free(s); + } + } + } + tc = tc->next; + pos++; + } + } + } + const cJSON *pe = cJSON_GetObjectItem(j, "prompt_eval_count"); + if (cJSON_IsNumber(pe)) + a->input_tokens = (unsigned long)pe->valuedouble; + const cJSON *dn = cJSON_GetObjectItem(j, "done"); + if (cJSON_IsBool(dn) && dn->valueint) + a->done = true; + cJSON_Delete(j); + return; + } + + /* OpenAI-compatible SSE. */ + if (strncmp(p, "data:", 5) != 0) + return; + const char *v = p + 5; + while (*v == ' ') + v++; + if (llm_streq(v, "[DONE]")) { + a->done = true; + return; + } + cJSON *j = cJSON_Parse(v); + if (!j) + return; + const cJSON *choices = cJSON_GetObjectItem(j, "choices"); + if (cJSON_IsArray(choices) && choices->child) { + const cJSON *choice = choices->child; + const cJSON *delta = cJSON_GetObjectItem(choice, "delta"); + if (delta) { + const cJSON *ct = cJSON_GetObjectItem(delta, "content"); + if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0]) + llm_buf_put(&a->text, ct->valuestring); + const cJSON *rc = cJSON_GetObjectItem(delta, "reasoning_content"); + if (!cJSON_IsString(rc) || !rc->valuestring) + rc = cJSON_GetObjectItem(delta, "reasoning"); + if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0]) + llm_buf_put(&a->reasoning, rc->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(delta, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + size_t idx = 0; + const cJSON *ci = cJSON_GetObjectItem(tc, "index"); + if (cJSON_IsNumber(ci)) + idx = (size_t)ci->valuedouble; + char *id = NULL, *name = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + } + llm_buf *ab = llm_acc_tool_args(a, idx, id, name); + if (fn) { + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + llm_buf_put(ab, ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + llm_buf_put(ab, s); + free(s); + } + } + } + } + } + const cJSON *fr = cJSON_GetObjectItem(choice, "finish_reason"); + if (cJSON_IsString(fr) && fr->valuestring && fr->valuestring[0]) + a->done = true; + } + } + const cJSON *usage = cJSON_GetObjectItem(j, "usage"); + if (usage) { + const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens"); + if (cJSON_IsNumber(pt)) + a->input_tokens = (unsigned long)pt->valuedouble; + } + cJSON_Delete(j); +} + +static inline bool llm_sse_acc_had_content(const llm_sse_acc *a) { + return a->text.len > 0 || a->reasoning.len > 0 || a->tc_len > 0 || a->done; +} + +/* Materialize the accumulated stream into `c` (ownership transfers). */ +static inline void llm_sse_acc_into(llm_sse_acc *a, llm_completion *c) { + /* Ownership transfers to `c` when non-empty; only free the buffer when + * `c` did not take it (empty). Freeing unconditionally would dangle the + * pointer `c` now owns -> double-free in llm_completion_free. */ + char *t = llm_buf_steal(&a->text); + if (t && t[0]) + c->text = t; + else + free(t); + char *r = llm_buf_steal(&a->reasoning); + if (r && r[0]) + c->reasoning = r; + else + free(r); + for (size_t i = 0; i < a->tc_len; i++) { + char *args = llm_buf_steal(&a->tc_args[i]); + char *id = a->tc_id[i] ? a->tc_id[i] + : llm_strfmt("call-%d", (int)(i + 1)); + char *name = a->tc_name[i] ? a->tc_name[i] : llm_strdup(""); + if (!args || !args[0]) { + free(args); + args = llm_strdup("{}"); + } + llm_add_tool_call(c, id, name, args); + a->tc_id[i] = a->tc_name[i] = NULL; + } + c->input_tokens = a->input_tokens; + a->input_tokens = 0; +} + +/* Write-callback state for a streaming POST. */ +typedef struct { + llm_buf body; + llm_buf headers; + llm_buf linebuf; /* bytes not yet terminated by a newline */ + llm_sse_acc *acc; + llm_provider prov; + llm_emit_cb emit; + void *user; + std::atomic<bool> *stop; + bool stopped; /* set when the transfer should abort */ + bool response_too_large; + size_t emitted_text; + size_t emitted_reasoning; +} llm_stream_ctx; + +static inline size_t llm_stream_write(char *d, size_t s, size_t n, void *ud) { + llm_stream_ctx *c = (llm_stream_ctx *)ud; + const size_t cap = 8u * 1024u * 1024u; + if ((s && n > SIZE_MAX / s) || c->body.len > cap || + s * n > cap - c->body.len || c->linebuf.len > cap - s * n) { + c->response_too_large = true; + return 0; + } + llm_buf_putn(&c->body, d, s * n); + llm_buf_putn(&c->linebuf, d, s * n); + for (;;) { + char *nl = (char *)memchr(c->linebuf.data, '\n', c->linebuf.len); + if (!nl) + break; + size_t lpos = (size_t)(nl - c->linebuf.data); + if (lpos > 0 && c->linebuf.data[lpos - 1] == '\r') + lpos--; + char *line = llm_strndup(c->linebuf.data, lpos); + size_t rest = c->linebuf.len - lpos - 1; + memmove(c->linebuf.data, c->linebuf.data + lpos + 1, rest); + c->linebuf.len = rest; + llm_sse_parse_line(c->prov, line, c->acc); + free(line); + + if (c->acc->text.len > c->emitted_text) { + char *delta = llm_strndup(c->acc->text.data + c->emitted_text, + c->acc->text.len - c->emitted_text); + c->emitted_text = c->acc->text.len; + if (c->emit && + c->emit(c->prov, LLM_EMIT_TEXT, delta, c->user) != 0) + c->stopped = true; + free(delta); + } + if (!c->stopped && c->acc->reasoning.len > c->emitted_reasoning) { + char *delta = llm_strndup(c->acc->reasoning.data + + c->emitted_reasoning, + c->acc->reasoning.len - + c->emitted_reasoning); + c->emitted_reasoning = c->acc->reasoning.len; + if (c->emit && + c->emit(c->prov, LLM_EMIT_REASONING, delta, c->user) != 0) + c->stopped = true; + free(delta); + } + if (c->stopped || (c->stop && c->stop->load())) { + c->stopped = true; + break; + } + } + if (c->stopped) + return 0; /* short write: abort the transfer */ + return s * n; +} + +static inline size_t llm_stream_header(char *d, size_t s, size_t n, void *ud) { + llm_stream_ctx *c = (llm_stream_ctx *)ud; + llm_buf_putn(&c->headers, d, s * n); + return s * n; +} + +static inline int llm_stream_xfer(void *clientp, curl_off_t t, curl_off_t dn, + curl_off_t ut, curl_off_t un) { + (void)t; + (void)dn; + (void)ut; + (void)un; + llm_stream_ctx *c = (llm_stream_ctx *)clientp; + if (c->stop && c->stop->load()) + return 1; + return 0; +} + +static inline int llm_post_stream(const llm_conn *c, const char *url, + const char *body, long *status_out, + char **body_out, char **headers_out, + llm_sse_acc *acc, llm_emit_cb emit, void *user, + std::atomic<bool> *stop, bool *stopped, + char **err, CURL **cached) { + if (body_out) + *body_out = NULL; + if (headers_out) + *headers_out = NULL; + if (stopped) + *stopped = false; + if (err) + *err = NULL; + if (status_out) + *status_out = 0; + + llm_stream_ctx ctx; + memset(&ctx, 0, sizeof ctx); + llm_buf_init(&ctx.body); + llm_buf_init(&ctx.headers); + llm_buf_init(&ctx.linebuf); + ctx.acc = acc; + ctx.prov = c->provider; + ctx.emit = emit; + ctx.user = user; + ctx.stop = stop; + + struct curl_slist *h = NULL; + CURL *curl = llm_post_setup(c, url, body, &h, err, cached); + if (!curl) { + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + llm_buf_free(&ctx.linebuf); + return -1; + } + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_stream_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_stream_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_stream_xfer); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx); + + CURLcode rc = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(h); + char *body_str = llm_buf_steal(&ctx.body); + char *hdr_str = llm_buf_steal(&ctx.headers); + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + llm_buf_free(&ctx.linebuf); + if (!cached || *cached != curl) + curl_easy_cleanup(curl); + + if (ctx.stopped) { + if (stopped) + *stopped = true; + free(body_str); + free(hdr_str); + return 0; + } + if (rc != CURLE_OK) { + if (err) + *err = llm_strdup(ctx.response_too_large + ? "LLM streaming response exceeds 8 MiB limit" + : curl_easy_strerror(rc)); + free(body_str); + free(hdr_str); + return -1; + } + if (status_out) + *status_out = code; + if (body_out) + *body_out = body_str; + else + free(body_str); + if (headers_out) + *headers_out = hdr_str; + else + free(hdr_str); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Request assembly */ +/* ------------------------------------------------------------------ */ + +/* JSON-encode an arbitrary string (NULL-safe). */ +static inline char *llm_json_str(const char *s) { + cJSON *j = cJSON_CreateString(s ? s : ""); + char *r = cJSON_PrintUnformatted(j); + cJSON_Delete(j); + return r ? r : llm_strdup("\"\""); +} + +/* OpenAI-compatible body (OpenAI + Ollama). `top_level_system` routes the + * preamble to a top-level `system` field (Ollama) instead of a leading + * system message (OpenAI). `p` may be NULL. */ +static inline void llm_emit_openai_body(llm_buf *req, const llm_conn *c, + const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, + bool top_level_system, bool stream, + const llm_params *p) { + llm_buf_put(req, "{\"model\":"); + char *model = llm_json_str(c->model); + llm_buf_put(req, model); + free(model); + llm_buf_put(req, ",\"stream\":"); + llm_buf_put(req, stream ? "true" : "false"); + llm_buf_push(req, ','); + if (top_level_system && preamble && preamble[0]) { + char *ps = llm_json_str(preamble); + llm_buf_put(req, "\"system\":"); + llm_buf_put(req, ps); + free(ps); + llm_buf_push(req, ','); + } + llm_buf_put(req, "\"messages\":"); + cJSON *om = llm_openai_messages(msgs, n); + if (!top_level_system && preamble && preamble[0]) { + /* OpenAI-compatible: the preamble becomes a leading system message. */ + cJSON *sys = cJSON_CreateObject(); + cJSON_AddStringToObject(sys, "role", "system"); + cJSON_AddStringToObject(sys, "content", preamble); + cJSON_InsertItemInArray(om, 0, sys); + } + char *oms = cJSON_PrintUnformatted(om); + llm_buf_put(req, oms ? oms : "[]"); + free(oms); + cJSON_Delete(om); + if (tools_json) { + llm_buf_push(req, ','); + llm_buf_put(req, tools_json); + } + if (p) { + if (top_level_system) { + /* Ollama: sampling knobs nest under `options`. */ + if (p->temperature >= 0.0 || p->max_tokens > 0) { + llm_buf_put(req, ",\"options\":{"); + bool first = true; + if (p->temperature >= 0.0) { + char *t = llm_strfmt("\"temperature\":%.6g", + p->temperature); + llm_buf_put(req, t); + free(t); + first = false; + } + if (p->max_tokens > 0) { + if (!first) + llm_buf_push(req, ','); + char *v = llm_strfmt("\"num_predict\":%lu", + p->max_tokens); + llm_buf_put(req, v); + free(v); + } + llm_buf_push(req, '}'); + } + if (p->reasoning_effort) + llm_buf_put(req, ",\"think\":true"); + if (p->response_schema && p->response_schema[0]) { + llm_buf_put(req, ",\"format\":"); + llm_buf_put(req, p->response_schema); + } + } else { + if (p->temperature >= 0.0) { + char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature); + llm_buf_put(req, t); + free(t); + } + if (p->max_tokens > 0) { + char *v = llm_strfmt(",\"max_tokens\":%lu", p->max_tokens); + llm_buf_put(req, v); + free(v); + } + if (p->reasoning_effort) { + char *e = llm_strfmt(",\"reasoning_effort\":\"%s\"", + p->reasoning_effort); + llm_buf_put(req, e); + free(e); + } + if (p->response_schema && p->response_schema[0]) { + llm_buf_put(req, + ",\"response_format\":{\"type\":\"json_schema\"," + "\"json_schema\":{\"name\":\"response\"," + "\"schema\":"); + llm_buf_put(req, p->response_schema); + llm_buf_put(req, "}}"); + } + } + } + llm_buf_push(req, '}'); +} + +/* Assemble a request body. `p` may be NULL for a plain request. */ +static inline char *llm_build_request_ex(const llm_conn *c, + const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, bool stream, + const llm_params *p) { + llm_buf req; + llm_buf_init(&req); + if (c->provider == LLM_ANTHROPIC) { + unsigned long budget = p ? p->thinking_budget : 0; + if (budget == 0 && p && p->reasoning_effort) { + if (llm_streq(p->reasoning_effort, "low")) + budget = LLM_EFFORT_BUDGET_LOW; + else if (llm_streq(p->reasoning_effort, "medium")) + budget = LLM_EFFORT_BUDGET_MEDIUM; + else + budget = LLM_EFFORT_BUDGET_HIGH; + } + unsigned long mt = LLM_DEFAULT_MAX_TOKENS; + if (p && p->max_tokens > 0) + mt = p->max_tokens; + if (budget && mt <= budget) + mt = budget + 1024; + llm_buf_put(&req, "{\"model\":"); + char *model = llm_json_str(c->model); + llm_buf_put(&req, model); + free(model); + { + char *mtb = llm_strfmt(",\"max_tokens\":%lu,\"stream\":", mt); + llm_buf_put(&req, mtb); + free(mtb); + } + llm_buf_put(&req, stream ? "true" : "false"); + if (budget) { + /* Extended thinking; Anthropic pins temperature to 1 with it. */ + char *th = llm_strfmt( + ",\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":%lu}", + budget); + llm_buf_put(&req, th); + free(th); + } else if (p && p->temperature >= 0.0) { + char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature); + llm_buf_put(&req, t); + free(t); + } + llm_buf_push(&req, ','); + const char *sys = preamble; + char *schema_sys = NULL; + if (p && p->response_schema && p->response_schema[0]) { + /* Anthropic has no response_format: steer via the system prompt. */ + schema_sys = llm_strfmt( + "%s\n\nRespond with a single JSON object that matches this " + "JSON Schema. Output nothing else.\n%s", + preamble ? preamble : "", p->response_schema); + sys = schema_sys; + } + if (sys && sys[0]) { + char *ps = llm_json_str(sys); + llm_buf_put(&req, "\"system\":"); + llm_buf_put(&req, ps); + free(ps); + llm_buf_push(&req, ','); + } + free(schema_sys); + llm_buf_put(&req, "\"messages\":"); + cJSON *m = llm_anthropic_messages(msgs, n); + char *ms = cJSON_PrintUnformatted(m); + llm_buf_put(&req, ms ? ms : "[]"); + free(ms); + cJSON_Delete(m); + if (tools_json) { + llm_buf_push(&req, ','); + llm_buf_put(&req, tools_json); + } + llm_buf_push(&req, '}'); + } else if (c->provider == LLM_OLLAMA) { + /* Ollama takes the system prompt as a top-level `system` field. */ + llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, true, + stream, p); + } else { + llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false, + stream, p); + } + char *out = llm_buf_steal(&req); + llm_buf_free(&req); + return out; +} + +static inline char *llm_build_request(const llm_conn *c, const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, bool stream) { + return llm_build_request_ex(c, preamble, msgs, n, tools_json, stream, NULL); +} + +#ifdef __cplusplus +} +#endif + +#endif /* LLM_H */ diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..b1247ee --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1902 @@ +/* llsh — a terminal LLM agent harness (C port). */ + +#include <ctype.h> +#include <errno.h> +#include <atomic> +#include <pthread.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <unistd.h> + +#include "escape.h" +#include "provider.h" +#include "agent.h" +#include "compact.h" +#include "config.h" +#include "dotagents.h" +#include "goal.h" +#include "input.h" +#include "limits.h" +#include "mcp.h" +#include "message.h" +#include "session.h" +#include "shell.h" +#include "tools.h" +#include "verbose.h" + +/* ------------------------------------------------------------------ */ +/* Terminal escapes (from escape.h; faint `2m` has no escape.h macro) */ +/* ------------------------------------------------------------------ */ +#define DIM ESC_DIM +#define BOLD_C ESC_BOLD ESC_COLOR_CYAN +#define RED ESC_COLOR_RED +#define YELLOW ESC_COLOR_YELLOW +#define RESET ESC_RESET +#define CHAT_PROMPT BOLD_C "\xE2\x80\xBA" RESET " " + +/* ------------------------------------------------------------------ */ +/* Output helpers */ +/* ------------------------------------------------------------------ */ + +static void out_str(const char *s) { + if (!s) + return; + fwrite(s, 1, strlen(s), stdout); + fflush(stdout); +} + +static void out_fmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = xvsnprintf(fmt, ap); + va_end(ap); + out_str(s); + free(s); +} + +/* ------------------------------------------------------------------ */ +/* Emit callback (model output -> terminal) */ +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* Typewriter pacer (ported from ../llsh src/session.rs) */ +/* */ +/* llm.h already streams: provider_stream calls the sess_emit_cb for */ +/* each TEXT/REASONING delta as it arrives. The pacer consumes those */ +/* emits (via emit_cb) into a queue and reveals them at an adaptive */ +/* pace, so a token burst -- or a non-streaming provider that returns */ +/* the whole body at once -- renders as a smooth typewriter instead of a */ +/* single dump. A verbatim/tool line flushes the buffered prose first. */ +/* ------------------------------------------------------------------ */ + +enum { PSTYLE_NORMAL, PSTYLE_DIM }; +enum { PSEG_TEXT, PSEG_VERBATIM }; + +/* Wake every TICK_MS; reveal ~backlog/SMOOTH_MS of the backlog each tick so + * the reveal tracks the model's speed, clamped to [MIN,MAX] chars/tick. */ +#define PACER_TICK_MS 16 +#define PACER_SMOOTH_MS 1000 +#define PACER_MIN 2 +#define PACER_MAX 24 + +typedef struct { + int kind; /* PSEG_TEXT or PSEG_VERBATIM */ + int style; /* PSTYLE_* (TEXT only) */ + char *text; /* owned */ + size_t pos; /* revealed bytes (TEXT only) */ +} pseg; + +typedef struct { + pseg *items; + size_t n, cap; + bool finished; + bool abort; + pthread_t th; + pthread_mutex_t mu; + pthread_cond_t cv; +} pacer; + +static void *pacer_run(void *arg); + +static pacer *pacer_new(void) { + pacer *p = (pacer *) xmalloc(sizeof *p); + memset(p, 0, sizeof *p); + pthread_mutex_init(&p->mu, NULL); + /* Use a monotonic clock so the tick timeout is immune to wall-clock + * adjustments (NTP steps); pthread_cond_timedwait must use the same + * clock as the absolute timeout we compute in pacer_run. */ + pthread_condattr_t ca; + pthread_condattr_init(&ca); + pthread_condattr_setclock(&ca, CLOCK_MONOTONIC); + pthread_cond_init(&p->cv, &ca); + pthread_condattr_destroy(&ca); + return p; +} + +static void pacer_free(pacer *p) { + pthread_mutex_lock(&p->mu); + for (size_t i = 0; i < p->n; i++) + free(p->items[i].text); + free(p->items); + p->n = 0; + pthread_mutex_unlock(&p->mu); + pthread_mutex_destroy(&p->mu); + pthread_cond_destroy(&p->cv); + free(p); +} + +static void pacer_shift(pacer *p) { + memmove(p->items, p->items + 1, (p->n - 1) * sizeof *p->items); + p->n--; +} + +/* Producer side (called from the turn worker via emit_cb). */ +static void pacer_push(pacer *p, int kind, int style, char *owned) { + pthread_mutex_lock(&p->mu); + if (p->n == p->cap) { + p->cap = p->cap ? p->cap * 2 : 8; + p->items = (pseg *) xrealloc(p->items, p->cap * sizeof *p->items); + } + p->items[p->n].kind = kind; + p->items[p->n].style = style; + p->items[p->n].text = owned; + p->items[p->n].pos = 0; + p->n++; + /* No condvar signal: a push must not wake the consumer. Reveal is strictly + * tick-paced (see pacer_run); waking here would dump MAX chars per arriving + * delta instead of spreading it across the smoothing window. */ + pthread_mutex_unlock(&p->mu); +} + +static void pacer_emit_text(pacer *p, int style, const char *text) { + if (!text || !text[0]) + return; + pacer_push(p, PSEG_TEXT, style, xstrdup(text)); +} + +static void pacer_emit_verbatim(pacer *p, const char *text) { + if (!text) + return; + pacer_push(p, PSEG_VERBATIM, PSTYLE_NORMAL, xstrdup(text)); +} + +static void pacer_finish(pacer *p) { + pthread_mutex_lock(&p->mu); + p->finished = true; + pthread_cond_broadcast(&p->cv); + pthread_mutex_unlock(&p->mu); +} + +static void pacer_abort(pacer *p) { + pthread_mutex_lock(&p->mu); + p->abort = true; + pthread_cond_broadcast(&p->cv); + pthread_mutex_unlock(&p->mu); +} + +static void pacer_start(pacer *p) { + pthread_create(&p->th, NULL, pacer_run, p); +} + +static const char *pstyle_transition(int open, int to) { + if (open == PSTYLE_NORMAL && to == PSTYLE_DIM) + return DIM; + if (open == PSTYLE_DIM && to == PSTYLE_NORMAL) + return RESET; + return ""; +} + +static size_t pacer_backlog(const pacer *p) { + size_t b = 0; + for (size_t i = 0; i < p->n; i++) + if (p->items[i].kind == PSEG_TEXT) + b += strlen(p->items[i].text) - p->items[i].pos; + return b; +} + +static size_t pacer_reveal_count(const pacer *p) { + size_t backlog = pacer_backlog(p); + if (backlog == 0) + return 0; + size_t prop = backlog * PACER_TICK_MS / PACER_SMOOTH_MS; + if (prop < PACER_MIN) + prop = PACER_MIN; + if (prop > PACER_MAX) + prop = PACER_MAX; + return prop; +} + +/* Reveal up to `budget` chars of TEXT from the front, writing style + * transitions. Caller holds p->mu. */ +static void pacer_drain_text(pacer *p, int *open, size_t budget) { + size_t remaining = budget; + xbuf out; + xbuf_init(&out); + while (remaining > 0 && p->n > 0) { + pseg *s = &p->items[0]; + if (s->kind != PSEG_TEXT) + break; + size_t len = strlen(s->text); + size_t left = len - s->pos; + if (left == 0) { + free(s->text); + pacer_shift(p); + continue; + } + size_t take = remaining < left ? remaining : left; + if (*open == PSTYLE_DIM && s->style == PSTYLE_NORMAL) { + xbuf_put(&out, RESET "\n\n"); + } else { + xbuf_put(&out, pstyle_transition(*open, s->style)); + } + *open = s->style; + xbuf_putn(&out, s->text + s->pos, take); + s->pos += take; + remaining -= take; + if (s->pos >= len) { + free(s->text); + pacer_shift(p); + } + } + if (out.len) + out_str(out.data); + xbuf_free(&out); +} + +/* Consumer thread: reveal the queue at the adaptive pace, write verbatims + * (flushing preceding prose), and shut down on finish/abort. */ +static void *pacer_run(void *arg) { + pacer *p = (pacer *) arg; + int open = PSTYLE_NORMAL; + for (;;) { + // Tick: wait up to PACER_TICK_MS, or wake early on a new emit/control. + pthread_mutex_lock(&p->mu); + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + ts.tv_nsec += (long)PACER_TICK_MS * 1000000L; + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec += 1; + ts.tv_nsec -= 1000000000L; + } + pthread_cond_timedwait(&p->cv, &p->mu, &ts); + pthread_mutex_unlock(&p->mu); + + pthread_mutex_lock(&p->mu); + + if (p->abort) { + for (size_t i = 0; i < p->n; i++) + free(p->items[i].text); + p->n = 0; + xbuf o; + xbuf_init(&o); + xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL)); + xbuf_put(&o, "\n"); + xbuf_put(&o, DIM); + xbuf_put(&o, "[stopped]"); + xbuf_put(&o, RESET); + xbuf_put(&o, "\n"); + out_str(o.data ? o.data : ""); + xbuf_free(&o); + open = PSTYLE_NORMAL; + pthread_mutex_unlock(&p->mu); + break; + } + + if (p->n > 0 && p->items[0].kind == PSEG_VERBATIM) { + // Preceding prose is already revealed; write the line immediately. + xbuf o; + xbuf_init(&o); + xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL)); + xbuf_put(&o, p->items[0].text); + out_str(o.data ? o.data : ""); + xbuf_free(&o); + open = PSTYLE_NORMAL; + free(p->items[0].text); + pacer_shift(p); + pthread_mutex_unlock(&p->mu); + continue; + } + + if (p->n > 0 && p->items[0].kind == PSEG_TEXT) { + size_t budget = pacer_reveal_count(p); + if (budget) + pacer_drain_text(p, &open, budget); + } + + bool done = false; + if (p->finished && p->n == 0) { + out_str(pstyle_transition(open, PSTYLE_NORMAL)); + out_str("\n"); + open = PSTYLE_NORMAL; + done = true; + } + pthread_mutex_unlock(&p->mu); + + if (done) + break; + } + return NULL; +} + +static int emit_cb(const sess_emit *e, void *user) { + pacer *p = (pacer *) user; + if (!e || !p) + return 0; + switch (e->kind) { + case SESS_EMIT_TEXT: + pacer_emit_text(p, PSTYLE_NORMAL, e->text); + break; + case SESS_EMIT_REASONING: + pacer_emit_text(p, PSTYLE_DIM, e->text); + break; + case SESS_EMIT_VERBATIM: + pacer_emit_verbatim(p, e->text); + break; + case SESS_EMIT_RESULT: + if (e->from_shell) { + char *v = xstrfmt(DIM "%s" RESET, e->text); + pacer_emit_verbatim(p, v); + free(v); + } else { + pacer_emit_verbatim(p, e->text); + } + break; + case SESS_EMIT_STATUS: + break; /* no inline output */ + } + return 0; +} + +/* Run one turn with the pacer active: start it, feed emits through emit_cb, + * then drain (or, if the user stopped, clear + note it) and join. */ +static int run_turn_paced(session *s, const char *prompt, + std::atomic<bool> *stop, char **err, int *result) { + pacer *p = pacer_new(); + pacer_start(p); + int rc = session_run_turn(s, prompt, stop, emit_cb, p, err, result); + if (result && *result == SESS_TURN_STOPPED) + pacer_abort(p); + else + pacer_finish(p); + pthread_join(p->th, NULL); + pacer_free(p); + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Destructive-command confirmation hook */ +/* ------------------------------------------------------------------ */ + +static bool interactive_confirm(const char *command, void *ud) { + input_key_reader *reader = (input_key_reader *) ud; + fprintf(stderr, "\n[confirm] run destructive command `%s`? [y/N] ", + command ? command : ""); + fflush(stderr); + if (!reader) + return true; /* non-interactive: allow */ + for (int i = 0; i < 200; i++) { + input_key key; + if (!input_key_reader_recv(reader, &key, NULL, NULL)) { + sleep_ms(20); + continue; + } + if (key.kind == KEY_CHAR) { + fputc((char)key.cp, stderr); + fputc('\n', stderr); + if (key.cp == 'y' || key.cp == 'Y') + return true; + if (key.cp == 'n' || key.cp == 'N') + return false; + } else if (key.kind == KEY_ENTER) { + fputc('\n', stderr); + return false; + } else if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C) { + fputc('\n', stderr); + return false; + } + } + return false; /* timeout: deny destructive commands by default */ +} + +/* ------------------------------------------------------------------ */ +/* /tools, /agents, /tasks, /memories listing */ +/* ------------------------------------------------------------------ */ + +static void list_tools(void) { + const tool_entry *entries = NULL; + size_t n = tool_list_entries(&entries); + for (size_t i = 0; i < n; i++) + out_fmt(" %-12s %s\n", entries[i].name, entries[i].desc); +} + +static void list_agents(const dotagents *dot, session *s) { + char *cur = session_profile_name(s); + out_fmt("current sub-agent: %s\n", cur ? cur : "default (main agent)"); + free(cur); + for (size_t i = 0; i < dot->sub_agent_count; i++) { + const sub_agent *a = &dot->sub_agents[i]; + if (!a->enabled) + continue; + char *cur2 = session_profile_name(s); + bool active = (cur2 && xstreq(cur2, a->id)); + free(cur2); + out_fmt(" %c %s — %s\n", active ? '*' : ' ', a->id, + a->description ? a->description : ""); + } + if (dot->sub_agent_count == 0) + out_str(" (none defined in .agents/agents/)\n"); +} + +static void list_tasks(const dotagents *dot) { + if (dot->task_count == 0) { + out_str("no repeat tasks defined (.agents/tasks/<id>/task.md)\n"); + return; + } + for (size_t i = 0; i < dot->task_count; i++) { + const task_entry *t = &dot->tasks[i]; + char *iv = t->has_interval ? itoa_dup((long long)t->interval_minutes) + : xstrdup("?"); + out_fmt(" %s %6s min %s — %s%s\n", t->enabled ? "on " : "off", iv, + t->id, t->name ? t->name : "", + t->run_on_startup ? " (runs at startup)" : ""); + free(iv); + } +} + +static void list_memories(const dotagents *dot) { + if (dot->memory_count == 0) { + out_str("no memories defined (.agents/memories/*.md)\n"); + return; + } + for (size_t i = 0; i < dot->memory_count; i++) { + const memory *m = &dot->memories[i]; + 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]); + } + out_fmt(" %-8s %s — %s", m->importance ? m->importance : "", m->id, + m->title ? m->title : ""); + if (m->tag_count && tags.data) + out_fmt(" [%s]", tags.data); + out_str("\n"); + xbuf_free(&tags); + } +} + +/* ------------------------------------------------------------------ */ +/* Turn worker (so ESC / Ctrl+C can stop it) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + session *s; + char *prompt; + std::atomic<bool> *stop; + char *err; + int result; + std::atomic<bool> done; +} turn_ctx; + +static void *turn_thread(void *arg) { + turn_ctx *tc = (turn_ctx *) arg; + char *err = NULL; + int result = SESS_TURN_COMPLETED; + int rc = run_turn_paced(tc->s, tc->prompt, tc->stop, &err, &result); + if (err) + tc->err = err; + tc->result = result; + tc->done.store(true); + (void)rc; + return NULL; +} + +static int run_turn_interactive(session *s, const char *prompt, + input_key_reader *reader) { + std::atomic<bool> stop(false); + turn_ctx tc = {s, xstrdup(prompt), &stop, NULL, SESS_TURN_COMPLETED, false}; + pthread_t th; + if (pthread_create(&th, NULL, turn_thread, &tc) != 0) { + char *err = NULL; + int result = 0; + int rc = run_turn_paced(s, prompt, &stop, &err, &result); + if (rc < 0 && err) { + out_fmt(RED "error: %s" RESET "\n", err); + free(err); + } + free(tc.prompt); + return result; + } + while (!tc.done.load()) { + input_key key; + if (input_key_reader_recv(reader, &key, NULL, NULL)) { + if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C) + stop.store(true); /* the pacer prints [stopped] when it drains */ + } else { + sleep_ms(25); + } + } + pthread_join(th, NULL); + if (tc.err) { + out_fmt(RED "error: %s" RESET "\n", tc.err); + free(tc.err); + } + free(tc.prompt); + return tc.result; +} + +/* ------------------------------------------------------------------ */ +/* /goal */ +/* ------------------------------------------------------------------ */ + +static void report_goal(session *s) { + const goal_report *r = session_goal_report(s); + if (r) { + out_fmt("last goal (%s): \"%s\" — %lu of %lu attempt(s), ~%llu input " + "tokens\n", + r->met ? "met" : "not met", r->criteria, r->turns_done, + r->max_tries, (unsigned long long)r->tokens_used); + } else { + out_str("no /goal run yet this session\n"); + } +} + +static goal_report *make_report(const goal_spec *spec, unsigned long turns, + unsigned long long tokens, bool met) { + goal_report *rep = (goal_report *) xmalloc(sizeof *rep); + rep->criteria = xstrdup(spec->criteria); + rep->max_tries = spec->max_tries; + rep->turns_done = turns; + rep->tokens_used = tokens; + rep->met = met; + return rep; +} + +static void run_goal(session *s, const goal_spec *spec, + input_key_reader *reader) { + char *feedback = NULL; + unsigned long long tokens_used = 0; + for (unsigned long attempt = 1; attempt <= spec->max_tries; attempt++) { + out_fmt("\n" DIM "── goal attempt %lu/%lu ──" RESET "\n", attempt, + spec->max_tries); + char *prompt = goal_prompt(spec, attempt, feedback); + unsigned long before = session_context_tokens(s); + int result = run_turn_interactive(s, prompt, reader); + free(prompt); + unsigned long after = session_context_tokens(s); + /* Accumulate the tokens added by this attempt (context delta), not + * the running context total. */ + if (after > before) + tokens_used += after - before; + if (result == SESS_TURN_STOPPED) { + session_set_goal_report(s, + make_report(spec, attempt, tokens_used, + false)); + out_fmt("goal stopped by the user on attempt %lu\n", attempt); + free(feedback); + return; + } + char *last = session_last_assistant_text(s); + goal_verdict v = {false, NULL}; + char *err = NULL; + int rc = session_evaluate_goal(s, spec, last ? last : "", &v, &err); + free(last); + if (rc != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "evaluating goal"); + free(err); + free(v.reason); + free(feedback); + return; + } + out_fmt(DIM "reviewer: %s — %s" RESET "\n", v.met ? "MET" : "NOT MET", + v.reason ? v.reason : ""); + if (v.met) { + session_set_goal_report(s, + make_report(spec, attempt, tokens_used, + true)); + out_fmt("goal met after %lu attempt(s)\n", attempt); + free(v.reason); + free(feedback); + return; + } + free(feedback); + feedback = v.reason; + v.reason = NULL; + } + session_set_goal_report(s, + make_report(spec, spec->max_tries, tokens_used, + false)); + out_fmt("goal not met after %lu attempt(s); try again with a higher cap\n", + spec->max_tries); + free(feedback); +} + +/* ------------------------------------------------------------------ */ +/* Commands (returns true when the session should end) */ +/* ------------------------------------------------------------------ */ + +/* Banner (defined below); reprinted inline on a model/agent switch. The + * provider-reported context window is adopted before the banner is printed, + * so it is shown correctly the first time. */ +static void banner_print(session *s, const dotagents *dot); +static void banner_worker_join(void); +static int banner_refresh_start(session *s); + +static bool handle_command(session *s, const dotagents *dot, const char *cmd, + input_key_reader *reader) { + const char *p = cmd; + while (*p == ' ' || *p == '\t') + p++; + const char *head_start = p; + while (*p && *p != ' ' && *p != '\t') + p++; + size_t head_len = (size_t)(p - head_start); + char *head = xstrndup(head_start, head_len); + const char *arg = p; + while (*arg == ' ' || *arg == '\t') + arg++; + + if (xstreq(head, "exit") || xstreq(head, "quit")) { + free(head); + return true; + } + if (xstreq(head, "clear")) { + session_clear_history(s); + out_str(ESC_CURSOR_POSITION(1, 1) ESC_CLEAR_SCREEN "\x1b[3J"); + free(head); + return false; + } + if (xstreq(head, "undo")) { + int removed = session_undo_last_turn(s); + if (removed < 0) + out_str("nothing to undo (no user turn in context)\n"); + else + out_fmt("rolled back the last turn (%d message(s) removed from " + "context)\n", + removed); + free(head); + return false; + } + if (xstreq(head, "compact")) { + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + int rc = session_compact_if_needed(s, true, &report, &err); + if (rc < 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "compacting"); + free(err); + } else if (rc == 1) { + session_print_compact(&report); + if (session_context_limit(s) == 0) + out_str("note: no context window is known, so automatic " + "compaction stays off (set `context_limit` to enable " + "it)\n"); + } else { + if (session_context_limit(s) == 0) + out_str("nothing to compact yet; automatic compaction is " + "also off because no context window is known (set " + "`context_limit` to enable it)\n"); + else + out_str("nothing to compact yet\n"); + } + free(head); + return false; + } + if (xstreq(head, "save")) { + const char *path = arg[0] ? arg : "/tmp/llsh-session.json"; + char *err = NULL; + size_t count = session_message_count(s); + if (session_save(s, path, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "saving"); + free(err); + } else { + out_fmt("saved %zu message(s) to %s — resume with: llsh " + "--resume %s\n", + count, path, path); + } + free(head); + return false; + } + if (xstreq(head, "export")) { + const char *path = arg[0] ? arg : "llsh-transcript.md"; + char *md = session_transcript_markdown(s); + char *err = NULL; + if (write_file_from_buf(path, md, strlen(md), &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "exporting"); + free(err); + } else { + out_fmt("exported %zu message(s) to %s\n", + session_message_count(s), path); + } + free(md); + free(head); + return false; + } + if (xstreq(head, "goal")) { + if (arg[0] == '\0') { + report_goal(s); + } else { + goal_spec spec; + char *err = NULL; + if (goal_parse(arg, &spec, &err) != 1) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "parsing goal"); + free(err); + free(head); + return false; + } + out_fmt("goal: %s — up to %lu try(ies)\n", spec.criteria, + spec.max_tries); + run_goal(s, &spec, reader); + goal_spec_free(&spec); + } + free(head); + return false; + } + if (xstreq(head, "model")) { + if (arg[0] == '\0') { + char *m = session_model(s); + out_fmt("current model: %s\n", m); + free(m); + } else { + char *err = NULL; + if (session_set_model(s, arg, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", + err ? err : "switching model"); + free(err); + } else { + banner_refresh_start(s); + out_fmt("model set to %s\n", arg); + /* Reprint the banner: the refresh above has landed, so the + * `model:` and `context window:` lines both reflect the new + * selection. */ + banner_print(s, dot); + } + } + free(head); + return false; + } + if (xstreq(head, "tools")) { + list_tools(); + free(head); + return false; + } + if (xstreq(head, "agent")) { + if (arg[0] == '\0') { + list_agents(dot, s); + } else if (xstreq(arg, "default")) { + char *err = NULL; + session_set_profile(s, NULL, &err); + if (err) { + out_fmt(RED "error: %s" RESET "\n", err); + free(err); + } else { + out_str("sub-agent reset to default (main agent)\n"); + banner_print(s, dot); + } + } else { + char *err = NULL; + if (session_set_profile(s, arg, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", + err ? err : "switching agent"); + free(err); + } else { + out_fmt("sub-agent set to %s\n", arg); + /* Reprint the banner so the `sub-agent:` line reflects the + * new selection immediately. */ + banner_print(s, dot); + } + } + free(head); + return false; + } + if (xstreq(head, "agents")) { + if (dot->found_count == 0) { + out_str("no .agents/ found (looked for .agents and ~/.agents)\n"); + } else { + for (size_t i = 0; i < dot->found_count; i++) + out_fmt(" %s: %s\n", layer_display(dot->found_layers[i]), + dot->found_paths[i]); + out_fmt(" system-prompt.md: %s\n" + " agents.md: %s\n" + " models.json: %zu preset(s)%s\n" + " mcp.json: %zu server(s)\n" + " skills: %zu\n" + " sub-agents: %zu\n" + " tasks: %zu\n" + " memories: %zu\n", + dot->system_prompt ? "yes" : "no", + dot->agents_md ? "yes" : "no", + dot->has_models ? dot->models.preset_count : 0, + (dot->has_models && dot->models.default_name) + ? xstrfmt(" (default: %s)", + dot->models.default_name) + : "", + dot->mcp_count, dot->skill_count, dot->sub_agent_count, + dot->task_count, dot->memory_count); + } + for (size_t i = 0; i < dot->warnings.len; i++) + out_fmt(" warning: %s\n", dot->warnings.items[i]); + free(head); + return false; + } + if (xstreq(head, "tasks")) { + list_tasks(dot); + free(head); + return false; + } + if (xstreq(head, "memories")) { + list_memories(dot); + free(head); + return false; + } + if (xstreq(head, "help")) { + out_str(" /model [name] show/switch model, or a `models` config\n" + " profile name (e.g. `/model local`)\n" + " /agent [name] list sub-agents, switch, or 'default'\n" + " /agents show the loaded .agents configuration\n" + " /tasks list repeat tasks\n" + " /memories list memories\n" + " /compact summarize the earlier context now\n" + " /undo roll back the last user turn\n" + " /clear clear the screen and the conversation\n" + " /save [path] save the session (default: " + "/tmp/llsh-session.json; resume with --resume <path>)\n" + " /export [path] write the transcript to Markdown\n" + " /goal [criteria] run a goal-based loop\n" + " /tools list available tools\n" + " /exit quit\n" + "\nkeys:\n" + " Enter submit\n" + " Esc / Ctrl+C stop the running generation\n" + " Ctrl+D quit when the line is empty\n" + " Up / Down walk the multi-line buffer; at the top or\n" + " bottom, recall the previous / next input\n" + " Left/Right, Home, End, Backspace, Delete edit the line\n" + " Ctrl+Y yank the most recently deleted text\n" + " Ctrl+_ undo the last edit\n" + " \\+Enter newline (works in every terminal)\n"); + free(head); + return false; + } + out_fmt(RED "unknown command `/%.*s` (try /help)" RESET "\n", + (int)head_len, head); + free(head); + return false; +} + +/* ------------------------------------------------------------------ */ +/* REPL */ +/* ------------------------------------------------------------------ */ + +/* True when a line submitted in the interactive shell is the shell's own + * `exit` builtin (with or without an exit-status argument). Mirrors + * shell_line_is_exit in main.rs. */ +static bool shell_line_is_exit(const char *line) { + while (*line == ' ' || *line == '\t') + line++; + const char *p = line; + while (*p && *p != ' ' && *p != '\t') + p++; + size_t n = (size_t)(p - line); + return n == 4 && xstreq(line, "exit"); +} + +/* Commit a shell-mode visit to the conversation context so the model sees the + * commands the user ran and their output. The captured PTY text already + * contains each echoed command line (with the prompt) followed by its output, + * sanitized of terminal escapes, so it is recorded as-is (trimmed and capped + * at `max_chars`). One user message + a short assistant acknowledgement. + * Must be called before `shell_free`; empty visits record nothing. */ +static void shell_transcript_record(session *s, interactive_shell *sh, + unsigned long max_chars) { + if (!s || !sh) + return; + char *out = shell_captured(sh); + size_t len = strlen(out); + /* Trim trailing whitespace so the message does not end in blank lines. */ + while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r' || + out[len - 1] == ' ' || out[len - 1] == '\t')) + len--; + if (len == 0) { + free(out); + return; /* empty visit: nothing to record */ + } + bool truncated = (max_chars > 0 && len > max_chars); + if (truncated) + len = max_chars; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, out, len); + xbuf_push(&b, '\n'); + if (truncated) + xbuf_put(&b, "… (shell output truncated)\n"); + char *body = xbuf_steal(&b); + xbuf_free(&b); + free(out); + session_push_history(s, msg_user_text(body)); + session_push_history(s, msg_assistant_text( + "Shell visit noted; the commands and their output are in context. " + "Continue in chat or press Backtab to return to the shell.")); + session_set_last_context_tokens(s, 0); /* re-reported next turn */ + free(body); +} + +/* Make the process — and with it the file tools, `run_shell`, and the + * workspace `.agents` layer — follow the directory the shell ended in. + * Mirrors adopt_shell_cwd in main.rs. */ +static void adopt_shell_cwd(session *s, interactive_shell *sh) { + char *dir = shell_final_cwd(sh); + if (!dir) + return; + char *cur = NULL; + if (getcwd(cur, 0) == NULL) { + cur = xstrdup("."); + } + bool same = false; + if (cur) + same = xstreq(dir, cur); + free(cur); + if (same) { + free(dir); + return; + } + if (chdir(dir) != 0) { + out_fmt(DIM "warning: the shell ended in `%s`, but llsh could not " + "change to it" RESET "\n", + dir); + free(dir); + return; + } + free(dir); + char *err = NULL; + if (session_refresh_dot(s, NULL, NULL, &err) != 0) { + fprintf(stderr, "warning: could not refresh the agent layer after " + "changing directory: %s\n", + err ? err : "unknown error"); + free(err); + } +} + +/* Terminal resized: the shell-mode branch propagates the new size to the + * PTY; in chat mode llsh owns no screen state, so the terminal reflows the + * scrollback itself (see the g_winch branch in repl). */ +static volatile sig_atomic_t g_winch = 0; +static void on_winch(int sig) { + (void)sig; + g_winch = 1; +} + +/* Echo a codepoint as UTF-8, the way the terminal would show it. */ +static void echo_cp(unsigned int cp) { + char tmp[8]; + size_t i = 0; + if (cp < 0x80) + tmp[i++] = (char)cp; + else if (cp < 0x800) { + tmp[i++] = (char)(0xC0 | (cp >> 6)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + tmp[i++] = (char)(0xE0 | (cp >> 12)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else { + tmp[i++] = (char)(0xF0 | (cp >> 18)); + tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } + tmp[i] = '\0'; + out_str(tmp); +} + +/* Current terminal width in columns (from the controlling tty). The editor + * must lay the buffer out against this width, or a line that wraps will leave + * stale rows behind and the caret will land on the wrong row. */ +static size_t term_cols(void) { + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return (size_t)ws.ws_col; + return 80; +} + +/* Map the editor caret (a byte offset) to a physical (row, col) on a terminal + * of width `w`. Shared with the tests via input.cpp. */ +static void caret_phys(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col) { + input_wrap_caret(text, caret, w, out_row, out_col); +} + +/* Total physical rows of the buffer and the caret's physical (row, col). */ +static void editor_geom(const editor *ed, size_t w, size_t *total, + size_t *cr, size_t *cc) { + const char *t = ed->text ? ed->text : ""; + *total = input_wrap_total(t, w); + caret_phys(t, ed->caret, w, cr, cc); +} + +/* Display width of the character ending just before `caret` — the one a + * backspace at the caret would delete. Zero-width characters count as 1. */ +static size_t prev_char_width(const char *s, size_t caret) { + if (!s || caret == 0) + return 1; + size_t start = caret; + while (start > 0 && (((unsigned char)s[start - 1]) & 0xC0) == 0x80) + start--; + size_t w = input_display_len_n(s + start, caret - start); + return w ? w : 1; +} + +/* Move the visible cursor from where the previous draw or edit parked it + * (from_row, from_col) to the editor caret, without touching the printed + * text. Vertical first, then horizontal: the horizontal step lands the + * column regardless of how the terminal resolves the vertical one (e.g. out + * of a deferred-wrap cell). */ +static void caret_move_to(const editor *ed, size_t w, size_t from_row, + size_t from_col) { + size_t nr, nc; + caret_phys(ed->text ? ed->text : "", ed->caret, w, &nr, &nc); + if (nr < from_row) + esc_cursor_up((u32)(from_row - nr)); + else if (nr > from_row) + esc_cursor_down((u32)(nr - from_row)); + if (nc < from_col) + esc_cursor_left((u32)(from_col - nc)); + else if (nc > from_col) + esc_cursor_right((u32)(nc - from_col)); + /* esc_cursor_* write via fprintf without flushing; a line-buffered tty + * would hold the movement until the next out_str. Flush now so an arrow + * key responds immediately instead of on the next keypress. */ + fflush(stdout); +} + +/* Write the input block and park the visible caret at the editor caret. + * After printing the whole buffer the terminal cursor sits where the + * delayed-wrap model puts the final byte — the "end" position. Walk from there + * to the caret: up/down to its row (CUU/CUD preserve the column, which stays + * the end column), then left/right to the caret's column. */ +static void input_block_draw(const editor *ed) { + out_str(CHAT_PROMPT); + const char *text = ed->text; + size_t len = text ? strlen(text) : 0; + if (len > 0) + out_str(text); + size_t w = term_cols(); + const char *t = text ? text : ""; + size_t er, ec; + caret_phys(t, len, w, &er, &ec); + size_t cr, cc; + caret_phys(t, ed->caret, w, &cr, &cc); + if (ec == w - 1) { + /* The last byte landed in the row's final cell. Some terminals leave + * the cursor in a pending-wrap state (visually on the next row); a CR + * collapses it back to this row, and the right walk restores the + * column so the park below starts from a concrete cell. */ + out_str("\r"); + esc_cursor_right((u32)ec); + } + if (cr < er) { + for (size_t i = 0; i < er - cr; i++) + out_str(ESC_CURSOR_UP(1)); + } else if (cr > er) { + for (size_t i = 0; i < cr - er; i++) + out_str(ESC_CURSOR_DOWN(1)); + } + if (cc < ec) { + for (size_t i = 0; i < ec - cc; i++) + out_str(ESC_CURSOR_LEFT(1)); + } else if (cc > ec) { + for (size_t i = 0; i < cc - ec; i++) + out_str(ESC_CURSOR_RIGHT(1)); + } + fflush(stdout); +} + +/* Repaint the whole input block, parking the caret. The visible cursor sits + * on `old_caret_row` of the previously drawn block (`old_total` physical + * rows). The erase walk stays inside the old block's footprint, so it can + * never scroll the screen (cursor-down clamps at the last row); the repaint + * then scrolls naturally if the new block grows past the bottom row. */ +static void line_redraw(const editor *ed, size_t old_caret_row, + size_t old_caret_col, size_t old_total) { + if (old_total == 0) old_total = 1; + if (old_caret_row >= old_total) old_caret_row = old_total - 1; + (void)old_caret_col; + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + for (size_t i = 0; i < old_caret_row; i++) + out_str(ESC_CURSOR_PREVIOUS(1) ESC_CLEAR_LINE); + for (size_t i = 0; i + 1 < old_total; i++) + out_str(ESC_CURSOR_DOWN(1) ESC_CLEAR_LINE); + for (size_t i = 0; i + 1 < old_total; i++) + out_str(ESC_CURSOR_PREVIOUS(1)); + input_block_draw(ed); +} + +/* The session's static header, printed verbatim above the prompt. Drawn + * once at startup (after the provider-reported context window has been + * adopted), and reprinted inline on a model/agent switch. */ +static void banner_print(session *s, const dotagents *dot) { + char *model = session_model(s); + out_fmt("llsh — model: %s\n", model); + free(model); + unsigned long limit = session_context_limit(s); + if (limit > 0) { + char *ls = itoa_dup((long long)limit); + out_fmt("context window: %s\n", ls); + free(ls); + } else { + out_str("context window: unknown — automatic compaction is off; set " + "`context_limit` in `.agents/models.json` or pass " + "--context-limit to enable it\n"); + } + char *preset = session_active_preset(s); + if (preset) { + out_fmt("model preset: %s (from .agents models.json)\n", preset); + free(preset); + } + char *profile = session_profile_name(s); + if (profile) { + out_fmt("sub-agent: %s\n", profile); + free(profile); + } + for (size_t i = 0; i < dot->found_count; i++) + out_fmt("loaded %s: %s\n", layer_display(dot->found_layers[i]), + dot->found_paths[i]); + for (size_t i = 0; i < dot->warnings.len; i++) + fprintf(stderr, "warning: %s\n", dot->warnings.items[i]); + out_str("type /help for commands, /exit to quit\n"); +} + +/* Background context-window refresh (started on a /model switch). The + * fetch runs in a worker thread so the prompt stays responsive; the banner + * is reprinted at the call site once the worker has landed. */ +static pthread_mutex_t banner_mu = PTHREAD_MUTEX_INITIALIZER; +static pthread_t banner_worker; + +static void *banner_worker_run(void *arg) { + session *s = (session *)arg; + session_refresh_context_limit(s); + return NULL; +} + +/* Start the refresh worker (joining any previous one first). Returns 0 on + * success; on failure the limit is adopted synchronously instead. */ +static int banner_refresh_start(session *s) { + pthread_mutex_lock(&banner_mu); + pthread_t old = banner_worker; + if (old != 0) { + pthread_mutex_unlock(&banner_mu); + pthread_join(old, NULL); + pthread_mutex_lock(&banner_mu); + } + int rc = pthread_create(&banner_worker, NULL, banner_worker_run, s); + if (rc != 0) { + banner_worker = 0; + pthread_mutex_unlock(&banner_mu); + session_refresh_context_limit(s); + return -1; + } + pthread_mutex_unlock(&banner_mu); + return 0; +} + +/* Join the background refresh worker, if one is running (idempotent). + * Must be called before the session is freed, since the worker reads it. */ +static void banner_worker_join(void) { + pthread_mutex_lock(&banner_mu); + pthread_t w = banner_worker; + banner_worker = 0; + pthread_mutex_unlock(&banner_mu); + if (w != 0) + pthread_join(w, NULL); +} + +/* Clear the pending flag (used after an immediate reprint). */ +static int repl(session *s, const dotagents *dot) { + banner_print(s, dot); + + /* The reader takes ownership of the ctx (and its parser) and releases + * them in input_key_reader_free, so heap-allocate here. */ + key_parser *parser = (key_parser *) xmalloc(sizeof *parser); + key_parser_init(parser); + reader_ctx *ctx = (reader_ctx *) xmalloc(sizeof *ctx); + memset(ctx, 0, sizeof *ctx); + ctx->parser = parser; + input_key_reader *reader = input_key_reader_new(ctx); + + if (input_setup_terminal() != 0) { + input_key_reader_free(reader); + fprintf(stderr, "could not enter raw mode\n"); + return -1; + } + if (input_key_reader_start(reader) != 0) { + input_teardown_terminal(); + input_key_reader_free(reader); + fprintf(stderr, "could not start the input reader\n"); + return -1; + } + set_confirm_hook(interactive_confirm, reader); + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_winch; + (void)sigaction(SIGWINCH, &sa, NULL); + + editor ed; + editor_init(&ed); + char *hist_path = input_default_history_path(); + input_history *hist = input_history_open(hist_path); + if (!hist) + hist = input_history_new(); + free(hist_path); + + bool running = true; + /* True when we're exiting with the cursor already parked at the start of a + * fresh line (a newline was the last thing emitted). The teardown must not + * add another, or the shell prompt lands a line too low. */ + bool clean_exit = false; + /* Interactive shell state (Backtab to enter/leave). NULL = chat mode. */ + interactive_shell *shell = NULL; + editor shell_shadow; + editor_init(&shell_shadow); + bool shell_exit_requested = false; + + /* Leave shell mode: adopt where the shell ended, tear it down, and return + * to the chat prompt (or quit if the shell's `exit` was submitted). */ + #define LEAVE_SHELL(record_visit) \ + do { \ + /* Record before freeing: the captured output lives in the \ + * shell, and its reader must be joined first. */ \ + if (record_visit) \ + shell_transcript_record(s, shell, \ + session_config(s)->max_output_chars); \ + adopt_shell_cwd(s, shell); \ + shell_free(shell); \ + shell = NULL; \ + editor_clear(&shell_shadow); \ + bool quit = shell_exit_requested; \ + shell_exit_requested = false; \ + /* The shell is gone: llsh owns the keys again. */ \ + out_str("\x1b[>1u"); \ + /* Erase the shell's prompt (drawn on our line): the next thing \ + * we print (or nothing, on quit) fully replaces it. */ \ + out_str("\r" ESC_CLEAR_LINE); \ + if (quit) { \ + running = false; \ + clean_exit = true; /* line is blank, cursor at col 0 */ \ + } \ + } while (0) + + while (running) { + if (shell) { + if (g_winch) { + g_winch = 0; + shell_update_winsize(shell); + } + input_key key; + const unsigned char *raw; + size_t raw_len; + if (input_key_reader_recv(reader, &key, &raw, &raw_len)) { + if (key.kind == KEY_BACKTAB) { + input_raw_free(raw); + LEAVE_SHELL(true); + continue; + } + shell_write(shell, raw, raw_len); + input_raw_free(raw); + char *submitted = shell_shadow_apply(&shell_shadow, key); + if (submitted) { + input_history_push(hist, submitted); + if (shell_line_is_exit(submitted)) + shell_exit_requested = true; + free(submitted); + } + continue; + } + if (shell_reap_if_dead(shell)) + LEAVE_SHELL(true); + sleep_ms(50); + continue; + } + + if (g_winch) { + g_winch = 0; + /* Chat mode: llsh owns no screen state — the terminal reflows the + * scrollback itself on resize, so there is nothing to do. */ + } + + /* Draw the current buffer and park the caret: a fresh prompt when + * empty, or a leftover buffer when returning from shell mode. The + * line is erased first — on a mode switch it still holds the + * shell's prompt, and at startup the parent shell's — so our prompt + * replaces it instead of printing behind it. */ + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + if (editor_is_empty(&ed)) + out_str(CHAT_PROMPT); + else + input_block_draw(&ed); + + /* Read keys until the buffer commits (submit, quit, or mode switch). + * Appends echo at the end of the buffer, backspaces erase in place, + * caret moves only move the cursor; mid-buffer edits repaint the + * whole block. */ + for (;;) { + input_key key; + bool got_key = input_key_reader_recv(reader, &key, NULL, NULL); + /* A resize reflows the screen and may park the cursor off the text; + * re-anchor the block to the caret before acting, or the next key + * lands on a stale line. */ + if (g_winch) { + g_winch = 0; + if (!editor_is_empty(&ed)) { + size_t w = term_cols(); + size_t gt, gcr, gcc; + editor_geom(&ed, w, >, &gcr, &gcc); + line_redraw(&ed, gcr, gcc, gt); + } + } + if (!got_key) { + sleep_ms(50); + continue; + } + /* The visible cursor sits where the last draw parked the caret. + * The old block's physical footprint (row count + caret row/col) + * is what the erase walk covers, so it must be captured before + * the key mutates the buffer. */ + size_t term_w = term_cols(); + size_t old_total, old_caret_row, old_caret_col; + editor_geom(&ed, term_w, &old_total, &old_caret_row, &old_caret_col); + + /* Up/Down: a single policy decides whether this press moves the + * caret or walks history. While composing, Up engages from the + * first line and Down only after a recall; once a recall is + * active the walk continues until any non-arrow key ends it. */ + if (key.kind == KEY_UP || key.kind == KEY_DOWN) { + char *recalled = input_history_walk( + hist, key.kind == KEY_UP ? -1 : +1, + editor_caret_line(&ed), ed.text); + if (recalled) { + editor_set(&ed, recalled); + free(recalled); + /* The recalled entry may be multi-line: the block + * grows/shrinks, so repaint the whole thing (the old + * block's footprint is what line_redraw erases). */ + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + continue; + } + } else { + input_history_reset(hist); + } + + size_t before = ed.text ? strlen(ed.text) : 0; + bool at_end = (ed.caret == before); + bool old_pending = false; + if (key.kind == KEY_BACKSPACE && at_end) + old_pending = + input_wrap_pending(ed.text ? ed.text : "", ed.caret, term_w); + input_action a; + editor_apply(&ed, key, &a); + bool changed = (ed.text ? strlen(ed.text) : 0) != before; + size_t new_caret_row = 0; + if (changed) { + size_t ncr, ncc; + caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &ncr, &ncc); + new_caret_row = ncr; + } + bool moved = (key.kind == KEY_LEFT || key.kind == KEY_RIGHT || + key.kind == KEY_HOME || key.kind == KEY_END || + key.kind == KEY_UP || key.kind == KEY_DOWN); + bool newline = (a.kind == ACT_NEWLINE); + + /* Render the key's effect shell-style: the text already on the + * terminal stays put; only what the key changed is emitted. + * - backspace at the end: "\b \b" erases the character in place + * (a wide one needs one more "\b " for its second cell); the + * blank left behind is past the caret, so no gap is visible. + * Use it only when the caret stays on the same physical row: + * a pending-wrap caret sits on the cell being erased, and an + * unwrap (deleting the last char of a row) would leave the + * visible cursor on the now-empty row below. + * - backspace deleting a newline at the end: the line below can + * only hold echo residue (the newline was the last byte), so + * the join is clear-that-line, up, across to the end of the + * line above. + * - append at the end: print the character — the terminal does + * the wrapping. + * - backslash+Enter at the end: the backslash became a newline — + * erase it and drop to the next line. + * - caret move: nothing was printed or erased, so just move the + * visible cursor to the caret. + * - everything else (mid-buffer insert, kills, yank, undo) + * repaints the whole block. */ + if (changed && key.kind == KEY_BACKSPACE && at_end && + old_caret_col != 0 && !old_pending && + new_caret_row == old_caret_row) { + out_str(prev_char_width(ed.text, ed.caret) >= 2 ? "\b \b " + : "\b \b"); + } else if (changed && key.kind == KEY_BACKSPACE && at_end && + old_caret_col == 0 && !old_pending) { + out_str(ESC_CLEAR_LINE); + esc_cursor_up(1); + size_t cr, cc; + caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &cr, + &cc); + if (cc > 0) + esc_cursor_right((u32)cc); + } else if (changed && at_end) { + if (key.kind == KEY_CHAR) + echo_cp(key.cp); + else if (key.kind == KEY_TAB) + out_str("\t"); + else if (key.kind == KEY_NEWLINE) + out_str("\n"); + else if (key.kind == KEY_CTRL_Y) + out_str(ed.killed ? ed.killed : ""); + else + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + } else if (newline && at_end) { + /* If the backslash wrapped onto its own visual row, erasing it + * leaves that row empty and a plain LF would drop past it, + * leaving a blank line. Collapse back to the end of the row + * above first, then drop down. */ + size_t pr = 0, pc = 0; + bool wrap_split = false; + if (old_caret_row > 0 && old_caret_col == 1 && ed.caret >= 2 && + ed.text[ed.caret - 2] != '\n') { + caret_phys(ed.text, ed.caret - 2, term_w, &pr, &pc); + if (pc == term_w - 1) + wrap_split = true; + } + if (wrap_split) { + out_str("\b "); + out_str(ESC_CURSOR_PREVIOUS(1)); + if (pc > 0) + esc_cursor_right((u32)pc); + out_str("\n"); + } else { + out_str("\b \n"); + } + } else if (moved) { + caret_move_to(&ed, term_w, old_caret_row, old_caret_col); + } else if (changed || newline) { + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + } + + bool leave = false; + switch (a.kind) { + case ACT_NONE: + case ACT_STOP: + case ACT_NEWLINE: /* already repainted above; stay in the loop */ + input_action_free(&a); + break; + case ACT_CLEAR_SCREEN: + input_action_free(&a); + out_str(ESC_CLEAR_SCREEN ESC_CURSOR_POSITION(1, 1)); + input_block_draw(&ed); + break; + case ACT_QUIT: + input_action_free(&a); + running = false; + leave = true; + break; + case ACT_TOGGLE_MODE: { + input_action_free(&a); + /* Erase the whole chat prompt line so the shell's own prompt + * prints on a clean line instead of after the leftover bullet. + * (The Rust reference does the same: erase the frame and let + * the shell draw its own prompt — no bullet reprint here.) */ + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + interactive_shell *sh = shell_spawn(); + if (!sh) { + out_fmt(RED "error: failed to start interactive shell" RESET + "\n"); + leave = true; + break; + } + /* The shell owns the keys from here: give it ordinary xterm + * sequences, not kitty protocol. */ + out_str("\x1b[>0u"); + editor_clear(&shell_shadow); + /* Bound the in-memory capture at the tool output limit so a + * pathological command can't grow it without limit; the + * recorded transcript is trimmed to the same limit. */ + sh->capture_cap = session_config(s)->max_output_chars; + shell_exit_requested = false; + shell = sh; + shell_update_winsize(shell); + leave = true; + break; + } + case ACT_SUBMIT: { + input_action_free(&a); + char *text = editor_text(&ed); + char *t = text; + size_t tn = strlen(t); + while (tn && (t[tn - 1] == ' ' || t[tn - 1] == '\n' || + t[tn - 1] == '\t' || t[tn - 1] == '\r')) + t[--tn] = '\0'; + while (*t == ' ' || *t == '\n' || *t == '\t' || *t == '\r') + t++; + if (tn != 0) { + input_history_push(hist, t); + /* Submit's newline must fire from the very end of the + * input: when the caret sits mid-line, on a wrapped row, + * or on a middle line, the visible cursor is parked there + * and the newline would land mid-block — the remaining + * text (and then the response) shifts up over it. Walk the + * cursor to the block's bottom-right first: down to the + * last physical row (CUD preserves the column), then to + * that row's end column. Enter itself never moves the + * editor caret, so `ed.caret` still describes where the + * visible cursor sits. */ + { + size_t w = term_cols(); + size_t len = strlen(ed.text); + size_t er, ec; + caret_phys(ed.text, len, w, &er, &ec); + size_t cr, cc; + caret_phys(ed.text, ed.caret, w, &cr, &cc); + if (cr < er) { + for (size_t i = 0; i < er - cr; i++) + out_str(ESC_CURSOR_DOWN(1)); + } else if (cr > er) { + for (size_t i = 0; i < cr - er; i++) + out_str(ESC_CURSOR_PREVIOUS(1)); + } + if (cc < ec) { + for (size_t i = 0; i < ec - cc; i++) + out_str(ESC_CURSOR_RIGHT(1)); + } else if (cc > ec) { + for (size_t i = 0; i < cc - ec; i++) + out_str(ESC_CURSOR_LEFT(1)); + } + } + out_str("\n"); + editor_clear(&ed); + if (t[0] == '/') { + if (handle_command(s, dot, t + 1, reader)) { + running = false; + clean_exit = true; /* Enter-`\n` already emitted */ + } else { + /* Every command's output ends in `\n`, and the + * next prompt draw erases the line before + * printing (`/clear` repositions to the + * top-left). */ + } + } else { + /* The pacer terminates the turn's line with "\n"; + * the next prompt draw erases before printing. */ + run_turn_interactive(s, t, reader); + } + leave = true; + } + free(text); + break; + } + } + if (leave) + break; + } + } + + /* Join any in-flight refresh (a /model switch) before the session is + * freed — the worker reads it. */ + banner_worker_join(); + if (shell) { + adopt_shell_cwd(s, shell); + shell_free(shell); + shell = NULL; + } + editor_free(&shell_shadow); + input_key_reader_stop(reader); + input_key_reader_free(reader); /* frees ctx and parser too */ + editor_free(&ed); + input_history_free(hist); + input_teardown_terminal(); + if (!clean_exit) + out_str("\n"); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* One-shot */ +/* ------------------------------------------------------------------ */ + +static int one_shot(session *s, const char *prompt) { + std::atomic<bool> stop(false); + char *err = NULL; + int result = 0; + int rc = run_turn_paced(s, prompt, &stop, &err, &result); + if (rc < 0) { + fprintf(stderr, "%serror: %s%s\n", RED, err ? err : "turn failed", + RESET); + free(err); + return 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +/* Worker for the parallel startup fetch: adopt the provider-reported + * context window (blocking, up to the HTTP timeout). */ +static void *startup_refresh(void *arg) { + session_refresh_context_limit((session *)arg); + return NULL; +} + +int main(int argc, char **argv) { + /* Writing to a pipe whose peer is gone (a dead MCP server, a shell that + * exited) must surface as EPIPE and be handled, not kill llsh with + * SIGPIPE. */ + signal(SIGPIPE, SIG_IGN); + char *err = NULL; + cli cli; + int prc = cli_parse(argc, argv, &cli, &err); + if (prc == CLI_HELP) { + cli_free(&cli); + return 0; + } + if (prc < 0) { + fprintf(stderr, "%s%s%s\n", RED, err ? err : "bad arguments", RESET); + free(err); + cli_free(&cli); + return 2; + } + + session *s = NULL; + llsh_model *main_model = NULL; + llsh_model *summarizer = NULL; + llsh_model *evaluator = NULL; + llsh_model *rubric = NULL; + mcp_registry mcp; + bool have_mcp = false; + int ret = 0; + + dotagents dot; + if (cli.resume && cli.resume[0]) { + session_snapshot *snap = session_load(cli.resume, &err); + if (!snap) { + fprintf(stderr, "%sresuming session from %s: %s%s\n", RED, + cli.resume, err ? err : "failed", RESET); + free(err); + cli_free(&cli); + return 1; + } + char *cwd = xstrdup(snap->cwd); + unsigned long count = (unsigned long) snap->hist.msgs.size(); + char *model = xstrdup(snap->cfg.model ? snap->cfg.model : ""); + if (chdir(cwd) != 0) { + fprintf(stderr, "entering saved working directory `%s`: %s\n", + cwd, strerror(errno)); + free(cwd); + free(model); + session_snapshot_free(snap); + cli_free(&cli); + return 1; + } + free(cwd); + dotagents_load(&dot); + strlist mcp_warnings; + sl_init(&mcp_warnings); + if (dot.mcp_count && cli.allow_mcp) { + mcp_connect_all((const char *const *)dot.mcp_names, + (const char *const *)dot.mcp_configs, + dot.mcp_count, &mcp, &mcp_warnings); + have_mcp = true; + } else if (dot.mcp_count) { + fprintf(stderr, "warning: %zu MCP server(s) not started; pass " + "--allow-mcp to trust .agents/mcp.json\n", + dot.mcp_count); + } + for (size_t i = 0; i < mcp_warnings.len; i++) + fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]); + sl_free(&mcp_warnings); + + main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL, + PROV_ROLE_MAIN); + s = session_resume(snap, dot, main_model, &err); + if (!s) { + fprintf(stderr, "resume failed: %s\n", err ? err : "unknown"); + free(err); + llsh_model_free(main_model); + if (have_mcp) + mcp_registry_free(&mcp); + cli_free(&cli); + return 1; + } + provider_model_attach(main_model, s); + summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER); + evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR); + rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC); + session_with_summarizer(s, summarizer); + session_with_goal_evaluator(s, evaluator); + session_with_rubric(s, rubric); + fprintf(stdout, "resumed session: %s — %lu message(s) in context\n", + model, count); + free(model); + } else { + config cfg; + if (config_from_cli(&cli, &cfg, &err) != 0) { + fprintf(stderr, "%sinvalid configuration: %s%s\n", RED, + err ? err : "unknown", RESET); + free(err); + cli_free(&cli); + return 2; + } + dotagents_load(&dot); + bool preset_applied = false; + if (dot.has_models && dot.models.default_name && + dot.models.default_name[0]) { + char *preset = config_apply_models_preset(&cfg, &dot.models, &cli, + &err); + if (!preset) { + fprintf(stderr, + "%sapplying .agents models.json default preset: %s%s\n", + RED, err ? err : "failed", RESET); + free(err); + config_free(&cfg); + dotagents_free(&dot); + cli_free(&cli); + return 2; + } + free(preset); + preset_applied = true; + } + if (!preset_applied && config_require_key_or_base(&cfg, &err) != 0) { + fprintf(stderr, "%s%s%s\n", RED, err ? err : "missing API key", + RESET); + free(err); + config_free(&cfg); + dotagents_free(&dot); + cli_free(&cli); + return 2; + } + strlist mcp_warnings; + sl_init(&mcp_warnings); + if (dot.mcp_count && cli.allow_mcp) { + mcp_connect_all((const char *const *)dot.mcp_names, + (const char *const *)dot.mcp_configs, + dot.mcp_count, &mcp, &mcp_warnings); + have_mcp = true; + } else if (dot.mcp_count) { + fprintf(stderr, "warning: %zu MCP server(s) not started; pass " + "--allow-mcp to trust .agents/mcp.json\n", + dot.mcp_count); + } + for (size_t i = 0; i < mcp_warnings.len; i++) + fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]); + sl_free(&mcp_warnings); + + main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL, + PROV_ROLE_MAIN); + s = session_new_with_model(main_model, cfg, dot); + provider_model_attach(main_model, s); + summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER); + evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR); + rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC); + session_with_summarizer(s, summarizer); + session_with_goal_evaluator(s, evaluator); + session_with_rubric(s, rubric); + } + + /* Wire process-wide behavior from the resolved config. */ + const config *cfg = session_config(s); + verbose_set(cfg->verbose); + if (cli.debug) + session_set_debug_path(s, xstrdup("debug.json")); + char *mname = session_model(s); + verbose_log("startup", + (vlog_kv[]){ VLOG_STR("model", mname ? mname : "") }, 1); + free(mname); + tool_limits tl; + tool_limits_defaults(&tl); + tl.shell_timeout_secs = cfg->shell_timeout_secs; + tl.max_output_chars = cfg->max_output_chars; + tl.max_file_bytes = cfg->max_file_bytes; + const strlist *allow = cli.shell_allowlist.len + ? &cli.shell_allowlist + : &cfg->shell_allowlist; + const strlist *deny = cli.shell_denylist.len + ? &cli.shell_denylist + : &cfg->shell_denylist; + for (size_t i = 0; i < allow->len; i++) + sl_push_copy(&tl.shell_allowlist, allow->items[i]); + for (size_t i = 0; i < deny->len; i++) + sl_push_copy(&tl.shell_denylist, deny->items[i]); + tl.confirm_destructive = cli.has_confirm_destructive + ? cli.confirm_destructive + : cfg->confirm_destructive; + tl.verbose = cfg->verbose; + tool_limits_set(&tl); + + /* Adopt the provider-reported context window before the banner is + * printed, so the `context window:` line is correct the first time. In + * the REPL this blocks (up to the HTTP timeout); in one-shot mode it + * runs in a thread so it overlaps the connectivity probe below. */ + if (cli.prompt == NULL && isatty(STDIN_FILENO)) { + session_refresh_context_limit(s); + } else { + pthread_t refresh_th; + int refresh_rc = pthread_create(&refresh_th, NULL, startup_refresh, s); + if (refresh_rc != 0) + session_refresh_context_limit(s); + if (refresh_rc == 0) + pthread_join(refresh_th, NULL); + } + + /* Connectivity probe. */ + provider_probe probe = agent_probe_provider(session_config(s)); + if (!probe.ok) { + fprintf(stderr, "warning: provider check failed: %s\n" + " check the provider, model, base_url, and " + "api_key; the first prompt may fail.\n", + probe.detail ? probe.detail : ""); + } + provider_probe_free(&probe); + + /* One-shot vs REPL. */ + char *piped_prompt = NULL; + const char *one_shot_prompt = cli.prompt; + if (!one_shot_prompt && !isatty(STDIN_FILENO)) { + xbuf piped; + xbuf_init(&piped); + char buf[4096]; + size_t n; + while ((n = read(STDIN_FILENO, buf, sizeof buf)) > 0) + xbuf_putn(&piped, buf, n); + char *s2 = xbuf_steal(&piped); + xbuf_free(&piped); + size_t l = strlen(s2); + while (l && isspace((unsigned char)s2[l - 1])) + s2[--l] = '\0'; + if (l) { + piped_prompt = s2; + one_shot_prompt = s2; + } else { + free(s2); + } + } + + if (one_shot_prompt && one_shot_prompt[0]) { + ret = one_shot(s, one_shot_prompt); + } else { + ret = repl(s, session_dot(s)); + } + free(piped_prompt); + + /* Join any background context-window refresh worker (e.g. left over + * from a /model switch) before the session is freed — it reads it. */ + banner_worker_join(); + + /* --debug: one last dump so the file reflects /undo, /clear, and any + * post-turn bookkeeping, not just the state at the last turn boundary. */ + if (s && cli.debug) { + char *derr = NULL; + if (session_debug_dump(s, &derr) == 0) { + fprintf(stderr, "[debug] wrote debug.json (%zu message(s))\n", + session_message_count(s)); + } else { + fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed"); + } + free(derr); + } + + /* Cleanup: session_free owns the models it adopted. */ + if (s) + session_free(s); + else { + llsh_model_free(main_model); + llsh_model_free(summarizer); + llsh_model_free(evaluator); + llsh_model_free(rubric); + } + if (have_mcp) + mcp_registry_free(&mcp); + cli_free(&cli); + return ret; +} diff --git a/src/mcp.cpp b/src/mcp.cpp new file mode 100644 index 0000000..94615a3 --- /dev/null +++ b/src/mcp.cpp @@ -0,0 +1,1010 @@ +#include "mcp.h" + +#include "config.h" /* LLSH_VERSION */ + +#include <cjson/cJSON.h> +#include <curl/curl.h> + +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <poll.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/wait.h> +#include <unistd.h> + +#define MCP_MAX_MESSAGE_BYTES (4u * 1024u * 1024u) + +typedef struct { + xbuf *buf; + size_t cap; +} mcp_write_ctx; + +static size_t mcp_curl_write(char *data, size_t size, size_t nmemb, void *ud) { + mcp_write_ctx *ctx = (mcp_write_ctx *)ud; + if (size && nmemb > SIZE_MAX / size) + return 0; + size_t n = size * nmemb; + if (n > ctx->cap - ctx->buf->len) + return 0; + xbuf_putn(ctx->buf, data, n); + return n; +} + +/* ------------------------------------------------------------------ */ +/* config parsing */ +/* ------------------------------------------------------------------ */ + +static char *jstr(const cJSON *v, const char *key) +{ + const cJSON *x = cJSON_GetObjectItemCaseSensitive(v, key); + if (cJSON_IsString(x) && x->valuestring) + return xstrdup(x->valuestring); + return NULL; +} + +static void jargs(const cJSON *v, mcp_server_config *out) +{ + const cJSON *a = cJSON_GetObjectItemCaseSensitive(v, "args"); + if (!cJSON_IsArray(a)) + return; + size_t n = (size_t)cJSON_GetArraySize(a); + char **arr = (char* * ) xmalloc((n ? n : 1) * sizeof(*arr)); + size_t used = 0; + const cJSON *x = NULL; + cJSON_ArrayForEach(x, a) + if (cJSON_IsString(x) && x->valuestring) + arr[used++] = xstrdup(x->valuestring); + out->args = arr; + out->arg_count = used; +} + +static void jkv(const cJSON *v, const char *key, mcp_kv **out, size_t *n) +{ + const cJSON *o = cJSON_GetObjectItemCaseSensitive(v, key); + if (!cJSON_IsObject(o)) + return; + size_t nitems = (size_t)cJSON_GetArraySize(o); + mcp_kv *arr = (mcp_kv *) xmalloc((nitems ? nitems : 1) * sizeof(*arr)); + size_t used = 0; + const cJSON *child = o->child; + while (child) { + if (child->string && cJSON_IsString(child) && child->valuestring) { + arr[used].key = xstrdup(child->string); + arr[used].val = xstrdup(child->valuestring); + used++; + } + child = child->next; + } + *out = arr; + *n = used; +} + +int mcp_server_config_from_json(const char *json, mcp_server_config *out) +{ + memset(out, 0, sizeof(*out)); + cJSON *v = json ? cJSON_Parse(json) : NULL; + if (!v || !cJSON_IsObject(v)) { + if (v) + cJSON_Delete(v); + return 0; + } + out->command = jstr(v, "command"); + out->url = jstr(v, "url"); + if (!out->command && !out->url) { + free(out->command); + free(out->url); + cJSON_Delete(v); + return 0; + } + jargs(v, out); + jkv(v, "env", &out->env, &out->env_count); + jkv(v, "headers", &out->headers, &out->header_count); + cJSON_Delete(v); + return 1; +} + +void mcp_server_config_free(mcp_server_config *c) +{ + if (!c) + return; + free(c->command); + free(c->url); + for (size_t i = 0; i < c->arg_count; i++) + free(c->args[i]); + free(c->args); + for (size_t i = 0; i < c->env_count; i++) { + free(c->env[i].key); + free(c->env[i].val); + } + free(c->env); + for (size_t i = 0; i < c->header_count; i++) { + free(c->headers[i].key); + free(c->headers[i].val); + } + free(c->headers); + memset(c, 0, sizeof(*c)); +} + +/* ------------------------------------------------------------------ */ +/* client */ +/* ------------------------------------------------------------------ */ + +enum { T_STDIO, T_HTTP, T_MEMORY }; + +struct mcp_client { + int kind; + unsigned long long next_id; + /* stdio */ + pid_t pid; + int in_fd; + int out_fd; + char *rb; /* carry-over bytes between line reads (partial line) */ + size_t rb_len; + /* http */ + char *url; + mcp_kv *headers; + size_t header_count; + /* memory */ + mcp_memory *mem; +}; + +long mcp_timeout_secs(void) +{ + const char *e = getenv("LLSH_MCP_TIMEOUT"); + if (e && e[0]) { + errno = 0; + char *end = NULL; + long v = strtol(e, &end, 10); + if (errno == 0 && end != e && *end == '\0' && v > 0) { + if (v > 2147483L) + return 2147483L; /* poll timeout is milliseconds in an int */ + return v; + } + } + return MCP_TIMEOUT_SECS; +} + +void mcp_memory_init(mcp_memory *m) +{ + sl_init(&m->responses); + sl_init(&m->sent); +} + +mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err) +{ + if (err) + *err = NULL; + if (!cfg->command) { + if (err) + *err = xstrdup("stdio server has no `command`"); + return NULL; + } + int in_pipe[2] = {-1, -1}, out_pipe[2] = {-1, -1}; + if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) { + if (err) + *err = xstrdup("i/o: pipe failed"); + if (in_pipe[0] >= 0) close(in_pipe[0]); + if (in_pipe[1] >= 0) close(in_pipe[1]); + if (out_pipe[0] >= 0) close(out_pipe[0]); + if (out_pipe[1] >= 0) close(out_pipe[1]); + return NULL; + } + /* Heap-allocate argv (a VLA sized by config data can overflow the stack + * for a large `args` array). Allocated pre-fork so the child inherits it. */ + char **argv = (char* * ) xmalloc((cfg->arg_count + 2) * sizeof *argv); + argv[0] = cfg->command; + for (size_t i = 0; i < cfg->arg_count; i++) + argv[i + 1] = cfg->args[i]; + argv[cfg->arg_count + 1] = NULL; + pid_t pid = fork(); + if (pid < 0) { + free(argv); + if (err) + *err = xstrdup("i/o: fork failed"); + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); + return NULL; + } + if (pid == 0) { + dup2(in_pipe[0], STDIN_FILENO); + dup2(out_pipe[1], STDOUT_FILENO); + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); + int dn = open("/dev/null", O_WRONLY); + if (dn >= 0) { + dup2(dn, STDERR_FILENO); + close(dn); + } + for (size_t i = 0; i < cfg->env_count; i++) + setenv(cfg->env[i].key, cfg->env[i].val, 1); + /* Own process group so freeing the client can kill the whole tree. */ + setpgid(0, 0); + execvp(cfg->command, argv); + _exit(127); + } + free(argv); + close(in_pipe[0]); + close(out_pipe[1]); + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_STDIO; + c->pid = pid; + c->in_fd = in_pipe[1]; + c->out_fd = out_pipe[0]; + return c; +} + +mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err) +{ + if (err) + *err = NULL; + if (!cfg->url) { + if (err) + *err = xstrdup("HTTP server has no `url`"); + return NULL; + } + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_HTTP; + c->url = xstrdup(cfg->url); + if (cfg->header_count) { + c->headers = (mcp_kv *) xmalloc(cfg->header_count * sizeof(*c->headers)); + memcpy(c->headers, cfg->headers, + cfg->header_count * sizeof(*c->headers)); + for (size_t i = 0; i < cfg->header_count; i++) { + c->headers[i].key = xstrdup(cfg->headers[i].key); + c->headers[i].val = xstrdup(cfg->headers[i].val); + } + c->header_count = cfg->header_count; + } + return c; +} + +/* Test-only: wrap already-open stdio fds (the client closes the two it is + * given on free; no child process is reaped). */ +mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err) +{ + if (err) + *err = NULL; + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_STDIO; + c->in_fd = in_fd; + c->out_fd = out_fd; + return c; +} + +mcp_client *mcp_connect_memory(mcp_memory *mem, char **err) +{ + if (err) + *err = NULL; + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_MEMORY; + c->mem = mem; + return c; +} + +void mcp_client_free(mcp_client *c) +{ + if (!c) + return; + if (c->kind == T_STDIO) { + if (c->in_fd >= 0) + close(c->in_fd); + if (c->out_fd >= 0) + close(c->out_fd); + free(c->rb); + if (c->pid > 0) { + int st = 0; + pid_t r = waitpid(c->pid, &st, WNOHANG); + if (r == 0) { + /* Kill the whole process group so grandchildren don't + * survive. */ + kill(-c->pid, SIGTERM); + for (int i = 0; i < 50 && + (r = waitpid(c->pid, &st, WNOHANG)) == 0; + i++) + usleep(10000); + if (r == 0) { + kill(-c->pid, SIGKILL); + waitpid(c->pid, &st, 0); + } + } + } + } else if (c->kind == T_HTTP) { + free(c->url); + for (size_t i = 0; i < c->header_count; i++) { + free(c->headers[i].key); + free(c->headers[i].val); + } + free(c->headers); + } + /* T_MEMORY: the mcp_memory belongs to the caller; we only borrowed it. */ + free(c); +} + +/* ------------------------------------------------------------------ */ +/* transports */ +/* ------------------------------------------------------------------ */ + +static int write_all_fd(int fd, const char *data, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t w = write(fd, data + off, len - off); + if (w < 0) { + if (errno == EINTR) + continue; + return -1; + } + off += (size_t)w; + } + return 0; +} + +/* One line (no trailing newline) or NULL on EOF/timeout; *err on failure. + * Reads in chunks and carries any partial line in c->rb across calls, so a + * burst of lines in one read() is handled without per-byte syscalls. */ +static char *read_line_fd(mcp_client *c, const char *method, char **err) +{ + long tmo = mcp_timeout_secs(); + char *out = NULL; + for (;;) { + for (size_t i = 0; i < c->rb_len; i++) { + if (c->rb[i] == '\n') { + out = xstrndup(c->rb, i); + size_t rest = c->rb_len - i - 1; + memmove(c->rb, c->rb + i + 1, rest); + c->rb_len = rest; + goto done; + } + } + struct pollfd p = { .fd = c->out_fd, .events = POLLIN }; + int pr = poll(&p, 1, (int)(tmo * 1000)); + if (pr < 0) { + if (errno == EINTR) + continue; + if (err) + *err = xstrdup("i/o: read failed"); + return NULL; + } + if (pr == 0) { + if (c->rb_len == 0) { + if (err) + *err = xstrfmt("request `%s` timed out after %lds", method, + tmo); + } else { + out = xstrndup(c->rb, c->rb_len); + c->rb_len = 0; + } + goto done; + } + char tmp[64 * 1024]; + ssize_t r = read(c->out_fd, tmp, sizeof tmp); + if (r < 0) { + if (errno == EINTR) + continue; + break; + } + if (r == 0) + break; + if ((size_t)r > MCP_MAX_MESSAGE_BYTES - c->rb_len) { + free(c->rb); + c->rb = NULL; + c->rb_len = 0; + if (err) + *err = xstrdup("MCP stdio response line exceeds 4 MiB limit"); + return NULL; + } + char *nb = (char *) xrealloc(c->rb, c->rb_len + (size_t)r); + memcpy(nb + c->rb_len, tmp, (size_t)r); + c->rb = nb; + c->rb_len += (size_t)r; + } + if (c->rb_len > 0) { + out = xstrndup(c->rb, c->rb_len); + c->rb_len = 0; + } + done: + if (out) { + size_t n = strlen(out); + if (n > 0 && out[n - 1] == '\r') + out[n - 1] = '\0'; + } + if (!out || out[0] == '\0') { + free(out); + /* Keep a more specific error already set (e.g. timeout). */ + if (err && !*err) + *err = xstrdup("server closed the connection before answering"); + return NULL; + } + return out; +} + +/* Test-only: one buffered line read with carry semantics (read_line_fd). */ +char *mcp_read_line(mcp_client *c, const char *method, char **err) +{ + return read_line_fd(c, method, err); +} + +static int rt_stdio(mcp_client *c, const char *line, unsigned long long id, + const char *method, cJSON **resp, char **err) +{ + if (write_all_fd(c->in_fd, line, strlen(line)) != 0 || + write_all_fd(c->in_fd, "\n", 1) != 0) { + if (err) + *err = xstrdup("i/o: write failed"); + return -1; + } + for (;;) { + char *l = read_line_fd(c, method, err); + if (!l) + return -1; + size_t tlen; + const char *t = xtrim_span(l, strlen(l), &tlen); + if (tlen == 0) { + free(l); + continue; + } + cJSON *v = cJSON_Parse(t); + free(l); + if (!v) { + if (err) + *err = xstrdup("json: invalid message from server"); + return -1; + } + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (cJSON_IsNumber(idv) && + (unsigned long long)idv->valuedouble == id && + (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) { + *resp = v; + return 0; + } + cJSON_Delete(v); /* notification or unrelated message: skip */ + } +} + +static int rt_http(mcp_client *c, const char *json, unsigned long long id, + cJSON **resp, char **err) +{ + CURL *e = curl_easy_init(); + if (!e) { + if (err) + *err = xstrdup("http: could not initialize curl"); + return -1; + } + xbuf body; + xbuf_init(&body); + struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json"); + for (size_t i = 0; i < c->header_count; i++) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val); + hl = curl_slist_append(hl, hb.data); + xbuf_free(&hb); + } + curl_easy_setopt(e, CURLOPT_URL, c->url); + curl_easy_setopt(e, CURLOPT_POST, 1L); + curl_easy_setopt(e, CURLOPT_POSTFIELDS, json); + curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs()); + curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(e, CURLOPT_CAINFO, ca); + curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl); + mcp_write_ctx write_ctx = {&body, MCP_MAX_MESSAGE_BYTES}; + curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write); + curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(e); + if (rc != CURLE_OK) { + char *e2 = xstrfmt("http: %s", curl_easy_strerror(rc)); + free(body.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (err) + *err = e2; + return -1; + } + long code = 0; + curl_easy_getinfo(e, CURLINFO_RESPONSE_CODE, &code); + if (code >= 400) { + char *e2 = xstrfmt("HTTP %ld", code); + free(body.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (err) + *err = e2; + return -1; + } + char *ct = NULL; + curl_easy_getinfo(e, CURLINFO_CONTENT_TYPE, &ct); + char *body_text = xbuf_steal(&body); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + + char *local_err = NULL; + char *json_text = mcp_extract_json(ct ? ct : "", body_text, &local_err); + free(body_text); + if (!json_text) { + if (err) + *err = local_err; + else + free(local_err); + return -1; + } + cJSON *v = cJSON_Parse(json_text); + free(json_text); + if (!v) { + if (err) + *err = xstrdup("json: invalid HTTP body"); + return -1; + } + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (!cJSON_IsNumber(idv) || (unsigned long long)idv->valuedouble != id) { + cJSON_Delete(v); + if (err) + *err = xstrdup("HTTP response id does not match the request"); + return -1; + } + *resp = v; + return 0; +} + +static int rt_memory(mcp_client *c, const char *line, unsigned long long id, + cJSON **resp, char **err) +{ + sl_push_copy(&c->mem->sent, line); + for (;;) { + char *queued = sl_pop_front(&c->mem->responses); + if (!queued) { + if (err) + *err = xstrdup("no queued response (test transport exhausted)"); + return -1; + } + cJSON *v = cJSON_Parse(queued); + free(queued); + if (!v) + continue; + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (cJSON_IsNumber(idv) && (unsigned long long)idv->valuedouble == id && + (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) { + *resp = v; + return 0; + } + cJSON_Delete(v); + } +} + +static int roundtrip(mcp_client *c, const char *line, unsigned long long id, + const char *method, cJSON **resp, char **err) +{ + switch (c->kind) { + case T_STDIO: + return rt_stdio(c, line, id, method, resp, err); + case T_HTTP: + return rt_http(c, line, id, resp, err); + default: + return rt_memory(c, line, id, resp, err); + } +} + +static int notify_rt(mcp_client *c, const char *line, char **err) +{ + switch (c->kind) { + case T_STDIO: { + int rc = write_all_fd(c->in_fd, line, strlen(line)); + if (rc == 0) + rc = write_all_fd(c->in_fd, "\n", 1); + if (rc != 0 && err) + *err = xstrdup("i/o: write failed"); + return rc; + } + case T_HTTP: { + CURL *e = curl_easy_init(); + if (!e) { + if (err) + *err = xstrdup("http: could not initialize curl"); + return -1; + } + struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json"); + for (size_t i = 0; i < c->header_count; i++) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val); + hl = curl_slist_append(hl, hb.data); + xbuf_free(&hb); + } + curl_easy_setopt(e, CURLOPT_URL, c->url); + curl_easy_setopt(e, CURLOPT_POST, 1L); + curl_easy_setopt(e, CURLOPT_POSTFIELDS, line); + curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs()); + curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(e, CURLOPT_CAINFO, ca); + curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl); + xbuf sink; + xbuf_init(&sink); + mcp_write_ctx write_ctx = {&sink, MCP_MAX_MESSAGE_BYTES}; + curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write); + curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(e); + free(sink.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (rc != CURLE_OK) { + if (err) + *err = xstrfmt("http: %s", curl_easy_strerror(rc)); + return -1; + } + return 0; + } + default: + sl_push_copy(&c->mem->sent, line); + return 0; + } +} + +/* ------------------------------------------------------------------ */ +/* JSON-RPC layer */ +/* ------------------------------------------------------------------ */ + +char *mcp_request(mcp_client *c, const char *method, const char *params_json, + char **err) +{ + if (err) + *err = NULL; + unsigned long long id = ++c->next_id; + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(req, "id", (double)id); + cJSON_AddStringToObject(req, "method", method); + cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject(); + if (!params) + params = cJSON_CreateObject(); + cJSON_AddItemToObject(req, "params", params); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + cJSON *resp = NULL; + int rc = roundtrip(c, line, id, method, &resp, err); + free(line); + if (rc != 0) + return NULL; + + cJSON *e = cJSON_GetObjectItemCaseSensitive(resp, "error"); + if (e && cJSON_IsObject(e)) { + cJSON *code = cJSON_GetObjectItemCaseSensitive(e, "code"); + char *code_text = code ? cJSON_PrintUnformatted(code) : xstrdup("null"); + cJSON *msg = cJSON_GetObjectItemCaseSensitive(e, "message"); + char *out = xstrfmt("server error %s: %s", code_text, + cJSON_IsString(msg) && msg->valuestring + ? msg->valuestring : "unknown error"); + free(code_text); + cJSON_Delete(resp); + if (err) + *err = out; + else + free(out); + return NULL; + } + cJSON *result = cJSON_GetObjectItemCaseSensitive(resp, "result"); + char *out = result ? cJSON_PrintUnformatted(result) : xstrdup("null"); + cJSON_Delete(resp); + return out; +} + +int mcp_notify(mcp_client *c, const char *method, const char *params_json, + char **err) +{ + if (err) + *err = NULL; + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "jsonrpc", "2.0"); + cJSON_AddStringToObject(req, "method", method); + cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject(); + if (!params) + params = cJSON_CreateObject(); + cJSON_AddItemToObject(req, "params", params); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + int rc = notify_rt(c, line, err); + free(line); + return rc == 0; +} + +int mcp_initialize(mcp_client *c, char **err) +{ + char *params = xstrfmt( + "{\"protocolVersion\":\"%s\",\"capabilities\":{},\"clientInfo\":" + "{\"name\":\"llsh\",\"version\":\"%s\"}}", + MCP_PROTOCOL_VERSION, LLSH_VERSION); + char *res = mcp_request(c, "initialize", params, err); + free(params); + if (!res) + return 0; + free(res); + char *err2 = NULL; + int ok = mcp_notify(c, "notifications/initialized", "{}", &err2); + if (!ok) { + if (err && !*err) + *err = err2; + else + free(err2); + return 0; + } + return 1; +} + +int mcp_list_tools(mcp_client *c, mcp_tool **out, size_t *n, char **err) +{ + char *res = mcp_request(c, "tools/list", "{}", err); + if (!res) + return 0; + cJSON *root = cJSON_Parse(res); + free(res); + if (!root) { + if (err) + *err = xstrdup("json: invalid tools/list result"); + return 0; + } + cJSON *tools = cJSON_GetObjectItemCaseSensitive(root, "tools"); + size_t count = cJSON_IsArray(tools) ? (size_t)cJSON_GetArraySize(tools) : 0; + mcp_tool *arr = (mcp_tool *) xmalloc((count ? count : 1) * sizeof(*arr)); + size_t used = 0; + if (cJSON_IsArray(tools)) { + const cJSON *t; + cJSON_ArrayForEach(t, tools) { + cJSON *name = cJSON_GetObjectItemCaseSensitive(t, "name"); + if (!cJSON_IsString(name) || !name->valuestring) + continue; + cJSON *desc = cJSON_GetObjectItemCaseSensitive(t, "description"); + cJSON *schema = cJSON_GetObjectItemCaseSensitive(t, "inputSchema"); + mcp_tool *slot = &arr[used++]; + memset(slot, 0, sizeof *slot); + slot->name = xstrdup(name->valuestring); + slot->description = cJSON_IsString(desc) && desc->valuestring + ? xstrdup(desc->valuestring) : xstrdup(""); + slot->input_schema = + schema ? cJSON_PrintUnformatted(schema) + : xstrdup("{\"type\":\"object\"}"); + if (!slot->input_schema) + slot->input_schema = xstrdup("{\"type\":\"object\"}"); + slot->client = c; + } + } + cJSON_Delete(root); + *out = arr; + *n = used; + return 1; +} + +void mcp_tools_free(mcp_tool *tools, size_t n) +{ + if (!tools) + return; + for (size_t i = 0; i < n; i++) { + free(tools[i].name); + free(tools[i].qualified_name); + free(tools[i].description); + free(tools[i].input_schema); + } + free(tools); +} + +char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json, + char **err) +{ + cJSON *name_j = cJSON_CreateString(name ? name : ""); + char *name_s = cJSON_PrintUnformatted(name_j); + cJSON_Delete(name_j); + char *params = xstrfmt("{\"name\":%s,\"arguments\":%s}", + name_s ? name_s : "\"\"", + args_json ? args_json : "{}"); + free(name_s); + char *res = mcp_request(c, "tools/call", params, err); + free(params); + if (!res) + return NULL; + cJSON *root = cJSON_Parse(res); + free(res); + if (!root) { + if (err) + *err = xstrdup("json: invalid tools/call result"); + return NULL; + } + cJSON *iserr = cJSON_GetObjectItemCaseSensitive(root, "isError"); + bool err_flag = cJSON_IsTrue(iserr); + cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content"); + xbuf text; + xbuf_init(&text); + if (cJSON_IsArray(content)) { + const cJSON *item; + cJSON_ArrayForEach(item, content) { + cJSON *t = cJSON_GetObjectItemCaseSensitive(item, "text"); + if (cJSON_IsString(t) && t->valuestring) { + if (text.len) + xbuf_push(&text, '\n'); + xbuf_put(&text, t->valuestring); + } else { + cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type"); + cJSON *data = cJSON_GetObjectItemCaseSensitive(item, "data"); + if ((cJSON_IsString(type) && type->valuestring && + strcmp(type->valuestring, "image") == 0) || data != NULL) { + if (text.len) + xbuf_push(&text, '\n'); + xbuf_put(&text, "[binary content]"); + } + } + } + } + if (text.len == 0) + xbuf_put(&text, "(no content)"); + char *out = xbuf_steal(&text); + cJSON_Delete(root); + if (err_flag) { + char *e = xstrfmt("tool `%s` reported an error: %s", name, out); + free(out); + if (err) + *err = e; + return NULL; + } + return out; +} + +char *mcp_extract_json(const char *content_type, const char *body, char **err) +{ + if (err) + *err = NULL; + if (content_type && strstr(content_type, "event-stream")) { + const char *p = body ? body : ""; + const char *best = NULL; + size_t best_len = 0; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 5 && strncmp(line, "data:", 5) == 0) { + size_t dlen; + const char *d = xtrim_span(line + 5, tlen - 5, &dlen); + if (dlen > 0) { + best = d; + best_len = dlen; + } + } + if (!nl) + break; + p = nl + 1; + } + if (!best) { + if (err) + *err = xstrdup("SSE body had no `data:` line"); + return NULL; + } + char *txt = xstrndup(best, best_len); + cJSON *v = cJSON_Parse(txt); + free(txt); + if (!v) { + if (err) + *err = xstrdup("json: invalid SSE data line"); + return NULL; + } + char *out = cJSON_PrintUnformatted(v); + cJSON_Delete(v); + return out; + } + cJSON *v = cJSON_Parse(body ? body : ""); + if (!v) { + if (err) + *err = xstrdup("json: invalid HTTP body"); + return NULL; + } + char *out = cJSON_PrintUnformatted(v); + cJSON_Delete(v); + return out; +} + +/* ------------------------------------------------------------------ */ +/* registry */ +/* ------------------------------------------------------------------ */ + +void mcp_connect_all(const char *const *names, const char *const *configs, + size_t n, mcp_registry *out, strlist *warnings) +{ + memset(out, 0, sizeof(*out)); + mcp_tool *tools = NULL; + size_t tool_count = 0; + mcp_client **clients = NULL; + size_t client_count = 0; + + for (size_t i = 0; i < n; i++) { + const char *name = names[i]; + const char *cfg_json = configs[i]; + mcp_server_config cfg; + if (!mcp_server_config_from_json(cfg_json, &cfg)) { + sl_push(warnings, + xstrfmt("mcp `%s`: no `command` or `url`; skipped", name)); + continue; + } + + mcp_client *c = NULL; + char *err = NULL; + if (cfg.command) + c = mcp_connect_stdio(&cfg, &err); + else + c = mcp_connect_http(&cfg, &err); + if (!c) { + sl_push(warnings, xstrfmt("mcp `%s`: failed to start", name)); + free(err); + mcp_server_config_free(&cfg); + continue; + } + if (!mcp_initialize(c, &err)) { + sl_push(warnings, + xstrfmt("mcp `%s`: handshake failed: %s", name, + err ? err : "unknown")); + free(err); + mcp_client_free(c); + mcp_server_config_free(&cfg); + continue; + } + mcp_tool *t = NULL; + size_t tn = 0; + char *err2 = NULL; + if (!mcp_list_tools(c, &t, &tn, &err2)) { + sl_push(warnings, xstrfmt("mcp `%s`: tools/list failed", name)); + free(err2); + mcp_client_free(c); + mcp_server_config_free(&cfg); + continue; + } + for (size_t j = 0; j < tn; j++) { + t[j].qualified_name = xstrfmt("mcp__%zu_%s__%s", strlen(name), + name, t[j].name); + } + tools = (mcp_tool *) xrealloc(tools, (tool_count + tn) * sizeof(*tools)); + memcpy(tools + tool_count, t, tn * sizeof(*t)); + tool_count += tn; + free(t); + clients = + (mcp_client **)xrealloc(clients, (client_count + 1) * sizeof(*clients)); + clients[client_count++] = c; + mcp_server_config_free(&cfg); + } + + out->tools = tools; + out->tool_count = tool_count; + out->clients = clients; + out->client_count = client_count; +} + +void mcp_registry_free(mcp_registry *r) +{ + if (!r) + return; + mcp_tools_free(r->tools, r->tool_count); + for (size_t i = 0; i < r->client_count; i++) + mcp_client_free(r->clients[i]); + free(r->clients); + memset(r, 0, sizeof(*r)); +} + +char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err) +{ + char *out = mcp_call_tool(t->client, t->name, args_json, err); + if (out) + return out; + char *e = err ? *err : NULL; + if (err) + *err = xstrfmt("MCP tool `%s` failed: %s", t->name, e ? e : "unknown"); + free(e); + return NULL; +} diff --git a/src/mcp.h b/src/mcp.h new file mode 100644 index 0000000..24a35a9 --- /dev/null +++ b/src/mcp.h @@ -0,0 +1,112 @@ +#ifndef LLSH_MCP_H +#define LLSH_MCP_H + +#include "util.h" + +/* Mirrors mcp.rs: a minimal Model Context Protocol client. + * mcpServers entries are either stdio (command/args/env) or HTTP (url/headers). + * Transport framing is JSON-RPC 2.0: one message per line over stdio, a single + * request/response (plain JSON or SSE) over HTTP. A wedged server must not hang + * the agent: every call is bounded by MCP_TIMEOUT_SECS. */ + +#define MCP_TIMEOUT_SECS 120L +#define MCP_PROTOCOL_VERSION "2024-11-05" + +/* Effective per-call timeout. MCP_TIMEOUT_SECS unless the LLSH_MCP_TIMEOUT + * environment variable sets a positive whole number of seconds. */ +long mcp_timeout_secs(void); + +typedef struct { + char *key; + char *val; +} mcp_kv; + +/* One mcpServers entry. All fields owned (NULL/0 when absent). */ +typedef struct { + char *command; + char **args; + size_t arg_count; + mcp_kv *env; + size_t env_count; + char *url; + mcp_kv *headers; + size_t header_count; +} mcp_server_config; + +typedef struct mcp_client mcp_client; + +/* A tool advertised by a server's tools/list. client is a back-reference. */ +typedef struct { + char *name; /* remote name */ + char *qualified_name; /* exposed as mcp__<server-length>_<server>__<tool> */ + char *description; + char *input_schema; /* JSON text */ + mcp_client *client; +} mcp_tool; + +/* Everything connected: the flattened tool list plus the owning clients. */ +typedef struct { + mcp_tool *tools; + size_t tool_count; + mcp_client **clients; + size_t client_count; +} mcp_registry; + +/* Test-only loopback transport: queued responses + recorded outgoing lines. + * Call mcp_memory_init before use; the caller keeps ownership, the client + * borrows it for the lifetime of the connection. */ +typedef struct { + strlist responses; + strlist sent; +} mcp_memory; + +void mcp_memory_init(mcp_memory *m); + +/* Parse a raw mcpServers value (JSON text). Returns 1 and fills *out, or 0 + * (no error) when it declares neither a command nor a url. */ +int mcp_server_config_from_json(const char *json, mcp_server_config *out); +void mcp_server_config_free(mcp_server_config *c); + +/* Spawn/attach a server. Returns NULL with *err on failure. */ +mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err); +mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err); +/* Test-only: wrap already-open stdio fds (closed on free, no child reaped). */ +mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err); +/* Test-only: one buffered line read with carry semantics. */ +char *mcp_read_line(mcp_client *c, const char *method, char **err); +mcp_client *mcp_connect_memory(mcp_memory *mem, char **err); +void mcp_client_free(mcp_client *c); + +/* Handshake: initialize request + notifications/initialized notification. */ +int mcp_initialize(mcp_client *c, char **err); + +/* Discover the server's tools. Caller frees with mcp_tools_free (does not + * free the clients). */ +int mcp_list_tools(mcp_client *c, mcp_tool **tools, size_t *n, char **err); +void mcp_tools_free(mcp_tool *tools, size_t n); + +/* Invoke a tool; returns its text content or NULL with *err set. */ +char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json, + char **err); + +/* Low-level JSON-RPC: returns the `result` value as JSON text (owned), or + * NULL with *err ("server error {code}: {message}" on JSON-RPC errors). */ +char *mcp_request(mcp_client *c, const char *method, const char *params_json, + char **err); +int mcp_notify(mcp_client *c, const char *method, const char *params_json, + char **err); + +/* Extract the JSON-RPC response from an HTTP body (plain or SSE). */ +char *mcp_extract_json(const char *content_type, const char *body, char **err); + +/* Connect, handshake, and discover every server, in order. Unreachable + * servers become warnings (`mcp \`name\`: reason`) and are skipped. */ +void mcp_connect_all(const char *const *names, const char *const *configs, + size_t n, mcp_registry *out, strlist *warnings); +void mcp_registry_free(mcp_registry *r); + +/* Dispatch through the owning client, wrapping failures as + * `MCP tool \`name\` failed: ...`. */ +char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err); + +#endif diff --git a/src/message.cpp b/src/message.cpp new file mode 100644 index 0000000..18e9c3b --- /dev/null +++ b/src/message.cpp @@ -0,0 +1 @@ +#include "message.h" diff --git a/src/message.h b/src/message.h new file mode 100644 index 0000000..6ff53f6 --- /dev/null +++ b/src/message.h @@ -0,0 +1,367 @@ +#ifndef LLSH_MESSAGE_H +#define LLSH_MESSAGE_H + +#include <nlohmann/json.hpp> + +#include <string> +#include <variant> +#include <vector> + +/* ------------------------------------------------------------------ */ +/* Message model (mirrors the rig message types llsh uses) */ +/* */ +/* message = system_message { text } */ +/* | user_message { parts: [user_part] } */ +/* | assistant_message { parts: [assistant_part] } */ +/* user_part = user_text_part { text } */ +/* | user_tool_result_part { call, name, */ +/* content: [trc] } */ +/* trc = trc_text { text } | trc_json { value } | trc_image */ +/* assistant_part = asst_text_part { text } */ +/* | asst_tool_call_part { name, args } */ +/* | asst_reasoning_part { text } */ +/* | asst_image_part */ +/* */ +/* JSON shape (recursive, nlohmann ADL below): each type serializes */ +/* to exactly this wire format and the `role`/`type` discriminators */ +/* keep old session files loadable. */ +/* ------------------------------------------------------------------ */ + +/* -- tool result content -- */ + +struct trc_text { + std::string text; +}; +struct trc_json { + std::string value; +}; +struct trc_image { +}; +using tool_result_content = std::variant<trc_text, trc_json, trc_image>; + +/* -- user parts -- */ + +struct user_text_part { + std::string text; +}; +struct user_tool_result_part { + std::string call; /* tool-call id */ + std::string name; /* tool name */ + std::vector<tool_result_content> content; +}; +using user_part = std::variant<user_text_part, user_tool_result_part>; + +/* -- assistant parts -- */ + +struct asst_text_part { + std::string text; +}; +struct asst_tool_call_part { + std::string name; + std::string args; /* stringified arguments */ +}; +struct asst_reasoning_part { + std::string text; +}; +struct asst_image_part { +}; +using assistant_part = + std::variant<asst_text_part, asst_tool_call_part, asst_reasoning_part, + asst_image_part>; + +/* -- messages -- */ + +struct system_message { + std::string text; +}; +struct user_message { + std::vector<user_part> parts; +}; +struct assistant_message { + std::vector<assistant_part> parts; +}; + +struct message { + std::variant<system_message, user_message, assistant_message> body; + + bool is_system() const { + return std::holds_alternative<system_message>(body); + } + bool is_user() const { + return std::holds_alternative<user_message>(body); + } + bool is_assistant() const { + return std::holds_alternative<assistant_message>(body); + } + + system_message &system() { return std::get<system_message>(body); } + const system_message &system() const { + return std::get<system_message>(body); + } + user_message &user() { return std::get<user_message>(body); } + const user_message &user() const { + return std::get<user_message>(body); + } + assistant_message &assistant() { + return std::get<assistant_message>(body); + } + const assistant_message &assistant() const { + return std::get<assistant_message>(body); + } +}; + +/* Constructors (fresh message, content copied). */ +inline message msg_system(const char *text) { + system_message m; + m.text = text ? text : ""; + return message{std::move(m)}; +} +inline message msg_user_text(const char *text) { + user_message m; + user_text_part p; + p.text = text ? text : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_user_tool_result(const char *call, const char *name, + const char *text) { + user_message m; + user_tool_result_part p; + p.call = call ? call : ""; + p.name = name ? name : ""; + trc_text t; + t.text = text ? text : ""; + p.content.push_back(std::move(t)); + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_assistant_text(const char *text) { + assistant_message m; + asst_text_part p; + p.text = text ? text : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_assistant_tool_call(const char *name, const char *args) { + assistant_message m; + asst_tool_call_part p; + p.name = name ? name : ""; + p.args = args ? args : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} + +/* Append parts to an existing message. */ +inline message &msg_push_user_text(message &m, const char *text) { + if (!m.is_user()) + m.body = user_message{}; + user_text_part p; + p.text = text ? text : ""; + m.user().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_user_tool_result(message &m, const char *call, + const char *name, const char *text) { + if (!m.is_user()) + m.body = user_message{}; + user_tool_result_part p; + p.call = call ? call : ""; + p.name = name ? name : ""; + trc_text t; + t.text = text ? text : ""; + p.content.push_back(std::move(t)); + m.user().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_text(message &m, const char *text) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_text_part p; + p.text = text ? text : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_tool_call(message &m, const char *name, + const char *args) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_tool_call_part p; + p.name = name ? name : ""; + p.args = args ? args : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_reasoning(message &m, const char *text) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_reasoning_part p; + p.text = text ? text : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} + +/* ------------------------------------------------------------------ */ +/* History (growable message list) */ +/* ------------------------------------------------------------------ */ + +struct history { + std::vector<message> msgs; +}; + +/* Destroy the stored messages and the vector buffer; the `history` object + * (wherever it lives) must not be used afterwards. */ +inline void history_free(history *h) { h->msgs.~vector<message>(); } +inline void history_push(history *h, message m) { + h->msgs.push_back(std::move(m)); +} +/* Remove and return element i. */ +inline message history_take(history *h, size_t i) { + message out = std::move(h->msgs[i]); + h->msgs.erase(h->msgs.begin() + static_cast<long>(i)); + return out; +} +inline size_t history_len(const history *h) { return h->msgs.size(); } +inline const message *history_at(const history *h, size_t i) { + return &h->msgs[i]; +} + +/* ------------------------------------------------------------------ */ +/* Recursive JSON (nlohmann ADL: to_json / from_json) */ +/* ------------------------------------------------------------------ */ + +/* Tolerant string read: missing / null / non-string values become "". */ +inline std::string jstring(const nlohmann::json &j, const char *key) { + if (j.contains(key) && j.at(key).is_string()) + return j.at(key).get<std::string>(); + return std::string(); +} + +inline void to_json(nlohmann::json &j, const tool_result_content &v) { + if (const auto *t = std::get_if<trc_text>(&v)) + j = nlohmann::json{{"text", t->text}}; + else if (const auto *s = std::get_if<trc_json>(&v)) + j = nlohmann::json{{"json", s->value}}; + else + j = nlohmann::json{{"image", "1"}}; +} +inline void from_json(const nlohmann::json &j, tool_result_content &v) { + if (j.contains("text")) { + trc_text t; + t.text = jstring(j, "text"); + v = std::move(t); + } else if (j.contains("json")) { + trc_json s; + s.value = jstring(j, "json"); + v = std::move(s); + } else { + v = trc_image{}; + } +} + +inline void to_json(nlohmann::json &j, const user_part &p) { + if (const auto *t = std::get_if<user_text_part>(&p)) + j = nlohmann::json{{"type", "text"}, {"text", t->text}}; + else { + const auto &r = std::get<user_tool_result_part>(p); + nlohmann::json arr = nlohmann::json::array(); + for (const auto &c : r.content) + arr.push_back(c); + j = nlohmann::json{{"type", "tool_result"}, + {"call", r.call}, + {"name", r.name}, + {"content", std::move(arr)}}; + } +} +inline void from_json(const nlohmann::json &j, user_part &p) { + const std::string type = jstring(j, "type"); + if (type == "text") { + user_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } else if (type == "tool_result") { + user_tool_result_part r; + r.call = jstring(j, "call"); + r.name = jstring(j, "name"); + if (j.contains("content") && j.at("content").is_array()) + for (const auto &c : j.at("content")) + r.content.push_back(c.get<tool_result_content>()); + p = std::move(r); + } else { + user_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } +} + +inline void to_json(nlohmann::json &j, const assistant_part &p) { + if (const auto *t = std::get_if<asst_text_part>(&p)) + j = nlohmann::json{{"type", "text"}, {"text", t->text}}; + else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) + j = nlohmann::json{{"type", "tool_call"}, + {"name", c->name}, + {"args", c->args}}; + else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) + j = nlohmann::json{{"type", "reasoning"}, {"text", r->text}}; + else + j = nlohmann::json::object(); +} +inline void from_json(const nlohmann::json &j, assistant_part &p) { + const std::string type = jstring(j, "type"); + if (type == "text") { + asst_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } else if (type == "tool_call") { + asst_tool_call_part c; + c.name = jstring(j, "name"); + c.args = jstring(j, "args"); + p = std::move(c); + } else if (type == "reasoning") { + asst_reasoning_part r; + r.text = jstring(j, "text"); + p = std::move(r); + } else { + p = asst_image_part{}; + } +} + +inline void to_json(nlohmann::json &j, const message &m) { + if (m.is_system()) + j = nlohmann::json{{"role", "system"}, {"text", m.system().text}}; + else if (m.is_user()) { + nlohmann::json parts = nlohmann::json::array(); + for (const auto &p : m.user().parts) + parts.push_back(p); + j = nlohmann::json{{"role", "user"}, {"parts", std::move(parts)}}; + } else { + nlohmann::json parts = nlohmann::json::array(); + for (const auto &p : m.assistant().parts) + parts.push_back(p); + j = + nlohmann::json{{"role", "assistant"}, {"parts", std::move(parts)}}; + } +} +inline void from_json(const nlohmann::json &j, message &m) { + const std::string role = jstring(j, "role"); + if (role == "system") { + system_message s; + s.text = jstring(j, "text"); + m.body = std::move(s); + } else if (role == "assistant") { + assistant_message a; + if (j.contains("parts") && j.at("parts").is_array()) + for (const auto &p : j.at("parts")) + if (p.is_object()) + a.parts.push_back(p.get<assistant_part>()); + m.body = std::move(a); + } else { + user_message u; + if (j.contains("parts") && j.at("parts").is_array()) + for (const auto &p : j.at("parts")) + if (p.is_object()) + u.parts.push_back(p.get<user_part>()); + m.body = std::move(u); + } +} + +#endif diff --git a/src/provider.cpp b/src/provider.cpp new file mode 100644 index 0000000..8ac95db --- /dev/null +++ b/src/provider.cpp @@ -0,0 +1,941 @@ +/* provider.c — the session-bound provider layer for llshc. + * + * Implements the `llsh_model` vtable on top of llm.h's protocol layer: + * wires the session (config, preamble, allowed tools, .agents snapshot), + * the built-in + MCP tool registry, and the session emit callback into the + * one-shot `complete` and the streaming agent `stream`. */ +#include "provider.h" + +#include <atomic> +#include <cmath> +#include <limits> +#include <stdlib.h> +#include <string.h> + +#include "compact.h" +#include "config.h" +#include "dotagents.h" +#include "goal.h" +#include "message.h" +#include "limits.h" +#include <cjson/cJSON.h> +#include "tools.h" +#include "util.h" + +#define PCAST(m) ((provider_model *)(m)) +#define jstr(o, k) ({ const cJSON *_v = (o) ? cJSON_GetObjectItem((o), (k)) : NULL; \ + (cJSON_IsString(_v) && _v->valuestring) ? _v->valuestring : ""; }) + +typedef struct provider_model { + llsh_model base; /* must be first */ + session *sess; /* non-owning back-pointer */ + mcp_registry *mcp; + provider_role role; + /* Cached `"tools":[...]` request fragment. Valid while (provider, + * allowed list, mcp tools) is unchanged; invalidated on key mismatch. */ + char *tools_json; + unsigned long tools_key; + CURL *curl; /* persistent easy handle, created on first post */ +} provider_model; + +/* ------------------------------------------------------------------ */ +/* Session -> llm.h adapters */ +/* ------------------------------------------------------------------ */ + +static llm_conn conn_of(const session *s) { + const config *cfg = session_active_config((session *)s); + llm_conn c; + switch (cfg->provider) { + case PROV_ANTHROPIC: + c.provider = LLM_ANTHROPIC; + break; + case PROV_OLLAMA: + c.provider = LLM_OLLAMA; + break; + default: + c.provider = LLM_OPENAI; + break; + } + c.model = cfg->model; + c.base_url = cfg->base_url; + c.api_key = cfg->api_key; + c.ca_bundle = llsh_ca_bundle(); + return c; +} + +/* Join the tool-result content parts of a user_tool_result_part. Caller + * frees. */ +static char *trc_join(const user_tool_result_part &r) { + xbuf b; + xbuf_init(&b); + bool first = true; + for (const auto &c : r.content) { + if (!first) + xbuf_push(&b, '\n'); + if (const auto *t = std::get_if<trc_text>(&c)) + xbuf_put(&b, t->text.c_str()); + else if (const auto *s = std::get_if<trc_json>(&c)) + xbuf_put(&b, s->value.c_str()); + first = false; + } + char *s = xbuf_steal(&b); + xbuf_free(&b); + return s; +} + +/* App messages -> borrowed llm_message array (tool-result parts joined). */ +typedef struct { + llm_message *msgs; + size_t n; + char **joined; /* owned joined trc strings */ + size_t joined_n; +} app_llm_msgs; + +static app_llm_msgs app_to_llm(const message *const *msgs, size_t n) { + app_llm_msgs a; + memset(&a, 0, sizeof a); + a.msgs = (llm_message *)xmalloc(n ? n * sizeof *a.msgs : 1); + a.n = n; + for (size_t i = 0; i < n; i++) { + const message *m = msgs[i]; + llm_message *o = &a.msgs[i]; + size_t cnt = 0; + if (m->is_system()) { + o->role = LLM_ROLE_SYSTEM; + cnt = 1; + } else if (m->is_user()) { + o->role = LLM_ROLE_USER; + cnt = m->user().parts.size(); + } else { + o->role = LLM_ROLE_ASSISTANT; + cnt = m->assistant().parts.size(); + } + if (cnt == 0) + continue; + o->parts = (llm_part *)xmalloc(cnt * sizeof *o->parts); + size_t k = 0; + if (m->is_system()) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = m->system().text.c_str(); + k++; + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if<user_text_part>(&p)) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = t->text.c_str(); + } else { + const auto &r = std::get<user_tool_result_part>(p); + char *joined = trc_join(r); + a.joined = + (char **)xrealloc(a.joined, + (a.joined_n + 1) * sizeof *a.joined); + a.joined[a.joined_n++] = joined; + o->parts[k].kind = LLM_TOOL_RESULT; + o->parts[k].text = joined; + o->parts[k].name = r.name.c_str(); + o->parts[k].call = r.call.c_str(); + } + k++; + } + } else { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if<asst_text_part>(&p)) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = t->text.c_str(); + } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) { + o->parts[k].kind = LLM_REASONING; + o->parts[k].text = r->text.c_str(); + } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) { + o->parts[k].kind = LLM_TOOL_CALL; + o->parts[k].name = c->name.c_str(); + o->parts[k].args = c->args.c_str(); + } + k++; + } + } + o->part_count = k; + } + return a; +} + +static void app_llm_msgs_free(app_llm_msgs *a) { + for (size_t i = 0; i < a->n; i++) + free(a->msgs[i].parts); + free(a->msgs); + for (size_t i = 0; i < a->joined_n; i++) + free(a->joined[i]); + free(a->joined); + memset(a, 0, sizeof *a); +} + +/* Bridge the session's emit callback onto llm.h's primitive emitter. */ +typedef struct { + sess_emit_cb cb; + void *user; +} emit_user; + +static int emit_bridge(llm_provider prov, llm_emit_kind kind, + const char *delta, void *ud) { + (void)prov; + emit_user *eu = (emit_user *) ud; + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = (kind == LLM_EMIT_TEXT) ? SESS_EMIT_TEXT : SESS_EMIT_REASONING; + ev.text = xstrdup(delta); + int rc = eu->cb ? eu->cb(&ev, eu->user) : 0; + sess_emit_free(&ev); /* creator owns the payload; cb only reads it */ + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Tool execution */ +/* ------------------------------------------------------------------ */ + +static bool tool_allowed(const char *name, const strlist *allowed); + +static const char *required_string(const cJSON *args, const char *key) { + const cJSON *v = args ? cJSON_GetObjectItemCaseSensitive(args, key) : NULL; + return cJSON_IsString(v) && v->valuestring ? v->valuestring : NULL; +} + +static char *bad_tool_arg(const char *tool, const char *arg, char **err) { + if (err) + *err = xstrfmt("%s requires string `%s`", tool, arg); + return NULL; +} + +static char *exec_builtin(const session *s, const char *name, + const char *args_json, unsigned long shell_timeout, + char **err) { + const dotagents *dot = s ? session_dot(s) : NULL; + const config *cfg = s ? session_config(s) : NULL; + if (err) + *err = NULL; + cJSON *a = cJSON_Parse(args_json ? args_json : "{}"); + if (!cJSON_IsObject(a)) { + cJSON_Delete(a); + if (err) + *err = xstrdup("tool arguments must be a JSON object"); + return NULL; + } + char *out = NULL; + if (xstreq(name, "read_file")) { + if (!required_string(a, "path")) { + out = bad_tool_arg(name, "path", err); + goto done; + } + unsigned long off = 0, lim = 0; + const cJSON *ov = cJSON_GetObjectItem(a, "offset"); + const cJSON *lv = cJSON_GetObjectItem(a, "limit"); + if (ov && (!cJSON_IsNumber(ov) || !std::isfinite(ov->valuedouble) || + ov->valuedouble < 1 || std::floor(ov->valuedouble) != ov->valuedouble || + ov->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) { + out = bad_tool_arg(name, "offset (positive integer)", err); + goto done; + } + if (ov) + off = (unsigned long)ov->valuedouble; + if (lv && (!cJSON_IsNumber(lv) || !std::isfinite(lv->valuedouble) || + lv->valuedouble < 1 || std::floor(lv->valuedouble) != lv->valuedouble || + lv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) { + out = bad_tool_arg(name, "limit (positive integer)", err); + goto done; + } + if (lv) + lim = (unsigned long)lv->valuedouble; + out = tool_read_file(jstr(a, "path"), off, lim, err); + } else if (xstreq(name, "write_file")) { + if (!required_string(a, "path") || !required_string(a, "content")) { + out = bad_tool_arg(name, + required_string(a, "path") ? "content" : "path", + err); + goto done; + } + out = tool_write_file(required_string(a, "path"), + required_string(a, "content"), err); + } else if (xstreq(name, "run_shell")) { + const char *command = required_string(a, "command"); + if (!command || !command[0]) { + out = bad_tool_arg(name, "command", err); + goto done; + } + const tool_limits *lim = tool_limits_current(); + if (shell_gate(command, lim, err) == 0) + out = tool_run_shell_timed(command, shell_timeout, err); + } else if (xstreq(name, "use_skill")) { + if (!required_string(a, "id")) { + out = bad_tool_arg(name, "id", err); + goto done; + } + out = tool_use_skill(dot, required_string(a, "id"), err); + } else if (xstreq(name, "search_memories")) { + if (!required_string(a, "query")) { + out = bad_tool_arg(name, "query", err); + goto done; + } + out = tool_search_memories(dot, required_string(a, "query"), err); + } else if (xstreq(name, "embed")) { + if (!required_string(a, "text")) { + out = bad_tool_arg(name, "text", err); + goto done; + } + out = tool_embed(required_string(a, "text"), cfg, dot, err); + } else if (xstreq(name, "llm")) { + llm_tool_call call; + memset(&call, 0, sizeof call); + call.temperature = -1.0; + call.prompt = required_string(a, "prompt"); + call.model = jstr(a, "model"); + const cJSON *tv = cJSON_GetObjectItem(a, "temperature"); + if (tv && (!cJSON_IsNumber(tv) || !std::isfinite(tv->valuedouble) || + tv->valuedouble < 0 || tv->valuedouble > 2)) { + out = bad_tool_arg(name, "temperature (0..2)", err); + goto done; + } + if (tv) + call.temperature = tv->valuedouble; + const cJSON *mv = cJSON_GetObjectItem(a, "max_tokens"); + if (mv && (!cJSON_IsNumber(mv) || !std::isfinite(mv->valuedouble) || + mv->valuedouble < 1 || std::floor(mv->valuedouble) != mv->valuedouble || + mv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) { + out = bad_tool_arg(name, "max_tokens (positive integer)", err); + goto done; + } + if (mv) + call.max_tokens = (unsigned long)mv->valuedouble; + call.reasoning_effort = jstr(a, "reasoning_effort"); + const cJSON *bv = cJSON_GetObjectItem(a, "thinking_budget"); + if (bv && (!cJSON_IsNumber(bv) || !std::isfinite(bv->valuedouble) || + bv->valuedouble < 1 || std::floor(bv->valuedouble) != bv->valuedouble || + bv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) { + out = bad_tool_arg(name, "thinking_budget (positive integer)", err); + goto done; + } + if (bv) + call.thinking_budget = (unsigned long)bv->valuedouble; + const cJSON *sv = cJSON_GetObjectItem(a, "response_schema"); + char *schema_ser = NULL; + if (sv) { + if (cJSON_IsString(sv) && sv->valuestring) + call.response_schema = sv->valuestring; + else { + schema_ser = cJSON_PrintUnformatted(sv); + call.response_schema = schema_ser; + } + } + const cJSON *ev = cJSON_GetObjectItem(a, "embed"); + if (ev && cJSON_IsBool(ev)) + call.embed = cJSON_IsTrue(ev); + if (!cfg) { + if (err) + *err = xstrdup("llm: no session config"); + } else { + out = tool_llm(&call, cfg, dot, err); + } + free(schema_ser); + } else if (xstreq(name, "compact")) { + compact_report rep; + memset(&rep, 0, sizeof rep); + int rc = session_force_compact((session *)s, &rep, err); + if (rc > 0) { + session_print_compact(&rep); + out = xstrfmt("compacted: %lu -> %lu tokens", rep.tokens_before, + rep.tokens_after); + } else if (rc == 0) { + out = xstrdup("history too short to compact"); + } + } else { + if (err) + *err = xstrfmt("unknown tool `%s`", name); + } +done: + cJSON_Delete(a); + return out; +} + +static char *exec_tool(const provider_model *p, const char *name, + const char *args_json, unsigned long shell_timeout, + char **err) { + const strlist *allowed = session_allowed_tools(p->sess); + if (!tool_allowed(name, allowed)) { + if (err) + *err = xstrfmt("tool `%s` is not allowed by the active profile", name); + return NULL; + } + if (p->mcp) { + for (size_t i = 0; i < p->mcp->tool_count; i++) + if (xstreq(p->mcp->tools[i].qualified_name, name)) + return mcp_tool_invoke(&p->mcp->tools[i], args_json, err); + } + return exec_builtin(p->sess, name, args_json, shell_timeout, err); +} + +/* ------------------------------------------------------------------ */ +/* Tool schemas (registry -> `"tools":[...]` fragment) */ +/* ------------------------------------------------------------------ */ + +static bool tool_allowed(const char *name, const strlist *allowed) { + if (!allowed || allowed->len == 0) + return true; + for (size_t i = 0; i < allowed->len; i++) + if (xstreq(allowed->items[i], name)) + return true; + return false; +} + +/* FNV-1a over (provider, allowed tools, mcp tool names) -- the cache key + * for the tool-schema fragment. */ +static unsigned long tfv(unsigned long h, const char *s) { + for (const char *c = s; *c; c++) + h = (h ^ (unsigned char)*c) * 1099511628211UL; + return (h ^ 0xff) * 1099511628211UL; +} + +static unsigned long tools_fingerprint(const config *cfg, + const strlist *allowed, + mcp_registry *mcp) { + unsigned long h = 1469598103934665603UL; + h = (h ^ (unsigned long)cfg->provider) * 1099511628211UL; + if (allowed) + for (size_t i = 0; i < allowed->len; i++) + h = tfv(h, allowed->items[i]); + if (mcp) + for (size_t i = 0; i < mcp->tool_count; i++) + h = tfv(h, mcp->tools[i].qualified_name); + return h; +} + +/* The provider-shaped `"tools":[...]` fragment, cached on the model. + * Rebuilt only when the fingerprint (provider, allowed list, mcp tools) + * changes. Borrowed from `p`; valid until provider_destroy. */ +static const char *tools_fragment(provider_model *p, const config *cfg, + const strlist *allowed) { + unsigned long key = tools_fingerprint(cfg, allowed, p->mcp); + if (p->tools_json && p->tools_key == key) + return p->tools_json; + free(p->tools_json); + p->tools_json = NULL; + p->tools_key = key; + + xbuf frag; + xbuf_init(&frag); + xbuf_put(&frag, "\"tools\":["); + bool first = true; + for (size_t i = 0; i < tool_builtin_count(); i++) { + const char *name = tool_builtin_name(i); + if (!tool_allowed(name, allowed)) + continue; + cJSON *t; + if (cfg->provider == PROV_ANTHROPIC) { + t = cJSON_CreateObject(); + cJSON_AddStringToObject(t, "name", name); + cJSON_AddStringToObject(t, "description", tool_desc(name)); + cJSON_AddItemToObject(t, "input_schema", tool_params_json(name)); + } else { + t = cJSON_CreateObject(); + cJSON_AddStringToObject(t, "type", "function"); + cJSON *f = cJSON_AddObjectToObject(t, "function"); + cJSON_AddStringToObject(f, "name", name); + cJSON_AddStringToObject(f, "description", tool_desc(name)); + cJSON_AddItemToObject(f, "parameters", tool_params_json(name)); + } + char *ts = cJSON_PrintUnformatted(t); + cJSON_Delete(t); + if (!first) + xbuf_push(&frag, ','); + xbuf_put(&frag, ts ? ts : "{}"); + free(ts); + first = false; + } + if (p->mcp) { + for (size_t i = 0; i < p->mcp->tool_count; i++) { + const mcp_tool *mt = &p->mcp->tools[i]; + if (!tool_allowed(mt->qualified_name, allowed)) + continue; + cJSON *t = cJSON_CreateObject(); + cJSON *schema = mt->input_schema ? cJSON_Parse(mt->input_schema) + : NULL; + if (!schema) { + schema = cJSON_CreateObject(); + cJSON_AddStringToObject(schema, "type", "object"); + } + if (cfg->provider == PROV_ANTHROPIC) { + cJSON_AddStringToObject(t, "name", mt->qualified_name); + if (mt->description) + cJSON_AddStringToObject(t, "description", mt->description); + cJSON_AddItemToObject(t, "input_schema", schema); + } else { + cJSON_AddStringToObject(t, "type", "function"); + cJSON *fn = cJSON_AddObjectToObject(t, "function"); + cJSON_AddStringToObject(fn, "name", mt->qualified_name); + if (mt->description) + cJSON_AddStringToObject(fn, "description", mt->description); + cJSON_AddItemToObject(fn, "parameters", schema); + } + char *ts = cJSON_PrintUnformatted(t); + cJSON_Delete(t); + if (!first) + xbuf_push(&frag, ','); + xbuf_put(&frag, ts ? ts : "{}"); + free(ts); + first = false; + } + } + xbuf_push(&frag, ']'); + p->tools_json = xbuf_steal(&frag); + xbuf_free(&frag); + return p->tools_json; +} + +/* ------------------------------------------------------------------ */ +/* complete (summarizer / evaluator) */ +/* ------------------------------------------------------------------ */ + +static char *provider_complete(const llsh_model *mm, const char *prompt, + sess_error *err) { + provider_model *p = PCAST(mm); + if (err) + *err = (sess_error){0}; + if (!p->sess) { + if (err) + err->message = xstrdup("provider model not attached to a session"); + return NULL; + } + const char *preamble; + if (p->role == PROV_ROLE_SUMMARIZER) + preamble = compact_summarizer_preamble(); + else if (p->role == PROV_ROLE_EVALUATOR) + preamble = goal_evaluator_preamble(); + else if (p->role == PROV_ROLE_RUBRIC) + preamble = compact_rubric_preamble(); + else + preamble = session_current_preamble(p->sess); + + /* single user message carrying the prompt */ + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = prompt ? prompt : ""; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + llm_conn conn = conn_of(p->sess); + char *req = llm_build_request(&conn, preamble, &one, 1, NULL, false); + 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, + &p->curl); + free(req); + free(url); + + if (hrc != 0) { + if (err) { + err->message = herr ? herr : xstrdup("transport error"); + err->http_status = 0; + err->transport = 1; + } + 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) { + if (c.text && c.text[0]) + result = c.text; + else + result = xstrdup(""); + c.text = NULL; + llm_completion_free(&c); + } + if (status < 200 || status >= 300 || !result) { + char *em = llm_parse_error(body ? body : "", status); + if (err) { + err->message = em; + err->http_status = (int)status; + err->transport = 0; + err->retry_after_secs = llm_header_retry_after(headers); + } + free(result); + result = NULL; + } + free(body); + free(headers); + return result; +} + +/* ------------------------------------------------------------------ */ +/* stream (agent turn) */ +/* ------------------------------------------------------------------ */ + +void provider_trim_dangling_calls(message *msgs, size_t n) { + for (size_t i = 0; i < n; i++) { + message *m = &msgs[i]; + if (!m->is_assistant() || m->assistant().parts.empty()) + continue; + size_t calls = 0; + for (const auto &p : m->assistant().parts) + if (std::holds_alternative<asst_tool_call_part>(p)) + calls++; + if (calls == 0) + continue; + size_t results = 0; + for (size_t k = i + 1; k < n; k++) { + message *u = &msgs[k]; + if (u->is_user() && !u->user().parts.empty() && + std::holds_alternative<user_tool_result_part>( + u->user().parts[0])) + results++; + else + break; + } + if (results >= calls) + continue; + size_t kept = 0; + auto &parts = m->assistant().parts; + for (size_t j = 0; j < parts.size(); j++) { + if (std::holds_alternative<asst_tool_call_part>(parts[j])) { + if (kept >= results) { + parts.erase(parts.begin() + static_cast<long>(j)); + j--; + } else { + kept++; + } + } + } + } +} + +static int provider_stream(const llsh_model *mm, + const message *history, size_t nhistory, + const char *prompt, sess_emit_cb cb, void *user, + std::atomic<bool> *stop, char **assistant_text, + unsigned long *input_tokens, message **new_messages, + size_t *n_new, sess_error *err) { + provider_model *p = PCAST(mm); + if (assistant_text) + *assistant_text = NULL; + if (input_tokens) + *input_tokens = 0; + if (new_messages) + *new_messages = NULL; + if (n_new) + *n_new = 0; + if (err) + *err = (sess_error){0}; + + if (!p->sess) { + if (err) + err->message = xstrdup("provider model not attached to a session"); + return SESS_STREAM_FAILED; + } + + const config *cfg = session_active_config(p->sess); + const char *preamble = session_current_preamble(p->sess); + const strlist *allowed = session_allowed_tools(p->sess); + const unsigned long max_turns = cfg->max_turns; /* 0 = unbounded */ + llm_conn conn = conn_of(p->sess); + emit_user eu = {cb, user}; + + /* local working messages for this turn (assistant tool calls + results) */ + std::vector<message> local; + int rc = SESS_STREAM_OK; + unsigned long last_in = 0; + char *final_text = NULL; + char *final_reasoning = NULL; + size_t call_counter = 0; + bool stopped = false; + + for (unsigned long turn = 0; max_turns == 0 || turn < max_turns; turn++) { + if (stop && stop->load()) { + stopped = true; + break; + } + /* combined = history + [prompt] + local */ + message pm = msg_user_text(prompt ? prompt : ""); + size_t total = nhistory + 1 + local.size(); + const message **all = (const message **)xmalloc(total * sizeof *all); + for (size_t i = 0; i < nhistory; i++) + all[i] = &history[i]; + all[nhistory] = ± + for (size_t i = 0; i < local.size(); i++) + all[nhistory + 1 + i] = &local[i]; + + app_llm_msgs am = app_to_llm(all, total); + free(all); + const char *tools = tools_fragment(p, cfg, allowed); + char *req = + llm_build_request(&conn, preamble, am.msgs, am.n, tools, true); + app_llm_msgs_free(&am); + char *url = llm_url(&conn); + long status = 0; + char *body = NULL, *headers = NULL, *herr = NULL; + bool stopped_post = false; + llm_sse_acc acc; + llm_sse_acc_init(&acc, conn.provider); + int hrc = llm_post_stream(&conn, url, req, &status, &body, &headers, + &acc, emit_bridge, &eu, stop, &stopped_post, + &herr, &p->curl); + free(req); + free(url); + + if (hrc != 0) { + /* A mid-stream transport drop is retryable and the partial + * response is discarded -- but the user has already seen the + * streamed text, so a retry re-shows it. Accepted (AUDIT). */ + if (err) { + err->message = herr ? herr : xstrdup("transport error"); + err->http_status = 0; + err->transport = 1; + } + free(body); + free(headers); + llm_sse_acc_free(&acc); + if (stopped_post) + stopped = true; /* user abort: not a retryable failure */ + else + rc = SESS_STREAM_RETRYABLE; + break; + } + if (status < 200 || status >= 300) { + char *em = llm_parse_error(body ? body : "", status); + bool transient = sess_is_transient_status(status); + rc = transient ? SESS_STREAM_RETRYABLE : SESS_STREAM_FAILED; + if (err) { + err->message = em; + err->http_status = (int)status; + err->transport = 0; + err->retry_after_secs = llm_header_retry_after(headers); + } + free(body); + free(headers); + llm_sse_acc_free(&acc); + break; + } + + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&acc, &c); + bool live = llm_sse_acc_had_content(&acc); + /* Fallback for endpoints that ignore `stream:true` and answer with + * one complete JSON body: parse it the non-streaming way; the text + * was not emitted live, so it is emitted below. */ + if (!live && body && body[0] == '{') + llm_parse_body(&conn, body, &c); + free(body); + free(headers); + llm_sse_acc_free(&acc); + + if (c.input_tokens) + last_in = c.input_tokens; + + /* emit reasoning (chain of thought), if the model returned one and + * it did not already stream live */ + if (!live && c.reasoning && c.reasoning[0]) { + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("reasoning", c.reasoning, NULL, &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + break; + } + sess_emit_free(&ev); + } + } + + /* emit text, unless it already streamed live */ + if (!live && c.text && c.text[0]) { + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("text", c.text, NULL, &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + break; + } + sess_emit_free(&ev); + } + } + + if (c.tc_len == 0) { + /* final answer: take ownership of c.text and reasoning */ + final_text = c.text ? c.text : xstrdup(""); + c.text = NULL; + final_reasoning = c.reasoning ? c.reasoning : NULL; + c.reasoning = NULL; + llm_completion_free(&c); + break; + } + + /* tool calls: build the assistant message + execute + tool results */ + message amsg; + if (c.reasoning && c.reasoning[0]) + msg_push_assistant_reasoning(amsg, c.reasoning); + if (c.text && c.text[0]) + msg_push_assistant_text(amsg, c.text); + for (size_t t = 0; t < c.tc_len; t++) + msg_push_assistant_tool_call(amsg, c.tc_name[t], c.tc_args[t]); + local.push_back(std::move(amsg)); + + for (size_t t = 0; t < c.tc_len; t++) { + if (stop && stop->load()) { + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + /* emit the tool call */ + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("tool_call", c.tc_name[t], c.tc_args[t], + &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + sess_emit_free(&ev); + } + /* execute */ + char *tresult = NULL, *terr = NULL; + tresult = exec_tool(p, c.tc_name[t], c.tc_args[t], + cfg->shell_timeout_secs, &terr); + if (!tresult) { + tresult = terr ? terr : xstrdup("tool failed"); + } else if (terr) { + free(terr); /* exec_tool: result and error are exclusive */ + } + char *bounded = tool_truncate_output(tresult); + free(tresult); + tresult = bounded; + /* emit the tool result */ + { + sess_emit ev2; + memset(&ev2, 0, sizeof ev2); + if (sess_tool_result_emit(c.tc_name[t], tresult, &ev2) == 0) { + if (cb && cb(&ev2, user)) { + sess_emit_free(&ev2); + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + sess_emit_free(&ev2); + } + } + /* tool result message */ + call_counter++; + char *id = c.tc_id[t] ? xstrdup(c.tc_id[t]) + : xstrfmt("call-%d", (int)call_counter); + local.push_back(msg_user_tool_result(id, c.tc_name[t], tresult)); + free(id); + free(tresult); + } + /* One tool round completed: count it for the rubric gates. */ + if (p->sess) + session_note_tool_round(p->sess); + llm_completion_free(&c); + } +end_loop: + if (input_tokens) + *input_tokens = last_in; + + if (stopped && !local.empty()) + provider_trim_dangling_calls(local.data(), local.size()); + + /* build new_messages = [user_text(prompt)] + local */ + { + std::vector<message> nm; + nm.push_back(msg_user_text(prompt ? prompt : "")); + for (size_t i = 0; i < local.size(); i++) + nm.push_back(local[i]); + if ((final_text && final_text[0]) || + (final_reasoning && final_reasoning[0])) { + message fm; + if (final_reasoning && final_reasoning[0]) + msg_push_assistant_reasoning(fm, final_reasoning); + if (final_text && final_text[0]) + msg_push_assistant_text(fm, final_text); + nm.push_back(std::move(fm)); + } + if (new_messages) { + *new_messages = new message[nm.size()]; + for (size_t i = 0; i < nm.size(); i++) + (*new_messages)[i] = std::move(nm[i]); + } + if (n_new) + *n_new = nm.size(); + } + if (assistant_text) + *assistant_text = xstrdup(final_text ? final_text : ""); + + free(final_text); + free(final_reasoning); + + if (stopped) + rc = SESS_STREAM_STOPPED; + + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------ */ + +static void provider_destroy(llsh_model *base) { + provider_model *p = PCAST(base); + free(p->tools_json); + if (p->curl) + curl_easy_cleanup(p->curl); + free(p); +} + +llsh_model *provider_model_new(session *sess, mcp_registry *mcp, + provider_role role) { + provider_model *p = (provider_model *) xmalloc(sizeof *p); + memset(p, 0, sizeof *p); + p->sess = sess; + p->mcp = mcp; + p->role = role; + p->base.complete = provider_complete; + p->base.stream = provider_stream; + p->base.destroy = provider_destroy; + return &p->base; +} + +void provider_model_attach(llsh_model *m, session *sess) { + PCAST(m)->sess = sess; +} + +char *provider_preview_request(session *sess, const char *prompt, + bool with_tools) { + llsh_model *mm = provider_model_new(sess, NULL, PROV_ROLE_MAIN); + provider_model *p = PCAST(mm); + const config *cfg = session_active_config(sess); + const char *preamble = session_current_preamble(sess); + const strlist *allowed = session_allowed_tools(sess); + + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = prompt ? prompt : ""; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + llm_conn conn = conn_of(sess); + const char *tools = with_tools ? tools_fragment(p, cfg, allowed) : NULL; + char *req = llm_build_request(&conn, preamble, &one, 1, tools, false); + llsh_model_free(mm); + return req; +} diff --git a/src/provider.h b/src/provider.h new file mode 100644 index 0000000..ae60896 --- /dev/null +++ b/src/provider.h @@ -0,0 +1,41 @@ +#ifndef LLSH_PROVIDER_H +#define LLSH_PROVIDER_H + +#include <stdbool.h> + +#include "llm.h" +#include "mcp.h" +#include "session.h" + +/* The session-bound provider layer: implements the `llsh_model` vtable on + * top of llm.h's protocol layer, wiring in the session (config, preamble, + * allowed tools, .agents), the built-in + MCP tool registry, and the + * session's emit callback. */ + +/* Which one-shot role this model serves. `complete` uses the matching + * preamble; `stream` (the agent turn) always uses the session preamble. */ +typedef enum { + PROV_ROLE_MAIN = 0, + PROV_ROLE_SUMMARIZER, + PROV_ROLE_EVALUATOR, + PROV_ROLE_RUBRIC, +} provider_role; + +/* Create a session-bound model. `sess` may be NULL until + * provider_model_attach; `mcp` may be NULL (no MCP tools). */ +llsh_model *provider_model_new(session *sess, mcp_registry *mcp, + provider_role role); +void provider_model_attach(llsh_model *m, session *sess); + +/* Test hook: build the request body (without sending) for the session's + * current config, preamble, and tool set, for a single one-shot prompt. + * Caller frees the returned string. */ +char *provider_preview_request(session *sess, const char *prompt, + bool with_tools); + +/* On stop, an assistant message can carry more tool calls than the + * tool-result messages that follow it (we stopped mid-loop). Trim the + * surplus calls so the committed history stays a valid request. */ +void provider_trim_dangling_calls(message *msgs, size_t n); + +#endif /* LLSH_PROVIDER_H */ diff --git a/src/session.cpp b/src/session.cpp new file mode 100644 index 0000000..7e82173 --- /dev/null +++ b/src/session.cpp @@ -0,0 +1,1555 @@ +#include "session.h" +#include <unistd.h> +#include <stdlib.h> +#ifndef LLSH_DBG +#define LLSH_DBG() (getenv("LLSH_DBG")!=NULL) +#define dbgf(...) do{ if(LLSH_DBG()) fprintf(stderr, __VA_ARGS__); }while(0) +#endif + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <sys/stat.h> + +#include <cjson/cJSON.h> + +#include "escape.h" + +#include "util.h" +#include "config.h" +#include "dotagents.h" +#include "message.h" +#include "compact.h" +#include "goal.h" +#include "agent.h" +#include "tools.h" +#include "llm.h" + +/* ANSI colors (session.rs), from escape.h. */ +#define DIM ESC_DIM +#define RESET ESC_RESET +#define RED ESC_COLOR_RED +#define YELLOW ESC_COLOR_YELLOW + +#define SNAPSHOT_VERSION 1UL + +/* ------------------------------------------------------------------ */ +/* Small local helpers */ +/* ------------------------------------------------------------------ */ + +static bool trimmed_nonempty(const char *s) { + if (!s) + return false; + while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') + s++; + return *s != 0; +} + +static const char *json_str(const cJSON *v, const char *key) { + const cJSON *item = cJSON_GetObjectItem(v, key); + if (cJSON_IsString(item) && item->valuestring) + return item->valuestring; + return ""; +} + +static config config_clone(const config *c) { + config out; + out.provider = c->provider; + out.model = c->model ? xstrdup(c->model) : NULL; + out.base_url = c->base_url ? xstrdup(c->base_url) : NULL; + out.api_key = c->api_key ? xstrdup(c->api_key) : NULL; + out.preamble = c->preamble ? xstrdup(c->preamble) : NULL; + out.max_turns = c->max_turns; + out.max_retries = c->max_retries; + out.context_limit = c->context_limit; + out.compact_threshold = c->compact_threshold; + out.clear_threshold = c->clear_threshold; + out.verbose = c->verbose; + out.skill_index_cap = c->skill_index_cap; + out.shell_timeout_secs = c->shell_timeout_secs; + out.max_output_chars = c->max_output_chars; + out.max_file_bytes = c->max_file_bytes; + sl_init(&out.shell_allowlist); + for (size_t i = 0; i < c->shell_allowlist.len; i++) + sl_push(&out.shell_allowlist, xstrdup(c->shell_allowlist.items[i])); + sl_init(&out.shell_denylist); + for (size_t i = 0; i < c->shell_denylist.len; i++) + sl_push(&out.shell_denylist, xstrdup(c->shell_denylist.items[i])); + out.confirm_destructive = c->confirm_destructive; + return out; +} + +/* ------------------------------------------------------------------ */ +/* Free helpers */ +/* ------------------------------------------------------------------ */ + +void sess_emit_free(sess_emit *e) { + if (!e) + return; + free(e->text); + e->text = NULL; +} + +void sess_error_clear(sess_error *e) { + if (!e) + return; + free(e->message); + e->message = NULL; + e->http_status = 0; + e->transport = 0; + e->retry_after_secs = 0; +} + +void sess_retry_free(sess_retry *r) { + if (!r) + return; + free(r->reason); + free(r); +} + +/* ------------------------------------------------------------------ */ +/* Pure helpers */ +/* ------------------------------------------------------------------ */ + +bool sess_is_transient_status(int status) { + return status == 408 || status == 429 || status == 500 || status == 502 || + status == 503 || status == 504 || status == 529; +} + +unsigned long sess_backoff_secs(unsigned long attempt) { + unsigned long exp = attempt > 1 ? attempt - 1 : 0; + if (exp > 5) + exp = 5; + unsigned long ms = 1000UL << exp; + if (ms > 30000) + ms = 30000; + return ms / 1000; +} + +unsigned long sess_human_secs(unsigned long secs) { + return secs < 1 ? 1 : secs; +} + +sess_retry *sess_classify_retry(const sess_error *e) { + if (!e) + return NULL; + if (e->http_status) { + if (sess_is_transient_status(e->http_status)) { + sess_retry *r = (sess_retry *) xmalloc(sizeof *r); + r->reason = xstrfmt("HTTP %d", e->http_status); + unsigned long ra = e->retry_after_secs; + if (ra > 60) + ra = 60; + r->retry_after_secs = ra; + r->has_retry_after = ra > 0; + return r; + } + return NULL; + } + if (e->transport) { + sess_retry *r = (sess_retry *) xmalloc(sizeof *r); + r->reason = xstrdup("transient connection error"); + r->retry_after_secs = 0; + r->has_retry_after = false; + return r; + } + return NULL; +} + +bool sess_error_is_context_overflow(const sess_error *e) { + if (!e || !e->message) + return false; + if (e->http_status == 413) + return true; + if (!e->http_status || e->http_status < 400 || e->http_status >= 500) + return false; + if (llm_strcasestr(e->message, "context") || + llm_strcasestr(e->message, "exceed")) + return true; + return false; +} + +int sess_assistant_emit(const char *kind, const char *text, const char *args, + sess_emit *out) { + if (xstreq(kind, "text")) { + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_TEXT; + out->text = text ? xstrdup(text) : NULL; + } + return 0; + } + if (xstreq(kind, "reasoning")) { + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_REASONING; + out->text = text ? xstrdup(text) : NULL; + } + return 0; + } + if (xstreq(kind, "tool_call")) { + /* One-line transcript display. Tools with a custom renderer in the + * display vtable (e.g. run_shell -> `$ <command>`) use it; everything + * else falls back to `# name <args>`, where <args> is the raw JSON the + * model sent (tool params are a JSON object). `# name` is yellow, the + * args are dimmed. Presentation only -- the call is executed directly + * by exec_builtin. */ + const char *name = text ? text : ""; + const char *a = (args && args[0]) ? args : NULL; + char *body = NULL; + const tool_display *op = tool_display_for(name); + if (op && op->render_call) + body = op->render_call(a); + if (!body) + body = a + ? xstrfmt("\n%s# %s%s %s%s%s\n", YELLOW, name, RESET, DIM, + a, RESET) + : xstrfmt("\n%s# %s%s\n", YELLOW, name, RESET); + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_VERBATIM; + out->text = body; + } else + free(body); + return 0; + } + return 1; +} + +int sess_tool_result_emit(const char *tool, const char *result, + sess_emit *out) { + if (!result || !result[0] || !out) + return 1; + size_t len = strlen(result); + while (len > 0 && result[len - 1] == '\n') + len--; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, result, len); + xbuf_push(&b, '\n'); + char *body = xbuf_steal(&b); + xbuf_free(&b); + *out = (sess_emit){0}; + out->kind = SESS_EMIT_RESULT; + out->text = body; + /* Shell-style tools (per the display vtable) render their results + * muted; other tool results stay in the normal color. */ + const tool_display *op = tool ? tool_display_for(tool) : NULL; + out->from_shell = op != NULL && op->dim_result; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Preamble / preset / profile helpers */ +/* ------------------------------------------------------------------ */ + +char *session_active_preset_from(const dotagents *dot) { + if (dot && dot->has_models && dot->models.default_name && + dot->models.default_name[0]) + return xstrdup(dot->models.default_name); + return NULL; +} + +char *sess_base_preamble_for(const config *cfg, const dotagents *dot) { + const char *base = trimmed_nonempty(dot->system_prompt) + ? dot->system_prompt + : (cfg->preamble ? cfg->preamble : ""); + return dotagents_compose_preamble(dot, base, cfg->skill_index_cap); +} + +char *sess_preamble_for_profile(const dotagents *dot, const config *cfg, + const char *profile) { + if (profile) { + const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); + if (a) { + char *body = xstr_rtrim_dup(a->body ? a->body : ""); + char *sections = dotagents_project_sections(dot, cfg->skill_index_cap); + char *r = xstrjoin2(body, sections); + free(body); + free(sections); + return r; + } + } + return sess_base_preamble_for(cfg, dot); +} + +int sess_apply_overrides(config *cfg, const char *model, const char *provider, + const char *base_url, const char *api_key, + bool strict, char **err) { + if (provider) { + char *perr = NULL; + provider_kind pk = provider_parse(provider, &perr); + if (perr) { + if (strict) { + if (err) + *err = perr; + else + free(perr); + return -1; + } + free(perr); + } else { + cfg->provider = pk; + } + } + if (model) { + free(cfg->model); + cfg->model = xstrdup(model); + } + if (base_url) { + free(cfg->base_url); + cfg->base_url = xstrdup(base_url); + } + if (api_key) { + free(cfg->api_key); + cfg->api_key = xstrdup(api_key); + } + return 0; +} + +config sess_effective_for_profile(const config *cfg, const dotagents *dot, + const char *profile) { + config out = config_clone(cfg); + if (profile) { + const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); + if (a && a->config) { + cJSON *root = cJSON_Parse(a->config); + if (root) { + const char *model = json_str(root, "model"); + const char *provider = json_str(root, "provider"); + const char *base_url = json_str(root, "base_url"); + const char *api_key = json_str(root, "api_key"); + if (provider[0]) { + char *perr = NULL; + provider_kind pk = provider_parse(provider, &perr); + if (perr) + free(perr); + else + out.provider = pk; + } + if (model[0]) { + free(out.model); + out.model = xstrdup(model); + } + if (base_url[0]) { + free(out.base_url); + out.base_url = xstrdup(base_url); + } + if (api_key[0]) { + free(out.api_key); + out.api_key = xstrdup(api_key); + } + cJSON_Delete(root); + } + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Session state */ +/* ------------------------------------------------------------------ */ + +struct session { + config cfg; + dotagents dot; + history hist; + char *base_preamble; + char *active_preset; + char *profile; + compact_config compact; + unsigned long last_context_tokens; + bool summary_attempted_this_turn; + /* Rubric compaction (SelfCompact) bookkeeping. */ + unsigned long total_rounds; /* completed tool rounds (the "iteration") */ + unsigned long rounds_since_probe; + unsigned long rubric_summaries; + char *debug_path; /* --debug: history is dumped here (NULL = off) */ + llsh_model *model; + llsh_model *summarizer; + llsh_model *goal_evaluator; + llsh_model *rubric; /* SelfCompact judge (PROV_ROLE_RUBRIC) */ + goal_report *goal; /* name avoids the C++ type/member clash */ + /* Cached effective state for (cfg, dot, profile). Rebuilt lazily; + * invalidated whenever any of the three changes. */ + config active; + bool active_valid; + char *active_preamble; + strlist active_allowed; +}; + +static void session_active_reset(session *s) { + if (s->active_valid) { + config_free(&s->active); + s->active_valid = false; + } + free(s->active_preamble); + s->active_preamble = NULL; + sl_free(&s->active_allowed); +} + +static void session_active_build(session *s) { + if (s->active_valid) + return; + s->active = sess_effective_for_profile(&s->cfg, &s->dot, s->profile); + s->active_preamble = sess_preamble_for_profile(&s->dot, &s->cfg, + s->profile); + if (s->profile) { + const sub_agent *a = dotagents_enabled_sub_agent(&s->dot, s->profile); + if (a && a->config) { + cJSON *root = cJSON_Parse(a->config); + if (root) { + cJSON *tools = cJSON_GetObjectItem(root, "tools"); + if (cJSON_IsArray(tools)) { + const cJSON *t = NULL; + cJSON_ArrayForEach(t, tools) + if (cJSON_IsString(t) && t->valuestring) + sl_push_copy(&s->active_allowed, t->valuestring); + } + cJSON_Delete(root); + } + } + } + s->active_valid = true; +} + +session *session_new_with_model(llsh_model *model, config cfg, dotagents dot) { + session *s = (session *) xmalloc(sizeof *s); + new (s) session{}; + s->model = model; + s->cfg = cfg; + s->dot = dot; + s->base_preamble = sess_base_preamble_for(&cfg, &dot); + s->active_preset = session_active_preset_from(&dot); + s->profile = NULL; + s->last_context_tokens = 0; + s->summary_attempted_this_turn = false; + s->total_rounds = 0; + s->rounds_since_probe = 0; + s->rubric_summaries = 0; + s->debug_path = NULL; + s->summarizer = NULL; + s->goal_evaluator = NULL; + s->goal = NULL; + compact_config_from(cfg.context_limit, cfg.compact_threshold, + cfg.clear_threshold, &s->compact); + return s; +} + +void session_free(session *s) { + if (!s) + return; + session_active_reset(s); + history_free(&s->hist); + free(s->debug_path); + free(s->base_preamble); + free(s->active_preset); + free(s->profile); + goal_report_free(s->goal); + llsh_model_free(s->model); + llsh_model_free(s->summarizer); + llsh_model_free(s->goal_evaluator); + llsh_model_free(s->rubric); + config_free(&s->cfg); + dotagents_free(&s->dot); + free(s); +} + +session *session_with_summarizer(session *s, llsh_model *summarizer) { + llsh_model_free(s->summarizer); + s->summarizer = summarizer; + return s; +} + +session *session_with_goal_evaluator(session *s, llsh_model *evaluator) { + llsh_model_free(s->goal_evaluator); + s->goal_evaluator = evaluator; + return s; +} + +session *session_with_rubric(session *s, llsh_model *rubric) { + llsh_model_free(s->rubric); + s->rubric = rubric; + return s; +} + +/* ------------------------------------------------------------------ */ +/* Accessors */ +/* ------------------------------------------------------------------ */ + +char *session_model(session *s) { + const config *cfg = session_active_config(s); + return xstrdup(cfg->model ? cfg->model : ""); +} + +const config *session_config(const session *s) { + return &s->cfg; +} + +char *session_active_preset(session *s) { + return s->active_preset ? xstrdup(s->active_preset) : NULL; +} + +char *session_profile_name(session *s) { + return s->profile ? xstrdup(s->profile) : NULL; +} + +const dotagents *session_dot(const session *s) { + return &s->dot; +} + +const strlist *session_allowed_tools(session *s) { + session_active_build(s); + return &s->active_allowed; +} + +unsigned long session_context_tokens(session *s) { + if (s->last_context_tokens > 0) + return s->last_context_tokens; + return compact_estimate_tokens(s->hist.msgs.data(), s->hist.msgs.size()); +} + +unsigned long session_context_limit(session *s) { + return s->compact.context_limit; +} + +const goal_report *session_goal_report(session *s) { + return s->goal; +} + +void session_set_goal_report(session *s, goal_report *r) { + goal_report_free(s->goal); + s->goal = r; +} + +char *session_last_assistant_text(session *s) { + for (size_t i = s->hist.msgs.size(); i-- > 0;) { + const message *m = &s->hist.msgs[i]; + if (!m->is_assistant()) + continue; + xbuf b; + xbuf_init(&b); + bool first = true; + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + const asst_text_part *t = std::get_if<asst_text_part>(&p); + if (t && !t->text.empty()) { + if (!first) + xbuf_push(&b, '\n'); + xbuf_put(&b, t->text.c_str()); + first = false; + } + } + char *text = xbuf_steal(&b); + xbuf_free(&b); + const char *p = text; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + if (*p) + return text; + free(text); + } + return NULL; +} + +const char *session_current_preamble(session *s) { + session_active_build(s); + return s->active_preamble ? s->active_preamble : ""; +} + +const config *session_active_config(session *s) { + session_active_build(s); + return &s->active; +} + +int session_refresh_context_limit(session *s) { + unsigned long limit = agent_fetch_context_limit(&s->cfg); + if (limit > 0) { + s->cfg.context_limit = limit; + s->compact.context_limit = limit; + return 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* History */ +/* ------------------------------------------------------------------ */ + +size_t session_history_len(session *s) { + return s->hist.msgs.size(); +} + +const message *session_history_at(session *s, size_t i) { + return i < s->hist.msgs.size() ? &s->hist.msgs[i] : NULL; +} + +void session_push_history(session *s, message m) { + s->hist.msgs.push_back(std::move(m)); +} + +unsigned long session_last_context_tokens(session *s) { + return s->last_context_tokens; +} + +void session_set_last_context_tokens(session *s, unsigned long t) { + s->last_context_tokens = t; +} + +void session_clear_history(session *s) { + s->hist.msgs.clear(); + s->last_context_tokens = 0; + s->total_rounds = 0; + s->rounds_since_probe = 0; + s->rubric_summaries = 0; +} + +int session_undo_last_turn(session *s) { + size_t n = s->hist.msgs.size(); + size_t last_user = compact_keep_boundary(s->hist.msgs.data(), n); + if (last_user >= n) + return -1; + size_t removed = n - last_user; + s->hist.msgs.erase(s->hist.msgs.begin() + static_cast<long>(last_user), + s->hist.msgs.end()); + s->last_context_tokens = 0; + return (int)removed; +} + +size_t session_message_count(session *s) { + return s->hist.msgs.size(); +} + +char *session_transcript_markdown(session *s) { + return compact_transcript_markdown(s->hist.msgs.data(), s->hist.msgs.size()); +} + +/* ------------------------------------------------------------------ */ +/* Compaction */ +/* ------------------------------------------------------------------ */ + +void session_print_compact(const compact_report *report) { + const char *icon, *label; + if (report->tier == TIER_CLEARED) { + icon = "\xE2\x97\x88"; + label = "cleared stale tool output"; + } else if (report->tier == TIER_SUMMARIZED) { + icon = "\xE2\x97\x86"; + label = "summarized earlier context"; + } else { + icon = "!"; + label = "summarization failed; cleared stale tool output"; + } + char *details; + if (report->tier == TIER_SUMMARIZED) + details = xstrfmt("%zu file(s) re-injected", report->files_reinjected); + else + details = xstrfmt("%zu tool result(s) cleared", + report->cleared_tool_results); + char *line = xstrfmt("\n" DIM "%s %s: %lu \xE2\x86\x92 %lu tokens (%s)" RESET + "\n", + icon, label, report->tokens_before, + report->tokens_after, details); + free(details); + fputs(line, stdout); + fflush(stdout); + free(line); +} + +int session_compact_if_needed(session *s, bool force, compact_report *report, + char **err) { + dbgf("CFN enter force=%d n=%zu limit=%lu\n",(int)force,s->hist.msgs.size(),s->compact.context_limit); + (void)err; + size_t n = s->hist.msgs.size(); + if (n == 0) + return 0; + if (s->compact.context_limit == 0 && !force) + return 0; + + unsigned long tokens = s->last_context_tokens > 0 + ? s->last_context_tokens + : compact_estimate_tokens(s->hist.msgs.data(), n); + + if (!force) { + unsigned long cbudget, kbudget; + bool clear_due = clear_at(&s->compact, &cbudget) && tokens >= cbudget; + bool compact_due = + compact_at(&s->compact, &kbudget) && tokens >= kbudget; + if (!clear_due && !compact_due) + return 0; + } + + size_t boundary = compact_keep_boundary(s->hist.msgs.data(), n); + dbgf("CFN boundary=%zu tokens=%lu last_tok=%lu\n",boundary,tokens,s->last_context_tokens); + if (boundary == 0 || boundary >= n) + return 0; + + fputs("\n" RED "~ compaction" RESET "\n", stdout); + fflush(stdout); + + unsigned long kb; + bool want_summary = + (force || (compact_at(&s->compact, &kb) && tokens >= kb)) && + !s->summary_attempted_this_turn; + + dbgf("CFN want_summary=%d\n",(int)want_summary); + if (!want_summary) { + size_t cleared = + compact_clear_old_tool_results(s->hist.msgs.data(), n, boundary); + if (report) { + report->tier = TIER_CLEARED; + report->tokens_before = tokens; + report->tokens_after = + compact_estimate_tokens(s->hist.msgs.data(), n); + report->cleared_tool_results = cleared; + report->files_reinjected = 0; + } + s->last_context_tokens = 0; + return 1; + } + + /* stale = msgs[0..boundary); tail = msgs[boundary..n) */ + const message *stale = s->hist.msgs.data(); + + char *summary = NULL; + bool summary_ok = false; + if (s->summarizer) { + s->summary_attempted_this_turn = true; + char *transcript = compact_transcript(stale, boundary); + sess_error se = {0}; + sess_error_clear(&se); + summary = s->summarizer->complete(s->summarizer, transcript, &se); + dbgf("CFN summarizer called -> summary=%p\n",(void*)summary); + free(transcript); + if (summary) + summary_ok = true; + else + sess_error_clear(&se); + } + + dbgf("CFN summary_ok=%d\n",(int)summary_ok); + if (summary_ok) { + strlist paths; + sl_init(&paths); + compact_recent_file_paths(summary, &paths); + const char **file_paths = NULL; + const char **file_contents = NULL; + size_t nfiles = 0; + if (paths.len) { + file_paths = (const char* * ) xmalloc(paths.len * sizeof *file_paths); + file_contents = (const char* * ) xmalloc(paths.len * sizeof *file_contents); + } + for (size_t i = 0; i < paths.len; i++) { + char *content = NULL; + size_t clen = 0; + char *ferr = NULL; + if (read_file_to_buf(paths.items[i], &content, &clen, &ferr) == 0) { + file_paths[nfiles] = xstrdup(paths.items[i]); + file_contents[nfiles] = content; + nfiles++; + } + free(ferr); + } + sl_free(&paths); + + const message *tail = s->hist.msgs.data() + boundary; + history *newhist = compact_compose_history( + summary, file_paths, file_contents, nfiles, tail, n - boundary); + s->hist.msgs = std::move(newhist->msgs); + history_free(newhist); + dbgf("CFN composed newhist len=%zu\n",s->hist.msgs.size()); + free(newhist); + free(summary); + for (size_t i = 0; i < nfiles; i++) { + free((char *)file_paths[i]); + free((char *)file_contents[i]); + } + free(file_paths); + free(file_contents); + + if (report) { + report->tier = TIER_SUMMARIZED; + report->tokens_before = tokens; + report->tokens_after = compact_estimate_tokens( + s->hist.msgs.data(), s->hist.msgs.size()); + report->cleared_tool_results = 0; + report->files_reinjected = nfiles; + } + s->last_context_tokens = 0; + return 1; + } + + /* fallback: clear stale tool results in place */ + size_t cleared = compact_clear_old_tool_results(s->hist.msgs.data(), + boundary, boundary); + if (report) { + report->tier = TIER_FALLBACK_CLEAR; + report->tokens_before = tokens; + report->tokens_after = + compact_estimate_tokens(s->hist.msgs.data(), n); + report->cleared_tool_results = cleared; + report->files_reinjected = 0; + } + fprintf(stderr, DIM + "[compact] summarization failed; cleared stale tool results instead" + RESET "\n"); + s->last_context_tokens = 0; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* Rubric compaction (SelfCompact, arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +void session_note_tool_round(session *s) { + if (!s) + return; + s->total_rounds++; + s->rounds_since_probe++; +} + +int session_force_compact(session *s, compact_report *report, char **err) { + if (!s) { + if (err) + *err = xstrdup("no session"); + return -1; + } + return session_compact_if_needed(s, true, report, err); +} + +int session_rubric_probe(session *s, char **err) { + if (err) + *err = NULL; + if (!s || !s->cfg.rubric_enabled || !s->rubric) + return 0; + + unsigned long tokens = s->last_context_tokens > 0 + ? s->last_context_tokens + : compact_estimate_tokens(s->hist.msgs.data(), + s->hist.msgs.size()); + if (!compact_rubric_due(s->total_rounds, tokens, s->rubric_summaries, + s->cfg.rubric_max_summaries, + s->rounds_since_probe)) + return 0; + + /* Probe the model with the whole trajectory plus the search rubric. */ + char *transcript = session_transcript_markdown(s); + if (!transcript) + return 0; + char *prompt = xstrfmt("%s\n\n%s", transcript, compact_rubric_prompt()); + free(transcript); + sess_error se = {0}; + sess_error_clear(&se); + char *answer = s->rubric->complete(s->rubric, prompt, &se); + free(prompt); + if (!answer) { + char *msg = xstrdup(se.message ? se.message : "rubric probe failed"); + sess_error_clear(&se); + if (err) + *err = msg; + else + free(msg); + return -1; + } + + compact_rubric r; + memset(&r, 0, sizeof r); + char *perr = NULL; + int prc = compact_rubric_parse(answer, &r, &perr); + free(answer); + if (prc != 0) { + char *msg = xstrdup(perr ? perr : "unparseable rubric verdict"); + free(perr); + if (err) + *err = msg; + else + free(msg); + return -1; + } + + /* A probe was made: restart the round gate regardless of the verdict. */ + s->rounds_since_probe = 0; + if (!compact_rubric_fire(&r)) + return 0; + + compact_report report; + memset(&report, 0, sizeof report); + int rc = session_compact_if_needed(s, true, &report, err); + if (rc > 0) { + s->rubric_summaries++; + session_print_compact(&report); + } + return rc > 0 ? 1 : 0; +} + +/* ------------------------------------------------------------------ */ +/* Goal */ +/* ------------------------------------------------------------------ */ + +int session_evaluate_goal(session *s, const goal_spec *spec, + const char *last_reply, goal_verdict *out, + char **err) { + if (!s->goal_evaluator) { + if (err) + *err = xstrdup("no goal evaluator configured"); + return -1; + } + const char *reply = trimmed_nonempty(last_reply) + ? last_reply + : "(the agent finished this turn without a text " + "reply \xE2\x80\x94 it made only tool calls)"; + char *prompt = xstrfmt( + "GOAL (the success condition):\n%s\n\nAGENT'S LATEST REPLY:\n%s", + spec->criteria, reply); + sess_error se = {0}; + sess_error_clear(&se); + char *verdict_text = s->goal_evaluator->complete(s->goal_evaluator, prompt, + &se); + free(prompt); + if (!verdict_text) { + char *msg = xstrfmt("asking the goal evaluator: %s", + se.message ? se.message : "unknown"); + sess_error_clear(&se); + if (err) + *err = msg; + else + free(msg); + return -1; + } + goal_parse_verdict(verdict_text, out); + free(verdict_text); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Model / profile */ +/* ------------------------------------------------------------------ */ + +static int session_apply_preset(session *s, const char *name, char **err) { + const model_preset *p = + (s->dot.has_models) ? models_file_get(&s->dot.models, name) : NULL; + if (!p) { + char *e = xstrfmt("preset `%s` not found", name); + if (err) + *err = e; + else + free(e); + return -1; + } + if (sess_apply_overrides(&s->cfg, p->model, p->provider, p->base_url, + p->api_key, true, err) != 0) + return -1; + if (p->has_max_turns) + s->cfg.max_turns = p->max_turns; + if (p->has_context_limit) + s->cfg.context_limit = p->context_limit; + compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, + s->cfg.clear_threshold, &s->compact); + s->last_context_tokens = 0; + if (s->cfg.provider != PROV_OLLAMA && !s->cfg.base_url && + !s->cfg.api_key) { + char *e = xstrfmt("no API key for provider `%s` from preset `%s`", + provider_name(s->cfg.provider), name); + if (err) + *err = e; + else + free(e); + return -1; + } + free(s->active_preset); + s->active_preset = xstrdup(name); + session_active_reset(s); + return 0; +} + +int session_set_model(session *s, const char *name, char **err) { + if (s->dot.has_models && models_file_get(&s->dot.models, name) != NULL) + return session_apply_preset(s, name, err); + free(s->active_preset); + s->active_preset = NULL; + free(s->cfg.model); + s->cfg.model = xstrdup(name); + s->last_context_tokens = 0; + session_active_reset(s); + return 0; +} + +int session_set_profile(session *s, const char *id, char **err) { + if (id) { + if (!dotagents_enabled_sub_agent(&s->dot, id)) { + xbuf b; + xbuf_init(&b); + bool first = true; + for (size_t i = 0; i < s->dot.sub_agent_count; i++) { + if (!first) + xbuf_put(&b, ", "); + xbuf_put(&b, s->dot.sub_agents[i].id); + first = false; + } + char *known = s->dot.sub_agent_count ? xbuf_steal(&b) + : xstrdup("none defined"); + xbuf_free(&b); + char *e = xstrfmt("no enabled sub-agent `%s` (known: %s)", id, + known); + free(known); + if (err) + *err = e; + else + free(e); + return -1; + } + } + free(s->profile); + s->profile = id ? xstrdup(id) : NULL; + session_active_reset(s); + return 0; +} + +int session_refresh_dot(session *s, const char *workspace, const char *global, + char **err) { + (void)err; + dotagents fresh; + dotagents_load_from(&fresh, workspace, global); + char *bp = sess_base_preamble_for(&s->cfg, &fresh); + dotagents_free(&s->dot); + s->dot = fresh; + free(s->base_preamble); + s->base_preamble = bp; + session_active_reset(s); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Save / load / resume */ +/* ------------------------------------------------------------------ */ + +void session_snapshot_free(session_snapshot *snap) { + if (!snap) + return; + free(snap->cwd); + config_free(&snap->cfg); + history_free(&snap->hist); + free(snap->active_preset); + free(snap->profile); + free(snap); +} + +static unsigned long long jnum(const nlohmann::json &j, const char *key) { + if (j.contains(key) && j.at(key).is_number()) + return (unsigned long long) j.at(key).get<double>(); + return 0; +} + +static nlohmann::json snapshot_to_json(const session_snapshot *snap) { + nlohmann::json cfg = nlohmann::json::object(); + cfg["model"] = snap->cfg.model ? snap->cfg.model : ""; + cfg["provider"] = provider_name(snap->cfg.provider); + if (snap->cfg.base_url) + cfg["base_url"] = snap->cfg.base_url; + if (snap->cfg.api_key) + cfg["api_key"] = snap->cfg.api_key; + cfg["max_turns"] = snap->cfg.max_turns; + cfg["max_retries"] = snap->cfg.max_retries; + cfg["context_limit"] = snap->cfg.context_limit; + cfg["compact_threshold"] = snap->cfg.compact_threshold; + cfg["clear_threshold"] = snap->cfg.clear_threshold; + cfg["verbose"] = snap->cfg.verbose; + cfg["skill_index_cap"] = snap->cfg.skill_index_cap; + cfg["confirm_destructive"] = snap->cfg.confirm_destructive; + cfg["shell_allowlist"] = nlohmann::json::array(); + for (size_t i = 0; i < snap->cfg.shell_allowlist.len; i++) + cfg["shell_allowlist"].push_back(snap->cfg.shell_allowlist.items[i]); + cfg["shell_denylist"] = nlohmann::json::array(); + for (size_t i = 0; i < snap->cfg.shell_denylist.len; i++) + cfg["shell_denylist"].push_back(snap->cfg.shell_denylist.items[i]); + nlohmann::json hist = nlohmann::json::array(); + for (const auto &m : snap->hist.msgs) + hist.push_back(m); + nlohmann::json o; + o["version"] = snap->version; + o["cwd"] = snap->cwd ? snap->cwd : ""; + o["saved_at"] = snap->saved_at; + o["config"] = cfg; + o["history"] = std::move(hist); + if (snap->active_preset) + o["active_preset"] = snap->active_preset; + if (snap->profile) + o["profile"] = snap->profile; + o["last_context_tokens"] = snap->last_context_tokens; + return o; +} + +static session_snapshot *json_to_snapshot(const nlohmann::json &o) { + session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); + new (snap) session_snapshot{}; + snap->version = (unsigned long) jnum(o, "version"); + sl_init(&snap->cfg.shell_allowlist); + sl_init(&snap->cfg.shell_denylist); + { + std::string cwd = jstring(o, "cwd"); + snap->cwd = xstrdup(cwd.c_str()); + } + snap->saved_at = (unsigned long long) jnum(o, "saved_at"); + /* config */ + if (o.contains("config") && o.at("config").is_object()) { + const nlohmann::json &cfg = o.at("config"); + snap->cfg.model = xstrdup(jstring(cfg, "model").c_str()); + std::string provider = jstring(cfg, "provider"); + char *perr = NULL; + snap->cfg.provider = provider.empty() + ? PROV_OPENAI + : provider_parse(provider.c_str(), &perr); + free(perr); + std::string base_url = jstring(cfg, "base_url"); + if (!base_url.empty()) + snap->cfg.base_url = xstrdup(base_url.c_str()); + std::string api_key = jstring(cfg, "api_key"); + if (!api_key.empty()) + snap->cfg.api_key = xstrdup(api_key.c_str()); + snap->cfg.max_turns = (unsigned long) jnum(cfg, "max_turns"); + snap->cfg.max_retries = (unsigned long) jnum(cfg, "max_retries"); + snap->cfg.context_limit = (unsigned long) jnum(cfg, "context_limit"); + if (cfg.contains("compact_threshold") && + cfg.at("compact_threshold").is_number()) + snap->cfg.compact_threshold = + cfg.at("compact_threshold").get<double>(); + if (cfg.contains("clear_threshold") && + cfg.at("clear_threshold").is_number()) + snap->cfg.clear_threshold = cfg.at("clear_threshold").get<double>(); + if (cfg.contains("verbose") && cfg.at("verbose").is_boolean()) + snap->cfg.verbose = cfg.at("verbose").get<bool>(); + snap->cfg.skill_index_cap = + (unsigned long) jnum(cfg, "skill_index_cap"); + if (cfg.contains("confirm_destructive") && + cfg.at("confirm_destructive").is_boolean()) + snap->cfg.confirm_destructive = + cfg.at("confirm_destructive").get<bool>(); + if (cfg.contains("shell_allowlist") && cfg.at("shell_allowlist").is_array()) + for (const auto &item : cfg.at("shell_allowlist")) + if (item.is_string()) + sl_push_copy(&snap->cfg.shell_allowlist, + item.get_ref<const std::string &>().c_str()); + if (cfg.contains("shell_denylist") && cfg.at("shell_denylist").is_array()) + for (const auto &item : cfg.at("shell_denylist")) + if (item.is_string()) + sl_push_copy(&snap->cfg.shell_denylist, + item.get_ref<const std::string &>().c_str()); + } + /* history */ + if (o.contains("history") && o.at("history").is_array()) + for (const auto &m : o.at("history")) + if (m.is_object()) + snap->hist.msgs.push_back(m.get<message>()); + std::string ap = jstring(o, "active_preset"); + if (!ap.empty()) + snap->active_preset = xstrdup(ap.c_str()); + std::string prof = jstring(o, "profile"); + if (!prof.empty()) + snap->profile = xstrdup(prof.c_str()); + snap->last_context_tokens = (unsigned long) jnum(o, "last_context_tokens"); + return snap; +} + +session_snapshot *session_snapshot_of(session *s) { + session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); + new (snap) session_snapshot{}; + snap->version = SNAPSHOT_VERSION; + char buf[4096]; + snap->cwd = getcwd(buf, sizeof buf) ? xstrdup(buf) : xstrdup(""); + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + snap->saved_at = (unsigned long long)ts.tv_sec; + snap->cfg = config_clone(&s->cfg); + for (const auto &m : s->hist.msgs) + snap->hist.msgs.push_back(m); + snap->active_preset = + s->active_preset ? xstrdup(s->active_preset) : NULL; + snap->profile = s->profile ? xstrdup(s->profile) : NULL; + snap->last_context_tokens = s->last_context_tokens; + return snap; +} + +/* Write `raw` to `path` atomically (same-dir temp file + rename). */ +static int write_atomic(const char *path, const char *raw, size_t len, + char **err) { + /* ensure parent dir(s) exist */ + const char *slash = strrchr(path, '/'); + if (slash && slash != path) { + char *parent = xstrndup(path, (size_t)(slash - path)); + if (mkdir_p(parent) != 0) { + free(parent); + if (err) + *err = xstrfmt("creating parent dirs for `%s`: %s", path, + strerror(errno)); + return -1; + } + free(parent); + } + char *tmp = xstrfmt("%s.XXXXXX", path); + int fd = mkstemp(tmp); + int rc = -1; + if (fd < 0) { + if (err) + *err = xstrfmt("creating temporary session file for `%s`: %s", + path, strerror(errno)); + } else if (fchmod(fd, 0600) != 0) { + if (err) + *err = xstrfmt("setting session file permissions: %s", strerror(errno)); + close(fd); + unlink(tmp); + } else { + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, raw + off, len - off); + if (n > 0) + off += (size_t)n; + else if (n < 0 && errno == EINTR) + continue; + else + break; + } + if (off == len && fsync(fd) == 0 && close(fd) == 0) { + rc = rename(tmp, path); + if (rc != 0 && err) + *err = xstrfmt("renaming session file to `%s`: %s", path, + strerror(errno)); + } else { + int e = errno ? errno : EIO; + close(fd); + errno = e; + if (err) + *err = xstrfmt("writing session file: %s", strerror(errno)); + } + if (rc != 0) + unlink(tmp); + } + free(tmp); + return rc; +} + +void session_set_debug_path(session *s, char *path) { + if (!s) + return; + free(s->debug_path); + s->debug_path = path; /* may be NULL (disabled) */ +} + +int session_debug_dump(session *s, char **err) { + if (!s) + return 0; + if (!s->debug_path || s->debug_path[0] == '\0') + return 0; + nlohmann::json hist = nlohmann::json::array(); + for (const auto &m : s->hist.msgs) + hist.push_back(m); + std::string raw; + try { + raw = hist.dump(2); + } catch (...) { + if (err) + *err = xstrdup("encoding debug dump"); + return -1; + } + return write_atomic(s->debug_path, raw.c_str(), raw.size(), err); +} + +int session_save(session *s, const char *path, char **err) { + session_snapshot *snap = session_snapshot_of(s); + std::string raw; + try { + raw = snapshot_to_json(snap).dump(); + } catch (...) { + session_snapshot_free(snap); + if (err) + *err = xstrdup("encoding session"); + return -1; + } + session_snapshot_free(snap); + return write_atomic(path, raw.c_str(), raw.size(), err); +} + +session_snapshot *session_load(const char *path, char **err) { + char *raw = NULL; + size_t len = 0; + char *rerr = NULL; + if (read_file_to_buf(path, &raw, &len, &rerr) != 0) { + free(rerr); + if (raw) + free(raw); + char *e = xstrfmt("reading %s", path); + if (err) + *err = e; + else + free(e); + return NULL; + } + free(rerr); + nlohmann::json json; + try { + json = nlohmann::json::parse(raw); + } catch (...) { + free(raw); + char *e = xstrfmt("parsing %s", path); + if (err) + *err = e; + else + free(e); + return NULL; + } + free(raw); + long long version = -1; + if (json.is_object() && json.contains("version") && + json.at("version").is_number()) + version = (long long) json.at("version").get<double>(); + if (version != (long long)SNAPSHOT_VERSION) { + char *e = xstrfmt("unsupported session version %lld (this build " + "understands %lu)", + version, SNAPSHOT_VERSION); + if (err) + *err = e; + else + free(e); + return NULL; + } + session_snapshot *snap = json_to_snapshot(json); + return snap; +} + +session *session_resume(session_snapshot *snap, dotagents dot, + llsh_model *model, char **err) { + (void)err; + char *profile = NULL; + if (snap->profile) { + if (dotagents_enabled_sub_agent(&dot, snap->profile)) { + profile = xstrdup(snap->profile); + } else { + fprintf(stderr, "warning: saved sub-agent `%s` is not available " + "here; using the main agent\n", + snap->profile); + } + } + session *s = (session *) xmalloc(sizeof *s); + new (s) session{}; + s->model = model; + s->cfg = config_clone(&snap->cfg); + s->dot = dot; + s->hist.msgs = snap->hist.msgs; + s->base_preamble = sess_base_preamble_for(&s->cfg, &s->dot); + s->active_preset = + snap->active_preset ? xstrdup(snap->active_preset) : NULL; + s->profile = profile; + s->last_context_tokens = snap->last_context_tokens; + s->summarizer = NULL; + s->goal_evaluator = NULL; + s->goal = NULL; + compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, + s->cfg.clear_threshold, &s->compact); + session_snapshot_free(snap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* Turn */ +/* ------------------------------------------------------------------ */ + +static void session_append_streamed(session *s, const char *prompt, + char *assistant, message *new_messages, + size_t n_new) { + if (new_messages && n_new > 0) { + for (size_t i = 0; i < n_new; i++) + s->hist.msgs.push_back(std::move(new_messages[i])); + delete[] new_messages; + } else { + history_push(&s->hist, msg_user_text(prompt)); + if (assistant && assistant[0]) + history_push(&s->hist, msg_assistant_text(assistant)); + } + free(assistant); +} + +static void session_discard_streamed(char *assistant, message *new_messages, + size_t n_new) { + if (new_messages) + delete[] new_messages; + free(assistant); +} + +static int session_run_turn_impl(session *s, const char *prompt, + std::atomic<bool> *stop, sess_emit_cb cb, + void *user, char **err, int *result) { + compact_report report; + memset(&report, 0, sizeof report); + char *cerr = NULL; + s->summary_attempted_this_turn = false; + int rc = session_compact_if_needed(s, false, &report, &cerr); + if (rc < 0) { + if (err) + *err = cerr; + else + free(cerr); + return -1; + } + if (rc == 1) + session_print_compact(&report); + + if (stop && stop->load()) { + history_push(&s->hist, msg_user_text(prompt)); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + + unsigned long max_retries = s->cfg.max_retries; + unsigned long retry = 0; + bool overflow_retried = false; + for (;;) { + sess_error e = {0}; + sess_error_clear(&e); + char *assistant = NULL; + unsigned long input_tokens = 0; + message *new_messages = NULL; + size_t n_new = 0; + int rcs = s->model->stream( + s->model, s->hist.msgs.data(), s->hist.msgs.size(), + prompt, cb, user, stop, &assistant, &input_tokens, + &new_messages, &n_new, &e); + if (rcs == SESS_STREAM_OK) { + session_append_streamed(s, prompt, assistant, new_messages, n_new); + if (input_tokens > 0) + s->last_context_tokens = input_tokens; + /* SelfCompact rubric probe: the turn is complete and the context + * size is final. A probe failure never fails the turn. */ + char *perr = NULL; + if (session_rubric_probe(s, &perr) != 0) { + fprintf(stderr, DIM "[rubric] %s" RESET "\n", + perr ? perr : "probe failed"); + free(perr); + } + if (result) + *result = SESS_TURN_COMPLETED; + return 0; + } + if (rcs == SESS_STREAM_STOPPED) { + session_append_streamed(s, prompt, assistant, new_messages, n_new); + sess_error_clear(&e); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + if (rcs == SESS_STREAM_RETRYABLE) { + session_discard_streamed(assistant, new_messages, n_new); + sess_retry *r = sess_classify_retry(&e); + if (retry < max_retries) { + retry++; + unsigned long delay = + (r && r->has_retry_after) ? r->retry_after_secs + : sess_backoff_secs(retry); + char *notice = xstrfmt( + "\n" DIM "[provider error (%s); retry %lu/%lu in %lus]" + RESET "\n", + r ? r->reason : "unknown", retry, max_retries, + sess_human_secs(delay)); + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = SESS_EMIT_VERBATIM; + ev.text = notice; + if (cb) + cb(&ev, user); + free(notice); + sess_retry_free(r); + sess_error_clear(&e); + /* Actually wait before retrying; a stop request ends the turn. */ + unsigned long delay_ms = delay * 1000UL; + for (unsigned long slept = 0; slept < delay_ms; slept += 50) { + if (stop && stop->load()) + break; + sleep_ms(50); + } + if (stop && stop->load()) { + history_push(&s->hist, msg_user_text(prompt)); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + continue; + } else { + char *msg = xstrfmt("provider error after %lu retries: %s", + retry, + e.message ? e.message : "unknown"); + sess_error_clear(&e); + sess_retry_free(r); + if (err) + *err = msg; + else + free(msg); + return -1; + } + } + /* SESS_STREAM_FAILED */ + /* Context overflow is not a provider fault: the request grew past + * the window, so compact the history and retry once. One recovery + * per turn keeps a too-large tail (or a provider that reports a + * smaller window than it enforces) from looping the turn. */ + dbgf("RUN overflow-retried=%d is_overflow=%d limit=%lu\n",(int)overflow_retried,(int)sess_error_is_context_overflow(&e),s->compact.context_limit); + if (!overflow_retried && s->compact.context_limit > 0 && + sess_error_is_context_overflow(&e)) { + overflow_retried = true; + char *reason = e.message ? xstrdup(e.message) + : xstrdup("context overflow"); + sess_error_clear(&e); + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = SESS_EMIT_VERBATIM; + ev.text = xstrfmt( + "\n" DIM + "[request exceeds the context window: %s; compacting and " + "retrying]" RESET "\n", + reason); + if (cb) + cb(&ev, user); + sess_emit_free(&ev); + free(reason); + compact_report crep; + memset(&crep, 0, sizeof crep); + char *cerr2 = NULL; + int crr = session_compact_if_needed(s, true, &crep, &cerr2); + if (cerr2) { + free(cerr2); + crr = 0; + } + if (crr > 0) + session_print_compact(&crep); + /* Retry once even when the compact pass was a no-op: a provider + * that reports a smaller window than it enforces may still accept + * the resubmitted request. overflow_retried stops a second pass. + * e was cleared above; the fresh error (if any) is surfaced below. */ + continue; + } + char *msg = xstrfmt("stream error: %s", + e.message ? e.message : "unknown"); + sess_error_clear(&e); + if (err) + *err = msg; + else + free(msg); + return -1; + } +} + +int session_run_turn(session *s, const char *prompt, std::atomic<bool> *stop, + sess_emit_cb cb, void *user, char **err, int *result) { + int rc = session_run_turn_impl(s, prompt, stop, cb, user, err, result); + /* --debug: refresh the history dump on every turn, whatever the outcome. + * A failed dump never fails the turn; it is reported on stderr. */ + char *derr = NULL; + if (session_debug_dump(s, &derr) != 0) { + if (s && s->debug_path) + fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed"); + free(derr); + } + return rc; +} diff --git a/src/session.h b/src/session.h new file mode 100644 index 0000000..ce4dedf --- /dev/null +++ b/src/session.h @@ -0,0 +1,293 @@ +#ifndef LLSH_SESSION_H +#define LLSH_SESSION_H + +#include <atomic> +#include <stdlib.h> + +#include "config.h" +#include "message.h" +#include "compact.h" +#include "goal.h" + +/* ------------------------------------------------------------------ */ +/* Model abstraction (mirrors the rig `Agent` a session drives). */ +/* A model is an opaque object with a small vtable: a single-shot */ +/* `complete` (summarizer / goal evaluator) and a `stream` that drains */ +/* one turn, emitting events as they arrive. */ +/* ------------------------------------------------------------------ */ + +/* One unit the stream can emit, in arrival order. */ +enum { + SESS_EMIT_TEXT = 0, + SESS_EMIT_REASONING, + SESS_EMIT_VERBATIM, + SESS_EMIT_STATUS, + SESS_EMIT_RESULT, +}; + +typedef struct sess_emit { + int kind; /* one of SESS_EMIT_* */ + char *text; /* TEXT/REASONING/VERBATIM/RESULT: body */ + bool from_shell; /* RESULT: came from a shell-style tool */ + unsigned long input_tokens; /* STATUS: the new context size */ +} sess_emit; + +void sess_emit_free(sess_emit *e); + +/* Callback invoked for each emit. Return 0 to continue, non-zero to abort + * (a user stop). */ +typedef int (*sess_emit_cb)(const sess_emit *e, void *user); + +/* Error reported by a model call. `message` is owned by the receiver and + * must be freed; the other fields are plain values. */ +typedef struct sess_error { + char *message; + int http_status; /* 0 = no HTTP status */ + int transport; /* 1 = bare transport error (no status) */ + unsigned long retry_after_secs; /* 0 = none */ +} sess_error; + +void sess_error_clear(sess_error *e); + +/* Terminal result of a model `stream` call. */ +enum { + SESS_STREAM_OK = 0, + SESS_STREAM_RETRYABLE, /* transient, safe to retry (no output committed) */ + SESS_STREAM_FAILED, /* non-retryable failure */ + SESS_STREAM_STOPPED, /* the user stopped the turn */ +}; + +typedef struct llsh_model llsh_model; +struct llsh_model { + /* Single-shot completion (summarizer / goal evaluator). Returns the + * model's text (caller frees) or NULL on error (`err` filled). */ + char *(*complete)(const llsh_model *m, const char *prompt, sess_error *err); + + /* Stream one turn: send history + prompt, call `cb` for each emit. + * `stop` is polled between emits; if set (or `cb` returns non-zero) the + * stream stops and returns SESS_STREAM_STOPPED. + * On SESS_STREAM_OK / SESS_STREAM_STOPPED: + * - *assistant_text (caller frees; may be empty) is the turn's prose; + * - *input_tokens (last completion call's input tokens, or 0) is set; + * - if the model produced structured history, *new_messages is an + * allocated array of owned `message` values and *n_new is its length; + * the caller takes ownership (release with `delete[]`). + * When no structured history is produced, leave *new_messages NULL and + * *n_new 0; the caller falls back to `assistant_text`. + * On SESS_STREAM_RETRYABLE / SESS_STREAM_FAILED fills *err. */ + int (*stream)(const llsh_model *m, + const message *history, size_t nhistory, + const char *prompt, + sess_emit_cb cb, void *user, + std::atomic<bool> *stop, + char **assistant_text, unsigned long *input_tokens, + message **new_messages, size_t *n_new, + sess_error *err); + + /* Optional destructor. When set, `llsh_model_free` calls it; otherwise + * it falls back to plain `free`. */ + void (*destroy)(llsh_model *m); +}; + +static inline void llsh_model_free(llsh_model *m) { + if (!m) + return; + if (m->destroy) + m->destroy(m); + else + free(m); +} + +/* ------------------------------------------------------------------ */ +/* Pure helpers (no session state; directly unit-testable). */ +/* ------------------------------------------------------------------ */ + +/* HTTP statuses treated as transient (worth retrying). */ +bool sess_is_transient_status(int status); + +/* Exponential backoff for the `attempt`-th retry (1-indexed), in whole + * seconds: 1, 2, 4, 8, 16, then capped at 30. */ +unsigned long sess_backoff_secs(unsigned long attempt); + +/* Render a duration (whole seconds, at least 1) for a retry notice. */ +unsigned long sess_human_secs(unsigned long secs); + +/* Classification of a retryable provider failure. */ +typedef struct sess_retry { + char *reason; /* owned */ + unsigned long retry_after_secs; /* 0 = none */ + bool has_retry_after; +} sess_retry; +void sess_retry_free(sess_retry *r); + +/* Classify a model error as retryable. Returns a freshly allocated + * sess_retry, or NULL if the error is permanent. Caller frees. */ +sess_retry *sess_classify_retry(const sess_error *e); + +/* True when a model error means the request exceeded the provider's context + * window (a 400-class error mentioning context length, or a 413). The turn + * can recover by compacting history and retrying once. */ +bool sess_error_is_context_overflow(const sess_error *e); + +/* Map one streamed assistant content item to an emit. + * `kind`: "text" | "reasoning" | "tool_call". + * `text`: the body (text/reasoning) or, for a tool call, the tool name. + * `args`: for a tool call, the args JSON; else NULL. + * Returns 0 and fills *out (caller frees the fields), or 1 for no emit. */ +int sess_assistant_emit(const char *kind, const char *text, const char *args, + sess_emit *out); + +/* Render a tool's result for display: the full result body, normalized to + * end with exactly one newline. Returns 0 and fills *out (caller frees the + * fields), or 1 for no emit (NULL / empty result). */ +int sess_tool_result_emit(const char *tool, const char *result, + sess_emit *out); + +/* The models.json `default` preset name active at startup, or NULL. + * Caller frees. */ +char *session_active_preset_from(const dotagents *dot); + +/* The base system preamble: `.agents` system-prompt.md (a full override) or + * the configured preamble, with the project sections appended. Caller frees. */ +char *sess_base_preamble_for(const config *cfg, const dotagents *dot); + +/* The full preamble for a profile: the sub-agent's agent.md body plus the + * project sections, or the base preamble when the profile is inactive or its + * sub-agent is missing. Caller frees. `profile` may be NULL. */ +char *sess_preamble_for_profile(const dotagents *dot, const config *cfg, + const char *profile); + +/* Apply model/connection overrides onto `cfg`. In strict mode (a preset) an + * unparseable provider is a hard error; otherwise it is skipped. Returns 0 on + * success, -1 on error (*err set). */ +int sess_apply_overrides(config *cfg, const char *model, const char *provider, + const char *base_url, const char *api_key, + bool strict, char **err); + +/* The effective config for a profile: a deep copy of `cfg` with the + * profile's config.json overrides applied. Caller frees with config_free. + * `profile` may be NULL (returns a plain clone). */ +config sess_effective_for_profile(const config *cfg, const dotagents *dot, + const char *profile); + +/* ------------------------------------------------------------------ */ +/* Session */ +/* ------------------------------------------------------------------ */ + +typedef struct session session; + +/* Turn result. */ +enum { SESS_TURN_COMPLETED = 0, SESS_TURN_STOPPED = 1 }; + +/* Adopt `cfg`, `dot`, and `model` (the caller no longer owns them). */ +session *session_new_with_model(llsh_model *model, config cfg, dotagents dot); +void session_free(session *s); + +/* Inject a tool-less summarizer / goal evaluator / rubric judge (adopted). + * Returns s. */ +session *session_with_summarizer(session *s, llsh_model *summarizer); +session *session_with_goal_evaluator(session *s, llsh_model *evaluator); +session *session_with_rubric(session *s, llsh_model *rubric); + +/* -- accessors (caller frees returned strings) -- */ +char *session_model(session *s); +const config *session_config(const session *s); +char *session_active_preset(session *s); +char *session_profile_name(session *s); +const dotagents *session_dot(const session *s); /* borrowed; do not free */ +/* Allowed built-in tools for the active profile (borrowed; do not free). + * An empty list means "all built-in tools allowed". */ +const strlist *session_allowed_tools(session *s); +unsigned long session_context_tokens(session *s); +unsigned long session_context_limit(session *s); +const goal_report *session_goal_report(session *s); +void session_set_goal_report(session *s, goal_report *r); /* adopts r */ +char *session_last_assistant_text(session *s); +/* Both return borrowed state, valid until the next set_model / set_profile / + * refresh_dot / apply_preset call. */ +const char *session_current_preamble(session *s); +const config *session_active_config(session *s); +int session_refresh_context_limit(session *s); /* 1 if a provider-reported limit was adopted, 0 if none was learned */ + +/* -- history -- */ +size_t session_history_len(session *s); +const message *session_history_at(session *s, size_t i); +void session_push_history(session *s, message m); /* copies m */ +unsigned long session_last_context_tokens(session *s); +void session_set_last_context_tokens(session *s, unsigned long t); +void session_clear_history(session *s); +/* Remove the most recent user turn. Returns -1 when none, else the number of + * messages removed. */ +int session_undo_last_turn(session *s); +size_t session_message_count(session *s); +char *session_transcript_markdown(session *s); + +/* -- compaction -- */ +/* Run the two-tier compaction pass if the context is large enough (or always, + * when `force`). Returns 0 = no pass (report untouched), 1 = pass ran + * (report filled), -1 = error (*err set). */ +int session_compact_if_needed(session *s, bool force, compact_report *report, + char **err); +void session_print_compact(const compact_report *report); + +/* -- rubric compaction (SelfCompact, arXiv:2606.23525) -- */ +/* Record one completed tool round. The provider calls this after each tool + * round; it feeds the rubric probe gates. */ +void session_note_tool_round(session *s); + +/* Model-invoked compaction (the `compact` tool): run the pass unconditionally. + * Returns 0 = no pass (history too short), 1 = pass ran (report filled), + * -1 = error (*err set). */ +int session_force_compact(session *s, compact_report *report, char **err); + +/* End-of-turn rubric probe: when the gates are open, ask the summarizer to + * judge the trajectory and compact only if the rubric fires. Returns + * 0 = no compaction, 1 = compacted, -1 = error (*err set). */ +int session_rubric_probe(session *s, char **err); + +/* -- goal -- */ +int session_evaluate_goal(session *s, const goal_spec *spec, + const char *last_reply, goal_verdict *out, + char **err); + +/* -- model / profile -- */ +int session_set_model(session *s, const char *name, char **err); +int session_set_profile(session *s, const char *id, char **err); +int session_refresh_dot(session *s, const char *workspace, const char *global, + char **err); + +/* -- debug dump -- */ +/* Set (or clear with NULL) the file the whole conversation history is + * written to when `--debug` is active. `path` is adopted. */ +void session_set_debug_path(session *s, char *path); +/* Write the current history to the debug path (no-op when unset). + * Returns 0 on success / no-op, -1 on error (*err set, caller frees). */ +int session_debug_dump(session *s, char **err); + +/* -- save / load / resume -- */ +typedef struct session_snapshot { + unsigned long version; + char *cwd; + unsigned long long saved_at; + config cfg; /* owned (name avoids the C++ type/member clash) */ + history hist; /* owned */ + char *active_preset; /* may be NULL */ + char *profile; /* may be NULL */ + unsigned long last_context_tokens; +} session_snapshot; + +void session_snapshot_free(session_snapshot *snap); +session_snapshot *session_snapshot_of(session *s); +int session_save(session *s, const char *path, char **err); +session_snapshot *session_load(const char *path, char **err); +/* Adopt `snap`, `dot`, and `model`; free the snapshot. */ +session *session_resume(session_snapshot *snap, dotagents dot, + llsh_model *model, char **err); + +/* Run one user turn. `stop` may be set by the caller/cb to abort; if already + * set the prompt is kept in context and the turn is Stopped. `err` and + * `result` are optional. Returns 0 on a completed/stopped turn, -1 on error. */ +int session_run_turn(session *s, const char *prompt, std::atomic<bool> *stop, + sess_emit_cb cb, void *user, char **err, int *result); + +#endif diff --git a/src/shell.cpp b/src/shell.cpp new file mode 100644 index 0000000..9b0bcae --- /dev/null +++ b/src/shell.cpp @@ -0,0 +1,303 @@ +#define _XOPEN_SOURCE 600 +#include "shell.h" + +#include <errno.h> +#include <pty.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <sys/wait.h> +#include <unistd.h> + +/* Fallback cap for in-memory PTY capture; matches the config + * `max_output_chars` default. The caller overrides with the effective value. */ +#define DEFAULT_SHELL_CAPTURE_CAP 16000UL + +/* Read the current terminal size (0x0 when not a TTY). */ +static struct winsize current_winsize(void) { + struct winsize ws = {0, 0, 0, 0}; + (void)ioctl(STDIN_FILENO, TIOCGWINSZ, &ws); + return ws; +} + +/* Reader thread: stream the shell's PTY output to stdout, collapsing CRLF to + * a single LF (the real terminal's ONLCR will re-expand it exactly once). */ +static void *shell_reader(void *arg) { + interactive_shell *s = (interactive_shell *) arg; + unsigned char buf[8192]; + unsigned char out[16384]; + size_t out_len = 0; + bool pending_cr = false; + + for (;;) { + ssize_t n = read(s->reader_fd, buf, sizeof buf); + if (n <= 0) + break; + for (ssize_t i = 0; i < n; i++) { + unsigned char b = buf[i]; + if (pending_cr) { + if (b == '\n') { + out[out_len++] = '\n'; + } else { + if (out_len + 2 > sizeof out) { + write(STDOUT_FILENO, out, out_len); + out_len = 0; + } + out[out_len++] = '\r'; + out[out_len++] = b; + } + pending_cr = false; + } else if (b == '\r') { + pending_cr = true; + } else { + out[out_len++] = b; + } + } + if (out_len) { + (void)write(STDOUT_FILENO, out, out_len); + pthread_mutex_lock(&s->out_mu); + if (s->outbuf.len < s->capture_cap) + xbuf_putn(&s->outbuf, (const char *)out, out_len); + pthread_mutex_unlock(&s->out_mu); + out_len = 0; + } + } + if (pending_cr) + out[out_len++] = '\r'; + if (out_len) { + (void)write(STDOUT_FILENO, out, out_len); + pthread_mutex_lock(&s->out_mu); + if (s->outbuf.len < s->capture_cap) + xbuf_putn(&s->outbuf, (const char *)out, out_len); + pthread_mutex_unlock(&s->out_mu); + } + pthread_mutex_lock(&s->done_mu); + s->reader_done = true; + pthread_mutex_unlock(&s->done_mu); + return NULL; +} + +interactive_shell *shell_spawn(void) { + const char *shell = getenv("SHELL"); + if (!shell || !*shell) + shell = "bash"; + + char *argv[] = {(char *)shell, "-i", NULL}; + struct winsize ws = current_winsize(); + + int master = 0; + pid_t pid = forkpty(&master, NULL, NULL, &ws); + if (pid < 0) + return NULL; + if (pid == 0) { + /* Child: reset inherited signal handlers, then exec. */ + signal(SIGINT, SIG_DFL); + signal(SIGQUIT, SIG_DFL); + signal(SIGTERM, SIG_DFL); + execvp(shell, argv); + _exit(127); + } + + interactive_shell *s = (interactive_shell *) xmalloc(sizeof *s); + memset(s, 0, sizeof *s); + s->pid = (int)pid; + s->master = master; + s->last_cwd = NULL; + + int reader_fd = dup(master); + if (reader_fd < 0) { + close(master); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s); + return NULL; + } + s->reader_fd = reader_fd; + s->reader_done = false; + xbuf_init(&s->outbuf); + pthread_mutex_init(&s->out_mu, NULL); + /* Cap the in-memory capture at the tool output limit (the config + * `max_output_chars` default); the caller overrides this with the + * effective config value. The transcript is trimmed to the same limit + * when recorded. */ + s->capture_cap = DEFAULT_SHELL_CAPTURE_CAP; + if (pthread_mutex_init(&s->done_mu, NULL) != 0) { + close(reader_fd); + close(master); + free(s); + return NULL; + } + if (pthread_create(&s->reader_th, NULL, shell_reader, s) != 0) { + (void)kill((pid_t)pid, SIGKILL); + (void)kill(-(pid_t)pid, SIGKILL); + int status = 0; + (void)waitpid((pid_t)pid, &status, 0); + close(reader_fd); + close(master); + pthread_mutex_destroy(&s->done_mu); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s); + return NULL; + } + return s; +} + +int shell_write(const interactive_shell *s, const void *buf, size_t len) { + if (s->master < 0) + return -1; + const unsigned char *p = (const unsigned char* ) buf; + size_t off = 0; + while (off < len) { + ssize_t n = write(s->master, p + off, len - off); + if (n < 0) { + if (errno == EINTR) + continue; + return -1; + } + off += (size_t)n; + } + return 0; +} + +void shell_update_winsize(const interactive_shell *s) { + if (s->master < 0) + return; + struct winsize ws = current_winsize(); + (void)ioctl(s->master, TIOCSWINSZ, &ws); +} + +/* Read a process's cwd from /proc/<pid>/cwd (Linux). Freshly allocated, or + * NULL when the process is gone / unreadable. */ +static char *read_proc_cwd(int pid) { + if (pid <= 0) + return NULL; + char path[64]; + snprintf(path, sizeof path, "/proc/%d/cwd", pid); + char link[4096]; + ssize_t n = readlink(path, link, sizeof link - 1); + if (n <= 0) + return NULL; + link[n] = '\0'; + return xstrdup(link); +} + +char *shell_final_cwd(const interactive_shell *s) { + char *live = read_proc_cwd(s->pid); + if (live) + return live; + if (s->last_cwd) + return xstrdup(s->last_cwd); + return NULL; +} + +/* Strip terminal control/escape sequences from `src` so the text is suitable + * for the conversation context (no colors, cursor moves, OSC prompts, or + * prompt decorations). Keeps printable text, tabs, and newlines; a lone CR + * (not part of the already-collapsed CRLF) becomes a newline. */ +static char *shell_sanitize(const char *src) { + xbuf b; + xbuf_init(&b); + const unsigned char *p = (const unsigned char *)src; + while (*p) { + if (*p == '\x1B') { + if (p[1] == '[') { + /* CSI: params + intermediates, terminated by 0x40-0x7E. */ + const unsigned char *q = p + 2; + while (*q && *q < 0x40) q++; + if (*q) q++; + p = q; + } else if (p[1] == ']') { + /* OSC: terminated by BEL (0x07) or ST (ESC \). */ + const unsigned char *q = p + 2; + while (*q && *q != '\x07' && + !(*q == '\x1B' && q[1] == '\\')) + q++; + if (*q == '\x07') + q++; + else if (*q == '\x1B') + q += 2; + p = q; + } else { + /* Other two/three-byte escapes (e.g. ESC ( B, ESC 7). */ + p += (p[1] != 0) ? 2 : 1; + } + continue; + } + if (*p == '\r') { + xbuf_push(&b, '\n'); + p++; + continue; + } + if (*p < 0x20 && *p != '\t' && *p != '\n') { + p++; + continue; + } + xbuf_push(&b, (char)*p); + p++; + } + return xbuf_steal(&b); +} + +char *shell_captured(interactive_shell *s) { + pthread_mutex_lock(&s->out_mu); + const char *raw = s->outbuf.data ? s->outbuf.data : ""; + char *out = shell_sanitize(raw); + xbuf_reset(&s->outbuf); + pthread_mutex_unlock(&s->out_mu); + return out; +} + +bool shell_reap_if_dead(interactive_shell *s) { + if (s->pid == 0) + return true; + /* Wait for the PTY reader to see EOF before returning to the chat prompt, + * so final shell output is not interleaved with it. */ + pthread_mutex_lock(&s->done_mu); + bool reader_done = s->reader_done; + pthread_mutex_unlock(&s->done_mu); + if (!reader_done) { + /* Track where the shell is while it is still alive: once it has + * exited, /proc/<pid>/cwd is unreadable even before the reap. */ + char *cwd = read_proc_cwd(s->pid); + if (cwd) { + free(s->last_cwd); + s->last_cwd = cwd; + } + return false; + } + int status = 0; + pid_t r = waitpid((pid_t)s->pid, &status, WNOHANG); + if (r == (pid_t)s->pid) { + s->pid = 0; + return true; + } + return false; +} + +void shell_free(interactive_shell *s) { + if (!s) + return; + if (s->pid != 0) { + /* Kill the shell (and its process group) so it does not print a + * newline or prompt fragment while we redraw the chat prompt. */ + (void)kill((pid_t)s->pid, SIGKILL); + (void)kill(-(pid_t)s->pid, SIGKILL); + int status = 0; + (void)waitpid((pid_t)s->pid, &status, 0); + s->pid = 0; + } + if (s->master >= 0) { + close(s->master); + s->master = -1; + } + (void)pthread_join(s->reader_th, NULL); + pthread_mutex_destroy(&s->done_mu); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s->last_cwd); + s->last_cwd = NULL; + free(s); +} diff --git a/src/shell.h b/src/shell.h new file mode 100644 index 0000000..18c8f67 --- /dev/null +++ b/src/shell.h @@ -0,0 +1,49 @@ +#ifndef LLSH_SHELL_H +#define LLSH_SHELL_H + +#include "util.h" +#include <pthread.h> +#include <stdbool.h> + +/* An interactive user shell running on a private PTY. llsh forwards raw + * terminal input to the PTY and streams the PTY's output back to the real + * terminal, so the shell's own line editor (Tab completion, history, …) is + * used while in shell mode. Mirrors the Rust `InteractiveShell` in main.rs. + * POSIX-only (forkpty/raw mode). */ +typedef struct interactive_shell { + int pid; /* shell PID; 0 once reaped */ + int master; /* PTY master fd; -1 once closed */ + int reader_fd; /* dup of master, owned by the reader thread */ + pthread_t reader_th; + pthread_mutex_t done_mu; /* guards reader_done */ + bool reader_done; /* set by the reader thread on EOF */ + xbuf outbuf; /* owned; output capture (shared with reader thread) */ + pthread_mutex_t out_mu; /* guards outbuf */ + unsigned long capture_cap; /* max bytes to capture (bounds memory) */ + char *last_cwd; /* owned; last observed cwd (fallback after reap) */ +} interactive_shell; + +/* Fork `$SHELL` (else `bash`) with `-i` on a private PTY and start a reader + * thread that streams the shell's output to stdout (CRLF collapsed to LF). + * Returns NULL on failure. */ +interactive_shell *shell_spawn(void); +/* Forward raw input bytes to the shell. 0 on success, -1 on error. */ +int shell_write(const interactive_shell *s, const void *buf, size_t len); +/* Resize the PTY to match the current terminal size. */ +void shell_update_winsize(const interactive_shell *s); +/* True once the shell has exited, its output is drained, and it has been + * reaped. Cheap to call each loop iteration while in shell mode. */ +bool shell_reap_if_dead(interactive_shell *s); +/* The directory the shell ended in: a live `/proc/<pid>/cwd` read while it + * is still alive, falling back to the last observed directory once reaped. + * Returns a freshly allocated string, or NULL when unknown. */ +char *shell_final_cwd(const interactive_shell *s); +/* Everything the shell has printed so far (owned copy), and reset the + * capture. Thread-safe against the reader thread; returns an empty string + * when nothing was captured. */ +char *shell_captured(interactive_shell *s); +/* Kill the shell (whole process group), close the PTY, and join the reader + * thread. Safe to call on a fully-reaped shell. */ +void shell_free(interactive_shell *s); + +#endif 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(); +} diff --git a/src/tools.h b/src/tools.h new file mode 100644 index 0000000..9423320 --- /dev/null +++ b/src/tools.h @@ -0,0 +1,118 @@ +#ifndef LLSH_TOOLS_H +#define LLSH_TOOLS_H + +#include <cjson/cJSON.h> + +#include "config.h" +#include "util.h" +#include "dotagents.h" + +/* Mirrors tools.rs: the built-in tools the agent can call. + * Each tool returns an owned string on success, or NULL with an owned + * error message in **err (caller frees whichever was returned). */ + +char *tool_read_file(const char *path, unsigned long offset, unsigned long limit, + char **err); +char *tool_write_file(const char *path, const char *content, char **err); +char *tool_run_shell(const char *command, char **err); +char *tool_run_shell_timed(const char *command, unsigned long timeout_secs, + char **err); +/* `dot` is the caller's loaded .agents snapshot (borrowed); NULL means "no + * skills/memories". */ +char *tool_use_skill(const dotagents *dot, const char *id, char **err); +char *tool_search_memories(const dotagents *dot, const char *query, char **err); + +/* Embedding tool. The `default` preset of `.agents/models.json` supplies + * the connection; preset fields fall back to the top-level values. */ +typedef struct { + char *model; /* owned */ + char *base_url; /* owned */ + char *api_key; /* owned */ +} embed_target; +void embed_target_free(embed_target *t); +/* Resolve the configured embedding connection. 0 + *out filled, or -1 with + * *err owned. */ +int tool_embed_resolve(const config *cfg, const dotagents *dot, + embed_target *out, char **err); + +/* Pure helpers (no network; unit-testable). */ +char *tool_embed_request(const char *model, const char *text, char **err); +char *tool_embed_url(const char *base_url); +/* `body` -> the `data[0].embedding` array as a JSON array string. 0 + + * *vector_json owned, or -1 with *err owned. */ +int tool_embed_parse(const char *body, char **vector_json, char **err); + +/* Full tool: resolve + POST {base}/embeddings + parse. Returns owned + * `{"dimensions":N,"vector":[...]}` or NULL with *err owned. */ +char *tool_embed(const char *text, const config *cfg, const dotagents *dot, + char **err); + +/* LLM call tool: a one-shot completion through the session's provider + * (OpenAI-compatible, Anthropic, or Ollama). `prompt` is the single user + * message. The remaining fields are optional generation parameters, each + * with an "unset" value (temperature < 0, max_tokens / thinking_budget 0, + * pointers NULL); `embed` true routes the prompt to the embeddings + * endpoint instead. All strings borrowed for the call's lifetime. */ +typedef struct { + const char *prompt; /* required */ + const char *model; /* optional override; NULL = session model */ + double temperature; /* < 0 = unset */ + unsigned long max_tokens; /* 0 = unset */ + const char *reasoning_effort; /* "low" / "medium" / "high"; NULL = unset */ + unsigned long thinking_budget; /* 0 = unset (tokens) */ + const char *response_schema; /* JSON Schema text; NULL = unset */ + bool embed; /* true = embed the prompt instead */ +} llm_tool_call; + +/* Full tool: one-shot completion (or embed when `call->embed`). Returns + * owned result text, or NULL with *err owned. */ +char *tool_llm(const llm_tool_call *call, const config *cfg, const dotagents *dot, + char **err); + +/* Pure helpers (mirroring the tested functions in tools.rs). */ +char *tool_truncate_output(const char *text); +void tool_tokenize(const char *query, strlist *out); +bool tool_is_subsequence(const char *needle, const char *haystack); +double tool_field_match_strength(const char *field, const char *term); +double tool_memory_relevance(const memory *m, const strlist *terms); + +/* Static registry for /tools display. Returns the entry count and sets + * *out to a static array valid for the process lifetime. */ +typedef struct { + const char *name; + const char *desc; +} tool_entry; +size_t tool_list_entries(const tool_entry **out); + +/* ------------------------------------------------------------------ */ +/* Display vtable: per-tool transcript rendering */ +/* ------------------------------------------------------------------ */ +/* + * Presentation hooks for the built-in tools. A tool may supply a + * `render_call` that renders the one-line transcript display for an + * invocation from its raw args JSON (freshly allocated; return NULL to + * fall back to the generic `# name <args>` line). Tools without an + * entry (e.g. MCP tools) always use the generic line. `dim_result` + * marks the tool's result as shell-style (the UI renders it muted). + */ +typedef char *(*tool_render_call)(const char *args_json); + +typedef struct { + const char *name; + tool_render_call render_call; /* NULL -> generic display */ + bool dim_result; +} tool_display; + +/* The vtable entry for a built-in tool (borrowed; valid for the + * process lifetime), or NULL when `name` is not a built-in tool. */ +const tool_display *tool_display_for(const char *name); + +/* Built-in tool registry (single source of truth; llm.h builds the + * provider tool schemas and the default preamble from it). */ +size_t tool_builtin_count(void); +const char *tool_builtin_name(size_t i); +const char *tool_desc(const char *name); /* schema description; "" if unknown */ +cJSON *tool_params_json(const char *name); /* fresh object; caller frees */ +char *tool_preamble_list(void); /* "Available tools:" section; caller frees */ + +#endif diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..4721e11 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,628 @@ +#include "util.h" + +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +/* ------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------ */ + +void *xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "llsh: out of memory\n"); + abort(); + } + return p; +} + +void *xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "llsh: out of memory\n"); + abort(); + } + return q; +} + +/* ------------------------------------------------------------------ */ +/* xbuf */ +/* ------------------------------------------------------------------ */ + +void xbuf_init(xbuf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +void xbuf_free(xbuf *b) { + free(b->data); + b->data = NULL; + b->len = b->cap = 0; +} + +void xbuf_reset(xbuf *b) { + b->len = 0; +} + +static void xbuf_reserve(xbuf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *) xrealloc(b->data, cap); + b->cap = cap; + } +} + +void xbuf_putn(xbuf *b, const char *s, size_t n) { + xbuf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +void xbuf_put(xbuf *b, const char *s) { + if (s) + xbuf_putn(b, s, strlen(s)); +} + +void xbuf_push(xbuf *b, char c) { + xbuf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +int xbuf_fmt(xbuf *b, const char *fmt, ...) { + va_list ap, ap2; + if (!fmt) + return 0; + va_start(ap, fmt); + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) { + va_end(ap); + return -1; + } + xbuf_reserve(b, (size_t)n); + vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap); + va_end(ap); + b->len += (size_t)n; + return n; +} + +char *xbuf_steal(xbuf *b) { + if (!b->data) { + b->data = (char *) xmalloc(1); + } + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = b->cap = 0; + return s; +} + +size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud) { + xbuf_putn((xbuf *)ud, data, sz * nm); + return sz * nm; +} + +/* ------------------------------------------------------------------ */ +/* Strings */ +/* ------------------------------------------------------------------ */ + +char *xstrdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *) xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +char *xstrndup(const char *s, size_t n) { + char *p = (char *) xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +int xstreq(const char *a, const char *b) { + return a && b && strcmp(a, b) == 0; +} + +const char *xtrim_span(const char *s, size_t n, size_t *out_len) { + size_t a = 0, b = n; + while (a < b && isascii((unsigned char)s[a]) && isspace((unsigned char)s[a])) + a++; + while (b > a && isascii((unsigned char)s[b - 1]) && + isspace((unsigned char)s[b - 1])) + b--; + *out_len = b - a; + return s + a; +} + +bool utf8_is_ws(uint32_t c) { + return c == 0x09 || c == 0x0A || c == 0x0B || c == 0x0C || c == 0x0D || + c == 0x20 || c == 0x85 || c == 0xA0 || c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || c == 0x2028 || c == 0x2029 || + c == 0x202F || c == 0x205F || c == 0x3000; +} + +static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len); + +char *xstr_ltrim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + break; + p += l; + } + return xstrndup(p, end - p); +} + +char *xstr_rtrim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len, *last = s; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + last = p + l; + p += l; + } + return xstrndup(s, last - s); +} + +char *xstr_trim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + break; + p += l; + } + if (p == end) + return xstrdup(""); + const char *start = p; + const char *last = p; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + last = p + l; + p += l; + } + return xstrndup(start, (size_t)(last - start)); +} + +char *xstrjoin2(const char *a, const char *b) { + xbuf b_; + xbuf_init(&b_); + xbuf_put(&b_, a); + xbuf_put(&b_, b); + return xbuf_steal(&b_); +} + +char *xvsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return xstrdup(""); + char *p = (char *) xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +char *xstrfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = xvsnprintf(fmt, ap); + va_end(ap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* UTF-8 */ +/* ------------------------------------------------------------------ */ + +static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len) { + unsigned char c = (unsigned char)p[0]; + if (c < 0x80) { + *out_len = 1; + return c; + } + if (end - p >= 2 && (c & 0xE0) == 0xC0 && (p[1] & 0xC0) == 0x80) { + *out_len = 2; + return ((uint32_t)(c & 0x1F) << 6) | (p[1] & 0x3F); + } + if (end - p >= 3 && (c & 0xF0) == 0xE0 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80) { + *out_len = 3; + return ((uint32_t)(c & 0x0F) << 12) | ((uint32_t)(p[1] & 0x3F) << 6) | + (p[2] & 0x3F); + } + if (end - p >= 4 && (c & 0xF8) == 0xF0 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80) { + *out_len = 4; + return ((uint32_t)(c & 0x07) << 18) | + ((uint32_t)(p[1] & 0x3F) << 12) | + ((uint32_t)(p[2] & 0x3F) << 6) | (p[3] & 0x3F); + } + /* invalid: consume one byte */ + *out_len = 1; + return 0xFFFD; +} + +size_t utf8_len(const char *s) { + size_t n = 0; + const char *p = s, *end = s + strlen(s); + while (p < end) { + size_t l; + decode_utf8(p, end, &l); + p += l; + n++; + } + return n; +} + +size_t utf8_byte_len(const char *s, size_t max_points) { + size_t n = 0; + const char *p = s, *end = s + strlen(s); + while (p < end && n < max_points) { + size_t l; + decode_utf8(p, end, &l); + p += l; + n++; + } + return (size_t)(p - s); +} + +const char *utf8_next(const char *p, const char *end, size_t *cp) { + size_t l; + uint32_t c = decode_utf8(p, end, &l); + if (cp) + *cp = c; + return p + l; +} + +static int is_wide(uint32_t c) { + return (c >= 0x1100 && c <= 0x115F) || + (c >= 0x2E80 && c <= 0x303E) || + (c >= 0x3041 && c <= 0x33FF) || + (c >= 0x3400 && c <= 0x4DBF) || + (c >= 0x4E00 && c <= 0x9FFF) || + (c >= 0xA000 && c <= 0xA4CF) || + (c >= 0xAC00 && c <= 0xD7A3) || + (c >= 0xF900 && c <= 0xFAFF) || + (c >= 0xFE30 && c <= 0xFE4F) || + (c >= 0xFF00 && c <= 0xFF60) || + (c >= 0xFFE0 && c <= 0xFFE6) || + (c >= 0x1F300 && c <= 0x1F64F) || + (c >= 0x1F900 && c <= 0x1F9FF) || + (c >= 0x20000 && c <= 0x3FFFD); +} + +size_t utf8_display_width(const char *s) { + size_t w = 0; + const char *p = s, *end = s + strlen(s); + while (p < end) { + size_t l; + uint32_t c = decode_utf8(p, end, &l); + if (c >= 0x200B && c <= 0x200F) { /* zero-width joiners etc. */ + p += l; + continue; + } + if (c == 0) { + p += l; + continue; + } + w += is_wide(c) ? 2 : 1; + p += l; + } + return w; +} + +bool utf8_is_valid(const char *s, size_t len) { + const unsigned char *p = (const unsigned char *)s; + const unsigned char *end = p + len; + while (p < end) { + unsigned char c = *p; + int n; + if (c < 0x80) { + n = 1; + } else if ((c & 0xE0) == 0xC0) { + n = 2; + } else if ((c & 0xF0) == 0xE0) { + n = 3; + } else if ((c & 0xF8) == 0xF0) { + n = 4; + } else { + return false; + } + if ((size_t)(end - p) < (size_t)n) + return false; + for (int i = 1; i < n; i++) + if ((p[i] & 0xC0) != 0x80) + return false; + if ((n == 2 && c < 0xC2) || + (n == 3 && ((c == 0xE0 && p[1] < 0xA0) || + (c == 0xED && p[1] >= 0xA0))) || + (n == 4 && (c > 0xF4 || + (c == 0xF0 && p[1] < 0x90) || + (c == 0xF4 && p[1] >= 0x90)))) + return false; + p += (size_t)n; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* strlist */ +/* ------------------------------------------------------------------ */ + +void sl_init(strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +void sl_free(strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + sl_init(l); +} + +void sl_push(strlist *l, char *owned) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = (char* * ) xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = owned; +} + +void sl_push_copy(strlist *l, const char *s) { + sl_push(l, xstrdup(s)); +} + +char *sl_pop_front(strlist *l) { + if (l->len == 0) + return NULL; + char *p = l->items[0]; + memmove(l->items, l->items + 1, (l->len - 1) * sizeof(*l->items)); + l->len--; + return p; +} + +/* ------------------------------------------------------------------ */ +/* Files */ +/* ------------------------------------------------------------------ */ + +int read_file_to_buf(const char *path, char **out, size_t *out_len, + char **err) { + FILE *f = fopen(path, "rb"); + if (!f) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + xbuf b; + xbuf_init(&b); + char chunk[8192]; + size_t r; + while ((r = fread(chunk, 1, sizeof chunk, f)) > 0) + xbuf_putn(&b, chunk, r); + if (ferror(f)) { + fclose(f); + xbuf_free(&b); + if (err) + *err = xstrdup("read error"); + return -1; + } + fclose(f); + size_t total = b.len; + if (out) + *out = xbuf_steal(&b); + if (out_len) + *out_len = total; + return 0; +} + +int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out, + size_t *out_len, char **err) { + if (out) + *out = NULL; + if (err) + *err = NULL; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + struct stat st; + int stat_rc = fstat(fd, &st); + if (stat_rc != 0 || !S_ISREG(st.st_mode)) { + int e = stat_rc != 0 ? errno : EINVAL; + close(fd); + errno = e; + if (err) + *err = xstrdup(stat_rc != 0 ? strerror(e) : "not a regular file"); + return -1; + } + xbuf b; + xbuf_init(&b); + char chunk[8192]; + int rc = 0; + for (;;) { + size_t room = max_bytes - b.len; + size_t want = room < sizeof chunk ? room + 1 : sizeof chunk; + ssize_t n = read(fd, chunk, want); + if (n > 0) { + if ((size_t)n > room) { + if (err) + *err = xstrdup("file exceeds configured size limit"); + rc = -1; + break; + } + xbuf_putn(&b, chunk, (size_t)n); + } else if (n == 0) { + break; + } else if (errno != EINTR) { + if (err) + *err = xstrdup(strerror(errno)); + rc = -1; + break; + } + } + close(fd); + if (rc != 0) { + xbuf_free(&b); + return -1; + } + size_t total = b.len; + if (out) + *out = xbuf_steal(&b); + if (out_len) + *out_len = total; + xbuf_free(&b); + return 0; +} + +int write_file_from_buf(const char *path, const char *data, size_t len, + char **err) { + FILE *f = fopen(path, "wb"); + if (!f) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + if (len && fwrite(data, 1, len, f) != len) { + fclose(f); + if (err) + *err = xstrdup("write error"); + return -1; + } + if (fclose(f) != 0) { + if (err) + *err = xstrdup("close error"); + return -1; + } + return 0; +} + +bool path_exists(const char *path) { + struct stat st; + return stat(path, &st) == 0; +} + +const char *llsh_ca_bundle(void) { + const char *env = getenv("SSL_CERT_FILE"); + if (env && env[0] && access(env, R_OK) == 0) + return env; + env = getenv("CURL_CA_BUNDLE"); + if (env && env[0] && access(env, R_OK) == 0) + return env; + static const char *const candidates[] = { + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", /* RHEL/Fedora */ + "/etc/ssl/cert.pem", /* -> the above */ + "/etc/pki/tls/certs/ca-bundle.crt", /* older RHEL */ + "/etc/ssl/certs/ca-certificates.crt", /* Debian/Ubuntu */ + "/etc/ssl/certs/ca-bundle.crt", + }; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) + if (access(candidates[i], R_OK) == 0) + return candidates[i]; + return NULL; +} + +int mkdir_p(const char *path) { + char *tmp = xstrdup(path); + int rc = 0; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, 0755) != 0 && errno != EEXIST) { + rc = -1; + break; + } + *p = '/'; + } + } + if (rc == 0 && mkdir(tmp, 0755) != 0 && errno != EEXIST) + rc = -1; + free(tmp); + return rc; +} + +char *expand_tilde(const char *s) { + if (strcmp(s, "~") == 0) { + const char *home = getenv("HOME"); + return xstrdup(home ? home : "/"); + } + if (strncmp(s, "~/", 2) == 0) { + const char *home = getenv("HOME"); + if (home) + return xstrjoin2(home, s + 1); + return xstrdup(s + 1); /* shell fallback: ~/x -> /x */ + } + return xstrdup(s); +} + +/* ------------------------------------------------------------------ */ +/* Misc */ +/* ------------------------------------------------------------------ */ + +double now_secs(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +void sleep_ms(unsigned long ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) == -1) { + /* EINTR: retry with remaining time */ + } +} + +char *itoa_dup(long long v) { + char tmp[32]; + snprintf(tmp, sizeof tmp, "%lld", v); + return xstrdup(tmp); +} + +char *dtoa_dup(double v) { + char tmp[64]; + /* Only cast to long long when v is a finite, in-range whole number; the + * cast itself is undefined for NaN/inf or out-of-range values. */ + int is_int = (v == v) && (v > -9.0e18) && (v < 9.0e18) && (v == (long long)v); + if (is_int) { + snprintf(tmp, sizeof tmp, "%lld", (long long)v); + } else { + snprintf(tmp, sizeof tmp, "%.15g", v); + } + return xstrdup(tmp); +} diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..41c8203 --- /dev/null +++ b/src/util.h @@ -0,0 +1,110 @@ +#ifndef LLSH_UTIL_H +#define LLSH_UTIL_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> + +/* ------------------------------------------------------------------ */ +/* Growable byte buffer (NUL-terminated on steal) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *data; + size_t len; + size_t cap; +} xbuf; + +void xbuf_init(xbuf *b); +void xbuf_free(xbuf *b); +void xbuf_reset(xbuf *b); +void xbuf_put(xbuf *b, const char *s); +void xbuf_putn(xbuf *b, const char *s, size_t n); +void xbuf_push(xbuf *b, char c); +int xbuf_fmt(xbuf *b, const char *fmt, ...); +char *xbuf_steal(xbuf *b); /* NUL-terminated string; buffer reset */ + +/* curl write callback that appends to an xbuf (pass `&buf` as the userdata). */ +size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud); + +/* ------------------------------------------------------------------ */ +/* Strings */ +/* ------------------------------------------------------------------ */ + +char *xstrdup(const char *s); +char *xstrndup(const char *s, size_t n); +int xstreq(const char *a, const char *b); +/* Trim ASCII whitespace from [s, s+n): returns a pointer into s and sets + * *out_len (no copy). */ +const char *xtrim_span(const char *s, size_t n, size_t *out_len); +/* Rust str::trim whitespace set. */ +bool utf8_is_ws(uint32_t cp); +char *xstr_ltrim_dup(const char *s); +char *xstr_rtrim_dup(const char *s); +char *xstr_trim_dup(const char *s); +char *xstrjoin2(const char *a, const char *b); +#include <stdarg.h> +char *xvsnprintf(const char *fmt, va_list ap); +/* Variadic convenience wrapper: freshly allocated, printf-style. */ +char *xstrfmt(const char *fmt, ...); +/* UTF-8 aware helpers */ +size_t utf8_len(const char *s); /* codepoint count */ +size_t utf8_byte_len(const char *s, size_t max_points); +const char *utf8_next(const char *p, const char *end, size_t *cp); +size_t utf8_display_width(const char *s); /* CJK fullwidth aware */ +bool utf8_is_valid(const char *s, size_t len); + +/* ------------------------------------------------------------------ */ +/* String list */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char **items; + size_t len; + size_t cap; +} strlist; + +void sl_init(strlist *l); +void sl_free(strlist *l); +void sl_push(strlist *l, char *owned); /* takes ownership */ +void sl_push_copy(strlist *l, const char *s); +char *sl_pop_front(strlist *l); /* NULL when empty */ + +/* ------------------------------------------------------------------ */ +/* Files */ +/* ------------------------------------------------------------------ */ + +int read_file_to_buf(const char *path, char **out, size_t *out_len, char **err); +int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out, + size_t *out_len, char **err); +int write_file_from_buf(const char *path, const char *data, size_t len, char **err); +bool path_exists(const char *path); +/* Create `path` and any missing parent directories (0755). 0 on success. */ +int mkdir_p(const char *path); + +/* Expand a leading `~` / `~/...` against $HOME (like a shell). + * Returns a freshly allocated string. */ +char *expand_tilde(const char *s); + +/* Resolve the system CA certificate bundle to pass as CURLOPT_CAINFO. + * The statically linked vendored OpenSSL has no usable default cert path + * (its --prefix points into the vendor tree, where no bundle is installed), + * so without this libcurl cannot verify any TLS peer. Honors + * $SSL_CERT_FILE / $CURL_CA_BUNDLE, then probes the usual distro locations. + * Returns NULL when nothing readable is found. */ +const char *llsh_ca_bundle(void); + +/* ------------------------------------------------------------------ */ +/* Misc */ +/* ------------------------------------------------------------------ */ + +double now_secs(void); +void sleep_ms(unsigned long ms); +void *xmalloc(size_t n); +void *xrealloc(void *p, size_t n); +char *itoa_dup(long long v); +/* Format a double the way Rust's Display does for common values */ +char *dtoa_dup(double v); + +#endif diff --git a/src/verbose.cpp b/src/verbose.cpp new file mode 100644 index 0000000..5de886f --- /dev/null +++ b/src/verbose.cpp @@ -0,0 +1,55 @@ +#include "verbose.h" + +#include <cjson/cJSON.h> +#include <stdio.h> +#include <stdlib.h> + +static bool g_verbose = false; +static void (*g_sink)(const char *, void *) = NULL; +static void *g_sink_ud = NULL; + +void verbose_set(bool on) { + g_verbose = on; +} + +bool verbose_enabled(void) { + return g_verbose; +} + +void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud) { + g_sink = sink; + g_sink_ud = ud; +} + +void verbose_log(const char *event, const vlog_kv *kvs, size_t n) { + if (!g_verbose) + return; + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "event", event); + for (size_t i = 0; i < n && kvs; i++) { + const vlog_kv *kv = &kvs[i]; + switch (kv->kind) { + case VLOG_STR: + cJSON_AddStringToObject(obj, kv->key, kv->s ? kv->s : ""); + break; + case VLOG_INT: + cJSON_AddNumberToObject(obj, kv->key, kv->i); + break; + case VLOG_BOOL: + cJSON_AddBoolToObject(obj, kv->key, kv->b); + break; + case VLOG_DBL: + cJSON_AddNumberToObject(obj, kv->key, kv->d); + break; + } + } + char *line = cJSON_PrintUnformatted(obj); + if (line) { + if (g_sink) + g_sink(line, g_sink_ud); + else + fprintf(stderr, "%s\n", line); + free(line); + } + cJSON_Delete(obj); +} diff --git a/src/verbose.h b/src/verbose.h new file mode 100644 index 0000000..35c2002 --- /dev/null +++ b/src/verbose.h @@ -0,0 +1,35 @@ +#ifndef LLSH_VERBOSE_H +#define LLSH_VERBOSE_H + +#include "util.h" + +/* Structured event logging (one JSON object per line) to stderr, + * enabled via --verbose / config. Mirrors verbose.rs. */ + +typedef enum { + VLOG_STR, + VLOG_INT, + VLOG_BOOL, + VLOG_DBL, +} vlog_kind; + +typedef struct { + const char *key; + vlog_kind kind; + const char *s; + long long i; + double d; + bool b; +} vlog_kv; + +#define VLOG_STR(k, v) { (k), VLOG_STR, (v), 0, 0, false } +#define VLOG_INT(k, v) { (k), VLOG_INT, NULL, (long long)(v), 0, false } +#define VLOG_BOOL(k, v) { (k), VLOG_BOOL, NULL, 0, 0, (v) } +#define VLOG_DBL(k, v) { (k), VLOG_DBL, NULL, 0, (v), false } + +void verbose_set(bool on); +bool verbose_enabled(void); +void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud); +void verbose_log(const char *event, const vlog_kv *kvs, size_t n); + +#endif diff --git a/tests/test_agent.cpp b/tests/test_agent.cpp new file mode 100644 index 0000000..c5212ae --- /dev/null +++ b/tests/test_agent.cpp @@ -0,0 +1,260 @@ +#include "agent.h" + +#include <cjson/cJSON.h> +#include <curl/curl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ_STR(a, b, msg) \ + do { \ + checks++; \ + const char *_a = (a), *_b = (b); \ + if (!_a || (_b && strcmp(_a, _b) != 0)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), _a ? _a : "(null)", \ + _b ? _b : "(null)"); \ + } \ + } while (0) + +static config mkcfg(provider_kind p, const char *model, const char *base, + const char *key) { + config c; + memset(&c, 0, sizeof c); + c.provider = p; + c.model = xstrdup(model); + c.base_url = base ? xstrdup(base) : NULL; + c.api_key = key ? xstrdup(key) : NULL; + return c; +} + +static void freecfg(config *c) { + free(c->model); + free(c->base_url); + free(c->api_key); +} + +static void test_extract_openai_context_length(void) { + config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL); + cJSON *j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\"," + "\"context_length\":262144}"); + CHECK(extract_context_limit(j, &c) == 262144, "openai top-level limit"); + cJSON_Delete(j); + j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\"}"); + CHECK(extract_context_limit(j, &c) == 0, "openai missing limit"); + cJSON_Delete(j); + j = cJSON_Parse("{\"object\":\"list\",\"data\":[" + "{\"id\":\"llm\",\"aliases\":[\"llm\"]," + "\"owned_by\":\"llamacpp\",\"meta\":{\"n_ctx\":131072}}," + "{\"id\":\"other\",\"meta\":{\"n_ctx\":8192}}]}"); + CHECK(extract_context_limit(j, &c) == 131072, + "llama.cpp meta.n_ctx for matching model"); + cJSON_Delete(j); + freecfg(&c); +} + +static void test_openai_model_present(void) { + CHECK(openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}", + "llm"), + "id match in data[]"); + CHECK(!openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}", + "nope"), + "id miss"); + CHECK(openai_model_present( + "{\"models\":[{\"name\":\"llm\"}]," + "\"data\":[{\"id\":\"x\",\"aliases\":[\"llm\"]}]}", + "llm"), + "models[].name and data[].aliases variants"); + CHECK(!openai_model_present("{\"data\":[]}", "llm"), "empty data[]"); + CHECK(!openai_model_present("not json", "llm"), "malformed body"); + /* parity: a non-array `data` blocks the `models` fallback (Rust + * get("data").or_else(|| get("models"))) */ + CHECK(!openai_model_present( + "{\"data\":{\"id\":\"x\"},\"models\":[{\"id\":\"llm\"}]}", "llm"), + "non-array data does not fall back to models"); +} + +static void test_extract_ollama_context_length(void) { + config c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL); + cJSON *j = cJSON_Parse("{\"name\":\"llama3.2\"," + "\"model_details\":{\"family\":\"llama\"," + "\"context_length\":8192}," + "\"modelfiles\":[{\"parameters\":" + "{\"context_length\":4096}}]}"); + CHECK(extract_context_limit(j, &c) == 8192, "ollama model_details"); + cJSON_Delete(j); + j = cJSON_Parse("{\"modelfiles\":[{\"parameters\":" + "{\"context_length\":4096}}]}"); + CHECK(extract_context_limit(j, &c) == 4096, "ollama modelfiles fallback"); + cJSON_Delete(j); + j = cJSON_Parse("{\"name\":\"llama3.2\",\"model_details\":{}}"); + CHECK(extract_context_limit(j, &c) == 0, "ollama absent"); + cJSON_Delete(j); + freecfg(&c); +} + +static void test_extract_anthropic_max_tokens(void) { + cJSON *j = cJSON_Parse("{\"data\":[" + "{\"id\":\"claude-haiku\",\"max_tokens\":8192}," + "{\"id\":\"claude-sonnet-4-6\",\"max_tokens\":64000}]}"); + config c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, NULL); + CHECK(extract_context_limit(j, &c) == 64000, "matching model wins"); + freecfg(&c); + c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL); + CHECK(extract_context_limit(j, &c) == 8192, + "unknown model falls back to the first entry"); + freecfg(&c); + cJSON_Delete(j); + j = cJSON_Parse("{\"data\":[]}"); + c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL); + CHECK(extract_context_limit(j, &c) == 0, "empty data[] -> none"); + cJSON_Delete(j); + freecfg(&c); +} + +static void test_anthropic_model_present(void) { + const char *body = + "{\"data\":[{\"id\":\"claude-haiku\"},{\"id\":\"claude-sonnet-4-6\"}]}"; + CHECK(anthropic_model_present(body, "claude-sonnet-4-6"), "id match"); + CHECK(!anthropic_model_present(body, "claude-unknown"), "id miss"); + CHECK(!anthropic_model_present("not json", "claude-haiku"), + "malformed body never present"); + CHECK(!anthropic_model_present("{\"data\":[]}", "claude-haiku"), + "empty list"); + CHECK(!anthropic_model_present("{\"data\":{\"id\":\"x\"}}", "x"), + "data not an array"); +} + +static void test_probe_helpers(void) { + provider_probe ok = {true, xstrdup("model `llm` reachable at http://x")}; + CHECK(ok.ok, "ok flag"); + CHECK_EQ_STR(ok.detail, "model `llm` reachable at http://x", "ok detail"); + provider_probe_free(&ok); + CHECK(ok.detail == NULL, "probe_free releases detail"); + provider_probe bad = {false, xstrdup("could not reach http://x " + "(connection: timeout)")}; + CHECK(!bad.ok, "fail flag"); + CHECK(strstr(bad.detail, "timeout") != NULL, "fail detail content"); + provider_probe_free(&bad); + CHECK(bad.detail == NULL, "probe_free releases failed detail"); +} + +static void test_default_base_urls(void) { + CHECK_EQ_STR(agent_default_base_url(PROV_OPENAI), "https://api.openai.com/v1", + "openai default base"); + CHECK_EQ_STR(agent_default_base_url(PROV_ANTHROPIC), + "https://api.anthropic.com", "anthropic default base"); + CHECK_EQ_STR(agent_default_base_url(PROV_OLLAMA), "http://localhost:11434", + "ollama default base"); +} + +static void test_models_url(void) { + config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL); + char *u = agent_models_url(&c); + CHECK_EQ_STR(u, "https://api.openai.com/v1/models", "openai default url"); + free(u); + freecfg(&c); + + c = mkcfg(PROV_OPENAI, "llm", "http://localhost:8080/v1/", "k"); + u = agent_models_url(&c); + CHECK_EQ_STR(u, "http://localhost:8080/v1/models", + "custom base, trailing slash trimmed"); + free(u); + freecfg(&c); + + c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, "k"); + u = agent_models_url(&c); + CHECK_EQ_STR(u, "https://api.anthropic.com/v1/models?limit=100", + "anthropic url"); + free(u); + freecfg(&c); + + c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL); + u = agent_models_url(&c); + CHECK_EQ_STR(u, "http://localhost:11434/api/show?name=llama3.2", + "ollama url"); + free(u); + freecfg(&c); + + c = mkcfg(PROV_OLLAMA, "llama 3.2", "http://host:1/x/", NULL); + u = agent_models_url(&c); + CHECK(strstr(u, "name=llama%203.2") != NULL, "ollama name is url-encoded"); + free(u); + freecfg(&c); +} + +static void test_auth(void) { + config c = mkcfg(PROV_OPENAI, "llm", NULL, "sk-1"); + char *h = NULL, *v = NULL; + agent_auth(&c, &h, &v); + CHECK_EQ_STR(h, "Authorization", "openai auth header"); + CHECK_EQ_STR(v, "Bearer sk-1", "openai bearer value"); + free(h); + free(v); + freecfg(&c); + + c = mkcfg(PROV_OPENAI, "llm", "http://x", NULL); + agent_auth(&c, &h, &v); + CHECK(h == NULL && v == NULL, "openai without key: no auth"); + free(h); + free(v); + freecfg(&c); + + c = mkcfg(PROV_ANTHROPIC, "m", NULL, "ak"); + agent_auth(&c, &h, &v); + CHECK_EQ_STR(h, "x-api-key", "anthropic auth header"); + CHECK_EQ_STR(v, "ak", "anthropic key passthrough"); + free(h); + free(v); + freecfg(&c); + + c = mkcfg(PROV_OLLAMA, "m", NULL, "ignored"); + agent_auth(&c, &h, &v); + CHECK(h == NULL && v == NULL, "ollama sends no auth"); + free(h); + free(v); + freecfg(&c); +} + +static void test_http_get_error_path(void) { + long status = 0; + char *body = NULL, *err = NULL; + /* port 1 on localhost: connection refused, no server expected */ + int rc = agent_http_get("http://127.0.0.1:1/models", NULL, NULL, &status, + &body, &err); + CHECK(rc == -1, "unreachable endpoint -> transport failure"); + CHECK(err != NULL, "transport error message present"); + CHECK(body == NULL, "no body on transport failure"); + free(err); + free(body); +} + +int main(void) { + curl_global_init(CURL_GLOBAL_DEFAULT); + test_extract_openai_context_length(); + test_openai_model_present(); + test_extract_ollama_context_length(); + test_extract_anthropic_max_tokens(); + test_anthropic_model_present(); + test_probe_helpers(); + test_default_base_urls(); + test_models_url(); + test_auth(); + test_http_get_error_path(); + curl_global_cleanup(); + printf("test_agent: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_clio.c b/tests/test_clio.c new file mode 100644 index 0000000..535df1d --- /dev/null +++ b/tests/test_clio.c @@ -0,0 +1,884 @@ +/* test_clio.c — standalone test suite for clio.h (+ escape.h). + * Zero dependencies: single TU, no linked libraries (defines + * ESCAPE_IMPLEMENTATION to pull in the esc_* symbols). */ +#define ESCAPE_IMPLEMENTATION +#include "clio.h" + +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +/* Capture stdout (fd 1) into a malloc'd buffer across the test body. */ +typedef struct cap { + int pfd[2]; + int saved; +} cap_t; + +/* dup2() returns the target fd (STDOUT_FILENO == 1) on success, -1 on + * failure — compare against -1, not 0. */ +static int cap_begin(cap_t *c) { + if (pipe(c->pfd) != 0) + return -1; + c->saved = dup(STDOUT_FILENO); + if (c->saved < 0) + return -1; + if (dup2(c->pfd[1], STDOUT_FILENO) == -1) { + close(c->pfd[0]); + close(c->pfd[1]); + close(c->saved); + return -1; + } + close(c->pfd[1]); + return 0; +} + +static char *cap_end(cap_t *c, size_t *len) { + fflush(stdout); + if (dup2(c->saved, STDOUT_FILENO) == -1) { + close(c->saved); + return NULL; + } + close(c->saved); + size_t cap = 256; + size_t n = 0; + char *buf = (char *) malloc(cap); + char tmp[1024]; + ssize_t r; + while ((r = read(c->pfd[0], tmp, sizeof tmp)) > 0) { + if (n + (size_t) r > cap) { + cap = (cap + (size_t) r) * 2; + buf = (char *) realloc(buf, cap); + } + memcpy(buf + n, tmp, (size_t) r); + n += (size_t) r; + } + close(c->pfd[0]); + buf[n] = '\0'; /* callers use both strlen() and len */ + *len = n; + return buf; +} + +static int contains(const char *hay, size_t len, const char *needle) { + size_t nl = strlen(needle); + if (nl > len) + return 0; + for (size_t i = 0; i + nl <= len; i++) + if (memcmp(hay + i, needle, nl) == 0) + return 1; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Text: UTF-8 + width */ +/* ------------------------------------------------------------------ */ + +static void test_utf8(void) { + u32 cp; + char out[8]; + CHECK(clio_utf8_next("a", &cp) == 1 && cp == 'a', "ascii decode"); + CHECK(clio_utf8_next("\xC3\xA9", &cp) == 2 && cp == 0xE9, "latin1 decode"); + CHECK(clio_utf8_next("\xE4\xB8\xAD", &cp) == 3 && cp == 0x4E2D, + "cjk decode"); + CHECK(clio_utf8_next("\xF0\x9F\x98\x80", &cp) == 4 && cp == 0x1F600, + "emoji decode"); + /* truncated sequence: consumes what is left */ + CHECK(clio_utf8_next("\xE4\xB8", &cp) == 2, "truncated decode"); + CHECK(clio_utf8_enc(0x4E2D, out) == 3 && memcmp(out, "\xE4\xB8\xAD", 3) == 0, + "cjk encode"); + CHECK(clio_utf8_enc(0x1F600, out) == 4, "emoji encode"); + CHECK(clio_utf8_enc(0xD800, out) == -1, "surrogate rejected"); + CHECK(clio_utf8_enc(0x110000, out) == -1, "out of range rejected"); + + CHECK(clio_text_width("abc") == 3, "ascii width"); + CHECK(clio_text_width("h\xC3\xA9llo") == 5, "latin width"); + CHECK(clio_text_width("\xE4\xB8\xAD\xE6\x96\x87") == 4, "cjk width"); + CHECK(clio_text_width("\xF0\x9F\x98\x80") == 2, "emoji width"); + CHECK(clio_cp_width('\n') == 0, "newline zero width"); + + const char *s = "ab\xE4\xB8\xAD" "c"; /* a b 中 c */ + CHECK(clio_char_start(s, 4) == 2, "char_start cjk"); + CHECK(clio_char_start(s, 2) == 2, "char_start at boundary"); + CHECK(clio_char_start(s, 1) == 1, "char_start ascii"); + CHECK(clio_char_end(s, 6, 2) == 5, "char_end cjk"); + CHECK(clio_char_end(s, 6, 5) == 6, "char_end last"); + + /* semantics verified against bash/readline Ctrl+W via pty: + * "foo bar baz"@12 -> 7, "foo bar"@8 -> 3, "a_b c"@5 -> 3 */ + CHECK(clio_word_back("foo bar baz", 12) == 7, "word_back word"); + CHECK(clio_word_back("foo bar", 8) == 3, "word_back across spaces"); + CHECK(clio_word_back("a_b c", 5) == 3, "word_back underscore"); +} + +/* ------------------------------------------------------------------ */ +/* Canvas */ +/* ------------------------------------------------------------------ */ + +static void test_buf(void) { + clio_buf b; + clio_style s = clio_style_default(); + s.fg = 248; + s.bg = 238; + s.attr = CLIO_BOLD | CLIO_REVERSE; + + CHECK(clio_buf_init(&b, 3, 8) == 0, "buf init"); + CHECK(b.rows == 3 && b.cols == 8, "buf dims"); + + /* default cell is blank + default style */ + clio_cell *c0 = b.cells; + CHECK(c0->ch == 0 && c0->fg == 0xFFFF && c0->bg == 0xFFFF && c0->attr == 0, + "default cell"); + + CHECK(clio_buf_put(&b, 0, 0, "hi", s, 0) == 2, "put ascii"); + CHECK(b.cells[0].ch == 'h' && b.cells[1].ch == 'i', "put stored"); + CHECK(b.cells[0].fg == 248 && b.cells[0].bg == 238, "put style"); + CHECK(b.cells[0].attr == (CLIO_BOLD | CLIO_REVERSE), "put attr"); + CHECK(b.cells[2].ch == 0, "put bounds"); + + /* clipping: no wrap */ + CHECK(clio_buf_put(&b, 0, 6, "abc", s, 0) == 2, "clip no wrap"); + CHECK(b.cells[7].ch == 'b', "clip stored edge"); + clio_buf_clear(&b); + + /* wrapping onto next row */ + CHECK(clio_buf_put(&b, 0, 6, "abcd", s, 1) == 4, "wrap put"); + CHECK(b.cells[7].ch == 'b', "wrap first row edge"); + CHECK(b.cells[8].ch == 'c' && b.cells[9].ch == 'd', "wrap second row"); + + clio_buf_clear(&b); + + /* newline moves to next row at col 0 */ + clio_buf_put(&b, 0, 0, "a\nb", s, 1); + CHECK(b.cells[0].ch == 'a' && b.cells[8].ch == 'b', "newline put"); + + clio_buf_clear(&b); + + /* wide char occupies two cells */ + clio_buf_put(&b, 0, 0, "\xE4\xB8\xAD\xE6\x96\x87", s, 1); + CHECK(b.cells[0].ch == 0x4E2D && b.cells[1].ch == 0, "wide first cell"); + CHECK(b.cells[2].ch == 0x6587 && b.cells[3].ch == 0, "wide second cell"); + + clio_buf_clear(&b); + + /* zero-width chars consume no cell */ + clio_buf_put(&b, 0, 0, "\xE2\x80\x8Bx", s, 1); /* ZWSP + x */ + CHECK(b.cells[0].ch == 'x', "zero width skips"); + + clio_buf_clear(&b); + + /* fill + clear region */ + clio_buf_fill_region(&b, 0, 0, 1, 3, '*', s); + CHECK(b.cells[0].ch == '*' && b.cells[3].ch == '*', "fill row 0"); + CHECK(b.cells[8].ch == '*' && b.cells[11].ch == '*', "fill row 1"); + CHECK(b.cells[4].ch == 0, "fill bounds"); + clio_buf_clear_region(&b, 0, 1, 1, 2); + CHECK(b.cells[0].ch == '*' && b.cells[1].ch == 0 && b.cells[2].ch == 0, + "clear region"); + CHECK(b.cells[10].ch == 0 && b.cells[11].ch == '*', "clear region row 1"); + clio_buf_clear(&b); + + /* box 2x4 in top-left */ + clio_buf_box(&b, 0, 0, 4, 2, '#', s); + CHECK(b.cells[0].ch == '#' && b.cells[3].ch == '#', "box top"); + CHECK(b.cells[8].ch == '#' && b.cells[11].ch == '#', "box bottom"); + clio_buf_clear(&b); + + /* scroll up: rows 0..2 shift, bottom becomes blank */ + clio_buf_put(&b, 0, 0, "aa", s, 0); + clio_buf_put(&b, 1, 0, "bb", s, 0); + clio_buf_put(&b, 2, 0, "cc", s, 0); + clio_buf_scroll_up(&b, 0, 0, 2, 1); + CHECK(b.cells[0].ch == 'b' && b.cells[8].ch == 'c', "scroll moved"); + CHECK(b.cells[16].ch == 0, "scroll bottom blank"); + + clio_buf_free(&b); + CHECK(b.cells == NULL, "buf free"); + + /* init with zero dims clamps to 1x1 */ + CHECK(clio_buf_init(&b, 0, 0) == 0 && b.rows == 1 && b.cols == 1, + "buf init clamps"); + clio_buf_free(&b); +} + +/* ------------------------------------------------------------------ */ +/* Key decoder */ +/* ------------------------------------------------------------------ */ + +static size_t decode(clio_keydec *d, const char *in, clio_key *k, size_t cap) { + clio_keydec_init(d); + return clio_keydec_feed(d, (const unsigned char *) in, strlen(in), k, cap); +} + +static void test_keydec(void) { + clio_keydec d; + clio_key k[16]; + size_t n; + + n = decode(&d, "\x1B[C", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "csi right"); + n = decode(&d, "\x1B[D", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_LEFT, "csi left"); + n = decode(&d, "\x1B[A", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_UP, "csi up"); + n = decode(&d, "\x1B[B", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_DOWN, "csi down"); + n = decode(&d, "\x1B[H", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi home"); + n = decode(&d, "\x1B[F", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_END, "csi end"); + n = decode(&d, "\x1B[Z", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_BACKTAB, "csi backtab"); + n = decode(&d, "\x1B[1~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 1~ home"); + n = decode(&d, "\x1B[7~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 7~ home"); + n = decode(&d, "\x1B[3~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "csi 3~ delete"); + n = decode(&d, "\x1B[4~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_END, "csi 4~ end"); + n = decode(&d, "\x1B[5~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_PAGEUP, "csi 5~ pageup"); + n = decode(&d, "\x1B[6~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_PAGEDOWN, "csi 6~ pagedown"); + + n = decode(&d, "\x1BOA", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_UP, "ss3 up"); + n = decode(&d, "\x1BOF", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_END, "ss3 end"); + + n = decode(&d, "\t\r\x7F\x08", k, 16); + CHECK(n == 4 && k[0].kind == CLIOK_TAB && k[1].kind == CLIOK_ENTER && + k[2].kind == CLIOK_BACKSPACE && k[3].kind == CLIOK_BACKSPACE, + "tab enter backspace del8"); + + n = decode(&d, "\x01\x03\x04\x05\x0B\x0C\x15\x17\x19\x1A", k, 16); + CHECK(n == 10 && k[0].kind == CLIOK_CTRL_A && k[1].kind == CLIOK_CTRL_C && + k[2].kind == CLIOK_CTRL_D && k[3].kind == CLIOK_CTRL_E && + k[4].kind == CLIOK_CTRL_K && k[5].kind == CLIOK_CTRL_L && + k[6].kind == CLIOK_CTRL_U && k[7].kind == CLIOK_CTRL_W && + k[8].kind == CLIOK_CTRL_Y && k[9].kind == CLIOK_CTRL_Z, + "ctrl letters"); + + n = decode(&d, "\x07", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_CTRL_G, "ctrl g"); + n = decode(&d, "\x1B", k, 16); + CHECK(n == 0, "bare esc not emitted yet"); + CHECK(clio_keydec_flush(&d, &k[0]) == 1 && k[0].kind == CLIOK_ESC, + "esc flush"); + + n = decode(&d, "h\xC3\xA9\xE4\xB8\xAD", k, 16); + CHECK(n == 3 && k[0].cp == 'h' && k[1].cp == 0xE9 && k[2].cp == 0x4E2D, + "utf8 keys"); + + /* kitty protocol */ + n = decode(&d, "\x1B[13u", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_ENTER, "kitty enter"); + n = decode(&d, "\x1B[127u", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "kitty delete"); + n = decode(&d, "\x1B[9u", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_TAB, "kitty tab"); + n = decode(&d, "\x1B[27u", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_ESC, "kitty esc"); + + /* bracketed paste: LF inside paste is a char; start/end emit no key */ + n = decode(&d, "\x1B[200~a\nb\x1B[201~", k, 16); + CHECK(n == 3 && k[0].cp == 'a' && k[1].cp == '\n' && k[2].cp == 'b', + "bracketed paste"); + + /* bare LF outside paste: dead key */ + n = decode(&d, "\n", k, 16); + CHECK(n == 0, "bare lf dead"); + + /* split feeds: escape sequence across chunk boundaries */ + clio_keydec_init(&d); + n = clio_keydec_feed(&d, (const unsigned char *) "\x1B", 1, k, 16); + CHECK(n == 0, "split esc"); + n = clio_keydec_feed(&d, (const unsigned char *) "[", 1, k, 16); + CHECK(n == 0, "split ["); + n = clio_keydec_feed(&d, (const unsigned char *) "C", 1, k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "split C"); + + /* split UTF-8 across chunk boundaries */ + clio_keydec_init(&d); + n = clio_keydec_feed(&d, (const unsigned char *) "\xC3", 1, k, 16); + CHECK(n == 0, "split utf8 lead"); + n = clio_keydec_feed(&d, (const unsigned char *) "\xA9", 1, k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_CHAR && k[0].cp == 0xE9, "split utf8"); + + /* unknown CSI emits UNKNOWN so the app can react (e.g. beep) */ + n = decode(&d, "\x1B[15~", k, 16); + CHECK(n == 1 && k[0].kind == CLIOK_UNKNOWN, "unknown csi -> UNKNOWN"); + /* cursor position report swallowed */ + n = decode(&d, "\x1B[12;40R", k, 16); + CHECK(n == 0, "cpr swallowed"); +} + +/* ------------------------------------------------------------------ */ +/* Color policy */ +/* ------------------------------------------------------------------ */ + +static void setenv0(const char *k, const char *v) { + if (v) + setenv(k, v, 1); + else + unsetenv(k); +} + +static void test_color_policy(void) { + setenv0("CLICOLOR_FORCE", NULL); + setenv0("NO_COLOR", NULL); + setenv0("CI", NULL); + setenv0("TERM", NULL); + CHECK(clio_color_policy(1) == 1, "tty on"); + CHECK(clio_color_policy(0) == 0, "notty off"); + setenv0("NO_COLOR", "1"); + CHECK(clio_color_policy(1) == 0, "NO_COLOR off"); + setenv0("NO_COLOR", NULL); + setenv0("TERM", "dumb"); + CHECK(clio_color_policy(1) == 0, "TERM=dumb off"); + setenv0("TERM", NULL); + setenv0("CI", "true"); + CHECK(clio_color_policy(1) == 0, "CI off"); + setenv0("CI", NULL); + setenv0("CLICOLOR_FORCE", "1"); + CHECK(clio_color_policy(0) == 1, "force on, notty"); + setenv0("NO_COLOR", "1"); + CHECK(clio_color_policy(1) == 1, "force beats NO_COLOR"); + setenv0("CLICOLOR_FORCE", "0"); + CHECK(clio_color_policy(1) == 0, "force 0 off"); + setenv0("CLICOLOR_FORCE", NULL); +} + +/* ------------------------------------------------------------------ */ +/* Style emission */ +/* ------------------------------------------------------------------ */ + +static void test_fstyle(void) { + cap_t c; + clio_style s = clio_style_default(); + s.fg = 38; + s.bg = 48; + s.attr = CLIO_BOLD | CLIO_UNDERLINE; + + CHECK(cap_begin(&c) == 0, "cap begin 1"); + clio_fstyle(stdout, s); + size_t len; + char *out = cap_end(&c, &len); + CHECK(out != NULL && len > 0, "fstyle wrote"); + if (out) { + /* exact sequence: reset, bold, underline, fg, bg (in that order) */ + CHECK(contains(out, len, ESC_RESET) == 1, "fstyle reset"); + CHECK(contains(out, len, ESC_BOLD) == 1, "fstyle bold"); + CHECK(contains(out, len, ESC_UNDERLINE) == 1, "fstyle underline"); + CHECK(contains(out, len, ESC_COLOR_FG_256(38)) == 1, "fstyle fg256"); + CHECK(contains(out, len, ESC_COLOR_BG_256(48)) == 1, "fstyle bg256"); + CHECK(contains(out, len, "48;5;48m") == 1, "bg uses 48 not 38"); + CHECK(contains(out, len, ESC_ITALIC) == 0, "no italic"); + /* reset first, fg before bg */ + const char *rp = strstr(out, ESC_RESET); + const char *fp = strstr(out, ESC_COLOR_FG_256(38)); + const char *bp = strstr(out, ESC_COLOR_BG_256(48)); + CHECK(rp && fp && bp && rp < fp && fp < bp, "fstyle order"); + free(out); + } + + /* default style: only reset */ + CHECK(cap_begin(&c) == 0, "cap begin 2"); + clio_fstyle(stdout, clio_style_default()); + out = cap_end(&c, &len); + CHECK(out != NULL && strcmp(out, ESC_RESET) == 0, "default = reset only"); + free(out); +} + +/* ------------------------------------------------------------------ */ +/* Flush (non-tty plain + tty diff) */ +/* ------------------------------------------------------------------ */ + +static void test_flush_plain(void) { + clio_term t; + clio_term_init(&t); + clio_buf b; + clio_buf_init(&b, 2, 8); + clio_style s = clio_style_default(); + s.fg = 248; + clio_buf_put(&b, 0, 0, "hi", s, 0); + clio_buf_put(&b, 1, 0, "yo", s, 0); + cap_t c; + CHECK(cap_begin(&c) == 0, "cap begin"); + clio_flush(&t, &b); + size_t len; + char *out = cap_end(&c, &len); + CHECK(out != NULL, "cap end"); + CHECK(len > 0, "plain wrote"); + CHECK(contains(out, len, "hi"), "plain row 0"); + CHECK(contains(out, len, "yo"), "plain row 1"); + CHECK(contains(out, len, "\x1B") == 0, "plain has no escapes"); + CHECK(contains(out, len, "hi\nyo\n") == 1, "plain exact lines"); + free(out); + clio_buf_free(&b); + clio_term_free(&t); +} + +static void test_flush_diff(void) { + clio_term t; + clio_term_init(&t); + /* force tty mode + fixed size for a deterministic test */ + t.is_tty = 1; + t.rows = 3; + t.cols = 8; + + clio_buf a; + clio_buf_init(&a, 3, 8); + clio_style s = clio_style_default(); + clio_buf_put(&a, 0, 0, "row0", s, 0); + clio_buf_put(&a, 1, 0, "row1", s, 0); + clio_buf_put(&a, 2, 0, "row2", s, 0); + + cap_t c; + CHECK(cap_begin(&c) == 0, "cap begin 1"); + clio_flush(&t, &a); + size_t len; + char *out = cap_end(&c, &len); + CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "first flush clears"); + CHECK(contains(out, len, "\x1B[1;1H") == 1, "first flush row 1 pos"); + CHECK(contains(out, len, "\x1B[3;1H") == 1, "first flush row 3 pos"); + CHECK(contains(out, len, "row0") == 1, "first flush content"); + free(out); + + /* change only row 2 */ + clio_buf b; + clio_buf_init(&b, 3, 8); + clio_buf_put(&b, 0, 0, "row0", s, 0); + clio_buf_put(&b, 1, 0, "row1", s, 0); + clio_buf_put(&b, 2, 0, "CHG2", s, 0); + + CHECK(cap_begin(&c) == 0, "cap begin 2"); + clio_flush(&t, &b); + out = cap_end(&c, &len); + CHECK(contains(out, len, "\x1B[3;1H") == 1, "diff row 3 pos"); + CHECK(contains(out, len, "CHG2") == 1, "diff row 2 content"); + CHECK(contains(out, len, "\x1B[1;1H") == 0, "diff skips row 1"); + CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 0, "diff no clear"); + CHECK(contains(out, len, "row0") == 0, "diff skips row 0 content"); + free(out); + + /* size change forces full repaint */ + clio_buf d2; + clio_buf_init(&d2, 2, 4); + clio_buf_put(&d2, 0, 0, "xy", s, 0); + clio_buf_put(&d2, 1, 0, "zw", s, 0); + clio_buf_free(&t.last); + t.has_last = 0; + t.rows = 2; + t.cols = 4; + CHECK(cap_begin(&c) == 0, "cap begin 3"); + clio_flush(&t, &d2); + out = cap_end(&c, &len); + CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "resize full repaint"); + CHECK(contains(out, len, "\x1B[1;1H") == 1, "resize row 1"); + free(out); + + clio_buf_free(&a); + clio_buf_free(&b); + clio_buf_free(&d2); + clio_term_free(&t); +} + +/* ------------------------------------------------------------------ */ +/* Term toggles emit the right codes */ +/* ------------------------------------------------------------------ */ + +static void test_term_toggles(void) { + clio_term t; + clio_term_init(&t); + t.is_tty = 1; + cap_t c; + CHECK(cap_begin(&c) == 0, "cap begin"); + clio_term_alt_on(&t); + clio_term_cursor_hide(&t); + clio_term_paste_on(&t); + clio_term_kitty_on(&t); + clio_term_bell(&t); + clio_term_alt_on(&t); /* idempotent: no second emission */ + size_t len; + char *out = cap_end(&c, &len); + CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on"); + CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on once"); + CHECK(contains(out, len, ESC_CURSOR_HIDE) == 1, "cursor hide"); + CHECK(contains(out, len, ESC_BRACKETED_PASTE_ON) == 1, "paste on"); + CHECK(contains(out, len, ESC_KITTY_ON) == 1, "kitty on"); + CHECK(contains(out, len, ESC_BELL) == 1, "bell"); + free(out); + + /* shutdown restores everything, in reverse, once each */ + CHECK(cap_begin(&c) == 0, "cap begin 2"); + clio_term_shutdown(&t); + out = cap_end(&c, &len); + CHECK(contains(out, len, ESC_KITTY_OFF) == 1, "kitty off"); + CHECK(contains(out, len, ESC_BRACKETED_PASTE_OFF) == 1, "paste off"); + CHECK(contains(out, len, ESC_CURSOR_SHOW) == 1, "cursor show"); + CHECK(contains(out, len, ESC_ALT_SCREEN_OFF) == 1, "alt off"); + /* order: kitty first, alt last */ + if (out) { + const char *kp = strstr(out, ESC_KITTY_OFF); + const char *ap = strstr(out, ESC_ALT_SCREEN_OFF); + CHECK(kp && ap && kp < ap, "shutdown order"); + } + free(out); + clio_term_free(&t); +} + +/* ------------------------------------------------------------------ */ +/* Readline (scripted key source) */ +/* ------------------------------------------------------------------ */ + +typedef struct keys { + clio_key *ks; + size_t n; + size_t i; +} keys_t; + +static int keys_next(void *ud, clio_key *k, int timeout_ms) { + (void) timeout_ms; + keys_t *s = (keys_t *) ud; + if (s->i >= s->n) + return 0; + *k = s->ks[s->i++]; + return 1; +} + +static void push(keys_t *s, int kind, u32 cp) { + s->ks[s->n].kind = kind; + s->ks[s->n].cp = cp; + s->n++; +} + +static char *run_readline(keys_t *s, int echo, int rows, int cols) { + clio_term t; + clio_term_init(&t); + t.is_tty = 1; + t.rows = (size_t) rows; + t.cols = (size_t) cols; + clio_buf b; + clio_buf_init(&b, (size_t) rows, (size_t) cols); + clio_style p = clio_style_default(); + clio_style st = clio_style_default(); + s->i = 0; + char *r = clio_readline(&t, &b, 0, "> ", p, st, echo, keys_next, s, 0); + clio_buf_free(&b); + clio_term_free(&t); + return r; +} + +static void test_readline(void) { + keys_t s; + clio_key ks[64]; + s.ks = ks; + s.n = 0; + char *r; + + /* type "ab", backspace, "c" -> "ac" */ + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_BACKSPACE, 0); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r != NULL && strcmp(r, "ac") == 0, "insert backspace"); + free(r); + + /* left arrow + insert: caret between a and c, x goes there -> "axc" */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_LEFT, 0); + push(&s, CLIOK_CHAR, 'x'); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "axc") == 0, "left insert"); + free(r); + + /* ctrl+a, 'z' -> "zaxc" */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'x'); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_CTRL_A, 0); + push(&s, CLIOK_CHAR, 'z'); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "zaxc") == 0, "ctrl+a insert"); + free(r); + + /* ctrl+u clears line -> "" (submit empty) */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_CTRL_U, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r == NULL, "ctrl+u clears all"); + + /* ctrl+a then ctrl+k kills whole line -> empty submit */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_CTRL_A, 0); + push(&s, CLIOK_CTRL_K, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r == NULL, "ctrl+k empty after"); + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_LEFT, 0); + push(&s, CLIOK_CTRL_K, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "ab") == 0, "ctrl+k kills rest"); + free(r); + + /* ctrl+w deletes a word */ + s.n = 0; + push(&s, CLIOK_CHAR, 'h'); + push(&s, CLIOK_CHAR, 'e'); + push(&s, CLIOK_CHAR, 'l'); + push(&s, CLIOK_CHAR, 'l'); + push(&s, CLIOK_CHAR, 'o'); + push(&s, CLIOK_CHAR, ' '); + push(&s, CLIOK_CHAR, 'w'); + push(&s, CLIOK_CHAR, 'o'); + push(&s, CLIOK_CHAR, 'r'); + push(&s, CLIOK_CHAR, 'l'); + push(&s, CLIOK_CHAR, 'd'); + push(&s, CLIOK_CTRL_W, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + /* matches bash/readline: the preceding space is killed too */ + CHECK(r && strcmp(r, "hello") == 0, "ctrl+w word"); + free(r); + + /* ctrl+c cancels */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CTRL_C, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r == NULL, "ctrl+c cancels"); + + /* escape cancels */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_ESC, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r == NULL, "esc cancels"); + + /* ctrl+d on empty submits */ + s.n = 0; + push(&s, CLIOK_CTRL_D, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r == NULL, "ctrl+d empty submits"); + + /* ctrl+d deletes the char after the caret (left first) */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_LEFT, 0); + push(&s, CLIOK_CTRL_D, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "a") == 0, "ctrl+d delete"); + free(r); + + /* delete key removes forward char (caret in the middle) */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_LEFT, 0); + push(&s, CLIOK_DELETE, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "a") == 0, "delete forward"); + free(r); + + /* utf8 input round trip */ + s.n = 0; + push(&s, CLIOK_CHAR, 0x4E2D); /* 中 */ + push(&s, CLIOK_CHAR, 0x6587); /* 文 */ + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 24, 80); + CHECK(r && strcmp(r, "\xE4\xB8\xAD\xE6\x96\x87") == 0, "utf8 input"); + free(r); + + /* echo=0: stars rendered, but value is real text */ + s.n = 0; + push(&s, CLIOK_CHAR, 'a'); + push(&s, CLIOK_CHAR, 'b'); + push(&s, CLIOK_CHAR, 'c'); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 0, 24, 80); + CHECK(r && strcmp(r, "abc") == 0, "echo off value"); + free(r); +} + +/* ------------------------------------------------------------------ */ +/* Long lines: no wrap, window scrolls, backspace spans the "wrap" */ +/* ------------------------------------------------------------------ */ + +static void test_readline_long(void) { + /* 10 cols, prompt "> " => 8-cell text area. */ + clio_term t; + clio_term_init(&t); + t.is_tty = 1; + t.rows = 3; + t.cols = 10; + clio_buf b; + clio_buf_init(&b, 3, 10); + clio_style p = clio_style_default(); + clio_style st = clio_style_default(); + clio_str txt; + clio_str_init(&txt); + clio_str_ins(&txt, 0, "abcdefghijkl", 12); + size_t col; + + /* caret at end: caret pinned to last col, tail visible, head scrolled */ + col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 1); + CHECK(col == 9, "caret pinned last col"); + CHECK(b.cells[2].ch == 'f' && b.cells[8].ch == 'l', "tail window"); + CHECK(b.cells[9].ch == 0, "nothing past caret"); + + /* one backspace of scroll: window slides left by one */ + col = clio_readline_paint(&b, 0, "> ", p, &txt, 11, st, 1); + CHECK(col == 9, "caret stays pinned"); + CHECK(b.cells[2].ch == 'e' && b.cells[9].ch == 'l', "window slid"); + + /* caret in the middle: anchored at 0, right edge clipped */ + col = clio_readline_paint(&b, 0, "> ", p, &txt, 3, st, 1); + CHECK(col == 5, "caret middle"); + CHECK(b.cells[2].ch == 'a' && b.cells[9].ch == 'h', "head window clipped"); + + /* wide chars: 8 CJK = 16 cells; window must start on a char + * boundary and the caret clamp inside the row */ + clio_str_free(&txt); + clio_str_init(&txt); + static const char cjk[] = "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD" + "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD"; + clio_str_ins(&txt, 0, cjk, sizeof cjk - 1); + col = clio_readline_paint(&b, 0, "> ", p, &txt, sizeof cjk - 1, st, 1); + CHECK(col == 9, "wide caret clamped"); + CHECK(b.cells[2].ch == 0x4E2D && b.cells[8].ch == 0x4E2D, + "wide window on boundary"); + + /* noecho: same windowing over star count */ + clio_str_free(&txt); + clio_str_init(&txt); + clio_str_ins(&txt, 0, "abcdefghijkl", 12); + col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 0); + CHECK(col == 9, "star caret pinned"); + CHECK(b.cells[2].ch == '*' && b.cells[8].ch == '*' && b.cells[9].ch == 0, + "star window"); + + clio_str_free(&txt); + clio_buf_free(&b); + clio_term_free(&t); + + /* integration: type past the edge, backspace across the scroll + * boundary — the logical result must be exact */ + keys_t s; + clio_key ks[64]; + s.ks = ks; + s.n = 0; + for (char ch = 'a'; ch <= 'l'; ch++) + push(&s, CLIOK_CHAR, (u32) ch); + push(&s, CLIOK_BACKSPACE, 0); + push(&s, CLIOK_BACKSPACE, 0); + push(&s, CLIOK_BACKSPACE, 0); + push(&s, CLIOK_ENTER, 0); + char *r = run_readline(&s, 1, 3, 10); + CHECK(r && strcmp(r, "abcdefghi") == 0, "backspace across wrap"); + free(r); + + /* left-arrow into the scrolled region, then backspace */ + s.n = 0; + for (char ch = 'a'; ch <= 'l'; ch++) + push(&s, CLIOK_CHAR, (u32) ch); + push(&s, CLIOK_LEFT, 0); + push(&s, CLIOK_BACKSPACE, 0); + push(&s, CLIOK_ENTER, 0); + r = run_readline(&s, 1, 3, 10); + CHECK(r && strcmp(r, "abcdefghijl") == 0, "left+backspace across wrap"); + free(r); +} + +/* ------------------------------------------------------------------ */ +/* Readline canvas paint */ +/* ------------------------------------------------------------------ */ + +static void test_readline_paint(void) { + clio_term t; + clio_term_init(&t); + t.is_tty = 1; + t.rows = 4; + t.cols = 20; + clio_buf b; + clio_buf_init(&b, 4, 20); + clio_style p = clio_style_default(); + clio_style st = clio_style_default(); + clio_str txt; + clio_str_init(&txt); + + /* type "ab", caret after 'b' (col 4) */ + clio_str_ins(&txt, 0, "ab", 2); + size_t col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1); + CHECK(col == 4, "caret col echo"); + CHECK(b.cells[0].ch == '>' && b.cells[1].ch == ' ', "prompt painted"); + CHECK(b.cells[2].ch == 'a' && b.cells[3].ch == 'b', "text painted"); + + /* echo off: stars */ + clio_buf_clear(&b); + col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 0); + CHECK(col == 4, "caret col noecho"); + CHECK(b.cells[2].ch == '*' && b.cells[3].ch == '*', "stars painted"); + + /* caret in the middle */ + clio_str_ins(&txt, 0, "ab", 2); + clio_str_ins(&txt, 1, "X", 1); + clio_str_ins(&txt, 2, "c", 1); + col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1); + CHECK(col == 4, "caret middle"); + + clio_str_free(&txt); + clio_buf_free(&b); + clio_term_free(&t); +} + +int main(void) { + test_utf8(); + test_buf(); + test_keydec(); + test_color_policy(); + test_fstyle(); + test_flush_plain(); + test_flush_diff(); + test_term_toggles(); + test_readline(); + test_readline_long(); + test_readline_paint(); + + printf("%d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_compact.cpp b/tests/test_compact.cpp new file mode 100644 index 0000000..a8b64f9 --- /dev/null +++ b/tests/test_compact.cpp @@ -0,0 +1,517 @@ +#include "compact.h" +#include "message.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ_U64(got, want, msg) \ + do { \ + checks++; \ + if ((unsigned long long)(got) != (unsigned long long)(want)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n", \ + __FILE__, __LINE__, (msg), (unsigned long long)(got), \ + (unsigned long long)(want)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +static char *repeat_char(char c, size_t n) { + char *s = (char* ) xmalloc(n + 1); + memset(s, c, n); + s[n] = '\0'; + return s; +} + +/* text of the first tool-result item of a user message (or ""). */ +static char *trc_text_of(const message *m) { + if (!m->is_user() || m->user().parts.empty()) + return xstrdup(""); + const user_part &p = m->user().parts[0]; + const user_tool_result_part *r = std::get_if<user_tool_result_part>(&p); + if (!r || r->content.empty()) + return xstrdup(""); + const trc_text *t = std::get_if<trc_text>(&r->content[0]); + return xstrdup(t ? t->text.c_str() : ""); +} + +/* ------------------------------------------------------------------ */ +/* estimate_tokens */ +/* ------------------------------------------------------------------ */ + +static void test_estimate_tokens(void) { + char *body = repeat_char('a', 40); + message m = msg_user_text(body); + CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 10, + "40 bytes -> 10 tokens"); + free(body); + + CHECK_EQ_U64(compact_estimate_tokens(NULL, 0), 0, "empty -> 0 tokens"); +} + +static void test_estimate_tokens_tool_call_args(void) { + message m = msg_assistant_tool_call("read_file", "{\"path\":\"a\"}"); + CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 6, + "name(9)+args(12)=21 -> 6 tokens"); +} + +/* ------------------------------------------------------------------ */ +/* keep_boundary */ +/* ------------------------------------------------------------------ */ + +static void test_keep_boundary(void) { + message msgs[6] = { + msg_user_text("read the file"), + msg_assistant_text("ok"), + msg_user_tool_result("c1", "read_file", "contents"), + msg_assistant_text("here it is"), + msg_user_text("now edit it"), + msg_assistant_text("done"), + }; + CHECK_EQ_U64(compact_keep_boundary(msgs, 6), 4, + "boundary is last user"); + + message n[2] = { + msg_user_tool_result("c1", "read_file", "contents"), + msg_assistant_text("done"), + }; + CHECK_EQ_U64(compact_keep_boundary(n, 2), 2, "none -> keep all"); + + CHECK_EQ_U64(compact_keep_boundary(NULL, 0), 0, "empty -> 0"); +} + +/* ------------------------------------------------------------------ */ +/* clear_old_tool_results */ +/* ------------------------------------------------------------------ */ + +static void test_clear_old_tool_results(void) { + message msgs[6] = { + msg_user_text("read the file"), + msg_assistant_text("ok"), + msg_user_tool_result("c1", "read_file", "lots of content"), + msg_assistant_text("here it is"), + msg_user_text("now edit it"), + msg_user_tool_result("c2", "write_file", "tail content"), + }; + size_t boundary = compact_keep_boundary(msgs, 6); + CHECK_EQ_U64(boundary, 4, "boundary == 4"); + + size_t cleared = compact_clear_old_tool_results(msgs, 6, boundary); + CHECK_EQ_U64(cleared, 1, "cleared exactly one"); + + char *t2 = trc_text_of(&msgs[2]); + CHECK(xstreq(t2, CLEARED_PLACEHOLDER), "stale result placeholdered"); + free(t2); + + char *t5 = trc_text_of(&msgs[5]); + CHECK(xstreq(t5, "tail content"), "tail result untouched"); + free(t5); + + /* name + call preserved on the cleared result */ + CHECK(msgs[2].is_user(), "still a user message"); + CHECK(std::holds_alternative<user_tool_result_part>(msgs[2].user().parts[0]), + "still a tool result"); + CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).name == + "read_file", + "name preserved"); + CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).call == + "c1", + "call id preserved"); + + /* idempotent */ + CHECK_EQ_U64(compact_clear_old_tool_results(msgs, 6, boundary), 0, + "second pass clears nothing"); +} + +/* ------------------------------------------------------------------ */ +/* transcript_markdown */ +/* ------------------------------------------------------------------ */ + +static void test_transcript_markdown(void) { + message msgs[4] = { + msg_user_text("read the file"), + msg_assistant_tool_call("read_file", "{\"path\":\"a.txt\"}"), + msg_user_tool_result("c1", "read_file", "line one\nline two"), + msg_assistant_text("The file has two lines."), + }; + char *out = compact_transcript_markdown(msgs, 4); + CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header"); + CHECK_CONT(out, "## User\n\nread the file", "user block"); + CHECK_CONT(out, "### Tool call: `read_file`", "tool call heading"); + CHECK_CONT(out, "```json", "json fence"); + CHECK_CONT(out, "\"path\"", "json path key"); + CHECK_CONT(out, "### Tool result: `read_file`", "tool result heading"); + CHECK_CONT(out, "line one\nline two", "result body"); + CHECK_CONT(out, "## Assistant\n\nThe file has two lines.", "assistant"); + free(out); +} + +static void test_transcript_markdown_empty(void) { + char *out = compact_transcript_markdown(NULL, 0); + CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header"); + CHECK_CONT(out, "_No messages yet._", "placeholder"); + free(out); +} + +/* ------------------------------------------------------------------ */ +/* recent_file_paths */ +/* ------------------------------------------------------------------ */ + +static void test_recent_file_paths(void) { + strlist out; + const char *s1 = "## Goal\ndo a thing\n\n## Files\n- src/foo.rs\n" + "* src/bar.rs\n\n## Next steps\n- more"; + compact_recent_file_paths(s1, &out); + CHECK(out.len == 2, "two paths"); + if (out.len == 2) { + CHECK(xstreq(out.items[0], "src/foo.rs"), "path 0"); + CHECK(xstreq(out.items[1], "src/bar.rs"), "path 1"); + } + sl_free(&out); + + compact_recent_file_paths("## Goal\nnothing here", &out); + CHECK(out.len == 0, "no files section -> empty"); + sl_free(&out); + + const char *s3 = "## Next steps\n- src/foo.rs\n\n## Files\n- src/bar.rs"; + compact_recent_file_paths(s3, &out); + CHECK(out.len == 1, "ignores bullets outside files"); + if (out.len == 1) + CHECK(xstreq(out.items[0], "src/bar.rs"), "only bar.rs"); + sl_free(&out); +} + +/* ------------------------------------------------------------------ */ +/* compose_history */ +/* ------------------------------------------------------------------ */ + +static void test_compose_history(void) { + message tail[2] = { + msg_user_text("now edit it"), + msg_assistant_text("done"), + }; + const char *paths[1] = {"src/foo.rs"}; + const char *contents[1] = {"fn main() {}"}; + history *h = compact_compose_history("## Goal\nship it", paths, contents, + 1, tail, 2); + CHECK_EQ_U64(history_len(h), 3, "one body + tail"); + const message *first = history_at(h, 0); + CHECK(first->is_user(), "first is user"); + CHECK_EQ_U64(first->user().parts.size(), 1, "single text part"); + const std::string &body = + std::get<user_text_part>(first->user().parts[0]).text; + CHECK_CONT(body.c_str(), "## Goal", "summary present"); + CHECK_CONT(body.c_str(), "src/foo.rs", "file path present"); + CHECK_CONT(body.c_str(), "fn main() {}", "file content present"); + CHECK(history_at(h, 1)->is_user(), "tail[0] user"); + CHECK(history_at(h, 2)->is_assistant(), "tail[1] assistant"); + history_free(h); + free(h); +} + +static void test_compose_history_caps(void) { + char *big = repeat_char('x', 200000); + const char *paths[1] = {"big.log"}; + const char *contents[1] = {big}; + history *h = + compact_compose_history("summary", paths, contents, 1, NULL, 0); + CHECK_EQ_U64(history_len(h), 1, "just the body"); + const std::string &body = + std::get<user_text_part>(history_at(h, 0)->user().parts[0]).text; + CHECK(utf8_len(body.c_str()) < 200000, "content capped, not full"); + char *want = repeat_char('x', PER_FILE_CAP); + CHECK_CONT(body.c_str(), want, "first PER_FILE_CAP chars present"); + free(want); + history_free(h); + free(h); + free(big); +} + +/* ------------------------------------------------------------------ */ +/* transcript (flat) */ +/* ------------------------------------------------------------------ */ + +static void test_transcript_flat(void) { + message msgs[3] = { + msg_user_text("read the file"), + msg_assistant_text("reading"), + msg_user_tool_result("c1", "read_file", "the body"), + }; + char *out = compact_transcript(msgs, 3); + CHECK_CONT(out, "User: read the file", "user line"); + CHECK_CONT(out, "Assistant: reading", "assistant line"); + CHECK_CONT(out, "ToolResult(read_file): the body", "tool result line"); + free(out); +} + +/* ------------------------------------------------------------------ */ +/* compact_at / clear_at */ +/* ------------------------------------------------------------------ */ + +static void test_thresholds(void) { + compact_config c; + compact_config_from(1000, 0.75, 0.5, &c); + unsigned long v; + CHECK(compact_at(&c, &v), "compact enabled"); + CHECK_EQ_U64(v, 750, "compact_at == 750"); + CHECK(clear_at(&c, &v), "clear enabled"); + CHECK_EQ_U64(v, 500, "clear_at == 500"); + + compact_config d; + compact_config_from(1000, 0.0, 0.0, &d); + CHECK(!compact_at(&d, &v), "compact disabled"); + CHECK(!clear_at(&d, &v), "clear disabled"); +} + +/* ------------------------------------------------------------------ */ +/* message model */ +/* ------------------------------------------------------------------ */ + +static void test_message_model(void) { + message m = msg_user_tool_result("c9", "grep", "match"); + CHECK(m.is_user(), "role user"); + CHECK_EQ_U64(m.user().parts.size(), 1, "one part"); + CHECK(std::holds_alternative<user_tool_result_part>(m.user().parts[0]), + "tool result kind"); + CHECK(std::get<user_tool_result_part>(m.user().parts[0]).name == "grep", + "name"); + CHECK(std::get<user_tool_result_part>(m.user().parts[0]).call == "c9", + "call"); + CHECK(std::get<trc_text>( + std::get<user_tool_result_part>(m.user().parts[0]).content[0]) + .text == "match", + "content"); + + /* copy round-trip */ + message orig = msg_assistant_tool_call("write_file", "{\"path\":\"a\"}"); + message copy = orig; + CHECK(copy.is_assistant(), "copy role"); + CHECK_EQ_U64(copy.assistant().parts.size(), 1, "copy len"); + CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name == + "write_file", + "copy name"); + CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).args == + "{\"path\":\"a\"}", + "copy args"); + /* independent storage: mutating orig does not affect copy */ + std::get<asst_tool_call_part>(orig.assistant().parts[0]).name = "other"; + CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name == + "write_file", + "copy independent"); + + /* multi-part assistant message */ + message multi = msg_assistant_text("thinking..."); + msg_push_assistant_tool_call(multi, "run_shell", "echo hi"); + msg_push_assistant_reasoning(multi, "because"); + CHECK_EQ_U64(multi.assistant().parts.size(), 3, "three parts"); + CHECK(std::holds_alternative<asst_text_part>(multi.assistant().parts[0]), + "part0 text"); + CHECK(std::holds_alternative<asst_tool_call_part>( + multi.assistant().parts[1]), + "part1 tool call"); + CHECK(std::holds_alternative<asst_reasoning_part>( + multi.assistant().parts[2]), + "part2 reasoning"); + + /* history push / take */ + history h; + history_push(&h, msg_user_text("one")); + history_push(&h, msg_user_text("two")); + history_push(&h, msg_user_text("three")); + CHECK_EQ_U64(history_len(&h), 3, "len 3"); + message mid = history_take(&h, 1); + CHECK(std::get<user_text_part>(mid.user().parts[0]).text == "two", + "take returns elem 1"); + CHECK_EQ_U64(history_len(&h), 2, "len 2 after take"); + CHECK(std::get<user_text_part>(history_at(&h, 0)->user().parts[0]).text == + "one", + "elem0 intact"); + CHECK(std::get<user_text_part>(history_at(&h, 1)->user().parts[0]).text == + "three", + "elem1 shifted"); + /* stack history: RAII destructor cleans up at scope exit */ + + /* preamble sanity */ + const char *p = compact_summarizer_preamble(); + CHECK_CONT(p, "## Goal", "preamble goal section"); + CHECK_CONT(p, "## Files", "preamble files section"); + CHECK_CONT(p, "under 800 words", "preamble word limit"); +} + +/* ------------------------------------------------------------------ */ +/* rubric (SelfCompact) */ +/* ------------------------------------------------------------------ */ + +static void test_rubric_parse_valid(void) { + const char *answer = + "C1: Y -- the last message ends with a finished tool result\n" + "C2: Y -- 1. fact one \"x\"\n" + "C3: Y -- a new URL was obtained\n" + "N1: N -- each recent call returned new content\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok"); + CHECK(err == NULL, "no err"); + CHECK_EQ_U64(r.c1, 1, "c1 Y"); + CHECK_EQ_U64(r.c2, 1, "c2 Y"); + CHECK_EQ_U64(r.c3, 1, "c3 Y"); + CHECK_EQ_U64(r.n1, 0, "n1 N"); + CHECK(compact_rubric_fire(&r), "fires when c1&c2&c3 & !n1"); +} + +static void test_rubric_parse_n(void) { + const char *answer = + "C1: N -- still mid-thought\n" + "C2: Y -- facts\n" + "C3: Y -- progress\n" + "N1: N -- fresh results\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok"); + CHECK_EQ_U64(r.c1, 0, "c1 N"); + CHECK(!compact_rubric_fire(&r), "does not fire with c1 N"); +} + +static void test_rubric_parse_stuck(void) { + const char *answer = + "C1: Y -- closed\n" + "C2: Y -- facts\n" + "C3: Y -- progress\n" + "N1: Y -- three of last four calls repeated\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok"); + CHECK_EQ_U64(r.n1, 1, "n1 Y"); + CHECK(!compact_rubric_fire(&r), "does not fire when stuck"); +} + +static void test_rubric_parse_missing(void) { + const char *answer = + "C1: Y -- closed\n" + "C2: Y -- facts\n" + "C3: Y -- progress\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) != 0, "missing N1 -> error"); + CHECK(err != NULL, "err set"); + free(err); +} + +static void test_rubric_parse_malformed(void) { + const char *answer = + "C1: MAYBE -- closed\n" + "C2: Y -- facts\n" + "C3: Y -- progress\n" + "N1: N -- fresh\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) != 0, "bad token -> error"); + CHECK(err != NULL, "err set"); + free(err); +} + +static void test_rubric_y_without_evidence_is_n(void) { + /* A Y with no evidence on the line is downgraded to N (the paper's rule). */ + const char *answer = + "C1: Y\n" + "C2: Y -- facts\n" + "C3: Y -- progress\n" + "N1: N -- fresh\n"; + compact_rubric r; + char *err = NULL; + CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok"); + CHECK_EQ_U64(r.c1, 0, "Y without evidence downgraded to N"); + CHECK(!compact_rubric_fire(&r), "does not fire"); +} + +static void test_rubric_prompt_and_preamble(void) { + const char *p = compact_rubric_prompt(); + CHECK_CONT(p, "C1", "prompt has C1"); + CHECK_CONT(p, "C2", "prompt has C2"); + CHECK_CONT(p, "C3", "prompt has C3"); + CHECK_CONT(p, "N1", "prompt has N1"); + CHECK_CONT(p, "C1: Y/N -- <evidence>", "prompt output format"); + + const char *pre = compact_rubric_preamble(); + CHECK_CONT(pre, "judge", "preamble is a judge role"); +} + +static void test_rubric_due(void) { + /* all gates satisfied */ + CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0, + RUBRIC_DEFAULT_MAX_SUMMARIES, + RUBRIC_MIN_ROUNDS_SINCE_PROBE), + "due at exact thresholds"); + /* iteration too low */ + CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION - 1, RUBRIC_MIN_TOKENS, 0, + RUBRIC_DEFAULT_MAX_SUMMARIES, + RUBRIC_MIN_ROUNDS_SINCE_PROBE), + "not due: iteration"); + /* tokens too low */ + CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS - 1, 0, + RUBRIC_DEFAULT_MAX_SUMMARIES, + RUBRIC_MIN_ROUNDS_SINCE_PROBE), + "not due: tokens"); + /* cap reached */ + CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 1, + RUBRIC_DEFAULT_MAX_SUMMARIES, + RUBRIC_MIN_ROUNDS_SINCE_PROBE), + "not due: cap"); + /* rounds since probe too low */ + CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0, + RUBRIC_DEFAULT_MAX_SUMMARIES, + RUBRIC_MIN_ROUNDS_SINCE_PROBE - 1), + "not due: rounds"); + /* unlimited cap (0) */ + CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 5, 0, + RUBRIC_MIN_ROUNDS_SINCE_PROBE), + "due: unlimited cap"); +} + +int main(void) { + test_estimate_tokens(); + test_estimate_tokens_tool_call_args(); + test_keep_boundary(); + test_clear_old_tool_results(); + test_transcript_markdown(); + test_transcript_markdown_empty(); + test_recent_file_paths(); + test_compose_history(); + test_compose_history_caps(); + test_transcript_flat(); + test_thresholds(); + test_message_model(); + test_rubric_parse_valid(); + test_rubric_parse_n(); + test_rubric_parse_stuck(); + test_rubric_parse_missing(); + test_rubric_parse_malformed(); + test_rubric_y_without_evidence_is_n(); + test_rubric_prompt_and_preamble(); + test_rubric_due(); + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_config.cpp b/tests/test_config.cpp new file mode 100644 index 0000000..c2b5e2b --- /dev/null +++ b/tests/test_config.cpp @@ -0,0 +1,654 @@ +#include "config.h" + +#include <math.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ_STR(a, b, msg) \ + do { \ + checks++; \ + const char *_a = (a), *_b = (b); \ + if (!_a || (_b && strcmp(_a, _b) != 0)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), _a ? _a : "(null)", \ + _b ? _b : "(null)"); \ + } \ + } while (0) + +static cli mkcli(void) { + cli c; + memset(&c, 0, sizeof c); + return c; +} + +static void freecfg(config *c) { + config_free(c); +} + +/* ------------------------------------------------------------------ */ +/* Provider parsing */ +/* ------------------------------------------------------------------ */ + +static void test_provider_parsing(void) { + char *err = NULL; + CHECK(provider_parse("openai", &err) == PROV_OPENAI, "openai"); + CHECK(provider_parse("OpenAI-compatible", &err) == PROV_OPENAI, + "OpenAI-compatible"); + CHECK(provider_parse("openai_compatible", &err) == PROV_OPENAI, + "openai_compatible"); + CHECK(provider_parse("Anthropic", &err) == PROV_ANTHROPIC, "Anthropic"); + CHECK(provider_parse("claude", &err) == PROV_ANTHROPIC, "claude"); + CHECK(provider_parse("ollama", &err) == PROV_OLLAMA, "ollama"); + + err = NULL; + provider_kind k = provider_parse("nope", &err); + CHECK(k == PROV_OPENAI && err && strstr(err, "nope") && + strstr(err, "unknown provider"), + "unknown provider error"); + free(err); + + CHECK(strcmp(provider_default_model(PROV_OPENAI), "gpt-5.2") == 0, + "default openai model"); + CHECK(strcmp(provider_default_model(PROV_ANTHROPIC), "claude-sonnet-4-6") == + 0, + "default anthropic model"); + CHECK(strcmp(provider_default_model(PROV_OLLAMA), "llama3.2") == 0, + "default ollama model"); +} + +/* ------------------------------------------------------------------ */ +/* CLI parsing */ +/* ------------------------------------------------------------------ */ + +static void test_cli_parse_basic(void) { + cli c; + char *err = NULL; + const char *a1 = "--provider"; + const char *a2 = "ollama"; + const char *a3 = "--model"; + const char *a4 = "llama3.2"; + const char *a5 = "hello world"; + const char *argv[] = { + "llsh", a1, a2, a3, a4, a5 + }; + int r = cli_parse(6, (char **)argv, &c, &err); + CHECK(r == 0, "parse ok"); + CHECK_EQ_STR(c.provider, "ollama", "provider"); + CHECK_EQ_STR(c.model, "llama3.2", "model"); + CHECK_EQ_STR(c.prompt, "hello world", "prompt positional"); + CHECK(!c.debug, "debug off by default"); + cli_free(&c); + free(err); + + /* -C and --verbose and numeric flags */ + const char *b1 = "-C"; + const char *b2 = "/tmp"; + const char *b3 = "--verbose"; + const char *b4 = "--max-turns"; + const char *b5 = "0"; + const char *b6 = "--max-retries"; + const char *b7 = "2"; + const char *b8 = "--debug"; + const char *b9 = "--allow-mcp"; + const char *argv2[] = { + "llsh", b1, b2, b3, b4, b5, b6, b7, b8, b9 + }; + r = cli_parse(10, (char **)argv2, &c, &err); + CHECK(r == 0, "parse -C/--verbose ok"); + CHECK_EQ_STR(c.cwd, "/tmp", "cwd"); + CHECK(c.verbose, "verbose flag"); + CHECK(c.debug, "debug flag"); + CHECK(c.allow_mcp, "allow-mcp trust opt-in"); + CHECK(c.has_max_turns && c.max_turns == 0, "max-turns 0 accepted"); + CHECK(c.has_max_retries && c.max_retries == 2, "max-retries"); + cli_free(&c); + free(err); + + /* unknown option */ + const char *u1 = "--frobnicate"; + const char *argv3[] = { + "llsh", u1 + }; + r = cli_parse(2, (char **)argv3, &c, &err); + CHECK(r == -1 && err, "unknown option errors"); + free(err); + cli_free(&c); + + /* two positionals */ + const char *p1 = "one"; + const char *p2 = "two"; + const char *argv4[] = { + "llsh", p1, p2 + }; + r = cli_parse(3, (char **)argv4, &c, &err); + CHECK(r == -1 && err && strstr(err, "unexpected argument"), + "two positionals error"); + free(err); + cli_free(&c); +} + +static void test_cli_shell_policy_options(void) { + cli c; + char *err = NULL; + const char *argv[] = {"llsh", "--shell-allow", "git", "--shell-allow", + "rg", "--shell-deny", "rm", "--confirm-destructive"}; + CHECK(cli_parse(8, (char **)argv, &c, &err) == 0, + "parse shell policy options"); + CHECK(c.shell_allowlist.len == 2, "repeatable shell allowlist"); + CHECK(c.shell_denylist.len == 1, "shell denylist"); + CHECK(c.confirm_destructive, "destructive confirmation enabled"); + config resolved; + CHECK(config_merge(&c, &resolved, &err) == 0, + "merge shell policy into runtime config"); + CHECK(resolved.shell_allowlist.len == 2 && + strcmp(resolved.shell_allowlist.items[0], "git") == 0, + "merged allowlist preserved"); + CHECK(resolved.shell_denylist.len == 1 && + strcmp(resolved.shell_denylist.items[0], "rm") == 0, + "merged denylist preserved"); + CHECK(resolved.confirm_destructive, + "merged destructive confirmation enabled"); + config_free(&resolved); + cli_free(&c); + free(err); +} + +static void test_cli_parse_rejects_negative_ulong(void) { + /* `--max-turns -1` used to parse as ULONG_MAX via strtoull; it must error. */ + cli c; + char *err = NULL; + const char *a1 = "--max-turns"; + const char *a2 = "-1"; + const char *argv[] = {"llsh", a1, a2}; + int r = cli_parse(3, (char **)argv, &c, &err); + CHECK(r == -1, "negative max-turns rejected"); + CHECK(err && strstr(err, "invalid value") != NULL, "invalid-value message"); + free(err); + cli_free(&c); + + /* A genuinely huge value that overflows unsigned long must also error. */ + const char *h1 = "--max-turns"; + const char *h2 = "99999999999999999999"; + const char *argv2[] = {"llsh", h1, h2}; + r = cli_parse(3, (char **)argv2, &c, &err); + CHECK(r == -1, "overflowing max-turns rejected"); + free(err); + cli_free(&c); +} + +static void test_cli_parse_fraction_thresholds(void) { + cli c; + char *err = NULL; + const char *good[] = {"llsh", "--compact-threshold", "0.75", + "--clear-threshold", "0"}; + CHECK(cli_parse(5, (char **)good, &c, &err) == 0, + "valid fractions accepted"); + CHECK(c.has_compact_threshold && c.compact_threshold == 0.75, + "compact fraction parsed"); + CHECK(c.has_clear_threshold && c.clear_threshold == 0, + "zero disables clear fraction"); + cli_free(&c); + free(err); + const char *bad_values[] = {"xyz", "0.75junk", "nan", "inf", "1.01"}; + for (size_t i = 0; i < sizeof bad_values / sizeof bad_values[0]; i++) { + const char *args[] = {"llsh", "--compact-threshold", bad_values[i]}; + err = NULL; + CHECK(cli_parse(3, (char **)args, &c, &err) == -1, + "malformed/out-of-range fraction rejected"); + CHECK(err != NULL, "fraction rejection explains error"); + free(err); + cli_free(&c); + } +} + +/* ------------------------------------------------------------------ */ +/* File config loading */ +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* Merge */ +/* ------------------------------------------------------------------ */ + +static void test_merge_cli_values(void) { + cli c = mkcli(); + c.provider = xstrdup("anthropic"); + c.model = xstrdup("cli-model"); + c.api_key = xstrdup("cli-key"); + + config cfg; + char *err = NULL; + int r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "merge ok"); + CHECK(cfg.provider == PROV_ANTHROPIC, "cli provider"); + CHECK_EQ_STR(cfg.model, "cli-model", "cli model"); + CHECK_EQ_STR(cfg.api_key, "cli-key", "cli key"); + freecfg(&cfg); + cli_free(&c); +} + +static void test_merge_defaults(void) { + cli c = mkcli(); + c.provider = xstrdup("ollama"); + + config cfg; + char *err = NULL; + int r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "merge ok"); + CHECK(cfg.provider == PROV_OLLAMA, "provider from cli"); + CHECK_EQ_STR(cfg.model, "llama3.2", "provider default model"); + CHECK(cfg.api_key == NULL, "no key for ollama"); + CHECK(cfg.max_turns == 0, "max_turns unbounded by default"); + CHECK(cfg.preamble && cfg.preamble[0] != '\0', "built-in preamble"); + CHECK(cfg.max_retries == 3, "default max_retries"); + CHECK(cfg.context_limit == 0, "no built-in window"); + CHECK(cfg.compact_threshold > 0.74 && cfg.compact_threshold < 0.76, + "default compact_threshold"); + CHECK(cfg.clear_threshold > 0.49 && cfg.clear_threshold < 0.51, + "default clear_threshold"); + CHECK(!cfg.verbose, "verbose off"); + CHECK(cfg.skill_index_cap == 50, "default skill cap"); + CHECK(cfg.shell_timeout_secs == 120, "default shell timeout"); + CHECK(cfg.max_output_chars == 16000, "default max output"); + CHECK(cfg.max_file_bytes == 2000000, "default max file bytes"); + freecfg(&cfg); + cli_free(&c); +} + +static void test_merge_api_key_rules(void) { + /* openai without base_url or key: the merge defers the check so a + * models.json preset can still supply the key */ + cli c = mkcli(); + c.provider = xstrdup("openai"); + config cfg; + char *err = NULL; + int r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "merge defers key check"); + r = config_require_key_or_base(&cfg, &err); + CHECK(r == -1 && err && strstr(err, "no API key"), + "openai needs key or base_url"); + free(err); + config_free(&cfg); + cli_free(&c); + + /* base_url is enough */ + c = mkcli(); + c.provider = xstrdup("openai"); + c.base_url = xstrdup("http://localhost:8080/v1"); + err = NULL; + r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "base_url ok"); + CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept"); + CHECK(cfg.api_key == NULL, "no key needed"); + freecfg(&cfg); + cli_free(&c); + + /* anthropic with key is fine */ + c = mkcli(); + c.provider = xstrdup("anthropic"); + c.api_key = xstrdup("k"); + err = NULL; + r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "anthropic+key ok"); + freecfg(&cfg); + cli_free(&c); +} + +static void test_merge_max_turns_and_retries(void) { + /* max_turns 0 is unbounded and stays 0 */ + cli c = mkcli(); + c.provider = xstrdup("ollama"); + c.max_turns = 0; + c.has_max_turns = true; + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.max_turns == 0, "max_turns 0 stays 0 (unbounded)"); + freecfg(&cfg); + cli_free(&c); + + /* explicit retries are honored */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.max_retries = 5; + c.has_max_retries = true; + err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.max_retries == 5, "retries honored"); + freecfg(&cfg); + cli_free(&c); +} + +static void test_merge_compaction(void) { + cli c = mkcli(); + c.provider = xstrdup("ollama"); + c.context_limit = 4000; + c.has_context_limit = true; + c.compact_threshold = 0.9; + c.has_compact_threshold = true; + c.clear_threshold = 0.25; + c.has_clear_threshold = true; + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.context_limit == 4000, "context_limit"); + CHECK(fabs(cfg.compact_threshold - 0.9) < 1e-9, "compact"); + CHECK(fabs(cfg.clear_threshold - 0.25) < 1e-9, "clear"); + freecfg(&cfg); + cli_free(&c); + + /* threshold out of range */ + int r; + double bad[] = {2.0, -0.1, 1.1, NAN, INFINITY}; + for (size_t i = 0; i < sizeof bad / sizeof *bad; i++) { + c = mkcli(); + c.provider = xstrdup("ollama"); + c.compact_threshold = bad[i]; + c.has_compact_threshold = true; + err = NULL; + config cfg2; + r = config_merge(&c, &cfg2, &err); + CHECK(r == -1 && err && strstr(err, "compact_threshold"), + "bad compact_threshold rejected"); + free(err); + config_free(&cfg2); + cli_free(&c); + } + double badc[] = {-0.1, 1.5, NAN}; + for (size_t i = 0; i < sizeof badc / sizeof *badc; i++) { + c = mkcli(); + c.provider = xstrdup("ollama"); + c.clear_threshold = badc[i]; + c.has_clear_threshold = true; + err = NULL; + config cfg2; + r = config_merge(&c, &cfg2, &err); + CHECK(r == -1 && err && strstr(err, "clear_threshold"), + "bad clear_threshold rejected"); + free(err); + config_free(&cfg2); + cli_free(&c); + } + + /* clear < compact required when a window is known */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.context_limit = 1000; + c.has_context_limit = true; + c.clear_threshold = 0.8; + c.has_clear_threshold = true; + c.compact_threshold = 0.5; + c.has_compact_threshold = true; + err = NULL; + config cfg2; + r = config_merge(&c, &cfg2, &err); + CHECK(r == -1 && err && strstr(err, "clear_threshold"), + "clear >= compact rejected"); + free(err); + config_free(&cfg2); + cli_free(&c); + + /* disabled tier (0.0) imposes no ordering constraint */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.context_limit = 1000; + c.has_context_limit = true; + c.clear_threshold = 0.0; + c.has_clear_threshold = true; + c.compact_threshold = 0.5; + c.has_compact_threshold = true; + err = NULL; + r = config_merge(&c, &cfg2, &err); + CHECK(r == 0, "clear=0 disables ordering"); + freecfg(&cfg2); + cli_free(&c); + + /* 0 thresholds are valid (tiers off) */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.compact_threshold = 0.0; + c.has_compact_threshold = true; + c.clear_threshold = 0.0; + c.has_clear_threshold = true; + err = NULL; + r = config_merge(&c, &cfg2, &err); + CHECK(r == 0, "0/0 thresholds ok"); + freecfg(&cfg2); + cli_free(&c); +} + +static void test_merge_lower_bounds_and_verbose(void) { + cli c = mkcli(); + c.provider = xstrdup("ollama"); + c.skill_index_cap = 0; + c.has_skill_index_cap = true; + c.shell_timeout_secs = 0; + c.has_shell_timeout_secs = true; + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.skill_index_cap == 1, "skill cap floor 1"); + CHECK(cfg.shell_timeout_secs == 1, "shell timeout floor 1"); + freecfg(&cfg); + cli_free(&c); + + /* verbose from CLI */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.verbose = true; + err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.verbose, "cli verbose applied"); + freecfg(&cfg); + cli_free(&c); +} + +/* ------------------------------------------------------------------ */ +/* Presets */ +/* ------------------------------------------------------------------ */ + +static models_file mkmodels(const char *def, const char *prov, + const char *model, const char *base_url, + const char *key, unsigned long mt, + unsigned long ctx) { + models_file m; + memset(&m, 0, sizeof m); + m.default_name = xstrdup(def); + m.presets = (model_preset* ) xmalloc(sizeof *m.presets); + memset(m.presets, 0, sizeof *m.presets); + m.presets[0].name = xstrdup(def); + m.presets[0].provider = prov ? xstrdup(prov) : NULL; + m.presets[0].model = model ? xstrdup(model) : NULL; + m.presets[0].base_url = base_url ? xstrdup(base_url) : NULL; + m.presets[0].api_key = key ? xstrdup(key) : NULL; + if (mt) { + m.presets[0].max_turns = mt; + m.presets[0].has_max_turns = true; + } + if (ctx) { + m.presets[0].context_limit = ctx; + m.presets[0].has_context_limit = true; + } + m.preset_count = 1; + return m; +} + +static void test_preset_overrides_defaults(void) { + /* CLI base_url (with an unparseable-ish provider) lets the preset supply + * provider/model/key; every preset field applies over the base. */ + cli c = mkcli(); + c.base_url = xstrdup("http://localhost:8080/v1"); + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + + models_file m = mkmodels("local", "openai", "llm", NULL, "pk", 7, 0); + char *name = config_apply_models_preset(&cfg, &m, &c, &err); + CHECK(name && strcmp(name, "local") == 0, "preset applied"); + CHECK(cfg.provider == PROV_OPENAI, "preset provider"); + CHECK_EQ_STR(cfg.model, "llm", "preset model"); + CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept"); + CHECK_EQ_STR(cfg.api_key, "pk", "preset api_key"); + CHECK(cfg.max_turns == 7, "preset max_turns"); + free(name); + models_file_free(&m); + freecfg(&cfg); + cli_free(&c); +} + +static void test_preset_cli_flag_wins(void) { + /* CLI --model / --provider are authoritative: the preset must not clobber + * them (it may still fill fields the CLI left unset). */ + cli c = mkcli(); + c.model = xstrdup("cli-model"); + c.provider = xstrdup("anthropic"); + c.base_url = xstrdup("http://localhost:8080/v1"); + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + + models_file m = mkmodels("local", "openai", "preset-model", + "http://preset.example/v1", NULL, 0, 0); + char *name = config_apply_models_preset(&cfg, &m, &c, &err); + CHECK(name, "preset applied"); + CHECK(cfg.provider == PROV_ANTHROPIC, "CLI provider wins"); + CHECK_EQ_STR(cfg.model, "cli-model", "CLI model wins"); + CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "CLI base_url wins"); + free(name); + models_file_free(&m); + freecfg(&cfg); + cli_free(&c); +} + +static void test_preset_errors(void) { + cli c = mkcli(); + c.provider = xstrdup("ollama"); + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + + models_file empty; + memset(&empty, 0, sizeof empty); + char *name = config_apply_models_preset(&cfg, &empty, &c, &err); + CHECK(!name && err && strstr(err, "no `default` preset"), + "no default preset error"); + free(err); + err = NULL; + + models_file ghost = mkmodels("ghost", NULL, NULL, NULL, NULL, 0, 0); + free(ghost.presets[0].name); + ghost.presets[0].name = xstrdup("other"); + name = config_apply_models_preset(&cfg, &ghost, &c, &err); + CHECK(!name && err && strstr(err, "not in presets"), + "default not in presets error"); + free(err); + models_file_free(&ghost); + freecfg(&cfg); + cli_free(&c); +} + +static void test_preset_supplies_api_key(void) { + /* Base is valid via base_url but has no key; the preset fills the key + * (and the model) for a key-requiring provider. */ + cli c = mkcli(); + c.base_url = xstrdup("http://localhost:8080/v1"); + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + CHECK(cfg.api_key == NULL, "no key before preset"); + + models_file m = mkmodels("cloud", "openai", "cm", NULL, "pk", 0, 0); + char *name = config_apply_models_preset(&cfg, &m, &c, &err); + CHECK(name, "preset applied"); + CHECK(cfg.provider == PROV_OPENAI, "preset provider"); + CHECK_EQ_STR(cfg.api_key, "pk", "preset supplies api_key"); + CHECK_EQ_STR(cfg.model, "cm", "preset model"); + free(name); + models_file_free(&m); + freecfg(&cfg); + cli_free(&c); +} + +static void test_preset_context_limit(void) { + cli c = mkcli(); + c.provider = xstrdup("ollama"); + c.base_url = xstrdup("http://localhost:8080/v1"); + config cfg; + char *err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + models_file m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000); + char *name = config_apply_models_preset(&cfg, &m, &c, &err); + CHECK(name && cfg.context_limit == 8000, "preset context_limit applied"); + free(name); + models_file_free(&m); + freecfg(&cfg); + cli_free(&c); + + /* explicit CLI --context-limit wins over the preset */ + c = mkcli(); + c.provider = xstrdup("ollama"); + c.base_url = xstrdup("http://localhost:8080/v1"); + c.context_limit = 16000; + c.has_context_limit = true; + err = NULL; + CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok"); + m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000); + name = config_apply_models_preset(&cfg, &m, &c, &err); + CHECK(name && cfg.context_limit == 16000, "CLI context_limit wins"); + free(name); + models_file_free(&m); + freecfg(&cfg); + cli_free(&c); +} + + +static void test_merge_openai_requires_key(void) { + cli c = mkcli(); + c.provider = xstrdup("openai"); + config cfg; + char *err = NULL; + int r = config_merge(&c, &cfg, &err); + CHECK(r == 0, "merge ok without key (preset may supply it)"); + r = config_require_key_or_base(&cfg, &err); + CHECK(r == -1 && err && strstr(err, "no API key"), + "openai without key/base_url rejected"); + free(err); + config_free(&cfg); + cli_free(&c); +} + +int main(void) { + test_provider_parsing(); + test_cli_parse_basic(); + test_cli_shell_policy_options(); + test_cli_parse_rejects_negative_ulong(); + test_cli_parse_fraction_thresholds(); + test_merge_cli_values(); + test_merge_defaults(); + test_merge_api_key_rules(); + test_merge_max_turns_and_retries(); + test_merge_compaction(); + test_merge_lower_bounds_and_verbose(); + test_preset_overrides_defaults(); + test_preset_cli_flag_wins(); + test_preset_errors(); + test_preset_supplies_api_key(); + test_preset_context_limit(); + test_merge_openai_requires_key(); + printf("test_config: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_dotagents.c b/tests/test_dotagents.c new file mode 100644 index 0000000..af51506 --- /dev/null +++ b/tests/test_dotagents.c @@ -0,0 +1,618 @@ +/* test_dotagents.c — standalone test suite for dotagents.h (the .agents + * protocol layer). Zero dependencies: compiles to a single TU with no + * linked libraries (frontmatter.h provides everything it needs). */ +#include "dotagents.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <unistd.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +static char *tmpdir(const char *tag) { + char path[256]; + snprintf(path, sizeof path, "/tmp/llshc-da-%s-%d", tag, (int)getpid()); + /* rm -rf */ + char cmd[300]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", path); + if (system(cmd) != 0) {} + /* mkdir -p */ + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path); + if (system(cmd) != 0) + exit(1); + return fm_strdup(path); +} + +static void wfile_bin(const char *dir, const char *rel, const char *content) { + char path[512]; + snprintf(path, sizeof path, "%s/%s", dir, rel); + char *slash = strrchr(path, '/'); + if (slash) { + char dirpart[512]; + size_t n = (size_t)(slash - path); + memcpy(dirpart, path, n); + dirpart[n] = '\0'; + char cmd[600]; + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart); + if (system(cmd) != 0) + exit(1); + } + FILE *f = fopen(path, "w"); + if (!f) + exit(1); + fputs(content, f); + fclose(f); +} + +static int count_warns(const dotagents *d, const char *needle) { + int n = 0; + for (size_t i = 0; i < d->warnings.len; i++) + if (strstr(d->warnings.items[i], needle)) + n++; + return n; +} + +/* ------------------------------------------------------------------ */ +/* Layer tests */ +/* ------------------------------------------------------------------ */ + +static void test_loads_global_only(void) { + char *ws = tmpdir("ws1"); + char *global = tmpdir("g1"); + wfile_bin(global, "agents.md", "global guidelines"); + wfile_bin(global, "skills/g1/skill.md", + "---\nid: g1\nname: G1\ndescription: global skill\n---\ndo g1"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.found_count == 1, "one layer found"); + CHECK(d.found_layers[0] == LAYER_GLOBAL, "layer is global"); + CHECK(d.agents_md && strcmp(d.agents_md, "global guidelines") == 0, + "agents_md"); + CHECK(d.skill_count == 1, "one skill"); + CHECK(strcmp(d.skills[0].id, "g1") == 0, "skill id"); + CHECK(strcmp(d.skills[0].body, "do g1") == 0, "skill body"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_md_filenames_case_insensitive(void) { + char *ws = tmpdir("ws-cs"); + wfile_bin(ws, "AGENTS.MD", "workspace guidelines"); + wfile_bin(ws, "System-Prompt.md", "the system prompt"); + wfile_bin(ws, "skills/UPPER/SKILL.md", + "---\nid: upper\nname: Upper\ndescription: caps skill\n---\ndo " + "upper"); + wfile_bin(ws, "agents/REVIEWER/Agent.md", + "---\nid: reviewer\nname: Reviewer\n---\nYou review."); + wfile_bin(ws, "tasks/daily/Task.MD", + "---\nid: daily\nname: Daily\nenabled: true\n---\nRun daily."); + wfile_bin(ws, "memories/note.MD", + "---\nid: note\ntitle: Note\n---\nbody"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.found_count == 1, "workspace found"); + CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0, + "AGENTS.MD"); + CHECK(d.system_prompt && strcmp(d.system_prompt, "the system prompt") == 0, + "System-Prompt.md"); + CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "upper") == 0, + "SKILL.md"); + CHECK(d.sub_agent_count == 1 && + strcmp(d.sub_agents[0].id, "reviewer") == 0, + "Agent.md"); + CHECK(d.task_count == 1 && strcmp(d.tasks[0].id, "daily") == 0, + "Task.MD"); + CHECK(d.memory_count == 1 && strcmp(d.memories[0].id, "note") == 0, + "note.MD"); + CHECK(d.warnings.len == 0, "no case warnings"); + dotagents_free(&d); + free(ws); +} + +static void test_workspace_wins(void) { + char *ws = tmpdir("ws2"); + char *global = tmpdir("g2"); + wfile_bin(global, "agents.md", "global guidelines"); + wfile_bin(global, "skills/review/skill.md", + "---\nid: review\nname: Review G\ndescription: global\n---\n" + "global body"); + wfile_bin(global, "memories/m.md", + "---\nid: m\ntitle: M Global\nimportance: low\ntags: a\n---\ng"); + wfile_bin(ws, "agents.md", "workspace guidelines"); + wfile_bin(ws, "skills/review/skill.md", + "---\nid: review\nname: Review W\ndescription: workspace\n---\n" + "workspace body"); + wfile_bin(ws, "skills/extra/skill.md", + "---\nid: extra\nname: Extra\n---\nextra body"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0, + "workspace agents.md wins"); + const skill *review = dotagents_find_skill(&d, "review"); + CHECK(review && review->layer == LAYER_WORKSPACE, "review layer"); + CHECK(review && strcmp(review->body, "workspace body") == 0, + "review body"); + CHECK(d.skill_count == 2, "review + extra"); + CHECK(d.memory_count == 1, "only global memory"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_json_layers_merge_by_key(void) { + char *ws = tmpdir("ws3"); + char *global = tmpdir("g3"); + wfile_bin(global, "models.json", + "{\"default\":\"ga\",\"presets\":{\"ga\":{\"model\":\"ga-" + "model\"},\"shared\":{\"model\":\"s\"}}}"); + wfile_bin(global, "mcp.json", + "{\"mcpServers\":{\"fs\":{\"command\":\"npx\"}," + "\"git\":{\"url\":\"g\"}}}"); + wfile_bin(ws, "models.json", + "{\"default\":\"wb\",\"presets\":{\"wb\":{\"provider\":\"" + "ollama\"},\"shared\":{\"model\":\"ws\"}}}"); + wfile_bin(ws, "mcp.json", "{\"mcpServers\":{\"git\":{\"url\":\"w\"}}}"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.has_models, "models present"); + CHECK(d.models.default_name && + strcmp(d.models.default_name, "wb") == 0, + "default=wb"); + CHECK(d.models.preset_count == 3, "3 presets"); + const model_preset *ga = models_file_get(&d.models, "ga"); + CHECK(ga && ga->model && strcmp(ga->model, "ga-model") == 0, "ga.model"); + const model_preset *sh = models_file_get(&d.models, "shared"); + CHECK(sh && sh->model && strcmp(sh->model, "ws") == 0, "shared.model"); + CHECK(d.mcp_count == 2, "2 mcp servers"); + const char *git = dotagents_mcp_config(&d, "git"); + CHECK(git && strstr(git, "\"url\":\"w\""), "git.url=w"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_malformed_files_warn_and_skip(void) { + char *ws = tmpdir("ws4"); + wfile_bin(ws, "models.json", "{not json"); + wfile_bin(ws, "mcp.json", "{\"nope\":true}"); + wfile_bin(ws, "skills/broken/skill.md", "no frontmatter"); + char cmd[300]; + snprintf(cmd, sizeof cmd, "mkdir -p '%s/skills/empty'", ws); + if (system(cmd) != 0) + exit(1); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(!d.has_models, "no models"); + CHECK(d.mcp_count == 0, "no mcp"); + CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "broken") == 0, + "broken skill kept"); + CHECK(d.skills[0].body && + strstr(d.skills[0].body, "no frontmatter"), + "broken skill body"); + CHECK(count_warns(&d, "models.json") > 0, "models.json warning"); + CHECK(count_warns(&d, "mcp.json") > 0, "mcp.json warning"); + CHECK(count_warns(&d, "skills/empty") > 0, "skills/empty warning"); + dotagents_free(&d); + free(ws); +} + +static void test_missing_layers_empty(void) { + char *ws = tmpdir("ws5"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.found_count == 0, "no layers"); + CHECK(d.skill_count == 0, "no skills"); + CHECK(d.warnings.len == 0, "no warnings"); + dotagents_free(&d); + free(ws); +} + +static void test_tasks_and_sub_agents(void) { + char *ws = tmpdir("ws6"); + wfile_bin(ws, "tasks/daily/task.md", + "---\nkind: task\nid: daily\nname: Daily Review\nintervalMinutes" + ": 60\nenabled: true\nrunOnStartup: true\n---\nReview open " + "PRs."); + wfile_bin(ws, "agents/reviewer/agent.md", + "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n" + "role: delegation-target\nenabled: true\n---\nYou are a " + "reviewer."); + wfile_bin(ws, "agents/reviewer/config.json", + "{\"modelConfig\":{\"mcpToolsOpenaiModel\":\"gpt-4o\"}}"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.task_count == 1, "one task"); + CHECK(d.tasks[0].has_interval && d.tasks[0].interval_minutes == 60, + "interval"); + CHECK(d.tasks[0].run_on_startup, "run_on_startup"); + CHECK(d.tasks[0].enabled, "enabled"); + CHECK(strcmp(d.tasks[0].body, "Review open PRs.") == 0, "task body"); + CHECK(d.sub_agent_count == 1, "one sub agent"); + CHECK(strcmp(d.sub_agents[0].role, "delegation-target") == 0, "role"); + CHECK(strcmp(d.sub_agents[0].body, "You are a reviewer.") == 0, + "sub agent body"); + CHECK(d.sub_agents[0].config != NULL, "config present"); + dotagents_free(&d); + free(ws); +} + +static void test_malformed_sub_agent_config(void) { + char *ws = tmpdir("ws9"); + wfile_bin(ws, "agents/broken/agent.md", + "---\nid: broken\nname: Broken\ndescription: has a bad config\n" + "---\nYou are broken."); + wfile_bin(ws, "agents/broken/config.json", "{not json"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.sub_agent_count == 1, "one sub agent"); + CHECK(d.sub_agents[0].config == NULL, "config dropped"); + CHECK(count_warns(&d, "agents/broken/config.json") > 0, + "config warning"); + dotagents_free(&d); + free(ws); +} + +static void test_memories_rank(void) { + memory low, high; + memset(&low, 0, sizeof low); + low.importance = fm_strdup("low"); + memset(&high, 0, sizeof high); + high.importance = fm_strdup("high"); + CHECK(memory_importance_rank(&high) < memory_importance_rank(&low), + "high ranks before low"); + free(low.importance); + free(high.importance); +} + +static void test_project_sections(void) { + char *ws = tmpdir("ws7"); + wfile_bin(ws, "agents.md", "# Guidelines\n- be nice"); + wfile_bin(ws, "skills/cr/skill.md", + "---\nid: cr\nname: CR\ndescription: code review\n---\nreview " + "steps"); + wfile_bin(ws, "memories/p.md", + "---\nid: p\ntitle: P\nimportance: high\ntags: x\n---\nbody"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + char *sections = dotagents_project_sections(&d, 50); + CHECK(strstr(sections, "- be nice"), "guidelines"); + CHECK(strstr(sections, "- cr: code review"), "skill line"); + CHECK(strstr(sections, "use_skill"), "use_skill mention"); + CHECK(strstr(sections, "search_memories"), "search_memories mention"); + char *pre = dotagents_compose_preamble(&d, "BASE", 50); + CHECK(strstr(pre, "BASE") && strstr(sections, "be nice") && + memcmp(pre, "BASE", 4) == 0, + "preamble = base + sections"); + free(sections); + free(pre); + dotagents_free(&d); + free(ws); +} + +static void test_skill_index_cap(void) { + char *ws = tmpdir("ws10"); + const char *ids[5] = {"a", "b", "c", "d", "e"}; + for (int i = 0; i < 5; i++) { + char rel[64]; + char content[128]; + snprintf(rel, sizeof rel, "skills/%s/skill.md", ids[i]); + snprintf(content, sizeof content, + "---\nid: %s\nname: %s\ndescription: skill %d\n---\nbody", + ids[i], ids[i], i); + wfile_bin(ws, rel, content); + } + dotagents d; + dotagents_load_from(&d, ws, NULL); + char *sections = dotagents_project_sections(&d, 2); + CHECK(strstr(sections, "- a: skill 0"), "a listed"); + CHECK(strstr(sections, "- b: skill 1"), "b listed"); + CHECK(!strstr(sections, "- c: skill 2"), "c not listed"); + CHECK(strstr(sections, "and 3 more"), "…and 3 more"); + free(sections); + dotagents_free(&d); + free(ws); +} + +static void test_disabled_skills_excluded(void) { + char *ws = tmpdir("ws8"); + wfile_bin(ws, "skills/off/skill.md", + "---\nid: off\ndescription: hidden\nenabled: false\n---\nx"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.skill_count == 1, "skill kept"); + CHECK(!d.skills[0].enabled, "disabled"); + char *sections = dotagents_project_sections(&d, 50); + CHECK(!strstr(sections, "off"), "not in sections"); + free(sections); + dotagents_free(&d); + free(ws); +} + +/* ------------------------------------------------------------------ */ +/* Spec-gap conformance */ +/* ------------------------------------------------------------------ */ + +static void test_task_profile_id(void) { + char *ws = tmpdir("ws-pid"); + wfile_bin(ws, "tasks/nightly/task.md", + "---\nkind: task\nid: nightly\nname: Nightly\nprofileId: " + "prof-9\nenabled: true\n---\nRun nightly."); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.task_count == 1, "one task"); + CHECK(d.tasks[0].profile_id && + strcmp(d.tasks[0].profile_id, "prof-9") == 0, + "profile_id parsed"); + dotagents_free(&d); + free(ws); +} + +static void test_task_profile_id_workspace_override(void) { + char *ws = tmpdir("ws-pid2"); + char *global = tmpdir("g-pid"); + wfile_bin(global, "tasks/nightly/task.md", + "---\nid: nightly\nprofileId: g-prof\n---\ng"); + wfile_bin(ws, "tasks/nightly/task.md", + "---\nid: nightly\nprofileId: w-prof\n---\nw"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.task_count == 1, "one task"); + CHECK(d.tasks[0].profile_id && + strcmp(d.tasks[0].profile_id, "w-prof") == 0, + "workspace profileId wins"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_agent_connection_type(void) { + char *ws = tmpdir("ws-ct"); + wfile_bin(ws, "agents/remote/agent.md", + "---\nid: remote\nname: Remote\nrole: delegation-target\n" + "connection-type: internal\nenabled: true\n---\nYou are " + "remote."); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.sub_agent_count == 1, "one agent"); + CHECK(d.sub_agents[0].connection_type && + strcmp(d.sub_agents[0].connection_type, "internal") == 0, + "connection-type parsed"); + dotagents_free(&d); + free(ws); +} + +static void test_settings_layers_merge(void) { + char *ws = tmpdir("ws-set"); + char *global = tmpdir("g-set"); + wfile_bin(global, "speakmcp-settings.json", + "{\"theme\":\"dark\",\"telemetry\":true,\"shared\":\"g\"}"); + wfile_bin(ws, "speakmcp-settings.json", + "{\"shared\":\"w\",\"volume\":2}"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.settings_json != NULL, "settings present"); + CHECK(d.settings_json && strstr(d.settings_json, "\"shared\":\"w\""), + "shared=w"); + CHECK(d.settings_json && strstr(d.settings_json, "\"theme\":\"dark\""), + "theme kept"); + CHECK(d.settings_json && strstr(d.settings_json, "\"volume\":2"), + "volume kept"); + CHECK(d.settings_json && strstr(d.settings_json, "\"telemetry\":true"), + "telemetry kept"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_settings_invalid_warns(void) { + char *ws = tmpdir("ws-setbad"); + wfile_bin(ws, "speakmcp-settings.json", "{nope"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.settings_json == NULL, "no settings"); + CHECK(count_warns(&d, "speakmcp-settings.json") > 0, "settings warning"); + dotagents_free(&d); + free(ws); +} + +static void test_layouts_ui_merge(void) { + char *ws = tmpdir("ws-lay"); + char *global = tmpdir("g-lay"); + wfile_bin(global, "layouts/ui.json", "{\"panes\":2,\"theme\":\"dark\"}"); + wfile_bin(ws, "layouts/ui.json", "{\"theme\":\"light\"}"); + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.layouts_json && strstr(d.layouts_json, "\"theme\":\"light\""), + "theme=w"); + CHECK(d.layouts_json && strstr(d.layouts_json, "\"panes\":2"), + "panes kept"); + dotagents_free(&d); + free(ws); + free(global); +} + +static void test_settings_only_layer_discovered(void) { + char *ws = tmpdir("ws-setonly"); + wfile_bin(ws, "speakmcp-settings.json", "{\"a\":1}"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.found_count == 1, "layer found"); + CHECK(d.settings_json != NULL, "settings"); + dotagents_free(&d); + free(ws); +} + +static void test_layouts_only_layer_discovered(void) { + char *ws = tmpdir("ws-layonly"); + wfile_bin(ws, "layouts/ui.json", "{\"a\":1}"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.found_count == 1, "layer found"); + CHECK(d.layouts_json != NULL, "layouts"); + dotagents_free(&d); + free(ws); +} + +static void test_backups_ignored(void) { + char *ws = tmpdir("ws-bk"); + wfile_bin(ws, "agents.md", "guidelines"); + wfile_bin(ws, ".backups/skills/ghost/skill.md", + "---\nid: ghost\nname: Ghost\n---\nbody"); + wfile_bin(ws, ".backups/memories/ghost.md", + "---\nid: ghost\ntitle: G\n---\nb"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.agents_md && strcmp(d.agents_md, "guidelines") == 0, + "agents_md"); + CHECK(d.skill_count == 0, "no ghost skill"); + CHECK(d.memory_count == 0, "no ghost memory"); + dotagents_free(&d); + free(ws); +} + +static void test_unknown_frontmatter_keys_accepted(void) { + char *ws = tmpdir("ws-unk"); + wfile_bin(ws, "agents/x/agent.md", + "---\nkind: agent\nid: x\nname: X\nmetadata: stuff\n---\nb"); + dotagents d; + dotagents_load_from(&d, ws, NULL); + CHECK(d.sub_agent_count == 1, "agent kept"); + CHECK(d.warnings.len == 0, "no warnings"); + dotagents_free(&d); + free(ws); +} + +/* Both ~/.agents and ./.agents must remain reachable in the model context: + * a global-only skill and a workspace-only skill both survive the merge, both + * appear in the skill index the model sees, and an id conflict resolves to + * the workspace copy. */ +static void test_both_layers_reachable_in_context(void) { + char *ws = tmpdir("ws-ctx"); + char *global = tmpdir("g-ctx"); + wfile_bin(global, "skills/g-only/skill.md", + "---\nid: g-only\nname: G Only\ndescription: global only skill\n" + "---\ng global body"); + wfile_bin(global, "skills/shared/skill.md", + "---\nid: shared\nname: Shared G\ndescription: shared global\n" + "---\ng shared body"); + wfile_bin(ws, "skills/w-only/skill.md", + "---\nid: w-only\nname: W Only\ndescription: workspace only " + "skill\n---\nw workspace body"); + wfile_bin(ws, "skills/shared/skill.md", + "---\nid: shared\nname: Shared W\ndescription: shared " + "workspace\n---\nw shared body"); + + dotagents d; + dotagents_load_from(&d, ws, global); + + /* Neither layer's skills are lost. */ + CHECK(d.skill_count == 3, "g-only + w-only + shared"); + + const skill *g = dotagents_find_skill(&d, "g-only"); + CHECK(g && g->layer == LAYER_GLOBAL, "g-only reachable"); + CHECK(g && g->body && strstr(g->body, "g global body"), "g-only body"); + + const skill *w = dotagents_find_skill(&d, "w-only"); + CHECK(w && w->layer == LAYER_WORKSPACE, "w-only reachable"); + CHECK(w && w->body && strstr(w->body, "w workspace body"), "w-only body"); + + /* An id conflict resolves to the workspace copy. */ + const skill *sh = dotagents_find_skill(&d, "shared"); + CHECK(sh && sh->layer == LAYER_WORKSPACE, "shared -> workspace"); + CHECK(sh && sh->body && strstr(sh->body, "w shared body"), + "shared workspace body"); + + /* Both layers remain visible in the context skill index. */ + char *sections = dotagents_project_sections(&d, 50); + CHECK(strstr(sections, "- g-only") != NULL, "g-only in context index"); + CHECK(strstr(sections, "- w-only") != NULL, "w-only in context index"); + CHECK(strstr(sections, "- shared") != NULL, "shared in context index"); + free(sections); + + /* ...and therefore in the composed preamble the model actually receives. */ + char *pre = dotagents_compose_preamble(&d, "BASE", 50); + CHECK(strstr(pre, "g-only") != NULL && strstr(pre, "w-only") != NULL, + "both layers in composed preamble"); + free(pre); + + dotagents_free(&d); + free(ws); + free(global); +} + +/* skill_index_cap must apply to the merged two-layer set, not silently drop + * global skills: with 3 global + 2 workspace skills and cap 2, the first two + * (by id) are listed and the rest are elided. */ +static void test_skill_index_cap_across_layers(void) { + char *ws = tmpdir("ws-cap"); + char *global = tmpdir("g-cap"); + wfile_bin(global, "skills/g1/skill.md", + "---\nid: g1\nname: G1\ndescription: g1\n---\nb"); + wfile_bin(global, "skills/g2/skill.md", + "---\nid: g2\nname: G2\ndescription: g2\n---\nb"); + wfile_bin(global, "skills/g3/skill.md", + "---\nid: g3\nname: G3\ndescription: g3\n---\nb"); + wfile_bin(ws, "skills/w1/skill.md", + "---\nid: w1\nname: W1\ndescription: w1\n---\nb"); + wfile_bin(ws, "skills/w2/skill.md", + "---\nid: w2\nname: W2\ndescription: w2\n---\nb"); + + dotagents d; + dotagents_load_from(&d, ws, global); + CHECK(d.skill_count == 5, "5 merged skills"); + + char *sections = dotagents_project_sections(&d, 2); + CHECK(strstr(sections, "- g1") != NULL, "g1 listed"); + CHECK(strstr(sections, "- g2") != NULL, "g2 listed"); + CHECK(!strstr(sections, "- g3"), "g3 capped"); + CHECK(!strstr(sections, "- w1"), "w1 capped"); + CHECK(strstr(sections, "and 3 more") != NULL, "...and 3 more"); + free(sections); + + dotagents_free(&d); + free(ws); + free(global); +} + +int main(void) { + test_loads_global_only(); + test_md_filenames_case_insensitive(); + test_workspace_wins(); + test_json_layers_merge_by_key(); + test_malformed_files_warn_and_skip(); + test_missing_layers_empty(); + test_tasks_and_sub_agents(); + test_malformed_sub_agent_config(); + test_memories_rank(); + test_project_sections(); + test_skill_index_cap(); + test_disabled_skills_excluded(); + test_task_profile_id(); + test_task_profile_id_workspace_override(); + test_agent_connection_type(); + test_settings_layers_merge(); + test_settings_invalid_warns(); + test_layouts_ui_merge(); + test_settings_only_layer_discovered(); + test_layouts_only_layer_discovered(); + test_backups_ignored(); + test_unknown_frontmatter_keys_accepted(); + test_both_layers_reachable_in_context(); + test_skill_index_cap_across_layers(); + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_frontmatter.c b/tests/test_frontmatter.c new file mode 100644 index 0000000..af4c2fc --- /dev/null +++ b/tests/test_frontmatter.c @@ -0,0 +1,236 @@ +/* test_frontmatter.c — standalone test suite for frontmatter.h. + * Zero dependencies: compiles to a single TU with no linked libraries. */ +#include "frontmatter.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +/* ------------------------------------------------------------------ */ +/* Frontmatter */ +/* ------------------------------------------------------------------ */ + +static void test_frontmatter_scalars_lists_quotes(void) { + doc d; + parse_frontmatter( + "---\nid: a-1\nname: \"A: One\"\nenabled: true\nn: 42\ntags: db, " + "arch\ntags2: [\"x\", \"y\"]\n---\nbody line", + &d); + CHECK(strcmp(d.body, "body line") == 0, "body"); + const fm_value *v = doc_get(&d, "id"); + CHECK(v && v->kind == FM_STR && strcmp(v->s, "a-1") == 0, "id"); + v = doc_get(&d, "name"); + CHECK(v && v->kind == FM_STR && strcmp(v->s, "A: One") == 0, "name"); + v = doc_get(&d, "enabled"); + CHECK(v && v->kind == FM_BOOL && v->b, "enabled"); + v = doc_get(&d, "n"); + CHECK(v && v->kind == FM_INT && v->i == 42, "n"); + v = doc_get(&d, "tags"); + CHECK(v, "tags present"); + fm_strlist items; + fm_list_items(v, &items); + CHECK(items.len == 2 && strcmp(items.items[0], "db") == 0 && + strcmp(items.items[1], "arch") == 0, + "tags csv"); + fm_sl_free(&items); + v = doc_get(&d, "tags2"); + CHECK(v && v->kind == FM_LIST && v->item_count == 2 && + strcmp(v->items[0], "x") == 0 && strcmp(v->items[1], "y") == 0, + "tags2 array"); + doc_free(&d); +} + +static void test_frontmatter_missing_or_unterminated(void) { + doc d; + parse_frontmatter("just a body\n", &d); + CHECK(d.count == 0, "no meta"); + CHECK(strcmp(d.body, "just a body") == 0, "body trimmed"); + doc_free(&d); + + parse_frontmatter("---\nid: x\nno closing fence\n", &d); + CHECK(d.count == 0, "unterminated: no meta"); + CHECK(strcmp(d.body, "---\nid: x\nno closing fence") == 0, + "unterminated: whole text is body"); + doc_free(&d); +} + +static void test_frontmatter_ignores_blank_comment_colonless(void) { + doc d; + parse_frontmatter("---\n# comment\n\nnotakey\nk: v\n---\n", &d); + CHECK(d.count == 1, "one meta entry"); + const fm_value *v = doc_get(&d, "k"); + CHECK(v && v->kind == FM_STR && strcmp(v->s, "v") == 0, "k=v"); + doc_free(&d); +} + +static void test_fm_list_items_variants(void) { + doc d; + parse_frontmatter( + "---\ncsv: a, b, c\njson: [\"x\", \"y\"]\nsingle: only\nempty:\n" + "nums: [1, 2]\n---\nbody", + &d); + fm_strlist out; + fm_list_items(doc_get(&d, "csv"), &out); + CHECK(out.len == 3 && strcmp(out.items[0], "a") == 0 && + strcmp(out.items[2], "c") == 0, + "csv"); + fm_sl_free(&out); + fm_list_items(doc_get(&d, "json"), &out); + CHECK(out.len == 2 && strcmp(out.items[0], "x") == 0, "json"); + fm_sl_free(&out); + fm_list_items(doc_get(&d, "single"), &out); + CHECK(out.len == 1 && strcmp(out.items[0], "only") == 0, "single"); + fm_sl_free(&out); + fm_list_items(doc_get(&d, "empty"), &out); + CHECK(out.len == 0, "empty list"); + fm_sl_free(&out); + fm_list_items(doc_get(&d, "nums"), &out); + CHECK(out.len == 2 && strcmp(out.items[0], "1") == 0, "non-string items"); + fm_sl_free(&out); + fm_list_items(NULL, &out); + CHECK(out.len == 0, "null value"); + fm_sl_free(&out); + doc_free(&d); +} + +/* ------------------------------------------------------------------ */ +/* Embedded JSON */ +/* ------------------------------------------------------------------ */ + +static void test_json_roundtrip(void) { + const char *src = + "{\"a\":1,\"b\":\"x\",\"c\":[true,null,1.5],\"d\":{\"e\":[1,2," + "\"z\"]}}"; + fm_json *v = fm_json_parse(src); + CHECK(v != NULL, "parse"); + CHECK(v && v->type == FMJ_OBJECT, "object"); + CHECK(v && v->count == 4, "4 keys"); + const fm_json *a = fm_json_get(v, "a"); + CHECK(a && a->type == FMJ_NUMBER && a->is_int && a->i == 1, "a=1"); + const fm_json *b = fm_json_get(v, "b"); + CHECK(b && b->type == FMJ_STRING && strcmp(b->str, "x") == 0, "b=x"); + const fm_json *c = fm_json_get(v, "c"); + CHECK(c && c->type == FMJ_ARRAY && c->count == 3, "c array"); + CHECK(c && c->items[0]->type == FMJ_BOOL && c->items[0]->b, "c[0]=true"); + CHECK(c && c->items[1]->type == FMJ_NULL, "c[1]=null"); + CHECK(c && c->items[2]->type == FMJ_NUMBER && c->items[2]->num == 1.5, + "c[2]=1.5"); + const fm_json *d = fm_json_get(v, "d"); + CHECK(d && d->type == FMJ_OBJECT, "d object"); + const fm_json *e = d ? fm_json_get(d, "e") : NULL; + CHECK(e && e->type == FMJ_ARRAY && e->count == 3, "d.e array"); + CHECK(e && e->items[1]->is_int && e->items[1]->i == 2, "d.e[1]=2"); + char *out = fm_json_serialize(v); + CHECK(out && strcmp(out, src) == 0, "roundtrip"); + free(out); + fm_json_free(v); +} + +static void test_json_escapes(void) { + fm_json *v = fm_json_parse("\"a\\\"b\\\\c\\nd\\u0041\""); + CHECK(v && v->type == FMJ_STRING, "parse"); + CHECK(v && strcmp(v->str, "a\"b\\c\ndA") == 0, "unescaped value"); + char *s = fm_json_serialize(v); + CHECK(s && strcmp(s, "\"a\\\"b\\\\c\\ndA\"") == 0, "escaped output"); + free(s); + fm_json_free(v); + + fm_json *u = fm_json_parse("\"\\u4e2d\""); + CHECK(u && u->type == FMJ_STRING, "unicode parse"); + CHECK(u && strcmp(u->str, "\xE4\xB8\xAD") == 0, "utf-8 out"); + fm_json_free(u); +} + +static void test_json_invalid(void) { + CHECK(fm_json_parse("{") == NULL, "open brace"); + CHECK(fm_json_parse("[1,") == NULL, "dangling comma"); + CHECK(fm_json_parse("{bad:1}") == NULL, "bare key"); + CHECK(fm_json_parse("{\"a\":1\"b\":2}") == NULL, "missing comma"); + CHECK(fm_json_parse("{}x") == NULL, "trailing garbage"); + CHECK(fm_json_parse("") == NULL, "empty"); + CHECK(fm_json_parse("01") == NULL, "leading zero"); + CHECK(fm_json_parse("1.") == NULL, "dangling dot"); + CHECK(fm_json_parse("-") == NULL, "bare minus"); + CHECK(fm_json_parse("tru") == NULL, "bare tru"); + CHECK(fm_json_parse("\"unterminated") == NULL, "unterminated string"); + CHECK(fm_json_parse("[1 2]") == NULL, "missing separator"); + fm_json *f1 = fm_json_parse("1.5"); + CHECK(f1 != NULL, "float ok"); + fm_json_free(f1); + fm_json *f2 = fm_json_parse("1e3"); + CHECK(f2 != NULL, "exponent ok"); + fm_json_free(f2); + fm_json *f3 = fm_json_parse("null"); + CHECK(f3 != NULL, "null ok"); + fm_json_free(f3); + fm_json *f4 = fm_json_parse("[]"); + CHECK(f4 != NULL, "empty array ok"); + fm_json_free(f4); + fm_json *f5 = fm_json_parse("{}"); + CHECK(f5 != NULL, "empty object ok"); + fm_json_free(f5); +} + +static void test_json_merge(void) { + fm_json *base = fm_json_parse("{\"a\":1,\"b\":{\"x\":1}}"); + fm_json *over = fm_json_parse("{\"b\":{\"y\":2},\"c\":3}"); + fm_json *m = fm_json_merge_owned(base, over); + char *s = fm_json_serialize(m); + CHECK(s && strcmp(s, "{\"b\":{\"y\":2},\"c\":3,\"a\":1}") == 0, + "merged content"); + free(s); + fm_json_free(m); + + fm_json *o2 = fm_json_parse("{\"z\":9}"); + fm_json *m2 = fm_json_merge_owned(NULL, o2); + char *s2 = fm_json_serialize(m2); + CHECK(s2 && strcmp(s2, "{\"z\":9}") == 0, "over only"); + free(s2); + fm_json_free(m2); + + fm_json *b2 = fm_json_parse("{\"q\":1}"); + fm_json *m3 = fm_json_merge_owned(b2, NULL); + char *s3 = fm_json_serialize(m3); + CHECK(s3 && strcmp(s3, "{\"q\":1}") == 0, "base only"); + free(s3); + fm_json_free(m3); + + fm_json *n1 = fm_json_parse("[1]"); + fm_json *n2 = fm_json_parse("\"x\""); + fm_json *m4 = fm_json_merge_owned(n1, n2); + char *s4 = fm_json_serialize(m4); + CHECK(s4 && strcmp(s4, "{}") == 0, "non-object inputs dropped"); + free(s4); + fm_json_free(m4); + + fm_json *m5 = fm_json_merge_owned(NULL, NULL); + char *s5 = fm_json_serialize(m5); + CHECK(s5 && strcmp(s5, "{}") == 0, "both null"); + free(s5); + fm_json_free(m5); +} + +int main(void) { + test_frontmatter_scalars_lists_quotes(); + test_frontmatter_missing_or_unterminated(); + test_frontmatter_ignores_blank_comment_colonless(); + test_fm_list_items_variants(); + test_json_roundtrip(); + test_json_escapes(); + test_json_invalid(); + test_json_merge(); + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_goal.cpp b/tests/test_goal.cpp new file mode 100644 index 0000000..8e06517 --- /dev/null +++ b/tests/test_goal.cpp @@ -0,0 +1,230 @@ +#include "goal.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, \ + "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle), hay); \ + } \ + } while (0) + +static void test_parse_defaults(void) +{ + goal_spec spec; + char *err = NULL; + CHECK(goal_parse("all tests pass", &spec, &err) == 1, "parse"); + CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria"); + CHECK(spec.max_tries == GOAL_DEFAULT_MAX_TRIES, "default cap"); + goal_spec_free(&spec); +} + +static void test_parse_reads_cap(void) +{ + goal_spec spec; + char *err = NULL; + CHECK(goal_parse("all tests pass, stop after 3 tries", &spec, &err) == 1, + "parse"); + CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria"); + CHECK(spec.max_tries == 3, "cap"); + goal_spec_free(&spec); +} + +static void test_parse_cap_without_tries_word(void) +{ + goal_spec spec; + char *err = NULL; + CHECK(goal_parse("make it green, stop after 10", &spec, &err) == 1, "parse"); + CHECK(strcmp(spec.criteria, "make it green") == 0, "criteria"); + CHECK(spec.max_tries == 10, "cap"); + goal_spec_free(&spec); +} + +static void test_parse_tries_word_case_insensitive(void) +{ + goal_spec spec; + char *err = NULL; + CHECK(goal_parse("ship it, stop after 4 TRIES", &spec, &err) == 1, + "uppercase TRIES accepted"); + CHECK(spec.max_tries == 4, "cap"); + goal_spec_free(&spec); +} + +static void test_parse_rejections(void) +{ + goal_spec spec; + char *err = NULL; + + err = NULL; + CHECK(goal_parse(" ", &spec, &err) == 0, "empty input rejected"); + CHECK(err && strstr(err, "success criteria") != NULL, "empty err msg"); + free(err); + + err = NULL; + CHECK(goal_parse("stop after 5 tries", &spec, &err) == 0, + "missing criteria rejected"); + CHECK(err && strstr(err, "before `stop after`") != NULL, "missing err msg"); + free(err); + + err = NULL; + CHECK(goal_parse("ship it, stop after", &spec, &err) == 0, + "missing cap rejected"); + CHECK(err && strstr(err, "number of tries") != NULL, "missing cap err"); + free(err); + + err = NULL; + CHECK(goal_parse("ship it, stop after 0 tries", &spec, &err) == 0, + "zero cap rejected"); + CHECK(err && strstr(err, "at least 1 try") != NULL, "zero cap err"); + free(err); + + err = NULL; + CHECK(goal_parse("ship it, stop after 5xyz", &spec, &err) == 0, + "junk after the number rejected"); + CHECK(err && strstr(err, "number of tries") != NULL, "junk err msg"); + free(err); +} + +static void test_first_attempt_prompt(void) +{ + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 5; + char *prompt = goal_prompt(&spec, 1, NULL); + CHECK_CONT(prompt, "all tests pass", "states the goal"); + CHECK_NOT_CONT(prompt, "NOT yet met", "no feedback on first attempt"); + CHECK_CONT(prompt, "fully met", "asks to keep going"); + free(prompt); + goal_spec_free(&spec); +} + +static void test_later_attempt_prompt(void) +{ + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 3; + char *prompt = goal_prompt(&spec, 2, "the build still fails on x86"); + CHECK_CONT(prompt, "attempt 2 of 3", "attempt framing"); + CHECK_CONT(prompt, "NOT yet met", "verdict phrasing"); + CHECK_CONT(prompt, "the build still fails on x86", "includes feedback"); + free(prompt); + + char *no_fb = goal_prompt(&spec, 4, NULL); + CHECK_CONT(no_fb, "attempt 4 of 3", "attempt number"); + CHECK_CONT(no_fb, "no specific feedback was given", "fallback feedback"); + free(no_fb); + goal_spec_free(&spec); +} + +static void test_verdict_met(void) +{ + goal_verdict v; + goal_parse_verdict("MET\nAll tests pass and the suite is green.", &v); + CHECK(v.met, "met detected"); + CHECK_CONT(v.reason, "green", "reason captured"); + free(v.reason); +} + +static void test_verdict_not_met(void) +{ + goal_verdict v; + goal_parse_verdict("NOT MET\nTwo tests still fail in the linux job.", &v); + CHECK(!v.met, "not met detected"); + CHECK_CONT(v.reason, "still fail", "reason captured"); + free(v.reason); +} + +static void test_verdict_garbage(void) +{ + goal_verdict v; + goal_parse_verdict("I think we are close! Let me keep going.", &v); + CHECK(!v.met, "conservative on garbage"); + CHECK_CONT(v.reason, "close", "first line is the reason"); + free(v.reason); +} + +static void test_verdict_unmet_is_not_met(void) +{ + /* "UNMET" contains the substring "MET"; it must not read as met. */ + goal_verdict v; + goal_parse_verdict("UNMET\nStill failing.", &v); + CHECK(!v.met, "UNMET is not met"); + free(v.reason); + + goal_parse_verdict("NOT UNMET", &v); + CHECK(!v.met, "NOT UNMET is not met"); + free(v.reason); + + goal_parse_verdict("unmet", &v); + CHECK(!v.met, "lowercase unmet is not met"); + free(v.reason); + + goal_parse_verdict("MET", &v); + CHECK(v.met, "plain MET is met"); + free(v.reason); +} + +static void test_verdict_no_reason(void) +{ + goal_verdict v; + goal_parse_verdict("MET", &v); + CHECK(v.met, "bare MET is met"); + CHECK_CONT(v.reason, "did not give a reason", "fallback reason"); + free(v.reason); +} + +static void test_preamble(void) +{ + const char *p = goal_evaluator_preamble(); + CHECK_CONT(p, "MET", "mentions MET token"); + CHECK_CONT(p, "NOT MET", "mentions NOT MET token"); + CHECK_CONT(p, "strict", "strict reviewer"); +} + +int main(void) +{ + test_parse_defaults(); + test_parse_reads_cap(); + test_parse_cap_without_tries_word(); + test_parse_tries_word_case_insensitive(); + test_parse_rejections(); + test_first_attempt_prompt(); + test_later_attempt_prompt(); + test_verdict_met(); + test_verdict_not_met(); + test_verdict_garbage(); + test_verdict_unmet_is_not_met(); + test_verdict_no_reason(); + test_preamble(); + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_input.cpp b/tests/test_input.cpp new file mode 100644 index 0000000..99a719f --- /dev/null +++ b/tests/test_input.cpp @@ -0,0 +1,1263 @@ +#include "input.h" +#include "util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, \ + "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle), hay); \ + } \ + } while (0) + +#define CHECK_STR(got, want, msg) \ + do { \ + checks++; \ + if (!got || strcmp(got, want) != 0) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), got ? got : "(null)", (want));\ + } \ + } while (0) + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static input_key kkind(int kind) { + input_key k; + k.kind = kind; + k.cp = 0; + return k; +} + +static evlist feed(const unsigned char *bytes, size_t n) { + key_parser p; + evlist ev; + key_parser_init(&p); + evlist_init(&ev); + key_parser_feed(&p, bytes, n, &ev); + key_parser_flush(&p, &ev); + key_parser_free(&p); + return ev; +} + +/* Apply a run of parsed keys; every action must be ACT_NONE. */ +static void type_str(editor *e, const char *s) { + evlist ev = feed((const unsigned char *)s, strlen(s)); + for (size_t i = 0; i < ev.len; i++) { + input_action a; + editor_apply(e, ev.items[i], &a); + CHECK(a.kind == ACT_NONE, "type_str: unexpected action while typing"); + input_action_free(&a); + } + evlist_free(&ev); +} + +/* Apply a single named key and release its action. */ +static void apply_key(editor *e, int kind) { + input_action a; + editor_apply(e, kkind(kind), &a); + input_action_free(&a); +} + +/* ================================================================== */ +/* Parser tests */ +/* ================================================================== */ + +static void test_parser_plain_chars(void) { + evlist ev = feed((const unsigned char *)"abc", 3); + CHECK(ev.len == 3, "three chars"); + if (ev.len == 3) { + CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a"); + CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 'b', "b"); + CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 'c', "c"); + CHECK(ev.raws[0] && ev.lens[0] == 1 && ev.raws[0][0] == 'a', "raw a"); + CHECK(ev.raws[2] && ev.lens[2] == 1 && ev.raws[2][0] == 'c', "raw c"); + } + evlist_free(&ev); +} + +static void test_parser_enter_variants(void) { + evlist ev = feed((const unsigned char *)"\r", 1); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CR -> Enter"); + evlist_free(&ev); + + /* LF (Ctrl+J) is a dead key outside of paste: no event. */ + ev = feed((const unsigned char *)"\n", 1); + CHECK(ev.len == 0, "LF -> no event (dead key)"); + evlist_free(&ev); +} + +/* Shift-modified Enter and Esc carry no special binding: they behave as the + * plain key. */ +static void test_parser_shift_enter_and_esc(void) { + evlist ev = feed((const unsigned char *)"\x1b[13;2u", 7); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CSI 13;2 -> Enter"); + evlist_free(&ev); + + ev = feed((const unsigned char *)"\x1b[27;2u", 7); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "CSI 27;2 -> Esc"); + evlist_free(&ev); +} + +static void test_parser_lone_escape_is_esc(void) { + /* a lone ESC is held until flush */ + key_parser p; + evlist ev; + key_parser_init(&p); + evlist_init(&ev); + key_parser_feed(&p, (const unsigned char *)"\x1b", 1, &ev); + CHECK(ev.len == 0, "no event before flush"); + CHECK(key_parser_flush(&p, &ev) == 1, "flush emits one"); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "flush -> Esc"); + evlist_free(&ev); + key_parser_free(&p); +} + +static void test_parser_alt_letter_is_the_letter(void) { + evlist ev = feed((const unsigned char *)"\x1b" "a", 2); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', + "alt+a -> Char('a')"); + if (ev.len == 1) { + CHECK(ev.raws[0] && ev.lens[0] == 2 && ev.raws[0][0] == '\x1b' && + ev.raws[0][1] == 'a', + "raw = ESC a"); + } + evlist_free(&ev); +} + +static void test_parser_raw_bytes_preserved(void) { + evlist ev = feed((const unsigned char *)"\x1b[1;5A", 6); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_UP, "UP"); + if (ev.len == 1) { + CHECK(ev.raws[0] && ev.lens[0] == 6, "raw len 6"); + if (ev.raws[0] && ev.lens[0] == 6) + CHECK(memcmp(ev.raws[0], "\x1b[1;5A", 6) == 0, "raw bytes"); + } + evlist_free(&ev); +} + +static void test_parser_control_keys(void) { + struct { + unsigned char b; + int want; + const char *m; + } cases[] = { + {0x01, KEY_CTRL_A, "Ctrl-A"}, + {0x03, KEY_CTRL_C, "Ctrl-C"}, + {0x04, KEY_CTRL_D, "Ctrl-D"}, + {0x05, KEY_CTRL_E, "Ctrl-E"}, + {0x0B, KEY_CTRL_K, "Ctrl-K"}, + {0x0C, KEY_CTRL_L, "Ctrl-L"}, + {0x15, KEY_CTRL_U, "Ctrl-U"}, + {0x17, KEY_CTRL_W, "Ctrl-W"}, + {0x19, KEY_CTRL_Y, "Ctrl-Y"}, + {0x1F, KEY_UNDO, "Ctrl-_ (undo)"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + evlist ev = feed(&cases[i].b, 1); + CHECK(ev.len == 1, cases[i].m); + if (ev.len == 1) { + checks++; + if (ev.items[0].kind != cases[i].want) { + failures++; + fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__, + __LINE__, cases[i].m, ev.items[0].kind, cases[i].want); + } + } + evlist_free(&ev); + } + + unsigned char bs[2] = {0x7F, 0x08}; + for (int i = 0; i < 2; i++) { + evlist ev = feed(&bs[i], 1); + CHECK(ev.len == 1 && ev.items[0].kind == KEY_BACKSPACE, "backspace"); + evlist_free(&ev); + } +} + +static void test_parser_unmapped_control_bytes(void) { + int mapped[256] = {0}; + mapped[0x09] = mapped[0x0A] = mapped[0x0D] = mapped[0x1B] = 1; + for (int b = 0; b < 0x20; b++) { + if (mapped[b]) + continue; + unsigned char c = (unsigned char)b; + evlist ev = feed(&c, 1); + CHECK(ev.len == 1, "exactly one control event"); + if (ev.len == 1) + CHECK(ev.items[0].kind != KEY_CHAR, "not a char"); + evlist_free(&ev); + } +} + +static void test_parser_unknown_sequence_consumed(void) { + // Unmapped CSI tilde: empty or a single Unknown (oracle allows either). + evlist ev = feed((const unsigned char *)"\x1b[999~", 6); + CHECK(ev.len == 0 || (ev.len == 1 && ev.items[0].kind == KEY_UNKNOWN), + "unknown CSI ~"); + evlist_free(&ev); + + // Empty bracketed paste: markers consumed, no events. + ev = feed((const unsigned char *)"\x1b[200~\x1b[201~", 12); + CHECK(ev.len == 0, "empty paste -> no events"); + evlist_free(&ev); + + // Mouse reports must not surface as editor keys. + ev = feed((const unsigned char *)"\x1b[M", 3); + CHECK(ev.len == 0, "mouse SGR -> no events"); + evlist_free(&ev); + + ev = feed((const unsigned char *)"\x1b[64;10;5M", 10); + CHECK(ev.len == 0, "mouse CSI -> no events"); + evlist_free(&ev); + + // Cursor position reports (DSR responses) are consumed. + ev = feed((const unsigned char *)"\x1b[12;40R", 8); + CHECK(ev.len == 0, "DSR report -> no events"); + evlist_free(&ev); +} + +static void test_parser_bracketed_paste(void) { + // Paste start marker: no event on its own. + evlist ev = feed((const unsigned char *)"\x1b[200~", 6); + CHECK(ev.len == 0, "paste start -> no event"); + evlist_free(&ev); + + // Pasted content is individual char events; a pasted CR is a Newline. + ev = feed((const unsigned char *)"\x1b[200~hello\rworld\x1b[201~", 23); + CHECK(ev.len == 11, "paste content = 11 events"); + if (ev.len == 11) { + CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h"); + CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o"); + CHECK(ev.items[5].kind == KEY_NEWLINE, "pasted CR -> Newline"); + CHECK(ev.items[10].kind == KEY_CHAR && ev.items[10].cp == 'd', "d"); + } + evlist_free(&ev); +} + +static void test_parser_utf8(void) { + evlist ev = feed((const unsigned char *)"h\xc3\xa9llo", 6); + CHECK(ev.len == 5, "five codepoints"); + if (ev.len == 5) { + CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h"); + CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 0xE9, "e-acute"); + CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o"); + } + evlist_free(&ev); + + /* split across feed() calls */ + key_parser p; + evlist ev2; + key_parser_init(&p); + evlist_init(&ev2); + key_parser_feed(&p, (const unsigned char *)"h\xc3", 2, &ev2); + CHECK(ev2.len == 1 && ev2.items[0].kind == KEY_CHAR && + ev2.items[0].cp == 'h', + "h flushed, partial UTF-8 held"); + key_parser_feed(&p, (const unsigned char *)"\xa9llo", 4, &ev2); + key_parser_flush(&p, &ev2); + CHECK(ev2.len == 5, "recombined to five codepoints"); + evlist_free(&ev2); + key_parser_free(&p); + + evlist ev3 = feed((const unsigned char *)"\xff", 1); + CHECK(ev3.len == 1 && ev3.items[0].kind == KEY_UNKNOWN, "invalid 0xFF"); + evlist_free(&ev3); +} + +static void test_parser_mixed_stream(void) { + unsigned char seq[] = "a\x1b[1;5A\xc3\xa9\x1b[13u"; + evlist ev = feed(seq, sizeof(seq) - 1); + CHECK(ev.len == 4, "four events"); + if (ev.len == 4) { + CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a"); + CHECK(ev.items[1].kind == KEY_UP, "UP"); + CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 0xE9, "e-acute"); + CHECK(ev.items[3].kind == KEY_ENTER, "Enter (CSI 13)"); + } + evlist_free(&ev); +} + +static void test_parser_kitty_and_ss3(void) { + struct { + const char *seq; + int want; + const char *m; + } cases[] = { + {"\x1b[8593;1u", KEY_UP, "kitty up"}, + {"\x1b[8594;1u", KEY_RIGHT, "kitty right"}, + {"\x1b[9u", KEY_TAB, "kitty tab"}, + {"\x1b[9;2u", KEY_BACKTAB, "kitty backtab"}, + {"\x1b[12u", KEY_BACKSPACE, "kitty backspace"}, + {"\x1b[127u", KEY_DELETE, "kitty delete"}, + {"\x1b[13u", KEY_ENTER, "kitty enter"}, + {"\x1b[27u", KEY_ESC, "kitty esc"}, + {"\x1bOA", KEY_UP, "ss3 up"}, + {"\x1bOD", KEY_LEFT, "ss3 left"}, + {"\x1b[H", KEY_HOME, "csi home"}, + {"\x1b[3~", KEY_DELETE, "csi delete"}, + {"\x1b[4~", KEY_END, "csi end"}, + {"\x1b[5~", KEY_PAGEUP, "csi pageup"}, + {"\x1b[6~", KEY_PAGEDOWN, "csi pagedown"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *seq = cases[i].seq; + evlist ev = feed((const unsigned char *)seq, strlen(seq)); + CHECK(ev.len == 1, cases[i].m); + if (ev.len == 1) { + checks++; + if (ev.items[0].kind != cases[i].want) { + failures++; + fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__, + __LINE__, cases[i].m, ev.items[0].kind, cases[i].want); + } + } + evlist_free(&ev); + } +} + +/* ================================================================== */ +/* Editor tests */ +/* ================================================================== */ + +static void test_editor_typing_and_submit(void) { + editor e; + editor_init(&e); + type_str(&e, "hello"); + char *t = editor_text(&e); + CHECK_STR(t, "hello", "text after typing"); + free(t); + + input_action a; + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_SUBMIT, "Enter submits"); + if (a.kind == ACT_SUBMIT) + CHECK_STR(a.text, "hello", "submitted text"); + input_action_free(&a); + editor_free(&e); +} + +static void test_editor_shift_enter_inserts_newline(void) { + editor e; + editor_init(&e); + type_str(&e, "he"); + apply_key(&e, KEY_NEWLINE); + type_str(&e, "llo"); + char *t = editor_text(&e); + CHECK_STR(t, "he\nllo", "NEWLINE key inserts a newline"); + free(t); + CHECK(editor_line_count(&e) == 2, "two lines"); + + input_action a; + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_SUBMIT, "Enter submits"); + if (a.kind == ACT_SUBMIT) + CHECK_STR(a.text, "he\nllo", "submitted multi-line text"); + input_action_free(&a); + editor_free(&e); +} + +/* Backslash + Enter at the end of the buffer is a line continuation (a + * universal newline trigger for terminals where Shift+Enter is not + * distinguishable from Enter); any other Enter still submits. */ +static void test_editor_backslash_enter_continues(void) { + editor e; + editor_init(&e); + type_str(&e, "ls \\"); + input_action a; + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_NEWLINE, "trailing backslash + Enter -> continuation"); + input_action_free(&a); + char *t = editor_text(&e); + CHECK_STR(t, "ls \n", "backslash became a newline"); + free(t); + CHECK(e.caret == 4, "caret after the newline"); + + type_str(&e, "-la"); + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_SUBMIT, "second line's Enter submits"); + if (a.kind == ACT_SUBMIT) + CHECK_STR(a.text, "ls \n-la", "multi-line text submitted"); + input_action_free(&a); + editor_free(&e); + + /* No backslash before the caret -> Enter still submits. */ + editor f; + editor_init(&f); + type_str(&f, "a\\b"); + input_action b; + editor_apply(&f, kkind(KEY_ENTER), &b); + CHECK(b.kind == ACT_SUBMIT, "no backslash before caret submits"); + if (b.kind == ACT_SUBMIT) + CHECK_STR(b.text, "a\\b", "text submitted unchanged"); + input_action_free(&b); + editor_free(&f); + + /* A backslash before the caret continues even with text after it. */ + editor g; + editor_init(&g); + type_str(&g, "a\\b"); + apply_key(&g, KEY_LEFT); /* caret sits right after the backslash */ + input_action c; + editor_apply(&g, kkind(KEY_ENTER), &c); + CHECK(c.kind == ACT_NEWLINE, "backslash before caret continues"); + input_action_free(&c); + char *gt = editor_text(&g); + CHECK_STR(gt, "a\nb", "newline inserted after the backslash"); + free(gt); + editor_free(&g); +} + +/* Regression: after building a multi-line buffer and moving the caret UP to + * a middle line, backslash+Enter on that line must split it (insert a + * newline), not submit. Previously the continuation only fired when the + * caret was at the very end of the whole buffer. */ +static void test_editor_middle_line_continuation(void) { + editor e; + editor_init(&e); + /* Build "alpha \nbeta": line 1 "alpha ", line 2 "beta". */ + type_str(&e, "alpha "); + type_str(&e, "\\"); + apply_key(&e, KEY_ENTER); /* "alpha \" -> "alpha \n" */ + type_str(&e, "beta"); /* -> "alpha \nbeta", caret at end */ + CHECK(editor_line_count(&e) == 2, "two lines before the split"); + /* Move the caret UP to the middle (first) line. */ + apply_key(&e, KEY_UP); + /* Walk to the end of that line (just before the newline). */ + while (e.caret < strlen(e.text) && e.text[e.caret] != '\n') + apply_key(&e, KEY_RIGHT); + CHECK(e.caret == 6, "caret at end of the middle line"); + /* Type a backslash on the middle line, then Enter -> split it. */ + type_str(&e, "\\"); + input_action a; + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_NEWLINE, "middle-line backslash+Enter continues"); + input_action_free(&a); + char *t = editor_text(&e); + CHECK_STR(t, "alpha \n\nbeta", "middle line was split"); + free(t); + CHECK(editor_line_count(&e) == 3, "three lines after the split"); + + /* And the buffer still submits as a whole from its last line. */ + apply_key(&e, KEY_DOWN); + while (e.caret < strlen(e.text) && e.text[e.caret] != '\n') + apply_key(&e, KEY_RIGHT); + editor_apply(&e, kkind(KEY_ENTER), &a); + CHECK(a.kind == ACT_SUBMIT, "last-line Enter submits"); + if (a.kind == ACT_SUBMIT) + CHECK_STR(a.text, "alpha \n\nbeta", "multi-line submit intact"); + input_action_free(&a); + editor_free(&e); +} + +/* End-to-end regression: Ctrl+J is LF (0x0A). Outside of bracketed paste it + * is a dead key — it neither submits nor inserts a newline. Multi-line input + * is via backslash+Enter or bracketed paste. */ +static void test_ctrl_j_is_dead_key(void) { + key_parser p; + evlist ev; + key_parser_init(&p); + evlist_init(&ev); + key_parser_feed(&p, (const unsigned char *)"hi\n", 3, &ev); + key_parser_free(&p); + CHECK(ev.len == 2, "only the two chars, LF dropped"); + + editor e; + editor_init(&e); + input_action a = {ACT_NONE, NULL}; + int submitted = 0; + for (size_t i = 0; i < ev.len; i++) { + editor_apply(&e, ev.items[i], &a); + if (a.kind == ACT_SUBMIT) + submitted = 1; + input_action_free(&a); + } + CHECK(!submitted, "Ctrl+J (LF) does not submit"); + char *t = editor_text(&e); + CHECK_STR(t, "hi", "LF left no newline in the buffer"); + free(t); + evlist_free(&ev); + editor_free(&e); +} + +static void test_editor_backspace_and_delete(void) { + editor e; + editor_init(&e); + type_str(&e, "he"); + apply_key(&e, KEY_NEWLINE); + type_str(&e, "llo"); + apply_key(&e, KEY_LEFT); + apply_key(&e, KEY_LEFT); + apply_key(&e, KEY_LEFT); + apply_key(&e, KEY_BACKSPACE); + char *t = editor_text(&e); + CHECK_STR(t, "hello", "backspace removed the newline"); + CHECK(e.caret == 2, "caret at the join"); + free(t); + editor_free(&e); + + /* A forward-delete needs the caret at the end of the first line: Up gets + * there (Left is now clamped to a line), then Delete removes the newline. */ + editor f; + editor_init(&f); + type_str(&f, "ab"); + apply_key(&f, KEY_NEWLINE); + type_str(&f, "cd"); + apply_key(&f, KEY_UP); + apply_key(&f, KEY_DELETE); + char *u = editor_text(&f); + CHECK_STR(u, "abcd", "delete removed the newline"); + CHECK(f.caret == 2, "caret at the join"); + free(u); + editor_free(&f); +} + +static void test_editor_multibyte_backspace(void) { + editor e; + editor_init(&e); + type_str(&e, "h\xc3\xa9llo"); + apply_key(&e, KEY_END); + apply_key(&e, KEY_BACKSPACE); + apply_key(&e, KEY_BACKSPACE); + apply_key(&e, KEY_BACKSPACE); + apply_key(&e, KEY_BACKSPACE); + char *t = editor_text(&e); + CHECK_STR(t, "h", "removed whole codepoints"); + free(t); + editor_free(&e); +} + +static void test_editor_horizontal_movement(void) { + editor e; + editor_init(&e); + type_str(&e, "one"); + apply_key(&e, KEY_NEWLINE); + type_str(&e, "two"); + CHECK(e.caret == 7, "caret at the end"); + + /* Left stays on the current line: three presses reach its start, and a + * fourth is a no-op (it no longer crosses into the previous line). */ + apply_key(&e, KEY_LEFT); + apply_key(&e, KEY_LEFT); + apply_key(&e, KEY_LEFT); + CHECK(e.caret == 4, "left moves the caret to the line start"); + apply_key(&e, KEY_LEFT); + CHECK(e.caret == 4, "left is a no-op at the line start"); + apply_key(&e, KEY_RIGHT); + CHECK(e.caret == 5, "right moves the caret"); + + /* HOME/END (and their Ctrl aliases) act on the current line, not the + * whole buffer. Move to the first line and exercise them there. */ + apply_key(&e, KEY_UP); + CHECK(editor_caret_line(&e) == 0, "on the first line"); + apply_key(&e, KEY_HOME); + CHECK(e.caret == 0, "home is the line start"); + apply_key(&e, KEY_END); + CHECK(e.caret == 3, "end is the line end"); + apply_key(&e, KEY_CTRL_A); + CHECK(e.caret == 0, "ctrl-a is the line start"); + apply_key(&e, KEY_CTRL_E); + CHECK(e.caret == 3, "ctrl-e is the line end"); + editor_free(&e); +} + +/* Vertical movement walks the lines, keeps the column, clamps it to short + * lines on a UTF-8 boundary, and stops at the top/bottom edges. */ +static void test_editor_vertical_movement(void) { + editor e; + editor_init(&e); + editor_set(&e, "one\ntwo\nthree"); + CHECK(editor_line_count(&e) == 3, "three lines"); + CHECK(editor_caret_line(&e) == 2, "caret on the last line"); + CHECK(e.caret == 13, "caret at the end"); + + apply_key(&e, KEY_UP); + CHECK(e.caret == 7, "up to the end of line 2"); + CHECK(editor_caret_line(&e) == 1, "caret on line 2"); + apply_key(&e, KEY_UP); + CHECK(e.caret == 3, "up to the end of line 1"); + CHECK(editor_caret_line(&e) == 0, "caret on line 1"); + apply_key(&e, KEY_UP); + CHECK(e.caret == 3, "up is a no-op on the first line"); + + apply_key(&e, KEY_DOWN); + CHECK(e.caret == 7, "down back to line 2"); + apply_key(&e, KEY_DOWN); + CHECK(e.caret == 11, "down into line 3, column preserved"); + CHECK(editor_caret_line(&e) == 2, "caret on line 3"); + apply_key(&e, KEY_DOWN); + CHECK(e.caret == 11, "down is a no-op on the last line"); + editor_free(&e); + + /* Column clamping: a column longer than the target line clamps to its + * end, and the clamped column is what comes back. */ + editor f; + editor_init(&f); + editor_set(&f, "abcdef\ngh"); + CHECK(f.caret == 9, "set caret at the end"); + apply_key(&f, KEY_UP); + CHECK(f.caret == 2, "up keeps the column"); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_RIGHT); + CHECK(f.caret == 6, "end of the long line"); + apply_key(&f, KEY_DOWN); + CHECK(f.caret == 9, "down clamps the column to the short line"); + apply_key(&f, KEY_UP); + CHECK(f.caret == 2, "up keeps the clamped column"); + editor_free(&f); + + /* A single-line buffer: UP/DOWN never move the caret. */ + editor h; + editor_init(&h); + editor_set(&h, "hello"); + apply_key(&h, KEY_HOME); + apply_key(&h, KEY_UP); + CHECK(h.caret == 0, "UP is a no-op on one line"); + apply_key(&h, KEY_DOWN); + CHECK(h.caret == 0, "DOWN is a no-op on one line"); + editor_free(&h); + + /* UTF-8: a column landing mid-codepoint snaps to the boundary. */ + editor u; + editor_init(&u); + editor_set(&u, "abc\n\xc3\xa9\xc3\xa9"); /* "abc", then two e-acute */ + CHECK(u.caret == 8, "caret at the end"); + apply_key(&u, KEY_UP); + CHECK(u.caret == 3, "up to the end of line 1"); + apply_key(&u, KEY_DOWN); + CHECK(u.caret == 6, "down clamps between the two codepoints"); + apply_key(&u, KEY_UP); + CHECK(u.caret == 2, "up keeps the clamped column"); + editor_free(&u); +} + +static void test_editor_insert_at_caret(void) { + editor e; + editor_init(&e); + type_str(&e, "helo"); + apply_key(&e, KEY_HOME); + apply_key(&e, KEY_RIGHT); + type_str(&e, "i"); + char *t = editor_text(&e); + CHECK_STR(t, "hielo", "inserted before the caret"); + CHECK(e.caret == 2, "caret after the inserted char"); + free(t); + editor_free(&e); + + editor f; + editor_init(&f); + apply_key(&f, KEY_BACKSPACE); + CHECK(editor_is_empty(&f), "backspace at 0 is a no-op"); + apply_key(&f, KEY_DELETE); + CHECK(editor_is_empty(&f), "delete at 0 is a no-op"); + editor_free(&f); +} + +static void test_editor_kill_and_word_kills(void) { + editor e; + editor_init(&e); + type_str(&e, "foo bar"); + apply_key(&e, KEY_CTRL_K); + char *t = editor_text(&e); + CHECK_STR(t, "foo bar", "Ctrl-K at end is a no-op"); + free(t); + editor_free(&e); + + editor f; + editor_init(&f); + type_str(&f, "foo bar"); + apply_key(&f, KEY_HOME); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_RIGHT); + apply_key(&f, KEY_CTRL_K); + char *u = editor_text(&f); + CHECK_STR(u, "foo", "Ctrl-K cut the rest of the line"); + free(u); + editor_free(&f); + + editor g; + editor_init(&g); + type_str(&g, "foo bar"); + apply_key(&g, KEY_END); + apply_key(&g, KEY_LEFT); + apply_key(&g, KEY_LEFT); + apply_key(&g, KEY_LEFT); + apply_key(&g, KEY_CTRL_U); + char *v = editor_text(&g); + CHECK_STR(v, "bar", "Ctrl-U cut the front of the line"); + free(v); + editor_free(&g); + + editor h; + editor_init(&h); + type_str(&h, "hello bar_baz"); + apply_key(&h, KEY_END); + apply_key(&h, KEY_CTRL_W); + char *w = editor_text(&h); + CHECK_STR(w, "hello", "Ctrl-W removed one word"); + free(w); + editor_free(&h); +} + +static void test_editor_undo(void) { + editor e; + editor_init(&e); + type_str(&e, "hello"); + char *t = editor_text(&e); + CHECK_STR(t, "hello", "typed"); + free(t); + + apply_key(&e, KEY_UNDO); + t = editor_text(&e); + CHECK_STR(t, "hell", "undo removed the last char"); + free(t); + + apply_key(&e, KEY_UNDO); + t = editor_text(&e); + CHECK_STR(t, "hel", "undo again"); + free(t); + + apply_key(&e, KEY_UNDO); + apply_key(&e, KEY_UNDO); + apply_key(&e, KEY_UNDO); + t = editor_text(&e); + CHECK_STR(t, "", "undo back to empty"); + free(t); + + apply_key(&e, KEY_UNDO); + t = editor_text(&e); + CHECK_STR(t, "", "undo past empty is a no-op"); + free(t); + editor_free(&e); +} + +static void test_editor_yank(void) { + /* Ctrl-W kill, then Ctrl-Y yank it back. */ + editor e; + editor_init(&e); + type_str(&e, "foo bar"); + apply_key(&e, KEY_END); + apply_key(&e, KEY_CTRL_W); + char *t = editor_text(&e); + CHECK_STR(t, "foo", "Ctrl-W killed the last word"); + free(t); + apply_key(&e, KEY_CTRL_Y); + t = editor_text(&e); + CHECK_STR(t, "foo bar", "Ctrl-Y yanked the killed word"); + free(t); + editor_free(&e); + + /* A backspaced char is also yankable. */ + editor f; + editor_init(&f); + type_str(&f, "ab"); + apply_key(&f, KEY_BACKSPACE); + char *u = editor_text(&f); + CHECK_STR(u, "a", "backspace removed b"); + free(u); + apply_key(&f, KEY_CTRL_Y); + u = editor_text(&f); + CHECK_STR(u, "ab", "Ctrl-Y yanked the backspaced char"); + free(u); + editor_free(&f); +} + +static void test_editor_ctrl_c_and_ctrl_d(void) { + editor e; + editor_init(&e); + type_str(&e, "hello"); + input_action a; + editor_apply(&e, kkind(KEY_CTRL_C), &a); + CHECK(a.kind == ACT_NONE, "Ctrl-C on non-empty is an edit op"); + CHECK(editor_is_empty(&e), "Ctrl-C clears the buffer"); + input_action_free(&a); + + input_action q; + editor_apply(&e, kkind(KEY_CTRL_C), &q); + CHECK(q.kind == ACT_QUIT, "Ctrl-C on empty -> quit"); + input_action_free(&q); + editor_free(&e); + + editor f; + editor_init(&f); + input_action d; + editor_apply(&f, kkind(KEY_CTRL_D), &d); + CHECK(d.kind == ACT_QUIT, "Ctrl-D on empty -> quit"); + input_action_free(&d); + + type_str(&f, "hello"); + input_action d2; + editor_apply(&f, kkind(KEY_CTRL_D), &d2); + CHECK(d2.kind == ACT_NONE, "Ctrl-D on non-empty is an edit op"); + char *t = editor_text(&f); + CHECK_STR(t, "hello", "no-op at end of a single line"); + free(t); + input_action_free(&d2); + editor_free(&f); +} + +static void test_editor_backtab_tab_and_esc(void) { + editor e; + editor_init(&e); + input_action a; + editor_apply(&e, kkind(KEY_BACKTAB), &a); + CHECK(a.kind == ACT_TOGGLE_MODE, "backtab toggles mode"); + input_action_free(&a); + editor_free(&e); + + editor f; + editor_init(&f); + input_action t; + editor_apply(&f, kkind(KEY_TAB), &t); + CHECK(t.kind == ACT_NONE, "tab is an insert op"); + char *ts = editor_text(&f); + CHECK_STR(ts, "\t", "tab inserted"); + free(ts); + input_action_free(&t); + editor_free(&f); + + editor g; + editor_init(&g); + input_action s; + editor_apply(&g, kkind(KEY_ESC), &s); + CHECK(s.kind == ACT_STOP, "esc -> stop"); + input_action_free(&s); + editor_free(&g); +} + +/* ================================================================== */ +/* Display tests */ +/* ================================================================== */ + +static void test_display_len(void) { + CHECK(input_display_len("\x1b[1;36m›\x1b[0m ") == 2, "SGR ignored"); + CHECK(input_display_len("plain") == 5, "plain ascii"); + CHECK(input_display_len("") == 0, "empty"); +} + +static void test_display_len_wide(void) { + CHECK(input_display_len("\xe4\xb8\xad\xe6\x96\x87") == 4, "two CJK = 4"); + CHECK(input_display_len("a\xe4\xb8\xad" "b") == 4, "a CJK b = 4"); + CHECK(input_display_len("h\xc3\xa9llo") == 5, "accented = 5"); +} + +static void test_display_len_escape_families(void) { + CHECK(input_display_len("\x1b]0;llsh\x1b\\hi") == 2, "OSC st"); + CHECK(input_display_len("\x1b]0;llsh\x07hi") == 2, "OSC BEL"); + CHECK(input_display_len("\x1bOA") == 0, "SS3"); + CHECK(input_display_len("\x1b(Bx") == 1, "charset"); + CHECK(input_display_len("\x1b[c") == 0, "CSI cursor"); + CHECK(input_display_len("a\x1b") == 1, "trailing lone ESC"); +} + +/* History tests */ +/* ================================================================== */ + +static char *hist_dir(void) { + static char buf[512]; + snprintf(buf, sizeof(buf), "/tmp/llshc_hist_%d", (int)getpid()); + return buf; +} + +static char *hist_path(const char *name) { + static char path[640]; + snprintf(path, sizeof(path), "%s/%s", hist_dir(), name); + return path; +} + +static void hist_cleanup(void) { + const char *names[] = {"a.json", "b.json"}; + for (size_t i = 0; i < 2; i++) { + char p[640]; + snprintf(p, sizeof(p), "%s/%s", hist_dir(), names[i]); + remove(p); + } + rmdir(hist_dir()); +} + +static void test_history_capture_submit_cycle(void) { + hist_cleanup(); + char *path = hist_path("a.json"); + + input_history *h = input_history_open(path); + CHECK(h != NULL, "open history"); + input_history_push(h, "echo hi"); + input_history_push(h, "ls -la"); + + /* While composing, Up engages only from the first line (caret_line 0); + * a mid-buffer Up moves the caret, not history. */ + CHECK(input_history_walk(h, -1, 1, "") == NULL, "Up mid-buffer -> no recall"); + + char *line = input_history_walk(h, -1, 0, ""); + CHECK_STR(line, "ls -la", "newest first"); + free(line); + + /* Mid-walk: Up advances toward the oldest regardless of the caret line. */ + line = input_history_walk(h, -1, 2, ""); + CHECK_STR(line, "echo hi", "oldest second"); + free(line); + + /* Oldest already shown: Up hands back to the caret. */ + CHECK(input_history_walk(h, -1, 0, "") == NULL, "Up at oldest -> NULL"); + + /* Down walks back toward the newest entry. */ + line = input_history_walk(h, +1, 0, ""); + CHECK_STR(line, "ls -la", "Down back to newest"); + free(line); + + /* Down past the newest restores the pre-recall line and ends the walk. */ + line = input_history_walk(h, +1, 0, ""); + CHECK_STR(line, "", "Down past newest restores pre-recall line"); + free(line); + CHECK(h->active == false, "deactivated after restoring"); + + input_history_free(h); + + input_history *h2 = input_history_open(path); + CHECK(h2 && h2->entries.len == 2, "reloaded 2 entries"); + input_history_free(h2); + hist_cleanup(); +} + +static void test_history_restores_pre_recall_line(void) { + hist_cleanup(); + char *path = hist_path("a.json"); + input_history *h = input_history_open(path); + input_history_push(h, "first"); + input_history_push(h, "second"); + + /* Down while composing never recalls — it moves the caret. */ + CHECK(input_history_walk(h, +1, 0, "typed but not sent") == NULL, + "Down while composing -> no recall"); + + char *line = input_history_walk(h, -1, 0, "typed but not sent"); + CHECK_STR(line, "second", "newest first"); + free(line); + + line = input_history_walk(h, +1, 0, "typed but not sent"); + CHECK_STR(line, "typed but not sent", "restored the pre-recall line"); + free(line); + CHECK(h->active == false, "deactivated after restoring"); + CHECK(h->snapshot == NULL, "snapshot consumed"); + input_history_free(h); + hist_cleanup(); +} + +static void test_history_skips_duplicates_and_empty(void) { + hist_cleanup(); + char *path = hist_path("a.json"); + input_history *h = input_history_open(path); + input_history_push(h, "same"); + input_history_push(h, "same"); + input_history_push(h, " "); + input_history_push(h, " spaced "); + input_history_push(h, "spaced"); + + CHECK(h->entries.len == 2, "two unique entries"); + if (h->entries.len == 2) { + CHECK_STR(h->entries.items[0], "same", "entry 0"); + CHECK_STR(h->entries.items[1], "spaced", "entry 1 trimmed"); + } + input_history_free(h); + hist_cleanup(); +} + +static void test_history_persistence_and_limit(void) { + hist_cleanup(); + char *path = hist_path("b.json"); + + input_history *h = input_history_open(path); + for (int i = 1; i <= LLSH_HISTORY_LIMIT + 1; i++) { + char buf[32]; + snprintf(buf, sizeof(buf), "cmd%03d", i); + input_history_push(h, buf); + } + size_t live = h->entries.len - h->head; + CHECK(live == LLSH_HISTORY_LIMIT, "limit enforced"); + if (live == LLSH_HISTORY_LIMIT) { + CHECK_STR(h->entries.items[h->head], "cmd002", "oldest evicted"); + CHECK_STR(h->entries.items[h->entries.len - 1], "cmd101", + "newest preserved"); + } + input_history_free(h); + + input_history *h2 = input_history_open(path); + CHECK(h2 && h2->entries.len - h2->head == LLSH_HISTORY_LIMIT, + "reloaded at limit"); + input_history_free(h2); + hist_cleanup(); +} + +/* ================================================================== */ +/* Shell shadow tests */ +/* ================================================================== */ + +static void test_shell_shadow(void) { + hist_cleanup(); + editor shadow; + editor_init(&shadow); + type_str(&shadow, "echo hi"); + char *out = shell_shadow_apply(&shadow, kkind(KEY_ENTER)); + CHECK_STR(out, "echo hi", "submitted line returned"); + free(out); + CHECK(editor_is_empty(&shadow), "shadow cleared after submit"); + editor_free(&shadow); + + editor s2; + editor_init(&s2); + type_str(&s2, "ls"); + char *o2 = shell_shadow_apply(&s2, kkind(KEY_LEFT)); + CHECK(o2 == NULL, "intermediate key -> NULL"); + CHECK(!editor_is_empty(&s2), "shadow NOT cleared on intermediate key"); + editor_free(&s2); + + editor s3; + editor_init(&s3); + type_str(&s3, " "); + char *o3 = shell_shadow_apply(&s3, kkind(KEY_ENTER)); + CHECK(o3 == NULL, "blank submit -> NULL"); + CHECK(!editor_is_empty(&s3), "shadow NOT cleared on blank"); + editor_free(&s3); + hist_cleanup(); +} + +/* ================================================================== */ +/* Reader queue */ +/* ================================================================== */ + +/* The reader's event queue must be FIFO: keys typed quickly must reach the + * editor in order (regression: a swap-pop scrambled the sequence, dropping + * and reordering characters). */ +static void test_reader_queue_fifo(void) { + reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx); + memset(ctx, 0, sizeof *ctx); + input_key_reader *r = input_key_reader_new(ctx); + + const unsigned int cps[] = {'a', 'b', 'c', 'd', 'e', 'f'}; + for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) { + input_key k = {KEY_CHAR, cps[i]}; + input_key_reader_send(r, &k); + } + for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) { + input_key out; + const unsigned char *raw; + size_t raw_len; + CHECK(input_key_reader_recv(r, &out, &raw, &raw_len), + "recv returns queued event"); + CHECK(out.kind == KEY_CHAR, "queued key kind preserved"); + CHECK(out.cp == cps[i], "queue is FIFO (order preserved)"); + input_raw_free(raw); + } + input_key out; + CHECK(!input_key_reader_recv(r, &out, NULL, NULL), "queue drained"); + input_key_reader_free(r); +} + +/* The raw buffer handed out by recv is owned by the caller: it must stay + * valid no matter what the queue does afterwards (regression: recv used to + * pop-and-free the very buffer it returned, so callers read freed memory and + * shell-mode keystrokes arrived scrambled). */ +static void test_recv_raw_owned(void) { + reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx); + memset(ctx, 0, sizeof *ctx); + input_key_reader *r = input_key_reader_new(ctx); + + input_key k = {KEY_CHAR, 'x'}; + input_key_reader_send_raw(r, &k, "payload"); + + input_key out; + const unsigned char *raw1 = NULL; + size_t raw_len = 0; + CHECK(input_key_reader_recv(r, &out, &raw1, &raw_len), "recv raw event"); + CHECK(raw_len == 7, "raw length preserved"); + + /* Fill the ring past one full lap so the queue reuses the first slot; + * with a borrowed (freed-on-pop) pointer, `raw1` would now be garbage. */ + for (int i = 0; i < 16; i++) + input_key_reader_send_raw(r, &k, "scratch"); + for (int i = 0; i < 16; i++) { + input_key o2; + const unsigned char *r2 = NULL; + if (!input_key_reader_recv(r, &o2, &r2, &raw_len)) + break; + input_raw_free(r2); + } + + CHECK(raw1 && raw1[0] == 'p' && memcmp(raw1, "payload", 7) == 0, + "recv'd raw stays valid after the queue laps it"); + input_raw_free(raw1); + input_key_reader_free(r); +} + +/* ================================================================== */ + +/* The renderer and these tests share input_wrap_total / input_wrap_caret to + * agree on how a buffer breaks into physical rows and where the caret lands on + * a terminal of a given width, using the delayed-wrap model real terminals use. + * Line 0 carries the 2-column prompt. */ +static void test_wrap_geometry(void) { + size_t r, c; + + /* A short first line: the prompt takes the first two columns, nothing + * wraps. */ + CHECK(input_wrap_total("hi", 10) == 1, "short line is one row"); + input_wrap_caret("hi", 0, 10, &r, &c); + CHECK(r == 0 && c == 2, "caret at text start sits after the prompt"); + input_wrap_caret("hi", 2, 10, &r, &c); + CHECK(r == 0 && c == 4, "caret at the text end"); + + /* A first line long enough to wrap: prompt + text flow across rows. The + * cursor sits on the last typed column, not a spurious next row. */ + CHECK(input_wrap_total("abcdef", 5) == 2, "wrapped line is two rows"); + input_wrap_caret("abcdef", 0, 5, &r, &c); + CHECK(r == 0 && c == 2, "caret before the first char"); + input_wrap_caret("abcdef", 3, 5, &r, &c); + CHECK(r == 0 && c == 4, "caret after 'abc' sits on the last column"); + input_wrap_caret("abcdef", 6, 5, &r, &c); + CHECK(r == 1 && c == 3, "caret at the wrapped end"); + + /* A multi-line buffer: only line 0 carries the prompt. */ + CHECK(input_wrap_total("ab\ncdef", 5) == 2, "two short lines are two rows"); + input_wrap_caret("ab\ncdef", 7, 5, &r, &c); + CHECK(r == 1 && c == 4, "caret at the buffer end"); + input_wrap_caret("ab\ncdef", 5, 5, &r, &c); + CHECK(r == 1 && c == 2, "caret mid second line"); + + /* Regression: a first line that ends exactly at the margin, followed by a + * newline, must NOT gain a spurious empty row. '» ab' is exactly 4 + * columns, so the 'cdefgh' line starts immediately on row 1. */ + CHECK(input_wrap_total("ab\ncdefgh", 4) == 3, + "full first line + newline is three rows, not four"); + input_wrap_caret("ab\ncdefgh", 9, 4, &r, &c); + CHECK(r == 2 && c == 2, "caret at the end of the third row"); + input_wrap_caret("ab\ncdefgh", 2, 4, &r, &c); + CHECK(r == 0 && c == 3, "caret at the end of the full first row"); + input_wrap_caret("ab\ncdefgh", 3, 4, &r, &c); + CHECK(r == 1 && c == 0, "caret at the start of the second line"); + + /* A trailing full line keeps the cursor on its last column (no empty row). */ + CHECK(input_wrap_total("abcdef", 4) == 2, "two full rows is two rows"); + input_wrap_caret("abcdef", 6, 4, &r, &c); + CHECK(r == 1 && c == 3, "caret at the end of a full row sits on it"); + + /* The renderer needs the pending flag to tell a cursor sitting on the last + * column (delayed wrap) from one merely after the second-to-last column. */ + CHECK(input_wrap_pending("hi", 2, 10) == false, + "short end is not pending"); + CHECK(input_wrap_pending("abcdef", 3, 5) == true, + "caret after the last-column char is pending"); + CHECK(input_wrap_pending("abcdef", 4, 5) == false, + "caret on the wrapped row is not pending"); + CHECK(input_wrap_pending("abcdefg", 7, 10) == false, + "second-to-last column end is not pending"); + CHECK(input_wrap_pending("abcdefgh", 8, 10) == true, + "last-column end is pending"); + CHECK(input_wrap_pending("a\xE4\xB8\xAD", 4, 10) == false, + "three-byte char advances one column"); + CHECK(input_wrap_pending("a\xF0\x9F\x98\x80", 5, 10) == false, + "four-byte char advances one column"); + CHECK(input_wrap_pending("a", 1, 1) == true, + "one-column terminal stays pending"); + CHECK(input_wrap_pending("a\n", 2, 1) == false, + "newline clears pending wrap"); + CHECK(input_wrap_pending("ab\ncdef\n", 8, 5) == false, + "a trailing newline is not pending"); + + /* A blank buffer still renders the prompt as one row. */ + CHECK(input_wrap_total("", 10) == 1, "blank buffer is one row"); + input_wrap_caret("", 0, 10, &r, &c); + CHECK(r == 0 && c == 2, "caret sits after the prompt"); +} + +int main(void) { + /* parser */ + test_parser_plain_chars(); + test_parser_enter_variants(); + test_parser_shift_enter_and_esc(); + test_parser_lone_escape_is_esc(); + test_parser_alt_letter_is_the_letter(); + test_parser_raw_bytes_preserved(); + test_parser_control_keys(); + test_parser_unmapped_control_bytes(); + test_parser_unknown_sequence_consumed(); + test_parser_bracketed_paste(); + test_parser_utf8(); + test_parser_mixed_stream(); + test_parser_kitty_and_ss3(); + + /* editor */ + test_editor_typing_and_submit(); + test_editor_shift_enter_inserts_newline(); + test_editor_backslash_enter_continues(); + test_editor_middle_line_continuation(); + test_ctrl_j_is_dead_key(); + test_editor_backspace_and_delete(); + test_editor_multibyte_backspace(); + test_editor_horizontal_movement(); + test_editor_vertical_movement(); + test_editor_insert_at_caret(); + test_editor_kill_and_word_kills(); + test_editor_undo(); + test_editor_yank(); + test_editor_ctrl_c_and_ctrl_d(); + test_editor_backtab_tab_and_esc(); + + /* display */ + test_display_len(); + test_display_len_wide(); + test_display_len_escape_families(); + + /* wrap geometry */ + test_wrap_geometry(); + + /* history */ + test_history_capture_submit_cycle(); + test_history_restores_pre_recall_line(); + test_history_skips_duplicates_and_empty(); + test_history_persistence_and_limit(); + + /* shell shadow */ + test_shell_shadow(); + + /* reader queue */ + test_reader_queue_fifo(); + test_recv_raw_owned(); + + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_limits.cpp b/tests/test_limits.cpp new file mode 100644 index 0000000..f425d64 --- /dev/null +++ b/tests/test_limits.cpp @@ -0,0 +1,379 @@ +#include "limits.h" +#include "verbose.h" + +#include <cjson/cJSON.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ_STR(a, b, msg) \ + do { \ + checks++; \ + const char *_a = (a), *_b = (b); \ + if (!_a || (_b && strcmp(_a, _b) != 0)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), _a ? _a : "(null)", \ + _b ? _b : "(null)"); \ + } \ + } while (0) + +static tool_limits mklimits(const char **allow, size_t nallow, + const char **deny, size_t ndeny) { + tool_limits l; + tool_limits_defaults(&l); + for (size_t i = 0; i < nallow; i++) + sl_push(&l.shell_allowlist, xstrdup(allow[i])); + for (size_t i = 0; i < ndeny; i++) + sl_push(&l.shell_denylist, xstrdup(deny[i])); + return l; +} + +static void test_defaults(void) { + tool_limits l; + tool_limits_defaults(&l); + CHECK(l.shell_timeout_secs == 120, "default shell_timeout_secs"); + CHECK(l.max_output_chars == 16000, "default max_output_chars"); + CHECK(l.max_file_bytes == 2000000, "default max_file_bytes"); + CHECK(l.shell_allowlist.len == 0, "default allowlist empty"); + CHECK(l.shell_denylist.len == 0, "default denylist empty"); + CHECK(!l.confirm_destructive, "default confirm_destructive off"); + CHECK(!l.verbose, "default verbose off"); + tool_limits_free(&l); +} + +static void test_set_get_roundtrip(void) { + const char *allow[] = {"git", "ls"}; + const char *deny[] = {"git push"}; + tool_limits l = mklimits(allow, 2, deny, 1); + l.confirm_destructive = true; + l.verbose = true; + tool_limits_set(&l); + tool_limits g; + tool_limits_get(&g); + CHECK(g.shell_timeout_secs == 120, "get timeout"); + CHECK(g.confirm_destructive, "get confirm_destructive"); + CHECK(g.verbose, "get verbose"); + CHECK(g.shell_allowlist.len == 2, "get allowlist len"); + CHECK(g.shell_allowlist.len == 2 && + strcmp(g.shell_allowlist.items[0], "git") == 0, + "get allowlist[0]"); + CHECK(g.shell_allowlist.len == 2 && + strcmp(g.shell_allowlist.items[1], "ls") == 0, + "get allowlist[1]"); + CHECK(g.shell_denylist.len == 1 && + strcmp(g.shell_denylist.items[0], "git push") == 0, + "get denylist[0]"); + tool_limits_free(&l); + tool_limits_free(&g); + /* restore defaults for later tests */ + tool_limits d; + tool_limits_defaults(&d); + tool_limits_set(&d); + tool_limits_free(&d); +} + +static void test_allowlist_allows_matching_and_refuses_others(void) { + const char *allow[] = {"git", "ls"}; + tool_limits l = mklimits(allow, 2, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED, + "git status allowed"); + CHECK(shell_policy("ls -la", &l, NULL) == SHELL_ALLOWED, "ls -la allowed"); + CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, + "rm -rf x refused"); + CHECK(reason && strstr(reason, "allowlist") != NULL, + "refusal mentions allowlist"); + free(reason); + tool_limits_free(&l); +} + +static void test_allowlist_entry_does_not_match_longer_word(void) { + const char *allow[] = {"git"}; + tool_limits l = mklimits(allow, 1, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("igit x", &l, &reason) == SHELL_DENIED, + "igit x refused (no word-boundary match)"); + free(reason); + CHECK(shell_policy("git push", &l, NULL) == SHELL_ALLOWED, + "git push allowed by bare-word entry"); + tool_limits_free(&l); +} + +static void test_denylist_wins_over_allowlist(void) { + const char *allow[] = {"git"}; + const char *deny[] = {"git push"}; + tool_limits l = mklimits(allow, 1, deny, 1); + CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED, + "git status allowed despite denylist presence"); + char *reason = NULL; + CHECK(shell_policy("git push origin", &l, &reason) == SHELL_DENIED, + "git push origin refused by denylist"); + CHECK_EQ_STR( + reason, + "command matches the denylist entry `git push`", + "exact denylist reason"); + free(reason); + tool_limits_free(&l); +} + +static void test_allowlist_reason_string(void) { + const char *allow[] = {"git", "ls"}; + tool_limits l = mklimits(allow, 2, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, "denied"); + CHECK_EQ_STR(reason, "command not in the allowlist (git, ls)", + "exact allowlist reason"); + free(reason); + tool_limits_free(&l); +} + +static void test_no_allowlist_means_no_allowlist(void) { + tool_limits l; + tool_limits_defaults(&l); + CHECK(shell_policy("anything at all", &l, NULL) == SHELL_ALLOWED, + "no allowlist: everything allowed"); + tool_limits_free(&l); +} + +static void test_gate_reason_wrapping(void) { + const char *deny[] = {"git push"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + char *err = NULL; + CHECK(shell_gate("git push origin", &l, &err) == -1, "gate denies"); + CHECK_EQ_STR(err, "run_shell blocked: command matches the denylist " + "entry `git push`", + "gate wraps policy reason"); + free(err); + tool_limits_free(&l); +} + +static void test_destructive_detection(void) { + CHECK(is_destructive("rm -rf /tmp/x"), "rm is destructive"); + CHECK(is_destructive("git reset --hard"), "git reset is destructive"); + CHECK(is_destructive("dd if=/dev/zero of=/dev/sda bs=1M"), + "dd with of= is destructive"); + CHECK(!is_destructive("ls -la"), "ls is not destructive"); + CHECK(!is_destructive("git status"), "git status is not destructive"); + CHECK(!is_destructive("echo hi"), "echo is not destructive"); + CHECK(is_destructive("chmod 777 /etc/passwd"), "chmod is destructive"); + CHECK(!is_destructive("dd if=/dev/zero bs=1M"), + "dd without of= is not destructive"); + CHECK(is_destructive("git checkout -- ."), "git checkout is destructive"); + CHECK(!is_destructive("git commit -m x"), "git commit is not destructive"); + CHECK(is_destructive(" rm x"), "leading whitespace still destructive"); + CHECK(is_destructive("echo safe; rm x"), "compound command requires confirmation"); + CHECK(is_destructive("echo safe | rm x"), "pipeline requires confirmation"); + CHECK(is_destructive("echo $(rm x)"), "expansion requires confirmation"); + CHECK(!is_destructive(""), "empty command not destructive"); +} + +static bool hook_yes(const char *cmd, void *ud) { (void)cmd; (void)ud; return true; } +static int hook_calls = 0; +static char hook_last[256]; +static bool hook_record(const char *cmd, void *ud) { + (void)ud; + hook_calls++; + snprintf(hook_last, sizeof hook_last, "%s", cmd); + return false; +} + +static void test_gate_refuses_when_hook_declines(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_record, NULL); + hook_calls = 0; + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, "declined => -1"); + CHECK(err && strstr(err, "declined") != NULL, "reason says declined"); + CHECK_EQ_STR(err, "user declined to run destructive command: " + "rm -rf /tmp/x", + "exact decline reason"); + free(err); + CHECK(hook_calls == 1, "hook invoked exactly once"); + CHECK_EQ_STR(hook_last, "rm -rf /tmp/x", "hook received the command"); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_gate_allows_when_hook_approves(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_yes, NULL); + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == 0, "approved => 0"); + CHECK(err == NULL, "no error on approval"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_gate_no_hook_denies(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(NULL, NULL); + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, + "no hook: destructive command denied"); + CHECK(err && strstr(err, "declined"), "no-hook denial has reason"); + free(err); + tool_limits_free(&l); +} + +static void test_gate_non_destructive_skips_hook(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_record, NULL); + hook_calls = 0; + char *err = NULL; + CHECK(shell_gate("ls -la", &l, &err) == 0, "non-destructive passes"); + CHECK(hook_calls == 0, "hook not consulted for non-destructive"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_policy_null_reason(void) { + const char *deny[] = {"git push"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + CHECK(shell_policy("git push", &l, NULL) == SHELL_DENIED, "denied"); + CHECK(shell_policy("ls", &l, NULL) == SHELL_ALLOWED, "allowed"); + tool_limits_free(&l); +} + +static void test_policy_checks_wrapped_commands(void) { + const char *deny[] = {"rm"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + CHECK(shell_policy("sudo -u root rm -rf /tmp/x", &l, NULL) == SHELL_DENIED, + "denylist catches sudo-wrapped rm"); + CHECK(is_destructive("env PATH=/bin git clean -fd"), + "destructive classifier catches env-wrapped git clean"); + CHECK(is_destructive("sudo git push origin main"), + "destructive classifier catches sudo-wrapped git push"); + tool_limits_free(&l); +} + +/* ---- verbose ---- */ + +static char **vlines = NULL; +static size_t vline_n = 0, vline_cap = 0; + +static void vrecord(const char *line, void *ud) { + (void)ud; + if (vline_n + 1 > vline_cap) { + vline_cap = vline_cap ? vline_cap * 2 : 8; + vlines = (char* * ) realloc(vlines, vline_cap * sizeof(char *)); + } + vlines[vline_n++] = xstrdup(line); +} + +static void vreset(void) { + for (size_t i = 0; i < vline_n; i++) + free(vlines[i]); + free(vlines); + vlines = NULL; + vline_n = 0; + vline_cap = 0; + verbose_set(false); + verbose_set_sink(NULL, NULL); +} + +static void test_verbose_defaults_off(void) { + verbose_set(false); + CHECK(!verbose_enabled(), "verbose defaults off"); +} + +static void test_log_emits_json_line_when_enabled(void) { + vreset(); + verbose_set(true); + verbose_set_sink(vrecord, NULL); + vlog_kv kvs[] = { + VLOG_STR("name", "grep"), + VLOG_STR("pattern", "x"), + VLOG_INT("count", 42), + VLOG_BOOL("ok", true), + VLOG_DBL("ratio", 0.5), + }; + verbose_log("tool_call", kvs, 5); + CHECK(vline_n == 1, "exactly one line emitted"); + cJSON *v = vline_n ? cJSON_Parse(vlines[0]) : NULL; + CHECK(v != NULL, "emitted line is valid JSON"); + if (v) { + CHECK(strcmp(cJSON_GetObjectItem(v, "event")->valuestring, + "tool_call") == 0, + "event field"); + CHECK(strcmp(cJSON_GetObjectItem(v, "name")->valuestring, "grep") == 0, + "name field"); + CHECK(strcmp(cJSON_GetObjectItem(v, "pattern")->valuestring, "x") == 0, + "pattern field"); + CHECK(cJSON_GetObjectItem(v, "count")->valueint == 42, "count field"); + CHECK(cJSON_IsTrue(cJSON_GetObjectItem(v, "ok")), "ok field"); + CHECK(cJSON_GetObjectItem(v, "ratio")->valuedouble == 0.5, + "ratio field"); + cJSON_Delete(v); + } + /* no kv at all: just the event */ + vreset(); + verbose_set(true); + verbose_set_sink(vrecord, NULL); + verbose_log("turn_start", NULL, 0); + CHECK(vline_n == 1, "event-only line emitted"); + cJSON *v2 = vline_n ? cJSON_Parse(vlines[0]) : NULL; + CHECK(v2 != NULL && + strcmp(cJSON_GetObjectItem(v2, "event")->valuestring, + "turn_start") == 0, + "event-only payload"); + if (v2) + cJSON_Delete(v2); + vreset(); +} + +static void test_log_noop_when_disabled(void) { + vreset(); + verbose_set(false); + verbose_set_sink(vrecord, NULL); + vlog_kv kvs[] = {VLOG_STR("k", "v")}; + verbose_log("never", kvs, 1); + CHECK(vline_n == 0, "no output when disabled"); + vreset(); +} + +int main(void) { + test_defaults(); + test_set_get_roundtrip(); + test_allowlist_allows_matching_and_refuses_others(); + test_allowlist_entry_does_not_match_longer_word(); + test_denylist_wins_over_allowlist(); + test_allowlist_reason_string(); + test_no_allowlist_means_no_allowlist(); + test_gate_reason_wrapping(); + test_destructive_detection(); + test_gate_refuses_when_hook_declines(); + test_gate_allows_when_hook_approves(); + test_gate_no_hook_denies(); + test_gate_non_destructive_skips_hook(); + test_policy_null_reason(); + test_policy_checks_wrapped_commands(); + test_verbose_defaults_off(); + test_log_emits_json_line_when_enabled(); + test_log_noop_when_disabled(); + vreset(); + printf("test_limits: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp new file mode 100644 index 0000000..ae112ba --- /dev/null +++ b/tests/test_mcp.cpp @@ -0,0 +1,579 @@ +#include "mcp.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include <fcntl.h> +#include <signal.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <unistd.h> + +#include <cjson/cJSON.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ(a, b, msg) \ + do { \ + checks++; \ + if (strcmp((a) ? (a) : "(null)", (b)) != 0) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), (a) ? (a) : "(null)", (b)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, \ + "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +static void mem_free(mcp_memory *m) +{ + sl_free(&m->responses); + sl_free(&m->sent); +} + +/* helpers: push a queued JSON-RPC response / read the nth sent message */ + +static void push_resp(mcp_memory *m, unsigned long id, const char *result) +{ + char *line = xstrfmt("{\"jsonrpc\":\"2.0\",\"id\":%lu,\"result\":%s}", id, + result); + sl_push(&m->responses, line); +} + +static cJSON *sent_msg(mcp_memory *m, size_t i) +{ + if (i >= m->sent.len) + return NULL; + return cJSON_Parse(m->sent.items[i]); +} + +static void test_parses_stdio_config(void) +{ + mcp_server_config cfg; + CHECK(mcp_server_config_from_json( + "{\"command\":\"node\",\"args\":[\"server.js\",\"--fast\"]," + "\"env\":{\"API_KEY\":\"k\"}}", + &cfg) == 1, + "stdio config parses"); + CHECK_EQ(cfg.command, "node", "command"); + CHECK(cfg.arg_count == 2, "args count"); + CHECK(cfg.arg_count == 2 && strcmp(cfg.args[0], "server.js") == 0, + "arg 0"); + CHECK(cfg.arg_count == 2 && strcmp(cfg.args[1], "--fast") == 0, "arg 1"); + CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].key, "API_KEY") == 0, + "env key"); + CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].val, "k") == 0, "env value"); + CHECK(cfg.url == NULL, "no url"); + mcp_server_config_free(&cfg); +} + +static void test_parses_http_config(void) +{ + mcp_server_config cfg; + CHECK(mcp_server_config_from_json( + "{\"url\":\"https://example.com/mcp\"," + "\"headers\":{\"Authorization\":\"Bearer t\"}}", + &cfg) == 1, + "http config parses"); + CHECK_EQ(cfg.url, "https://example.com/mcp", "url"); + CHECK(cfg.header_count == 1 && + strcmp(cfg.headers[0].key, "Authorization") == 0, + "header key"); + CHECK(cfg.header_count == 1 && + strcmp(cfg.headers[0].val, "Bearer t") == 0, + "header value"); + CHECK(cfg.command == NULL, "no command"); + mcp_server_config_free(&cfg); +} + +static void test_rejects_no_transport(void) +{ + mcp_server_config cfg; + CHECK(mcp_server_config_from_json("{\"foo\":\"bar\"}", &cfg) == 0, + "object without transport"); + mcp_server_config_free(&cfg); + CHECK(mcp_server_config_from_json("42", &cfg) == 0, "non-object"); + mcp_server_config_free(&cfg); + CHECK(mcp_server_config_from_json(NULL, &cfg) == 0, "null"); + mcp_server_config_free(&cfg); +} + +static void test_handshake_discovery_call(void) +{ + mcp_memory mem; + mcp_memory_init(&mem); + push_resp(&mem, 1, + "{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"fake\",\"version\":\"0\"}}"); + push_resp(&mem, 2, + "{\"tools\":[{\"name\":\"add\",\"description\":\"Add two " + "integers\",\"inputSchema\":{\"type\":\"object\",\"properties\":" + "{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}}," + "\"required\":[\"a\",\"b\"]}}]}"); + push_resp(&mem, 3, "{\"content\":[{\"type\":\"text\",\"text\":\"7\"}]}"); + + char *err = NULL; + mcp_client *c = mcp_connect_memory(&mem, &err); + CHECK(c != NULL, "connect memory"); + + CHECK(mcp_initialize(c, &err) == 1, "handshake"); + CHECK(err == NULL, "no handshake error"); + + mcp_tool *tools = NULL; + size_t n = 0; + err = NULL; + CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools/list"); + CHECK(n == 1, "one tool"); + if (n == 1) { + CHECK_EQ(tools[0].name, "add", "tool name"); + CHECK_EQ(tools[0].description, "Add two integers", "tool description"); + CHECK_CONT(tools[0].input_schema, "\"type\":\"object\"", "schema type"); + CHECK_CONT(tools[0].input_schema, "\"required\"", "schema required"); + } + + err = NULL; + char *out = mcp_call_tool(c, "add", "{\"a\":3,\"b\":4}", &err); + CHECK(out != NULL, "tools/call"); + CHECK_EQ(out, "7", "call result"); + free(out); + + /* wire order and ids must be exactly the MCP handshake */ + CHECK(mem.sent.len == 4, "four messages sent"); + const char *want[] = {"initialize", "notifications/initialized", + "tools/list", "tools/call"}; + for (size_t i = 0; i < 4 && i < mem.sent.len; i++) { + cJSON *m = sent_msg(&mem, i); + cJSON *method = m ? cJSON_GetObjectItemCaseSensitive(m, "method") : NULL; + CHECK(cJSON_IsString(method) && strcmp(method->valuestring, want[i]) == 0, + "method order"); + cJSON_Delete(m); + } + { + cJSON *m0 = sent_msg(&mem, 0); + cJSON *m2 = sent_msg(&mem, 2); + cJSON *m3 = sent_msg(&mem, 3); + CHECK(m0 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m0, "id")->valuedouble == 1, + "initialize id 1"); + CHECK(m2 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m2, "id")->valuedouble == 2, + "tools/list id 2"); + CHECK(m3 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m3, "id")->valuedouble == 3, + "tools/call id 3"); + if (m3) { + cJSON *params = cJSON_GetObjectItemCaseSensitive(m3, "params"); + cJSON *name = params ? cJSON_GetObjectItemCaseSensitive(params, "name") : NULL; + CHECK(cJSON_IsString(name) && strcmp(name->valuestring, "add") == 0, + "call params name"); + cJSON *args = params ? cJSON_GetObjectItemCaseSensitive(params, "arguments") : NULL; + CHECK(args && (long long)cJSON_GetObjectItemCaseSensitive(args, "a")->valuedouble == 3, + "call params a=3"); + } + cJSON_Delete(m0); + cJSON_Delete(m2); + cJSON_Delete(m3); + } + mcp_tools_free(tools, n); + mcp_client_free(c); + mem_free(&mem); +} + +static void test_server_error_is_surfaced(void) +{ + mcp_memory mem; + mcp_memory_init(&mem); + sl_push(&mem.responses, + xstrdup("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601," + "\"message\":\"Method not found\"}}")); + char *err = NULL; + mcp_client *c = mcp_connect_memory(&mem, &err); + err = NULL; + char *res = mcp_request(c, "initialize", "{}", &err); + CHECK(res == NULL, "request failed"); + CHECK(err && strstr(err, "server error -32601") != NULL, "error code"); + CHECK(err && strstr(err, "Method not found") != NULL, "error message"); + free(err); + mcp_client_free(c); + mem_free(&mem); +} + +static void test_notification_lines_are_skipped(void) +{ + mcp_memory mem; + mcp_memory_init(&mem); + sl_push(&mem.responses, xstrdup( + "{\"jsonrpc\":\"2.0\",\"method\":" + "\"notifications/message\",\"params\":{}}")); + push_resp(&mem, 1, "{\"ok\":true}"); + char *err = NULL; + mcp_client *c = mcp_connect_memory(&mem, &err); + err = NULL; + char *res = mcp_request(c, "ping", "{}", &err); + CHECK(res != NULL, "response past the notification"); + CHECK_CONT(res, "\"ok\":true", "result ok"); + free(res); + mcp_client_free(c); + mem_free(&mem); +} + +static void test_tool_invoke_roundtrip(void) +{ + mcp_memory mem; + mcp_memory_init(&mem); + push_resp(&mem, 1, + "{\"tools\":[{\"name\":\"echo\",\"description\":\"echo\"," + "\"inputSchema\":{\"type\":\"object\"}}]}"); + push_resp(&mem, 2, "{\"content\":[{\"type\":\"text\",\"text\":\"pong\"}]}"); + char *err = NULL; + mcp_client *c = mcp_connect_memory(&mem, &err); + + mcp_tool *tools = NULL; + size_t n = 0; + err = NULL; + CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools"); + CHECK(n == 1, "one tool"); + if (n == 1) { + CHECK_EQ(tools[0].name, "echo", "tool name"); + err = NULL; + char *out = mcp_tool_invoke(&tools[0], "{\"msg\":\"hi\"}", &err); + CHECK(out != NULL, "execute"); + CHECK_CONT(out, "pong", "server output"); + free(out); + } + mcp_tools_free(tools, n); + mcp_client_free(c); + mem_free(&mem); +} + +static void test_tool_invoke_wraps_errors(void) +{ + mcp_memory mem; + mcp_memory_init(&mem); + push_resp(&mem, 1, + "{\"tools\":[{\"name\":\"boom\",\"description\":\"\",\"inputSchema\":{\"type\":\"object\"}}]}"); + push_resp(&mem, 2, + "{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"kaboom\"}]}"); + char *err = NULL; + mcp_client *c = mcp_connect_memory(&mem, &err); + mcp_tool *tools = NULL; + size_t n = 0; + err = NULL; + CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools"); + if (n == 1) { + err = NULL; + char *out = mcp_tool_invoke(&tools[0], "{}", &err); + CHECK(out == NULL, "isError surfaces as failure"); + CHECK(err && strstr(err, "MCP tool `boom` failed") != NULL, + "wrapped error"); + CHECK(err && strstr(err, "kaboom") != NULL, "server text included"); + free(err); + } + mcp_tools_free(tools, n); + mcp_client_free(c); + mem_free(&mem); +} + +static void test_extract_json_plain_and_sse(void) +{ + char *err = NULL; + char *v = mcp_extract_json( + "application/json", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"a\":1}}", &err); + CHECK(v != NULL, "plain parses"); + CHECK_CONT(v, "\"a\":1", "plain result"); + free(v); + + const char *sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2," + "\"result\":{\"b\":2}}\n\n"; + err = NULL; + v = mcp_extract_json("text/event-stream", sse, &err); + CHECK(v != NULL, "sse parses"); + CHECK_CONT(v, "\"b\":2", "sse result"); + free(v); + + err = NULL; + v = mcp_extract_json("text/event-stream", "event: message\n\n", &err); + CHECK(v == NULL, "sse without data line fails"); + CHECK(err && strstr(err, "data:") != NULL, "sse error mentions data:"); + free(err); +} + +static void test_connect_all_collects_warnings(void) +{ + const char *names[] = {"good", "bad"}; + const char *configs[] = { + "{\"command\":\"/definitely/not/a/real/binary-llsh\"}", + "{\"foo\":\"bar\"}", + }; + strlist warnings; + sl_init(&warnings); + mcp_registry reg; + mcp_connect_all(names, configs, 2, ®, &warnings); + CHECK(reg.tool_count == 0, "no tools from unreachable servers"); + CHECK(warnings.len == 2, "one warning per failed server"); + if (warnings.len == 2) { + CHECK_CONT(warnings.items[0], "`good`", "good warning"); + CHECK_CONT(warnings.items[1], "`bad`", "bad warning"); + CHECK_CONT(warnings.items[1], "no `command` or `url`", "bad reason"); + } + for (size_t i = 0; i < warnings.len; i++) + free(warnings.items[i]); + free(warnings.items); + mcp_registry_free(®); +} + +/* ------------------------------------------------------------------ */ +/* read_line_fd (via mcp_read_line) -- buffered stdio reads */ +/* ------------------------------------------------------------------ */ + +/* Spawn `sh -c cmd` with stdout piped; returns the read end and the pid. */ +static int spawn_sh(const char *cmd, pid_t *out_pid) +{ + int pfd[2]; + if (pipe(pfd) != 0) + return -1; + pid_t pid = fork(); + if (pid < 0) { + close(pfd[0]); + close(pfd[1]); + return -1; + } + if (pid == 0) { + dup2(pfd[1], STDOUT_FILENO); + close(pfd[0]); + close(pfd[1]); + int dn = open("/dev/null", O_WRONLY); + if (dn >= 0) { + dup2(dn, STDERR_FILENO); + close(dn); + } + setpgid(0, 0); + execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); + _exit(127); + } + close(pfd[1]); + if (out_pid) + *out_pid = pid; + return pfd[0]; +} + +static void kill_sh(pid_t pid) +{ + if (pid <= 0) + return; + kill(-pid, SIGKILL); + int st; + waitpid(pid, &st, 0); +} + +/* A burst of lines arriving in one read(): the first line must come back + * alone, with the rest carried for the following calls. */ +static void test_read_line_burst(void) +{ + pid_t pid = 0; + int fd = spawn_sh("printf 'a\\nb\\nc\\n'", &pid); + CHECK(fd >= 0, "server spawned"); + char *err = NULL; + mcp_client *c = mcp_connect_fds(-1, fd, &err); + CHECK(c != NULL, "fd client created"); + char *l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "a") == 0, "first line of the burst"); + free(l); + err = NULL; + l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "b") == 0, "second line of the burst"); + free(l); + err = NULL; + l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "c") == 0, "third line of the burst"); + free(l); + err = NULL; + l = mcp_read_line(c, "t", &err); + CHECK(l == NULL, "EOF after the burst"); + CHECK(err && strstr(err, "server closed") != NULL, "EOF error message"); + free(err); + mcp_client_free(c); + kill_sh(pid); +} + +/* A line split across two read() calls must be reassembled from the carry + * buffer. */ +static void test_read_line_partial_across_reads(void) +{ + pid_t pid = 0; + int fd = spawn_sh("printf 'hel'; sleep 0.5; printf 'lo\\nworld\\n'", &pid); + CHECK(fd >= 0, "server spawned"); + char *err = NULL; + mcp_client *c = mcp_connect_fds(-1, fd, &err); + char *l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "hello") == 0, "line assembled across two reads"); + free(l); + err = NULL; + l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "world") == 0, "following line intact"); + free(l); + mcp_client_free(c); + kill_sh(pid); +} + +/* Timeout with bytes in flight: the partial line is what we got. */ +static void test_read_line_timeout_partial(void) +{ + setenv("LLSH_MCP_TIMEOUT", "1", 1); + pid_t pid = 0; + int fd = spawn_sh("printf 'partial'; sleep 5", &pid); + CHECK(fd >= 0, "server spawned"); + char *err = NULL; + mcp_client *c = mcp_connect_fds(-1, fd, &err); + char *l = mcp_read_line(c, "t", &err); + CHECK(l && strcmp(l, "partial") == 0, "partial line returned on timeout"); + free(l); + mcp_client_free(c); + kill_sh(pid); + unsetenv("LLSH_MCP_TIMEOUT"); +} + +/* Timeout with a silent server: an error, not a hang or a fake line. */ +static void test_read_line_timeout_empty(void) +{ + setenv("LLSH_MCP_TIMEOUT", "1", 1); + pid_t pid = 0; + int fd = spawn_sh("sleep 5", &pid); + CHECK(fd >= 0, "server spawned"); + char *err = NULL; + mcp_client *c = mcp_connect_fds(-1, fd, &err); + char *l = mcp_read_line(c, "t", &err); + CHECK(l == NULL, "no line when the server is silent"); + CHECK(err && strstr(err, "timed out") != NULL, "timeout error message"); + free(err); + mcp_client_free(c); + kill_sh(pid); + unsetenv("LLSH_MCP_TIMEOUT"); +} + +static void test_stdio_line_size_limit(void) +{ + setenv("LLSH_MCP_TIMEOUT", "3", 1); + pid_t pid = 0; + int fd = spawn_sh("head -c 4194305 /dev/zero", &pid); + CHECK(fd >= 0, "large-response server spawned"); + char *err = NULL; + mcp_client *c = mcp_connect_fds(-1, fd, &err); + char *line = mcp_read_line(c, "large", &err); + CHECK(line == NULL, "oversized MCP response line rejected"); + CHECK(err && strstr(err, "4 MiB limit"), "size-limit error returned"); + free(line); + free(err); + mcp_client_free(c); + kill_sh(pid); + unsetenv("LLSH_MCP_TIMEOUT"); +} + +/* ------------------------------------------------------------------ */ +/* mcp_connect_stdio -- real fork/pipe wiring */ +/* ------------------------------------------------------------------ */ + +/* The child must be able to write to stdout and the parent to read it + * back: the pipe ends were historically swapped, which made every stdio + * server fail. A child that answers on its own (and stays alive so the + * parent's write to its stdin does not hit EPIPE) exercises both ends. */ +static void test_connect_stdio_roundtrip(void) +{ + mcp_server_config cfg; + memset(&cfg, 0, sizeof cfg); + cfg.command = xstrdup("/bin/sh"); + cfg.args = (char **)xmalloc(2 * sizeof *cfg.args); + cfg.args[0] = xstrdup("-c"); + cfg.args[1] = + xstrdup("printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"pong\"}" + "\\n'; sleep 1"); + cfg.arg_count = 2; + + char *err = NULL; + mcp_client *c = mcp_connect_stdio(&cfg, &err); + CHECK(c != NULL, "stdio client spawned"); + if (!c) { + if (err) + free(err); + mcp_server_config_free(&cfg); + return; + } + char *res = mcp_request(c, "ping", "{}", &err); + CHECK(res != NULL, "request round-trips to the child's stdout"); + if (res) + CHECK(strstr(res, "pong") != NULL, "child response read back"); + free(res); + if (err) + free(err); + mcp_client_free(c); + mcp_server_config_free(&cfg); +} + +static void test_connect_stdio_sends_newline_terminated_request(void) +{ + setenv("LLSH_MCP_TIMEOUT", "2", 1); + mcp_server_config cfg; + memset(&cfg, 0, sizeof cfg); + cfg.command = xstrdup("/bin/sh"); + cfg.args = (char **)xmalloc(2 * sizeof *cfg.args); + cfg.args[0] = xstrdup("-c"); + cfg.args[1] = xstrdup( + "IFS= read -r req || exit 2; printf '{\"jsonrpc\":\"2.0\"," + "\"id\":1,\"result\":\"newline-ok\"}\\n'; sleep 1"); + cfg.arg_count = 2; + char *err = NULL; + mcp_client *c = mcp_connect_stdio(&cfg, &err); + CHECK(c != NULL, "stdio client spawned"); + char *res = c ? mcp_request(c, "ping", "{}", &err) : NULL; + CHECK(res && strstr(res, "newline-ok"), + "server receives complete newline-framed request"); + free(res); + free(err); + mcp_client_free(c); + mcp_server_config_free(&cfg); + unsetenv("LLSH_MCP_TIMEOUT"); +} + +int main(void) +{ + /* Writes to a pipe whose server already died (e.g. a failed exec) must + * surface as EPIPE, not kill the process with SIGPIPE. */ + signal(SIGPIPE, SIG_IGN); + test_parses_stdio_config(); + test_parses_http_config(); + test_rejects_no_transport(); + test_handshake_discovery_call(); + test_server_error_is_surfaced(); + test_notification_lines_are_skipped(); + test_tool_invoke_roundtrip(); + test_tool_invoke_wraps_errors(); + test_extract_json_plain_and_sse(); + test_connect_all_collects_warnings(); + test_read_line_burst(); + test_read_line_partial_across_reads(); + test_read_line_timeout_partial(); + test_read_line_timeout_empty(); + test_stdio_line_size_limit(); + test_connect_stdio_roundtrip(); + test_connect_stdio_sends_newline_terminated_request(); + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_provider.cpp b/tests/test_provider.cpp new file mode 100644 index 0000000..7bc983d --- /dev/null +++ b/tests/test_provider.cpp @@ -0,0 +1,647 @@ +#include "provider.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "config.h" +#include "dotagents.h" +#include "message.h" +#include "session.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (found unwanted `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle)); \ + } \ + } while (0) + +static const char *SENTINEL = "SENTINEL-PREAMBLE-XYZ"; + +static void mock_destroy(llsh_model *m) { (void)m; } +static llsh_model MOCK = {NULL, NULL, mock_destroy}; + +static session *make_session(const char *provider) { + config c; + memset(&c, 0, sizeof c); + c.provider = provider_parse(provider, NULL); + c.model = xstrdup("test-model"); + c.preamble = xstrdup(SENTINEL); + dotagents d; + memset(&d, 0, sizeof d); + return session_new_with_model(&MOCK, c, d); +} + +/* The preamble must reach the wire for every provider. Before the fix it was + * only emitted for Anthropic and silently dropped for OpenAI/Ollama. */ +static void test_preamble_openai(void) { + session *s = make_session("openai"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "openai body built"); + /* OpenAI carries the system prompt as a leading system message. */ + CHECK_CONT(body, "\"role\":\"system\"", "has a system message"); + CHECK_CONT(body, SENTINEL, "system message carries the preamble"); + CHECK_CONT(body, "\"role\":\"user\"", "user message still present"); + CHECK_CONT(body, "hello", "prompt present"); + free(body); + session_free(s); +} + +static void test_preamble_ollama(void) { + session *s = make_session("ollama"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "ollama body built"); + /* Ollama carries the system prompt as a top-level `system` field. */ + CHECK_CONT(body, "\"system\":", "has a top-level system field"); + CHECK_CONT(body, SENTINEL, "system field carries the preamble"); + CHECK_CONT(body, "hello", "prompt present"); + free(body); + session_free(s); +} + +static void test_preamble_anthropic(void) { + session *s = make_session("anthropic"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "anthropic body built"); + CHECK_CONT(body, "\"system\":", "has a system field"); + CHECK_CONT(body, SENTINEL, "system carries the preamble"); + free(body); + session_free(s); +} + +/* A model name with JSON-special characters must not corrupt the body. */ +static void test_model_name_escaped(void) { + config c; + memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("we\"ird/model"); + c.preamble = xstrdup(SENTINEL); + dotagents d; + memset(&d, 0, sizeof d); + session *s = session_new_with_model(&MOCK, c, d); + char *body = provider_preview_request(s, "hi", false); + CHECK(body != NULL, "body built"); + /* The raw `"` in the model name must be escaped to \". */ + CHECK_CONT(body, "we\\\"ird/model", "model name quote is escaped"); + free(body); + session_free(s); +} + +static void test_no_preamble_omits_system(void) { + config c; + memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("m"); + c.preamble = NULL; /* no preamble */ + dotagents d; + memset(&d, 0, sizeof d); + session *s = session_new_with_model(&MOCK, c, d); + char *body = provider_preview_request(s, "hi", false); + CHECK(body != NULL, "body built"); + CHECK_NOT_CONT(body, "\"role\":\"system\"", "no system message without preamble"); + free(body); + session_free(s); +} + +/* A turn stopped mid tool-loop leaves an assistant message with more tool + * calls than the tool-result messages that follow it. The trim must drop the + * surplus calls so the history stays a valid request. */ +static void test_trim_dangling_calls(void) { + message am = msg_assistant_text("working on it"); + msg_push_assistant_tool_call(am, "read_file", "{\"path\":\"a\"}"); + msg_push_assistant_tool_call(am, "write_file", "{\"path\":\"b\"}"); + message r1 = msg_user_tool_result("call-1", "read_file", "contents"); + message msgs[2] = {am, r1}; + + CHECK(am.assistant().parts.size() == 3, + "assistant has text + 2 calls before trim"); + provider_trim_dangling_calls(msgs, 2); + int calls = 0; + for (size_t i = 0; i < msgs[0].assistant().parts.size(); i++) + if (std::holds_alternative<asst_tool_call_part>( + msgs[0].assistant().parts[i])) + calls++; + CHECK(calls == 1, "surplus call trimmed to match the single result"); + + /* Fully-answered calls must be untouched. */ + message am2 = msg_assistant_text(""); + msg_push_assistant_tool_call(am2, "read_file", "{}"); + message ra = msg_user_tool_result("c1", "read_file", "x"); + message rb = msg_user_tool_result("c2", "write_file", "ok"); + msg_push_assistant_tool_call(am2, "write_file", "{}"); + message msgs2[3] = {am2, ra, rb}; + provider_trim_dangling_calls(msgs2, 3); + int calls2 = 0; + for (size_t i = 0; i < msgs2[0].assistant().parts.size(); i++) + if (std::holds_alternative<asst_tool_call_part>( + msgs2[0].assistant().parts[i])) + calls2++; + CHECK(calls2 == 2, "complete call/result pair is preserved"); +} + +/* Reasoning models return the chain of thought in provider-specific fields. + * None of the parsers extracted it before the fix, so it was never displayed. */ +static void test_reasoning_openai(void) { + const char *body = + "{\"choices\":[{\"message\":{\"content\":\"42\"," + "\"reasoning_content\":\"think... think... 42\"}}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OPENAI; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "openai body parses"); + CHECK_CONT(comp.text, "42", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted (was NULL before fix)"); + CHECK_CONT(comp.reasoning, "think...", "reasoning content preserved"); + llm_completion_free(&comp); +} + +static void test_reasoning_ollama(void) { + const char *body = + "{\"message\":{\"content\":\"the answer\"," + "\"thinking\":\"pondering the answer...\"}}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OLLAMA; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "ollama body parses"); + CHECK_CONT(comp.text, "the answer", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted from message.thinking"); + CHECK_CONT(comp.reasoning, "pondering", "reasoning content preserved"); + llm_completion_free(&comp); +} + +static void test_reasoning_anthropic(void) { + const char *body = + "{\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm, let me " + "reason...\"},{\"type\":\"text\",\"text\":\"done\"}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_ANTHROPIC; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "anthropic body parses"); + CHECK_CONT(comp.text, "done", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted from thinking block"); + CHECK_CONT(comp.reasoning, "reason", "reasoning content preserved"); + llm_completion_free(&comp); +} + +/* A plain (non-reasoning) response must not fabricate a reasoning field. */ +static void test_no_reasoning_when_absent(void) { + const char *body = + "{\"choices\":[{\"message\":{\"content\":\"just an answer\"}}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OPENAI; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "plain body parses"); + CHECK_CONT(comp.text, "just an answer", "answer text extracted"); + CHECK(comp.reasoning == NULL, "no reasoning field fabricated"); + llm_completion_free(&comp); +} + +/* ------------------------------------------------------------------ */ +/* sse_parse_line -- the pure streaming unit (AUDIT phase 2 test seam) */ +/* ------------------------------------------------------------------ */ + +/* OpenAI: text deltas, two interleaved multi-chunk tool calls, + * finish_reason, [DONE], usage. */ +static void test_sse_openai(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}", &a); + CHECK(a.text.len == 5, "text deltas accumulate"); + CHECK_CONT(a.text.data, "Hello", "text content"); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"read_file\"," + "\"arguments\":\"{\\\"pa\"}}]}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"grep\"," + "\"arguments\":\"{\\\"pat\"}}]}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}", + &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":1,\"function\":{\"arguments\":\"tern\\\":\\\"x\\\"}\"}}]}}]}", + &a); + CHECK(a.tc_len == 2, "two tool calls indexed"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "c1") == 0, "call 0 id"); + CHECK(a.tc_id[1] && strcmp(a.tc_id[1], "c2") == 0, "call 1 id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "read_file") == 0, "call 0 name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\"a.txt\"", "call 0 args assembled"); + CHECK_CONT(a.tc_args[1].data, "\"pattern\":\"x\"", "call 1 args assembled"); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", + &a); + CHECK(a.done, "finish_reason marks the stream done"); + llm_sse_parse_line(LLM_OPENAI, "data: [DONE]", &a); + llm_sse_parse_line(LLM_OPENAI, "data: {\"usage\":{\"prompt_tokens\":42}}", &a); + CHECK(a.input_tokens == 42, "usage.prompt_tokens captured"); + llm_sse_acc_free(&a); +} + +/* Anthropic: event:/data: pairing, thinking + text blocks, a tool_use + * block with chunked input_json_delta, message_start usage, message_stop. */ +static void test_sse_anthropic(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_ANTHROPIC); + llm_sse_parse_line(LLM_ANTHROPIC, "event: message_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"message\":{\"usage\":{\"input_tokens\":7}}}", &a); + CHECK(a.input_tokens == 7, "usage from message_start"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"thinking\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm \"}}", + &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"text\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}", &a); + CHECK_CONT(a.reasoning.data, "hmm", "thinking_delta captured"); + CHECK_CONT(a.text.data, "ok", "text_delta captured"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\"," + "\"name\":\"write_file\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"{\\\"pa\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"th\\\":\\\"x\\\"}\"}}", &a); + CHECK(a.tc_len == 1, "one tool_use block"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "t1") == 0, "tool_use id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "write_file") == 0, "tool_use name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\"x\"", "chunked partial_json assembled"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: message_stop", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "data: [DONE]", &a); + CHECK(a.done, "message_stop marks done"); + llm_sse_acc_free(&a); +} + +/* Ollama NDJSON: content + thinking chunks, prompt_eval_count, tool_calls + * on the done chunk (object arguments), done:true. */ +static void test_sse_ollama(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OLLAMA); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"He\"}}", &a); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"thinking\":\"think\"}}", &a); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"llo\"}}", &a); + CHECK_CONT(a.text.data, "Hello", "content chunks accumulate"); + CHECK_CONT(a.reasoning.data, "think", "thinking chunk captured"); + llm_sse_parse_line(LLM_OLLAMA, "{\"prompt_eval_count\":9}", &a); + CHECK(a.input_tokens == 9, "prompt_eval_count captured"); + llm_sse_parse_line(LLM_OLLAMA, + "{\"message\":{\"tool_calls\":[{\"id\":\"o1\",\"function\":" + "{\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}}]}," + "\"done\":true}", &a); + CHECK(a.tc_len == 1, "tool call on the done chunk"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "o1") == 0, "tool id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "list_dir") == 0, "tool name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\".\"", "object args serialized"); + CHECK(a.done, "done:true marks done"); + llm_sse_acc_free(&a); +} + +/* Lines outside the protocol (stray text, bad JSON) are ignored without + * corrupting the accumulator. */ +static void test_sse_noise_lines(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_sse_parse_line(LLM_OPENAI, "event: ping", &a); /* not `data:` -> ignored */ + llm_sse_parse_line(LLM_OPENAI, "data: not-json", &a); /* bad JSON -> ignored */ + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[]}", &a); /* empty choices */ + CHECK(a.text.len == 0 && a.tc_len == 0 && !a.done, "noise left acc empty"); + llm_sse_acc_free(&a); + + llm_sse_acc b; + llm_sse_acc_init(&b, LLM_ANTHROPIC); + llm_sse_parse_line(LLM_ANTHROPIC, "data: {\"no\":\"event\"}", &b); /* no event set */ + llm_sse_parse_line(LLM_ANTHROPIC, "data: {", &b); /* truncated JSON */ + CHECK(b.text.len == 0 && b.tc_len == 0 && !b.done, "anthropic noise ignored"); + llm_sse_acc_free(&b); + + llm_sse_acc c; + llm_sse_acc_init(&c, LLM_OLLAMA); + llm_sse_parse_line(LLM_OLLAMA, "hello", &c); /* not JSON */ + llm_sse_parse_line(LLM_OLLAMA, "{}", &c); /* no message field */ + CHECK(c.text.len == 0 && c.tc_len == 0 && !c.done, "ollama noise ignored"); + llm_sse_acc_free(&c); +} + +/* sse_acc_into: unnamed/unargued calls get default ids and `{}` args. */ +static void test_sse_acc_into_defaults(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OLLAMA); + llm_acc_push_tool(&a, NULL, NULL); + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&a, &c); + CHECK(c.tc_len == 1, "one materialized call"); + CHECK(c.tc_id[0] && strstr(c.tc_id[0], "call-1") != NULL, "default id"); + CHECK(c.tc_args[0] && strcmp(c.tc_args[0], "{}") == 0, "default empty args"); + llm_completion_free(&c); + llm_sse_acc_free(&a); +} + +/* sse_acc_into: non-empty text/reasoning transfer ownership to `c` and must + * survive completion_free + sse_acc_free without a double-free. Regression: + * the old code freed the stolen buffer right after handing it to `c`, + * dangling c->text/c->reasoning (crashed on real streamed responses). */ +static void test_sse_acc_into_text_reasoning_ownership(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_buf_put(&a.text, "the answer is 42"); + llm_buf_put(&a.reasoning, "pondering deeply"); + llm_acc_push_tool(&a, xstrdup("call-9"), xstrdup("read_file")); + llm_buf_put(&a.tc_args[0], "\"path\":\"AUDIT.md\""); + + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&a, &c); + CHECK(c.text && strcmp(c.text, "the answer is 42") == 0, + "text transferred intact"); + CHECK(c.reasoning && strcmp(c.reasoning, "pondering deeply") == 0, + "reasoning transferred intact"); + CHECK(c.tc_len == 1 && c.tc_args[0] && + strcmp(c.tc_args[0], "\"path\":\"AUDIT.md\"") == 0, + "tool args transferred intact"); + /* Both frees must be safe: `c` owns the strings now, `a` is reset. */ + llm_completion_free(&c); + llm_sse_acc_free(&a); +} + +/* ================= llm_params -> request body ================= + * The `llm` tool threads llm_params (temperature, max_tokens, reasoning + * effort, thinking budget, response schema) into the wire body. These are + * pure request-builder tests (no network) per provider. */ + +static llm_message one_user(const char *text) { + static llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = text; + llm_message m; + memset(&m, 0, sizeof m); + m.role = LLM_ROLE_USER; + m.parts = ∂ + m.part_count = 1; + return m; +} + +static void test_params_openai(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OPENAI; + c.model = "test-model"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.5; + p.max_tokens = 256; + p.reasoning_effort = "high"; + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "openai body built"); + if (body) { + CHECK_CONT(body, "\"temperature\":0.5", "openai temperature"); + CHECK_CONT(body, "\"max_tokens\":256", "openai max_tokens"); + CHECK_CONT(body, "\"reasoning_effort\":\"high\"", + "openai reasoning_effort"); + CHECK_CONT(body, "\"response_format\":{\"type\":\"json_schema\"", + "openai response_format"); + CHECK_CONT(body, "\"type\":\"object\"", "openai schema inlined"); + free(body); + } +} + +static void test_params_ollama(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OLLAMA; + c.model = "ollama-model"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.75; /* 3/4: exact in binary, so %.6g is stable */ + p.max_tokens = 128; + p.reasoning_effort = "medium"; + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p); + CHECK(body != NULL, "ollama body built"); + if (body) { + CHECK_CONT(body, "\"system\":\"SYS\"", "ollama top-level system"); + CHECK_CONT(body, "\"options\":{\"temperature\":0.75", + "ollama options.temperature"); + CHECK_CONT(body, "\"num_predict\":128", "ollama num_predict"); + CHECK_CONT(body, "\"think\":true", "ollama think enabled"); + CHECK_CONT(body, "\"format\":", "ollama format field"); + CHECK_CONT(body, "\"type\":\"object\"", "ollama schema"); + free(body); + } +} + +static void test_params_anthropic_thinking(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.thinking_budget = 16384; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "\"thinking\":{\"type\":\"enabled\"," + "\"budget_tokens\":16384}", + "anthropic thinking budget"); + /* max_tokens must be bumped above the thinking budget. */ + CHECK_CONT(body, "\"max_tokens\":17408", + "anthropic max_tokens > budget"); + free(body); + } +} + +static void test_params_anthropic_effort(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.reasoning_effort = "low"; /* maps to a 1024-token budget */ + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) + CHECK_CONT(body, "\"budget_tokens\":1024", + "effort 'low' maps to a 1024 budget"); + free(body); +} + +static void test_params_anthropic_temperature(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.25; /* 1/4: exact in binary, no thinking budget */ + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "\"temperature\":0.25", "anthropic temperature"); + CHECK_NOT_CONT(body, "\"thinking\"", + "no thinking block without a budget"); + free(body); + } +} + +static void test_params_anthropic_schema(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "SYS", "preamble kept"); + CHECK_CONT(body, "JSON Schema", "schema instruction appended"); + /* The schema is JSON-encoded into the system string, so its quotes + * are escaped in the body. */ + CHECK_CONT(body, "\\\"type\\\"", "schema text embedded (escaped)"); + CHECK_NOT_CONT(body, "response_format", + "anthropic has no response_format"); + free(body); + } +} + +static void test_params_unset(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OPENAI; + c.model = "m"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, NULL); + CHECK(body != NULL, "body built with NULL params"); + if (body) { + CHECK_NOT_CONT(body, "temperature", "no temperature when unset"); + CHECK_NOT_CONT(body, "max_tokens", "no max_tokens when unset"); + CHECK_NOT_CONT(body, "response_format", + "no response_format when unset"); + free(body); + } +} + +/* ================= `compact` built-in tool ================= + * session_force_compact is the dispatch target for the `compact` tool call. */ + +static void test_force_compact_short(void) { + session *s = make_session("openai"); + session_push_history(s, msg_user_text("hello")); + session_push_history(s, msg_assistant_text("hi")); + compact_report rep; + memset(&rep, 0, sizeof rep); + char *err = NULL; + int rc = session_force_compact(s, &rep, &err); + /* user+assistant only: keep-boundary at index 0 -> no pass can run. */ + CHECK(rc == 0, "no pass on a too-short history"); + free(err); + session_free(s); +} + +static void test_force_compact_clears(void) { + session *s = make_session("openai"); + session_push_history(s, msg_user_text("read the file")); + session_push_history(s, msg_assistant_tool_call("read_file", "{}")); + session_push_history(s, msg_user_tool_result("c1", "read_file", + "a lot of cleared content")); + session_push_history(s, msg_assistant_text("done reading")); + session_push_history(s, msg_user_text("now edit it")); + + compact_report rep; + memset(&rep, 0, sizeof rep); + char *err = NULL; + int rc = session_force_compact(s, &rep, &err); + CHECK(rc == 1, "compaction pass ran"); + CHECK(rep.tier == TIER_FALLBACK_CLEAR, + "no summarizer -> fallback clear tier"); + CHECK(rep.cleared_tool_results >= 1, "stale tool result cleared"); + free(err); + session_free(s); +} + +int main(void) { + test_preamble_openai(); + test_preamble_ollama(); + test_preamble_anthropic(); + test_model_name_escaped(); + test_no_preamble_omits_system(); + test_trim_dangling_calls(); + test_reasoning_openai(); + test_reasoning_ollama(); + test_reasoning_anthropic(); + test_no_reasoning_when_absent(); + test_sse_openai(); + test_sse_anthropic(); + test_sse_ollama(); + test_sse_noise_lines(); + test_sse_acc_into_defaults(); + test_sse_acc_into_text_reasoning_ownership(); + test_params_openai(); + test_params_ollama(); + test_params_anthropic_thinking(); + test_params_anthropic_effort(); + test_params_anthropic_temperature(); + test_params_anthropic_schema(); + test_params_unset(); + test_force_compact_short(); + test_force_compact_clears(); + printf("provider: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_quine.c b/tests/test_quine.c new file mode 100644 index 0000000..c010539 --- /dev/null +++ b/tests/test_quine.c @@ -0,0 +1,27 @@ +/* test_quine.c — a C quine: running it prints its own source. + * + * How it works: the file is a fixed point of a "quinate" function. + * The two strings below are that function's data; everything around + * them is code that re-emits the data verbatim, spliced back into the + * same program shape. No argv, no /proc/self, no tricks — just two + * printf calls. + * + * Build: musl-gcc -std=gnu11 -Oz -Wall -Wextra -o test_quine tests/test_quine.c + * Check: ./test_quine | diff - tests/test_quine.c (no output = quine) */ + +#include <stdio.h> + +int main(void) { + const char *s = + "const char *s = \"%s\";\n" + "const char *f = \"%s\";\n" + "printf(f, s, s, f);\n" + "return 0;\n"; + const char *f = + "int main(void) {\n" + " %s printf(f, s, s, f);\n" + " return 0;\n" + "}\n"; + printf(f, s, s, f); + return 0; +} diff --git a/tests/test_session.cpp b/tests/test_session.cpp new file mode 100644 index 0000000..7b893f4 --- /dev/null +++ b/tests/test_session.cpp @@ -0,0 +1,1659 @@ +#include "session.h" + +#include <atomic> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +/* ------------------------------------------------------------------ */ +/* Harness */ +/* ------------------------------------------------------------------ */ + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, \ + "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle), hay); \ + } \ + } while (0) + +#define CHECK_EQ_U64(got, want, msg) \ + do { \ + checks++; \ + if ((unsigned long long)(got) != (unsigned long long)(want)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n", \ + __FILE__, __LINE__, (msg), (unsigned long long)(got), \ + (unsigned long long)(want)); \ + } \ + } while (0) + +/* ------------------------------------------------------------------ */ +/* Mock model */ +/* ------------------------------------------------------------------ */ + +typedef struct mock_model { + llsh_model m; + /* stream */ + int results[8]; + size_t nresults; + size_t calls; + char *assistant; + unsigned long input_tokens; + int http_status; + int transport; + unsigned long retry_after; + char *errmsg; + /* complete */ + char *reply; + int complete_fail; + char *complete_errmsg; + size_t complete_calls; +} mock_model; + +static char *mock_complete(const llsh_model *mm, const char *prompt, + sess_error *err) { + (void)prompt; + mock_model *m = (mock_model *)mm; + m->complete_calls++; + if (m->complete_fail) { + if (err) { + err->message = xstrdup(m->complete_errmsg + ? m->complete_errmsg + : "boom"); + err->http_status = 500; + err->transport = 0; + err->retry_after_secs = 0; + } + return NULL; + } + return xstrdup(m->reply ? m->reply : ""); +} + +static int mock_stream(const llsh_model *mm, const message *history, + size_t nhistory, const char *prompt, sess_emit_cb cb, + void *user, std::atomic<bool> *stop, + char **assistant_text, unsigned long *input_tokens, + message **new_messages, size_t *n_new, sess_error *err) { + (void)history; + (void)nhistory; + (void)prompt; + (void)stop; + mock_model *m = (mock_model *)mm; + size_t idx = m->calls < m->nresults ? m->calls : m->nresults - 1; + int result = m->results[idx]; + m->calls++; + if (new_messages) + *new_messages = NULL; + if (n_new) + *n_new = 0; + if (result == SESS_STREAM_OK || result == SESS_STREAM_STOPPED) { + if (result == SESS_STREAM_OK) { + if (cb) { + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = SESS_EMIT_TEXT; + ev.text = xstrdup(m->assistant ? m->assistant : ""); + cb(&ev, user); + sess_emit_free(&ev); + } + if (assistant_text) + *assistant_text = xstrdup(m->assistant ? m->assistant : ""); + if (input_tokens) + *input_tokens = m->input_tokens; + } + return result; + } + if (err) { + err->message = xstrdup(m->errmsg ? m->errmsg : "provider said no"); + err->http_status = m->http_status; + err->transport = m->transport; + err->retry_after_secs = m->retry_after; + } + return result; +} + +static void mock_destroy(llsh_model *self); + +static mock_model *mk_model(void) { + mock_model *m = (mock_model* ) xmalloc(sizeof *m); + memset(m, 0, sizeof *m); + m->m.complete = mock_complete; + m->m.stream = mock_stream; + m->m.destroy = mock_destroy; + return m; +} + +static void mock_set_stream(mock_model *m, const int *results, size_t n, + const char *assistant, unsigned long input_tokens) { + size_t k = n < 8 ? n : 8; + for (size_t i = 0; i < k; i++) + m->results[i] = results[i]; + m->nresults = k ? k : 1; + if (k == 0) + m->results[0] = SESS_STREAM_OK; + free(m->assistant); + m->assistant = xstrdup(assistant ? assistant : ""); + m->input_tokens = input_tokens; +} + +static mock_model *mk_complete(const char *reply, int fail, + const char *errmsg) { + mock_model *m = mk_model(); + m->reply = xstrdup(reply ? reply : ""); + m->complete_fail = fail; + m->complete_errmsg = xstrdup(errmsg ? errmsg : "boom"); + return m; +} + +static void mock_destroy(llsh_model *self) { + mock_model *m = (mock_model *)self; + free(m->assistant); + free(m->errmsg); + free(m->reply); + free(m->complete_errmsg); + free(m); +} + +/* ------------------------------------------------------------------ */ +/* Fixtures */ +/* ------------------------------------------------------------------ */ + +static config base_config(void) { + config c; + memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("gpt-test"); + c.api_key = xstrdup("sk-test"); + c.max_retries = 3; + c.context_limit = 0; /* compaction off by default */ + return c; +} + +static dotagents empty_dot(void) { + dotagents d; + memset(&d, 0, sizeof d); + return d; +} + +static dotagents dot_with_reviewer(void) { + dotagents d = empty_dot(); + d.sub_agent_count = 1; + d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent)); + memset(&d.sub_agents[0], 0, sizeof(sub_agent)); + d.sub_agents[0].id = xstrdup("reviewer"); + d.sub_agents[0].name = xstrdup("Reviewer"); + d.sub_agents[0].description = xstrdup("Reviews code"); + d.sub_agents[0].role = xstrdup("reviewer"); + d.sub_agents[0].enabled = true; + d.sub_agents[0].body = xstrdup("You are a reviewer."); + d.sub_agents[0].layer = LAYER_WORKSPACE; + d.sub_agents[0].config = NULL; + return d; +} + +static dotagents dot_with_profile_config(void) { + dotagents d = empty_dot(); + d.sub_agent_count = 1; + d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent)); + memset(&d.sub_agents[0], 0, sizeof(sub_agent)); + d.sub_agents[0].id = xstrdup("rev"); + d.sub_agents[0].name = xstrdup("Rev"); + d.sub_agents[0].description = xstrdup(""); + d.sub_agents[0].role = xstrdup("rev"); + d.sub_agents[0].enabled = true; + d.sub_agents[0].body = xstrdup("You are a reviewer."); + d.sub_agents[0].layer = LAYER_WORKSPACE; + d.sub_agents[0].config = xstrdup( + "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\"}"); + return d; +} + +/* A profile that overrides the model/provider AND restricts the allowed + * built-in tools via its config JSON. */ +static dotagents dot_with_profile_tools(void) { + dotagents d = empty_dot(); + d.sub_agent_count = 1; + d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent)); + memset(&d.sub_agents[0], 0, sizeof(sub_agent)); + d.sub_agents[0].id = xstrdup("rev"); + d.sub_agents[0].name = xstrdup("Rev"); + d.sub_agents[0].description = xstrdup(""); + d.sub_agents[0].role = xstrdup("rev"); + d.sub_agents[0].enabled = true; + d.sub_agents[0].body = xstrdup("You are a reviewer."); + d.sub_agents[0].layer = LAYER_WORKSPACE; + d.sub_agents[0].config = xstrdup( + "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\"," + "\"tools\":[\"read_file\",\"grep\"]}"); + return d; +} + +static dotagents dot_with_presets(void) { + dotagents d = empty_dot(); + d.has_models = true; + d.models.preset_count = 2; + d.models.presets = (model_preset* ) xmalloc(2 * sizeof(model_preset)); + d.models.presets[0].name = xstrdup("fast"); + d.models.presets[0].provider = xstrdup("openai"); + d.models.presets[0].model = xstrdup("m-fast"); + d.models.presets[0].base_url = xstrdup("https://fast.example"); + d.models.presets[0].api_key = xstrdup("kfast"); + d.models.presets[0].has_max_turns = false; + d.models.presets[0].has_context_limit = false; + d.models.presets[1].name = xstrdup("badprov"); + d.models.presets[1].provider = xstrdup("wibbly"); + d.models.presets[1].model = xstrdup("m-bad"); + d.models.presets[1].base_url = xstrdup("https://x"); + d.models.presets[1].api_key = xstrdup("kb"); + d.models.presets[1].has_max_turns = false; + d.models.presets[1].has_context_limit = false; + return d; +} + +/* Text of the first text-bearing part of a user/assistant message (or NULL). */ +static const char *first_text(const message *m) { + if (!m) + return NULL; + if (m->is_user() && !m->user().parts.empty()) { + const user_part &p = m->user().parts[0]; + if (const user_text_part *t = std::get_if<user_text_part>(&p)) + return t->text.c_str(); + if (const user_tool_result_part *r = + std::get_if<user_tool_result_part>(&p)) + if (!r->content.empty()) + if (const trc_text *tt = std::get_if<trc_text>(&r->content[0])) + return tt->text.c_str(); + } else if (m->is_assistant() && !m->assistant().parts.empty()) { + const assistant_part &p = m->assistant().parts[0]; + if (const asst_text_part *t = std::get_if<asst_text_part>(&p)) + return t->text.c_str(); + if (const asst_reasoning_part *r = std::get_if<asst_reasoning_part>(&p)) + return r->text.c_str(); + } + return NULL; +} + +/* A history with a stale tool result before a pure-text user boundary. + * layout: [user"first", user tool_result, user"now", assistant"b"] */ +static void push_compact_hist(session *s) { + session_push_history(s, msg_user_text("first")); + char *big = (char* ) xmalloc(600 + 1); + memset(big, 'x', 600); + big[600] = '\0'; + message tr = msg_user_tool_result("c1", "read_file", big); + free(big); + session_push_history(s, tr); + session_push_history(s, msg_user_text("now")); + session_push_history(s, msg_assistant_text("b")); +} + +/* ------------------------------------------------------------------ */ +/* Pure helpers */ +/* ------------------------------------------------------------------ */ + +static void test_is_transient_status(void) { + CHECK(sess_is_transient_status(408), "408 transient"); + CHECK(sess_is_transient_status(429), "429 transient"); + CHECK(sess_is_transient_status(500), "500 transient"); + CHECK(sess_is_transient_status(502), "502 transient"); + CHECK(sess_is_transient_status(503), "503 transient"); + CHECK(sess_is_transient_status(504), "504 transient"); + CHECK(sess_is_transient_status(529), "529 transient"); + CHECK(!sess_is_transient_status(400), "400 not transient"); + CHECK(!sess_is_transient_status(401), "401 not transient"); + CHECK(!sess_is_transient_status(403), "403 not transient"); + CHECK(!sess_is_transient_status(404), "404 not transient"); + CHECK(!sess_is_transient_status(418), "418 not transient"); + CHECK(!sess_is_transient_status(0), "0 not transient"); +} + +static void test_backoff_secs(void) { + CHECK_EQ_U64(sess_backoff_secs(1), 1, "attempt 1 -> 1s"); + CHECK_EQ_U64(sess_backoff_secs(2), 2, "attempt 2 -> 2s"); + CHECK_EQ_U64(sess_backoff_secs(3), 4, "attempt 3 -> 4s"); + CHECK_EQ_U64(sess_backoff_secs(4), 8, "attempt 4 -> 8s"); + CHECK_EQ_U64(sess_backoff_secs(5), 16, "attempt 5 -> 16s"); + CHECK_EQ_U64(sess_backoff_secs(6), 30, "attempt 6 -> 30s cap"); + CHECK_EQ_U64(sess_backoff_secs(7), 30, "attempt 7 -> 30s cap"); + CHECK_EQ_U64(sess_backoff_secs(10), 30, "attempt 10 -> 30s cap"); +} + +static void test_human_secs(void) { + CHECK_EQ_U64(sess_human_secs(0), 1, "0 -> 1"); + CHECK_EQ_U64(sess_human_secs(1), 1, "1 -> 1"); + CHECK_EQ_U64(sess_human_secs(5), 5, "5 -> 5"); +} + +static void test_classify_retry(void) { + sess_error e; + + CHECK(sess_classify_retry(NULL) == NULL, "NULL -> no retry"); + + e.message = xstrdup("boom"); + e.http_status = 429; + e.transport = 0; + e.retry_after_secs = 120; + sess_retry *r = sess_classify_retry(&e); + CHECK(r != NULL, "429 -> retryable"); + CHECK_CONT(r->reason, "HTTP 429", "reason names the status"); + CHECK_EQ_U64(r->retry_after_secs, 60, "retry-after capped at 60"); + CHECK(r->has_retry_after, "retry-after present"); + sess_retry_free(r); + free(e.message); + + e.message = xstrdup("nope"); + e.http_status = 400; + e.transport = 0; + e.retry_after_secs = 0; + CHECK(sess_classify_retry(&e) == NULL, "400 -> permanent"); + free(e.message); + + e.message = xstrdup("conn reset"); + e.http_status = 0; + e.transport = 1; + e.retry_after_secs = 0; + sess_retry *r2 = sess_classify_retry(&e); + CHECK(r2 != NULL, "transport -> retryable"); + CHECK_CONT(r2->reason, "transient connection error", "transport reason"); + CHECK(!r2->has_retry_after, "transport has no retry-after"); + sess_retry_free(r2); + free(e.message); + + e.message = xstrdup("plain"); + e.http_status = 0; + e.transport = 0; + e.retry_after_secs = 0; + CHECK(sess_classify_retry(&e) == NULL, "no status/transport -> no retry"); + free(e.message); +} + +static void test_context_overflow_detection(void) { + sess_error e; + + e.message = xstrdup( + "request (143063 tokens) exceeds the available context size (131072 " + "tokens), try increasing it"); + e.http_status = 400; + e.transport = 0; + CHECK(sess_error_is_context_overflow(&e), + "400 + `exceeds the available context size` -> overflow"); + free(e.message); + + e.message = xstrdup("maximum context length of 131072 tokens exceeded"); + e.http_status = 400; + e.transport = 0; + CHECK(sess_error_is_context_overflow(&e), + "400 + `maximum context length` -> overflow"); + free(e.message); + + e.message = xstrdup("Request entity too large"); + e.http_status = 413; + e.transport = 0; + CHECK(sess_error_is_context_overflow(&e), "413 -> overflow"); + free(e.message); + + e.message = xstrdup("bad request"); + e.http_status = 400; + e.transport = 0; + CHECK(!sess_error_is_context_overflow(&e), "400 without hint -> not"); + free(e.message); + + e.message = xstrdup("exceeded budget"); + e.http_status = 500; + e.transport = 0; + CHECK(!sess_error_is_context_overflow(&e), "5xx -> not overflow"); + free(e.message); + + e.message = xstrdup("connection reset"); + e.http_status = 0; + e.transport = 1; + CHECK(!sess_error_is_context_overflow(&e), "transport -> not overflow"); + free(e.message); + + e.message = NULL; + e.http_status = 400; + e.transport = 0; + CHECK(!sess_error_is_context_overflow(&e), "no message -> not overflow"); +} + +static void test_assistant_emit(void) { + sess_emit ev; + + CHECK(sess_assistant_emit("text", "hello", NULL, &ev) == 0, "text ok"); + CHECK(ev.kind == SESS_EMIT_TEXT, "text kind"); + CHECK(ev.text && strcmp(ev.text, "hello") == 0, "text body"); + sess_emit_free(&ev); + + CHECK(sess_assistant_emit("reasoning", "hmm", NULL, &ev) == 0, + "reasoning ok"); + CHECK(ev.kind == SESS_EMIT_REASONING, "reasoning kind"); + CHECK(ev.text && strcmp(ev.text, "hmm") == 0, "reasoning body"); + sess_emit_free(&ev); + + CHECK(sess_assistant_emit("tool_call", "run_shell", + "{\"command\":\"ls\"}", &ev) == 0, + "tool_call ok"); + CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call -> verbatim display"); + CHECK_CONT(ev.text, "$ ", "run_shell renders as a shell line"); + CHECK_CONT(ev.text, "ls", "shell line carries the command"); + CHECK_NOT_CONT(ev.text, "# run_shell", + "no generic # name line for shell tools"); + CHECK_NOT_CONT(ev.text, "command", "raw JSON key does not leak"); + sess_emit_free(&ev); + + CHECK(sess_assistant_emit("tool_call", "run_shell", + "{\"command\":\"\"}", &ev) == 0, + "run_shell empty command ok"); + CHECK(ev.kind == SESS_EMIT_VERBATIM, "empty command -> fallback display"); + CHECK_CONT(ev.text, "# run_shell", "fallback names the tool"); + sess_emit_free(&ev); + + CHECK(sess_assistant_emit("tool_call", "frobnicate", "{}", + &ev) == 0, + "tool_call unknown ok"); + CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call unknown -> verbatim"); + CHECK_CONT(ev.text, "frobnicate", "verbatim names the tool"); + sess_emit_free(&ev); + + CHECK(sess_assistant_emit("blob", "x", NULL, &ev) == 1, + "unknown kind -> no emit"); +} + +static void test_tool_result_emit(void) { + sess_emit ev; + + CHECK(sess_tool_result_emit("run_shell", NULL, &ev) == 1, "NULL -> no emit"); + CHECK(sess_tool_result_emit("run_shell", "", &ev) == 1, "empty -> no emit"); + CHECK(sess_tool_result_emit("run_shell", "ls", NULL) == 1, + "NULL out -> no emit"); + + CHECK(sess_tool_result_emit("read_file", "hello", &ev) == 0, "plain ok"); + CHECK(ev.kind == SESS_EMIT_RESULT, "result kind"); + CHECK(ev.from_shell, "read_file result marked shell-style"); + CHECK(ev.text && strcmp(ev.text, "hello\n") == 0, + "body normalized to one trailing newline"); + sess_emit_free(&ev); + + CHECK(sess_tool_result_emit("run_shell", "a\nb\n\n\n", &ev) == 0, + "multiline ok"); + CHECK(ev.from_shell, "run_shell result marked shell-style"); + CHECK(ev.text && strcmp(ev.text, "a\nb\n") == 0, + "extra trailing newlines collapsed"); + sess_emit_free(&ev); + + CHECK(sess_tool_result_emit("search_memories", "hit", &ev) == 0, + "non-shell tool ok"); + CHECK(!ev.from_shell, "search_memories not shell-style"); + sess_emit_free(&ev); + + CHECK(sess_tool_result_emit("run_shell", "out\n[stderr]\nboom\n[exit code 1]", &ev) == 0, + "shell-shaped ok"); + CHECK(ev.from_shell, "run_shell result marked shell-style"); + CHECK(ev.text && + strcmp(ev.text, "out\n[stderr]\nboom\n[exit code 1]\n") == 0, + "body preserved verbatim"); + sess_emit_free(&ev); +} + +/* ------------------------------------------------------------------ */ +/* Preamble / preset / profile helpers */ +/* ------------------------------------------------------------------ */ + +static void test_active_preset_from(void) { + dotagents d = empty_dot(); + d.has_models = true; + d.models.default_name = xstrdup("fast"); + char *p = session_active_preset_from(&d); + CHECK(p && strcmp(p, "fast") == 0, "default preset returned"); + free(p); + dotagents_free(&d); + + dotagents e = empty_dot(); + CHECK(session_active_preset_from(&e) == NULL, "no models -> NULL"); + dotagents_free(&e); +} + +static void test_base_preamble_for(void) { + config c = base_config(); + c.preamble = xstrdup("BASE PREAMBLE"); + + dotagents d = empty_dot(); + d.system_prompt = xstrdup("SYS OVERRIDE"); + char *r = sess_base_preamble_for(&c, &d); + CHECK_CONT(r, "SYS OVERRIDE", "system-prompt overrides base"); + CHECK_NOT_CONT(r, "BASE PREAMBLE", "base not used when overridden"); + free(r); + dotagents_free(&d); + + dotagents e = empty_dot(); + char *r2 = sess_base_preamble_for(&c, &e); + CHECK_CONT(r2, "BASE PREAMBLE", "config preamble used when no override"); + free(r2); + dotagents_free(&e); + + config_free(&c); +} + +static void test_preamble_for_profile(void) { + config c = base_config(); + c.preamble = xstrdup("BASE PREAMBLE"); + + dotagents d = dot_with_reviewer(); + char *r = sess_preamble_for_profile(&d, &c, "reviewer"); + CHECK_CONT(r, "You are a reviewer.", "profile body used"); + free(r); + + char *r2 = sess_preamble_for_profile(&d, &c, NULL); + CHECK_CONT(r2, "BASE PREAMBLE", "NULL profile -> base preamble"); + free(r2); + + dotagents_free(&d); + config_free(&c); +} + +static void test_apply_overrides(void) { + config c; memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("old"); + char *err = NULL; + CHECK(sess_apply_overrides(&c, "m2", "anthropic", "https://bu", "key2", + true, &err) == 0, + "apply ok"); + CHECK(c.model && strcmp(c.model, "m2") == 0, "model applied"); + CHECK(c.provider == PROV_ANTHROPIC, "provider applied"); + CHECK(c.base_url && strcmp(c.base_url, "https://bu") == 0, "base_url applied"); + CHECK(c.api_key && strcmp(c.api_key, "key2") == 0, "api_key applied"); + config_free(&c); + + config c2; memset(&c2, 0, sizeof c2); + c2.provider = PROV_OPENAI; + err = NULL; + CHECK(sess_apply_overrides(&c2, "m", "wibbly", NULL, NULL, true, + &err) == -1, + "strict bad provider -> error"); + CHECK(err && strstr(err, "unknown provider") != NULL, "strict err msg"); + free(err); + config_free(&c2); + + config c3; memset(&c3, 0, sizeof c3); + c3.provider = PROV_OPENAI; + c3.model = xstrdup("keep"); + err = NULL; + CHECK(sess_apply_overrides(&c3, "m", "wibbly", NULL, NULL, false, + &err) == 0, + "non-strict bad provider -> skipped"); + CHECK(c3.model && strcmp(c3.model, "m") == 0, "model still applied"); + CHECK(c3.provider == PROV_OPENAI, "provider unchanged (skipped)"); + config_free(&c3); +} + +static void test_effective_for_profile(void) { + config base = base_config(); + dotagents d = dot_with_profile_config(); + + config eff = sess_effective_for_profile(&base, &d, "rev"); + CHECK(eff.model && strcmp(eff.model, "m-profile") == 0, "profile model"); + CHECK(eff.provider == PROV_ANTHROPIC, "profile provider"); + CHECK(eff.api_key && strcmp(eff.api_key, "pk") == 0, "profile api_key"); + config_free(&eff); + + config plain = sess_effective_for_profile(&base, &d, NULL); + CHECK(plain.model && strcmp(plain.model, "gpt-test") == 0, + "no profile -> base model"); + CHECK(plain.provider == PROV_OPENAI, "no profile -> base provider"); + CHECK(plain.api_key && strcmp(plain.api_key, "sk-test") == 0, + "no profile -> base api_key"); + config_free(&plain); + + dotagents_free(&d); + config_free(&base); +} + +/* ------------------------------------------------------------------ */ +/* Turn */ +/* ------------------------------------------------------------------ */ + +static void test_stop_before_start(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int ok_results[] = {SESS_STREAM_OK}; + mock_set_stream(m, ok_results, 1, "unused", 0); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(true); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0, + "stopped turn returns 0"); + CHECK(result == SESS_TURN_STOPPED, "result is Stopped"); + CHECK_EQ_U64(session_history_len(s), 1, "prompt kept in context"); + const message *u = session_history_at(s, 0); + CHECK(u && u->is_user() && u->user().parts.size() == 1 && + std::get<user_text_part>(u->user().parts[0]).text == "hi", + "kept prompt is the user text"); + CHECK(m->calls == 0, "model not called when stopped"); + session_free(s); +} + +static void test_completed_turn_updates_history(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int ok_results[] = {SESS_STREAM_OK}; + mock_set_stream(m, ok_results, 1, "hello there", 42); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0, + "completed turn returns 0"); + CHECK(result == SESS_TURN_COMPLETED, "result is Completed"); + CHECK_EQ_U64(session_history_len(s), 2, "user + assistant recorded"); + CHECK_EQ_U64(session_last_context_tokens(s), 42, "provider usage recorded"); + const message *a = session_history_at(s, 1); + CHECK(a && a->is_assistant() && a->assistant().parts.size() == 1 && + std::get<asst_text_part>(a->assistant().parts[0]).text == + "hello there", + "assistant text recorded"); + char *last = session_last_assistant_text(s); + CHECK(last && strcmp(last, "hello there") == 0, "last assistant text"); + free(last); + session_free(s); +} + +static void test_retry_then_success(void) { + config c = base_config(); + c.max_retries = 3; + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_RETRYABLE, SESS_STREAM_OK}; + mock_set_stream(m, seq, 2, "recovered", 10); + m->http_status = 429; + m->errmsg = xstrdup("rate limited"); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0, + "retry then success returns 0"); + CHECK(result == SESS_TURN_COMPLETED, "result is Completed"); + CHECK(m->calls == 2, "model called twice"); + CHECK_EQ_U64(session_history_len(s), 2, "history updated after success"); + CHECK(err == NULL, "no error surfaced"); + session_free(s); +} + +static void test_gives_up_after_retries(void) { + config c = base_config(); + c.max_retries = 2; + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_RETRYABLE}; + mock_set_stream(m, seq, 1, "never", 0); + m->http_status = 429; + m->errmsg = xstrdup("rate limited"); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1, + "exhausted retries returns -1"); + CHECK(m->calls == 3, "2 retries + 1 initial = 3 calls"); + CHECK(err && strstr(err, "provider error after 2 retries") != NULL, + "error names retry count"); + CHECK(err && strstr(err, "rate limited") != NULL, "error carries cause"); + free(err); + CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed"); + session_free(s); +} + +static void test_no_retry_on_permanent(void) { + config c = base_config(); + c.max_retries = 3; + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_FAILED}; + mock_set_stream(m, seq, 1, "never", 0); + m->http_status = 400; + m->errmsg = xstrdup("bad request"); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1, + "permanent failure returns -1"); + CHECK(m->calls == 1, "no retries for permanent failure"); + CHECK(err && strstr(err, "stream error") != NULL, "stream error message"); + CHECK(err && strstr(err, "bad request") != NULL, "error carries cause"); + free(err); + CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed"); + session_free(s); +} + +static void test_retry_disabled(void) { + config c = base_config(); + c.max_retries = 0; + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_RETRYABLE}; + mock_set_stream(m, seq, 1, "never", 0); + m->http_status = 429; + m->errmsg = xstrdup("rate limited"); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1, + "retry disabled returns -1"); + CHECK(m->calls == 1, "only one attempt when retries disabled"); + CHECK(err && strstr(err, "provider error after 0 retries") != NULL, + "error names 0 retries"); + free(err); + session_free(s); +} + +static void test_overflow_recover_by_compacting(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; /* budget 500 */ + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + + push_compact_hist(s); + session_set_last_context_tokens(s, 143063); + int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK}; + mock_set_stream(main, seq, 2, "recovered", 42); + main->http_status = 400; + main->errmsg = xstrdup( + "request (143063 tokens) exceeds the available context size (131072 " + "tokens), try increasing it"); + + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0, + "overflow -> compact -> retry succeeds"); + CHECK(result == SESS_TURN_COMPLETED, "turn completed after recovery"); + CHECK(main->calls == 2, "model called twice (fail + retry)"); + CHECK_EQ_U64(sum->complete_calls, 1, "summarizer compacted the prefix"); + CHECK(err == NULL, "no error surfaced"); + CHECK_EQ_U64(session_history_len(s), 5, "summary + kept tail + new turn"); + const message *sum0 = session_history_at(s, 0); + CHECK(sum0 && sum0->is_user(), + "first message is the summary user message"); + CHECK_CONT(first_text(sum0), "EARLIER SUMMARY", "summary body present"); + session_free(s); +} + +static void test_overflow_fallback_clear_still_recovers(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete(NULL, 1, "summarizer down"); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + + push_compact_hist(s); + session_set_last_context_tokens(s, 143063); + int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK}; + mock_set_stream(main, seq, 2, "recovered", 42); + main->http_status = 400; + main->errmsg = xstrdup("context window exceeded"); + + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0, + "overflow -> fallback clear -> retry succeeds"); + CHECK(result == SESS_TURN_COMPLETED, "turn completed"); + CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted"); + CHECK(err == NULL, "no error surfaced"); + CHECK_EQ_U64(session_history_len(s), 6, + "stale cleared in place, kept tail + new turn retained"); + const message *tr = session_history_at(s, 1); + CHECK(tr && tr->is_user() && first_text(tr) && + strstr(first_text(tr), "tool output cleared") != NULL, + "stale result cleared by the fallback"); + session_free(s); +} + +static void test_overflow_retries_only_once(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete("S", 0, NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + push_compact_hist(s); + session_set_last_context_tokens(s, 143063); + int seq[] = {SESS_STREAM_FAILED}; + mock_set_stream(main, seq, 1, "never", 0); + main->http_status = 400; + main->errmsg = xstrdup("context window exceeded"); + + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1, + "still overflowing after compact -> turn fails"); + CHECK(main->calls == 2, "exactly one compact-and-retry"); + CHECK(err && strstr(err, "stream error") != NULL, "error surfaced"); + CHECK(err && strstr(err, "context window exceeded") != NULL, + "provider message preserved"); + free(err); + session_free(s); +} + +static void test_overflow_no_recovery_without_limit(void) { + config c = base_config(); + c.context_limit = 0; /* compaction disabled */ + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_FAILED}; + mock_set_stream(m, seq, 1, "never", 0); + m->http_status = 400; + m->errmsg = xstrdup("context window exceeded"); + + session *s = session_new_with_model(&m->m, c, d); + push_compact_hist(s); + session_set_last_context_tokens(s, 143063); + + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1, + "no limit -> overflow surfaces as stream error"); + CHECK(m->calls == 1, "no recovery attempt without a context limit"); + CHECK(err && strstr(err, "context window exceeded") != NULL, + "provider message preserved"); + free(err); + CHECK_EQ_U64(session_history_len(s), 4, "history untouched"); + session_free(s); +} + +static void test_overflow_no_stale_prefix_no_recovery(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete("S", 0, NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + session_push_history(s, msg_user_text("only prompt")); + int seq[] = {SESS_STREAM_FAILED}; + mock_set_stream(main, seq, 1, "never", 0); + main->http_status = 400; + main->errmsg = xstrdup("context window exceeded"); + + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1, + "nothing to compact -> turn fails"); + CHECK(main->calls == 2, "compact pass ran, retry still failed"); + CHECK_EQ_U64(sum->complete_calls, 0, + "no stale prefix -> summarizer not called"); + CHECK(err && strstr(err, "context window exceeded") != NULL, + "provider message preserved"); + free(err); + session_free(s); +} + +static void test_records_usage(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int seq[] = {SESS_STREAM_OK}; + mock_set_stream(m, seq, 1, "ok", 1234); + + session *s = session_new_with_model(&m->m, c, d); + std::atomic<bool> stop(false); + int result = -1; + char *err = NULL; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0, + "turn ok"); + CHECK_EQ_U64(session_last_context_tokens(s), 1234, "usage recorded"); + + /* zero input tokens should not clobber */ + session_clear_history(s); + session_set_last_context_tokens(s, 0); + int seq0[] = {SESS_STREAM_OK}; + mock_set_stream(m, seq0, 1, "ok", 0); + CHECK(session_run_turn(s, "again", &stop, NULL, NULL, &err, &result) == 0, + "turn 2 ok"); + CHECK_EQ_U64(session_last_context_tokens(s), 0, "zero tokens -> stays 0"); + session_free(s); +} + +/* ------------------------------------------------------------------ */ +/* Model / profile */ +/* ------------------------------------------------------------------ */ + +static void test_set_profile(void) { + config c = base_config(); + dotagents d = dot_with_reviewer(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + + CHECK(session_set_profile(s, "reviewer", &err) == 0, "set known profile"); + char *pn = session_profile_name(s); + CHECK(pn && strcmp(pn, "reviewer") == 0, "profile name set"); + free(pn); + + err = NULL; + CHECK(session_set_profile(s, "nope", &err) == -1, "unknown profile fails"); + CHECK(err && strstr(err, "no enabled sub-agent `nope`") != NULL, + "unknown profile err names id"); + free(err); + + CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile ok"); + CHECK(session_profile_name(s) == NULL, "profile cleared"); + + session_free(s); +} + +static void test_set_model_preset(void) { + config c = base_config(); + dotagents d = dot_with_presets(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + + CHECK(session_set_model(s, "fast", &err) == 0, "apply preset"); + CHECK(strcmp(session_config(s)->model, "m-fast") == 0, "preset model"); + CHECK(strcmp(session_config(s)->base_url, "https://fast.example") == 0, + "preset base_url"); + CHECK(strcmp(session_config(s)->api_key, "kfast") == 0, "preset api_key"); + char *ap = session_active_preset(s); + CHECK(ap && strcmp(ap, "fast") == 0, "active preset set"); + free(ap); + + CHECK(session_set_model(s, "custom-model", &err) == 0, "raw model ok"); + CHECK(strcmp(session_config(s)->model, "custom-model") == 0, "raw model set"); + CHECK(session_active_preset(s) == NULL, "raw model clears preset"); + + err = NULL; + CHECK(session_set_model(s, "badprov", &err) == -1, + "preset with bad provider fails (strict)"); + CHECK(err && strstr(err, "unknown provider") != NULL, "bad provider err"); + free(err); + + session_free(s); +} + +static void test_current_preamble_switches_with_profile(void) { + config c = base_config(); + c.preamble = xstrdup("BASE PREAMBLE"); + dotagents d = dot_with_reviewer(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + + CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE", + "base preamble before profile"); + + CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile"); + CHECK_CONT(session_current_preamble(s), "You are a reviewer.", + "profile preamble after switch"); + + session_free(s); +} + +/* The per-profile effective state (model, preamble, allowed tools) is + * cached on the session. Switching profiles must invalidate the cache so + * none of the three is served stale -- in either direction. */ +static void test_profile_cache_invalidates(void) { + config c = base_config(); + c.preamble = xstrdup("BASE PREAMBLE"); + dotagents d = dot_with_profile_tools(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + + /* Baseline (no profile): base model, base preamble, no tool restriction. */ + char *model = session_model(s); + CHECK_CONT(model, "gpt-test", "base model before profile"); + free(model); + CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE", + "base preamble before profile"); + CHECK(session_allowed_tools(s)->len == 0, "no tool restriction at base"); + + /* Activate the profile: all three must reflect the profile, not the + * previously cached base state. */ + CHECK(session_set_profile(s, "rev", &err) == 0, "set profile"); + model = session_model(s); + CHECK_CONT(model, "m-profile", "profile model after switch"); + CHECK_NOT_CONT(model, "gpt-test", "base model not leaked"); + free(model); + CHECK_CONT(session_current_preamble(s), "You are a reviewer.", + "profile preamble after switch"); + CHECK_NOT_CONT(session_current_preamble(s), "BASE PREAMBLE", + "base preamble not leaked"); + const strlist *allowed = session_allowed_tools(s); + CHECK(allowed->len == 2, "profile restricts tools to two"); + CHECK(allowed->len == 2 && + xstreq(allowed->items[0], "read_file") && + xstreq(allowed->items[1], "grep"), + "restricted tool set is correct"); + + /* Clear the profile: back to the base state, not the stale profile. */ + CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile"); + model = session_model(s); + CHECK_CONT(model, "gpt-test", "base model restored"); + CHECK_NOT_CONT(model, "m-profile", "profile model not leaked"); + free(model); + CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE", + "base preamble restored"); + CHECK_NOT_CONT(session_current_preamble(s), "You are a reviewer.", + "profile preamble not leaked"); + CHECK(session_allowed_tools(s)->len == 0, "tool restriction lifted"); + + session_free(s); +} + +/* ------------------------------------------------------------------ */ +/* Compaction */ +/* ------------------------------------------------------------------ */ + +static void test_compact_disabled_at_zero_limit(void) { + config c = base_config(); + c.context_limit = 0; + dotagents d = empty_dot(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + push_compact_hist(s); + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, false, &report, &err) == 0, + "zero limit -> no pass"); + CHECK(err == NULL, "no error"); + CHECK_EQ_U64(session_history_len(s), 4, "history untouched"); + session_free(s); +} + +static void test_compact_below_thresholds(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; /* budget 500 */ + c.clear_threshold = 0.1; /* budget 100 */ + dotagents d = empty_dot(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + push_compact_hist(s); + session_set_last_context_tokens(s, 50); /* below both */ + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, false, &report, &err) == 0, + "below thresholds -> no pass"); + CHECK_EQ_U64(session_history_len(s), 4, "history untouched"); + session_free(s); +} + +static void test_compact_clears_stale_tools(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; /* budget 500 */ + c.clear_threshold = 0.1; /* budget 100 */ + dotagents d = empty_dot(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + push_compact_hist(s); + session_set_last_context_tokens(s, 200); /* >=100, <500 */ + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, false, &report, &err) == 1, + "clear tier ran"); + CHECK(report.tier == TIER_CLEARED, "tier is CLEARED"); + CHECK_EQ_U64(report.cleared_tool_results, 1, "one stale result cleared"); + CHECK(report.tokens_after < report.tokens_before, "tokens shrank"); + CHECK_EQ_U64(session_last_context_tokens(s), 0, "usage reset after pass"); + session_free(s); +} + +static void test_compact_summarizes_at_threshold(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + push_compact_hist(s); + session_set_last_context_tokens(s, 600); /* >=500 */ + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, false, &report, &err) == 1, + "summarize tier ran"); + CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED"); + CHECK_EQ_U64(report.files_reinjected, 0, "no files listed -> none"); + CHECK_EQ_U64(sum->complete_calls, 1, "summarizer called once"); + CHECK_EQ_U64(session_history_len(s), 3, "summary + tail (2) = 3"); + const message *sum0 = session_history_at(s, 0); + CHECK(sum0 && sum0->is_user(), "first is the summary user message"); + const message *kept = session_history_at(s, 1); + CHECK(kept && kept->is_user() && kept->user().parts.size() == 1 && + std::get<user_text_part>(kept->user().parts[0]).text == "now", + "tail preserved"); + session_free(s); +} + +static void test_forced_compact_reinjects_files(void) { + const char *path = "/tmp/opencode/llsh_reinject.txt"; + char *content = xstrdup("REINJECTED FILE CONTENT"); + CHECK(write_file_from_buf(path, content, strlen(content), NULL) == 0, + "wrote temp file"); + free(content); + + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + char *sumreply = xstrfmt("SUMMARY\n## Files\n- %s\n", path); + mock_model *sum = mk_complete(sumreply, 0, NULL); + free(sumreply); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + push_compact_hist(s); + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, true, &report, &err) == 1, "forced ran"); + CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED"); + CHECK_EQ_U64(report.files_reinjected, 1, "one file re-injected"); + const message *sum0 = session_history_at(s, 0); + CHECK_CONT(first_text(sum0), "REINJECTED FILE CONTENT", + "file content inlined"); + CHECK_CONT(first_text(sum0), path, "file path shown"); + unlink(path); + session_free(s); +} + +static void test_forced_compact_fallback_clear(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete(NULL, 1, "summarizer exploded"); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + push_compact_hist(s); + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, true, &report, &err) == 1, + "forced pass ran"); + CHECK(report.tier == TIER_FALLBACK_CLEAR, "tier is FALLBACK_CLEAR"); + CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted"); + CHECK_EQ_U64(session_history_len(s), 4, "history not rebuilt (in place)"); + const message *tr = session_history_at(s, 1); + CHECK_CONT(first_text(tr), "tool output cleared", + "stale result replaced with placeholder"); + session_free(s); +} + +static void test_forced_compact_noop_no_stale(void) { + config c = base_config(); + c.context_limit = 1000; + c.compact_threshold = 0.5; + c.clear_threshold = 0.1; + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *sum = mk_complete("S", 0, NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_summarizer(s, &sum->m); + session_push_history(s, msg_user_text("first")); /* boundary would be 0 */ + session_push_history(s, msg_assistant_text("b")); + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + CHECK(session_compact_if_needed(s, true, &report, &err) == 0, + "no stale prefix -> no pass"); + CHECK_EQ_U64(sum->complete_calls, 0, "summarizer not called"); + CHECK_EQ_U64(session_history_len(s), 2, "history untouched"); + session_free(s); +} + +static void test_undo_last_turn(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + session_push_history(s, msg_user_text("first")); + session_push_history(s, msg_assistant_text("a")); + session_push_history(s, msg_user_text("second")); + session_push_history(s, msg_assistant_text("b")); + + CHECK(session_undo_last_turn(s) == 2, "removes most recent prompt + reply"); + CHECK_EQ_U64(session_history_len(s), 2, "earlier turn retained"); + const message *u0 = session_history_at(s, 0); + CHECK(u0 && u0->is_user() && + std::get<user_text_part>(u0->user().parts[0]).text == "first", + "first prompt retained"); + + /* no pure-text user -> nothing to remove */ + mock_model *m2 = mk_model(); + session *s2 = session_new_with_model(&m2->m, base_config(), empty_dot()); + session_push_history(s2, msg_assistant_text("a")); + session_push_history(s2, + msg_user_tool_result("c1", "read_file", "body")); + CHECK(session_undo_last_turn(s2) == -1, "no pure-text user -> -1"); + CHECK_EQ_U64(session_history_len(s2), 2, "history untouched"); + session_free(s2); + + session_free(s); +} + +/* ------------------------------------------------------------------ */ +/* Goal */ +/* ------------------------------------------------------------------ */ + +static void test_goal_met(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *eval = mk_complete("MET\nAll tests pass and are green.", 0, + NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_goal_evaluator(s, &eval->m); + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 5; + goal_verdict v; + char *err = NULL; + CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0, + "evaluate ok"); + CHECK(v.met, "verdict met"); + CHECK_EQ_U64(eval->complete_calls, 1, "evaluator called once"); + free(v.reason); + goal_spec_free(&spec); + session_free(s); +} + +static void test_goal_not_met(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *eval = mk_complete("NOT MET\nTwo tests still fail.", 0, + NULL); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_goal_evaluator(s, &eval->m); + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 5; + goal_verdict v; + char *err = NULL; + CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0, + "evaluate ok"); + CHECK(!v.met, "verdict not met"); + free(v.reason); + goal_spec_free(&spec); + session_free(s); +} + +static void test_goal_no_evaluator(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *main = mk_model(); + session *s = session_new_with_model(&main->m, c, d); + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 5; + goal_verdict v; + char *err = NULL; + CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1, + "no evaluator -> -1"); + CHECK(err && strstr(err, "no goal evaluator") != NULL, "err names cause"); + free(err); + goal_spec_free(&spec); + session_free(s); +} + +static void test_goal_evaluator_error(void) { + config c = base_config(); + dotagents d = empty_dot(); + mock_model *main = mk_model(); + mock_model *eval = mk_complete(NULL, 1, "evaluator exploded"); + session *s = session_new_with_model(&main->m, c, d); + s = session_with_goal_evaluator(s, &eval->m); + goal_spec spec; + spec.criteria = xstrdup("all tests pass"); + spec.max_tries = 5; + goal_verdict v; + char *err = NULL; + CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1, + "evaluator error -> -1"); + CHECK(err && strstr(err, "asking the goal evaluator") != NULL, + "err names cause"); + free(err); + goal_spec_free(&spec); + session_free(s); +} + +/* ------------------------------------------------------------------ */ +/* Save / load / resume */ +/* ------------------------------------------------------------------ */ + +static void test_save_resume_roundtrip(void) { + const char *path = "/tmp/opencode/llsh_save.json"; + unlink(path); + + config c = base_config(); + free(c.model); + c.model = xstrdup("m1"); + free(c.api_key); + c.api_key = xstrdup("k"); + c.context_limit = 500; + dotagents d = dot_with_reviewer(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile"); + session_push_history(s, msg_user_text("hi")); + session_push_history(s, msg_assistant_text("yo")); + session_set_last_context_tokens(s, 777); + + CHECK(session_save(s, path, &err) == 0, "save ok"); + CHECK(err == NULL, "save no error"); + + session_snapshot *snap = session_load(path, &err); + CHECK(snap != NULL, "load ok"); + if (snap) { + CHECK(strcmp(snap->cfg.model, "m1") == 0, "config model restored"); + CHECK(snap->cfg.provider == PROV_OPENAI, "config provider restored"); + CHECK(strcmp(snap->cfg.api_key, "k") == 0, "config api_key restored"); + CHECK_EQ_U64(snap->cfg.context_limit, 500, + "config context_limit restored"); + CHECK_EQ_U64(snap->hist.msgs.size(), 2, "history length restored"); + CHECK(snap->hist.msgs[0].is_user() && + std::get<user_text_part>( + snap->hist.msgs[0].user().parts[0]) + .text == "hi", + "user message restored"); + CHECK(snap->hist.msgs[1].is_assistant() && + std::get<asst_text_part>( + snap->hist.msgs[1].assistant().parts[0]) + .text == "yo", + "assistant message restored"); + CHECK(snap->profile && strcmp(snap->profile, "reviewer") == 0, + "profile restored"); + CHECK_EQ_U64(snap->last_context_tokens, 777, "usage restored"); + + dotagents d2 = dot_with_reviewer(); + mock_model *m2 = mk_model(); + session *s2 = session_resume(snap, d2, &m2->m, &err); + CHECK(s2 != NULL, "resume ok"); + if (s2) { + CHECK(strcmp(session_config(s2)->model, "m1") == 0, + "resumed config model"); + char *pn = session_profile_name(s2); + CHECK(pn && strcmp(pn, "reviewer") == 0, "resumed profile"); + free(pn); + CHECK_EQ_U64(session_history_len(s2), 2, "resumed history"); + CHECK_EQ_U64(session_last_context_tokens(s2), 777, + "resumed usage"); + session_free(s2); + } + } + unlink(path); + session_free(s); +} + +static void test_resume_falls_back_to_main(void) { + const char *path = "/tmp/opencode/llsh_save_fb.json"; + unlink(path); + + config c = base_config(); + dotagents d = dot_with_reviewer(); + mock_model *m = mk_model(); + session *s = session_new_with_model(&m->m, c, d); + char *err = NULL; + CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile"); + session_push_history(s, msg_user_text("hi")); + CHECK(session_save(s, path, &err) == 0, "save ok"); + + session_snapshot *snap = session_load(path, &err); + CHECK(snap != NULL, "load ok"); + if (snap) { + dotagents d2 = empty_dot(); /* no 'reviewer' here */ + mock_model *m2 = mk_model(); + session *s2 = session_resume(snap, d2, &m2->m, &err); + CHECK(s2 != NULL, "resume ok"); + if (s2) { + CHECK(session_profile_name(s2) == NULL, + "profile fell back to main agent"); + CHECK_EQ_U64(session_history_len(s2), 1, "history still restored"); + session_free(s2); + } + } + unlink(path); + session_free(s); +} + +static void test_load_rejects_bad_version(void) { + const char *path = "/tmp/opencode/llsh_badver.json"; + const char *body = + "{\"version\": 999, \"history\": [], \"config\": {}}"; + CHECK(write_file_from_buf(path, body, strlen(body), NULL) == 0, + "wrote bad-version file"); + char *err = NULL; + session_snapshot *snap = session_load(path, &err); + CHECK(snap == NULL, "bad version rejected"); + CHECK(err && strstr(err, "unsupported session version") != NULL, + "err names the problem"); + free(err); + unlink(path); +} + +static void test_load_missing_file(void) { + char *err = NULL; + session_snapshot *snap = + session_load("/tmp/opencode/llsh_does_not_exist_12345.json", &err); + CHECK(snap == NULL, "missing file -> NULL"); + CHECK(err && strstr(err, "reading") != NULL, "err names the path action"); + free(err); +} + +static void test_debug_dump(void) { + const char *path = "/tmp/opencode/llsh_debug_test.json"; + unlink(path); + + config c = base_config(); + dotagents d = empty_dot(); + mock_model *m = mk_model(); + int ok_results[] = {SESS_STREAM_OK}; + mock_set_stream(m, ok_results, 1, "hello there", 42); + + session *s = session_new_with_model(&m->m, c, d); + + /* No debug path set: the dump is a no-op and creates nothing. */ + char *err = NULL; + CHECK(session_debug_dump(s, &err) == 0, "dump with no path is a no-op"); + CHECK(err == NULL, "no-op sets no error"); + CHECK(access(path, R_OK) != 0, "no-op creates no file"); + + /* Enabled: every turn refreshes the whole-history file. */ + session_set_debug_path(s, xstrdup(path)); + std::atomic<bool> stop(false); + int result = -1; + CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0, + "turn completes"); + CHECK(result == SESS_TURN_COMPLETED, "result is Completed"); + CHECK(access(path, R_OK) == 0, "debug file written after turn"); + + char *raw = NULL; + size_t len = 0; + char *rerr = NULL; + CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0, "file readable"); + free(rerr); + bool parsed = true; + nlohmann::json o; + try { + o = nlohmann::json::parse(raw); + } catch (...) { + parsed = false; + } + CHECK(parsed, "file is valid JSON"); + if (parsed) { + CHECK(o.is_array() && o.size() == 2, "file is the whole conversation"); + CHECK(o[0].at("role") == "user", "first is user"); + CHECK(o[0].at("parts")[0].at("text") == "hi", "user text preserved"); + CHECK(o[1].at("role") == "assistant", "second is assistant"); + CHECK(o[1].at("parts")[0].at("text") == "hello there", + "assistant text preserved"); + } + free(raw); + + /* A second turn grows the history and the file tracks it. */ + mock_set_stream(m, ok_results, 1, "again", 99); + CHECK(session_run_turn(s, "hi again", &stop, NULL, NULL, &err, &result) == + 0, + "second turn completes"); + raw = NULL; + rerr = NULL; + CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0, + "file readable again"); + free(rerr); + bool parsed2 = true; + nlohmann::json o2; + try { + o2 = nlohmann::json::parse(raw); + } catch (...) { + parsed2 = false; + } + CHECK(parsed2, "second dump is valid JSON"); + if (parsed2) { + CHECK(o2.is_array() && o2.size() == 4, "history grew to 4 messages"); + CHECK(o2[3].at("parts")[0].at("text") == "again", + "latest assistant text"); + } + free(raw); + + /* Disabling again turns the dump off. */ + session_set_debug_path(s, NULL); + unlink(path); + err = NULL; + CHECK(session_debug_dump(s, &err) == 0, "dump after disable is a no-op"); + CHECK(err == NULL, "no error after disable"); + CHECK(access(path, R_OK) != 0, "disable creates no file"); + + session_free(s); +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +int main(void) { + test_is_transient_status(); + test_backoff_secs(); + test_human_secs(); + test_classify_retry(); + test_context_overflow_detection(); + test_assistant_emit(); + test_tool_result_emit(); + + test_active_preset_from(); + test_base_preamble_for(); + test_preamble_for_profile(); + test_apply_overrides(); + test_effective_for_profile(); + + test_stop_before_start(); + test_completed_turn_updates_history(); + test_retry_then_success(); + test_gives_up_after_retries(); + test_no_retry_on_permanent(); + test_retry_disabled(); + test_overflow_recover_by_compacting(); + test_overflow_fallback_clear_still_recovers(); + test_overflow_retries_only_once(); + test_overflow_no_recovery_without_limit(); + test_overflow_no_stale_prefix_no_recovery(); + test_records_usage(); + + test_set_profile(); + test_set_model_preset(); + test_current_preamble_switches_with_profile(); + test_profile_cache_invalidates(); + + test_compact_disabled_at_zero_limit(); + test_compact_below_thresholds(); + test_compact_clears_stale_tools(); + test_compact_summarizes_at_threshold(); + test_forced_compact_reinjects_files(); + test_forced_compact_fallback_clear(); + test_forced_compact_noop_no_stale(); + test_undo_last_turn(); + + test_goal_met(); + test_goal_not_met(); + test_goal_no_evaluator(); + test_goal_evaluator_error(); + + test_save_resume_roundtrip(); + test_resume_falls_back_to_main(); + test_load_rejects_bad_version(); + test_load_missing_file(); + + test_debug_dump(); + + printf("session: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_shell.cpp b/tests/test_shell.cpp new file mode 100644 index 0000000..bd10642 --- /dev/null +++ b/tests/test_shell.cpp @@ -0,0 +1,131 @@ +#include "shell.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +/* Spawn a shell, confirm it is a live process, then reap and free it. */ +static void test_spawn_and_reap(void) +{ + interactive_shell *s = shell_spawn(); + CHECK(s != NULL, "shell_spawn returns a shell"); + if (!s) + return; + CHECK(s->pid > 0, "shell has a live pid"); + CHECK(s->master >= 0, "shell has a master fd"); + + /* Let it settle, then confirm it is still alive (not yet reaped). */ + usleep(100 * 1000); + CHECK(s->pid > 0, "shell still alive before reap"); + + /* Free kills it and reaps; afterwards the pid is 0. */ + shell_free(s); +} + +/* Writing to a freed/closed shell must not crash. */ +static void test_write_after_free_is_safe(void) +{ + interactive_shell *s = shell_spawn(); + CHECK(s != NULL, "shell_spawn"); + if (!s) + return; + shell_free(s); + /* shell_write on a NULL shell is guarded by the caller; on a freed one we + * cannot call it (use-after-free), so just confirm free was clean. */ +} + +/* A shell that `cd`s and then exits must leave its final directory recorded + * (regression: the cwd was read after waitpid, when /proc/<pid>/cwd is gone, + * so `shell_final_cwd` returned NULL and llsh stayed in the old directory). */ +static void test_final_cwd_after_exit(void) +{ + setenv("SHELL", "/bin/bash", 1); + interactive_shell *s = shell_spawn(); + CHECK(s != NULL, "shell_spawn"); + if (!s) + return; + /* The sleep holds the shell alive after the cd, so the cwd poll (which + * only sees a live process) is guaranteed to observe /tmp. */ + const char *cmd = "cd /tmp\nsleep 0.3\nexit\n"; + for (size_t i = 0; i < strlen(cmd); i++) + CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell"); + bool reaped = false; + for (int i = 0; i < 200 && !reaped; i++) { + if (shell_reap_if_dead(s)) + reaped = true; + else + usleep(25 * 1000); + } + CHECK(reaped, "shell reaped after exit"); + char *cwd = shell_final_cwd(s); + CHECK(cwd != NULL, "final cwd is known"); + if (cwd) { + CHECK(xstreq(cwd, "/tmp"), "final cwd is the shell's last directory"); + free(cwd); + } + shell_free(s); +} + +/* Output the shell prints is captured (sanitized, terminal escapes stripped) + * alongside streaming to stdout, so llsh can record a shell-mode visit + * (commands and output) in the conversation context. `shell_captured` + * returns the text and resets the buffer. */ +static void test_output_captured(void) +{ + setenv("SHELL", "/bin/bash", 1); + interactive_shell *s = shell_spawn(); + CHECK(s != NULL, "shell_spawn"); + if (!s) + return; + /* `echo OUTMARKER-$((1+1))` prints `OUTMARKER-2`, which only appears in + * the command's *output* (the input echo shows the unexpanded form), so a + * match proves the output was captured, not just the typed command. */ + const char *cmd = "echo OUTMARKER-$((1+1))\n"; + for (size_t i = 0; i < strlen(cmd); i++) + CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell"); + char *cap = NULL; + for (int i = 0; i < 200 && !cap; i++) { + usleep(50 * 1000); + char *c = shell_captured(s); + if (strstr(c, "OUTMARKER-2")) + cap = c; /* keep it */ + else + free(c); + } + CHECK(cap != NULL, "captured output contains the command's output"); + if (cap) { + /* The sanitized text must have no terminal escapes left. */ + CHECK(strchr(cap, '\x1B') == NULL, "captured output has no ESC"); + fprintf(stderr, "\n=== CAPTURED (%zu bytes) ===\n%s\n=== END ===\n", + strlen(cap), cap); + } + free(cap); + shell_free(s); +} + +int main(int argc, char **argv) +{ + if (argc > 1 && xstreq(argv[1], "dump")) { + test_output_captured(); + return 0; + } + test_spawn_and_reap(); + test_write_after_free_is_safe(); + test_final_cwd_after_exit(); + test_output_captured(); + printf("shell: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_tools.cpp b/tests/test_tools.cpp new file mode 100644 index 0000000..3a92e05 --- /dev/null +++ b/tests/test_tools.cpp @@ -0,0 +1,1174 @@ +#include "tools.h" + +#include <cjson/cJSON.h> +#include <ctype.h> +#include <dirent.h> +#include <math.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#include "limits.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), hay ? hay : "(null)", \ + (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, must not contain `%s`)\n", \ + __FILE__, __LINE__, (msg), hay, (needle)); \ + } \ + } while (0) + +static char *tmpdir(const char *tag) { + char path[256]; + snprintf(path, sizeof path, "/tmp/llshc-tools-%s-%d", tag, (int)getpid()); + char cmd[320]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", path); + if (system(cmd) != 0) {} + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path); + if (system(cmd) != 0) + exit(1); + return xstrdup(path); +} + +static void wfile(const char *dir, const char *rel, const char *content) { + char path[512]; + snprintf(path, sizeof path, "%s/%s", dir, rel); + char *slash = strrchr(path, '/'); + if (slash) { + char dirpart[512]; + size_t n = (size_t)(slash - path); + memcpy(dirpart, path, n); + dirpart[n] = '\0'; + char cmd[600]; + snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart); + if (system(cmd) != 0) + exit(1); + } + FILE *f = fopen(path, "w"); + if (!f) + exit(1); + fputs(content, f); + fclose(f); +} + +static tool_limits mklimits2(const char **list, size_t n, bool to_deny) { + tool_limits l; + tool_limits_defaults(&l); + for (size_t i = 0; i < n; i++) + sl_push_copy(to_deny ? &l.shell_denylist : &l.shell_allowlist, + list[i]); + return l; +} + +static void test_read_write_roundtrip(void) { + char *dir = tmpdir("rw"); + char *p = xstrfmt("%s/nested/roundtrip.txt", dir); + char *err = NULL; + char *r = tool_write_file(p, "hi there", &err); + CHECK(r != NULL, "write_file"); + if (!r && err) + fprintf(stderr, " write_file err: %s\n", err); + free(r); + char *content = tool_read_file(p, 0, 0, &err); + CHECK(content && strcmp(content, "hi there") == 0, "read_file content"); + if (!content && err) + fprintf(stderr, " read_file err: %s\n", err); + free(content); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(p); + free(dir); +} + +static void test_read_file_missing_errors(void) { + char *dir = tmpdir("missing"); + char *p = xstrfmt("%s/nope.txt", dir); + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "missing file should fail"); + CHECK(err && strstr(err, "nope.txt") != NULL, "error names the path"); + free(err); + free(p); + free(dir); +} + +static void test_read_file_empty_path_errors(void) { + char *err = NULL; + CHECK(tool_read_file("", 0, 0, &err) == NULL, "empty path should fail"); + CHECK(err && strstr(err, "path") != NULL, "error names the missing path"); + free(err); + err = NULL; + CHECK(tool_read_file(NULL, 0, 0, &err) == NULL, "NULL path should fail"); + CHECK(err && strstr(err, "path") != NULL, "NULL path error names it"); + free(err); +} + +static void test_read_file_too_large(void) { + char *dir = tmpdir("large"); + char *p = xstrfmt("%s/big.bin", dir); + /* 3 MiB of zeros: above the 2 MiB default max_file_bytes */ + FILE *f = fopen(p, "wb"); + if (f) { + char chunk[4096]; + memset(chunk, 0, sizeof chunk); + for (int i = 0; i < 768; i++) + fwrite(chunk, 1, sizeof chunk, f); + fclose(f); + } + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "oversized file should fail"); + CHECK(err && strstr(err, "too large to read") != NULL, + "error says too large"); + free(err); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_binary_rejected(void) { + char *dir = tmpdir("bin"); + char *p = xstrfmt("%s/blob.bin", dir); + FILE *f = fopen(p, "wb"); + if (f) { + unsigned char b[] = {0xFF, 0xFE, 0x01}; + fwrite(b, 1, sizeof b, f); + fclose(f); + } + char *err = NULL; + char *r = tool_read_file(p, 0, 0, &err); + CHECK(r == NULL, "binary file should fail"); + CHECK(err && strstr(err, "valid UTF-8") != NULL, + "error mentions UTF-8"); + free(err); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_offset_limit(void) { + char *dir = tmpdir("offlimit"); + char *p = xstrfmt("%s/lines.txt", dir); + wfile(dir, "lines.txt", "l1\nl2\nl3\nl4\nl5\n"); + char *err = NULL; + + /* offset=1, limit=2 -> first two lines + a truncation note */ + char *r = tool_read_file(p, 1, 2, &err); + CHECK(r != NULL, "read with offset/limit succeeds"); + CHECK_CONT(r, "l1\nl2", "first two lines present"); + CHECK_CONT(r, "3 more lines", "notes how many lines remain"); + CHECK_CONT(r, "offset=3", "notes the offset to continue from"); + free(r); + + /* offset=3, limit=2 -> lines 3-4, exactly one line remaining */ + r = tool_read_file(p, 3, 2, &err); + CHECK(r != NULL && + strcmp(r, "l3\nl4\n\n[... 1 more line; use offset=5 to continue " + "...]") == 0, + "middle slice + singular note, exact"); + free(r); + + /* limit beyond the end -> the rest of the file, no note */ + r = tool_read_file(p, 2, 100, &err); + CHECK(r != NULL && strcmp(r, "l2\nl3\nl4\nl5\n") == 0, + "limit beyond end returns the rest with no note"); + free(r); + + /* the last single line, no note */ + r = tool_read_file(p, 5, 1, &err); + CHECK(r != NULL && strcmp(r, "l5\n") == 0, "last line only, no note"); + free(r); + + /* offset past the end -> empty string */ + r = tool_read_file(p, 6, 1, &err); + CHECK(r != NULL && strcmp(r, "") == 0, "offset past the end is empty"); + free(r); + + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_offset_after_unterminated_last_line(void) { + char *dir = tmpdir("unterminated"); + char *path = xstrfmt("%s/one.txt", dir); + wfile(dir, "one.txt", "only line"); + char *err = NULL; + char *out = tool_read_file(path, 2, 1, &err); + CHECK(out && strcmp(out, "") == 0, + "offset beyond unterminated final line returns empty"); + free(out); + free(err); + free(path); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_read_file_rejects_embedded_nul(void) { + char *dir = tmpdir("nul"); + char *path = xstrfmt("%s/nul.txt", dir); + FILE *f = fopen(path, "wb"); + const char bytes[] = {'a', '\0', 'b'}; + fwrite(bytes, 1, sizeof bytes, f); + fclose(f); + char *err = NULL; + char *out = tool_read_file(path, 0, 0, &err); + CHECK(out == NULL, "embedded NUL is rejected as non-text"); + free(out); + free(err); + free(path); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_write_file_rejects_missing_content(void) { + char *err = NULL; + CHECK(tool_write_file("/tmp/llsh-must-not-be-created", NULL, &err) == NULL, + "missing content rejected"); + CHECK(err && strstr(err, "content"), "missing content is explained"); + free(err); +} + +static void test_run_shell_success_output(void) { + char *err = NULL; + char *out = tool_run_shell("echo hello-llsh", &err); + CHECK(out != NULL, "run_shell"); + CHECK_CONT(out, "hello-llsh", "stdout captured"); + CHECK_NOT_CONT(out, "exit code 0", "no status note on success"); + free(out); +} + +static void test_run_shell_success_no_output(void) { + char *err = NULL; + char *out = tool_run_shell("true", &err); + CHECK(out && strcmp(out, "(no output)") == 0, "no-output marker"); + if (!out && err) + fprintf(stderr, " err: %s\n", err); + free(out); +} + +static void test_run_shell_failure_status(void) { + char *err = NULL; + char *out = tool_run_shell("exit 3", &err); + CHECK(out != NULL, "failing command still returns output"); + CHECK_CONT(out, "exit code 3", "exit code reported"); + free(out); +} + +static void test_run_shell_stderr_section(void) { + char *err = NULL; + char *out = tool_run_shell("echo out-line; echo err-line 1>&2", &err); + CHECK(out != NULL, "run_shell with stderr"); + CHECK_CONT(out, "out-line", "stdout part"); + CHECK_CONT(out, "[stderr]\nerr-line", "stderr section"); + free(out); +} + +static void test_run_shell_signal(void) { + char *err = NULL; + char *out = tool_run_shell("kill -9 $$", &err); + CHECK(out != NULL, "signaled command returns output"); + CHECK_CONT(out, "terminated by signal", "signal note"); + free(out); +} + +static void test_run_shell_denylist_blocks(void) { + tool_limits saved; + tool_limits_get(&saved); + const char *deny[] = {"curl"}; + tool_limits l = mklimits2(deny, 1, true); + tool_limits_set(&l); + char *err = NULL; + char *out = tool_run_shell("curl example.com", &err); + CHECK(out == NULL, "denylisted command blocked"); + CHECK(err && strstr(err, "denylist") != NULL, "denylist mentioned"); + free(err); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +static void test_run_shell_allowlist_requires_match(void) { + tool_limits saved; + tool_limits_get(&saved); + const char *allow[] = {"git"}; + tool_limits l = mklimits2(allow, 1, false); + tool_limits_set(&l); + char *err = NULL; + char *out = tool_run_shell("git status", &err); + CHECK(out != NULL, "allowlisted command runs"); + free(out); + err = NULL; + out = tool_run_shell("ls -la", &err); + CHECK(out == NULL, "non-allowlisted command blocked"); + CHECK(err && strstr(err, "allowlist") != NULL, "allowlist mentioned"); + free(err); + free(out); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +static bool deny_hook(const char *cmd, void *ud) { + (void)cmd; + (void)ud; + return false; +} + +static void test_run_shell_confirm_hook_aborts(void) { + tool_limits saved; + tool_limits_get(&saved); + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + tool_limits_set(&l); + set_confirm_hook(deny_hook, NULL); + char *err = NULL; + char *out = tool_run_shell("rm -rf /tmp/llsh-test", &err); + CHECK(out == NULL, "declined destructive command aborts"); + CHECK(err && strstr(err, "declined") != NULL, "declined mentioned"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); + tool_limits_set(&saved); + tool_limits_free(&saved); +} + +/* Number of live /proc entries whose cmdline contains `needle`. */ +static int leftover_cmdlines(const char *needle) { + DIR *d = opendir("/proc"); + if (!d) + return 0; + int found = 0; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + bool digits = e->d_name[0] != '\0'; + for (const char *c = e->d_name; *c; c++) + if (!isdigit((unsigned char)*c)) { + digits = false; + break; + } + if (!digits) + continue; + char path[300]; + snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name); + FILE *f = fopen(path, "rb"); + if (!f) + continue; + char buf[8192]; + size_t n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = '\0'; + for (size_t i = 0; i <= n && !found; i++) { + if (i < n && buf[i] != '\0') + continue; + if (strstr(buf + i, needle)) + found = 1; + i++; + } + } + closedir(d); + return found; +} + +static void test_run_shell_timeout_kills_process_group(void) { + char *err = NULL; + char *out = tool_run_shell_timed("sleep 60 & echo spawned; wait", 1, &err); + CHECK(out == NULL, "long command times out"); + CHECK(err && strstr(err, "timed out") != NULL, "timeout message"); + free(err); + /* Give the kernel a moment to reap, then check nothing survived. */ + struct timespec ts = {0, 300 * 1000 * 1000}; + nanosleep(&ts, NULL); + CHECK(leftover_cmdlines("sleep 60") == 0, + "backgrounded sleep must die with its process group"); +} + +static void test_run_shell_large_output_does_not_hang(void) { + /* A child writing more than MAX_SHELL_OUTPUT_BYTES (1MB) used to block on + * a full pipe because draining stopped at the cap, forcing a full timeout. + * It must now complete promptly with its output capped. */ + char *err = NULL; + char *out = tool_run_shell_timed("head -c 2000000 /dev/zero | tr '\\0' 'x'", + 5, &err); + CHECK(out != NULL, "large output completes (no spurious timeout)"); + if (out) { + CHECK(strlen(out) <= 16000 + 256, "output is capped/truncated"); + free(out); + } + free(err); +} + +static void test_truncate_output(void) { + xbuf long_b; + xbuf_init(&long_b); + for (int i = 0; i < 20000; i++) + xbuf_push(&long_b, 'x'); + char *long_s = xbuf_steal(&long_b); + char *t = tool_truncate_output(long_s); + CHECK_CONT(t, "truncated", "truncation note"); + CHECK(strlen(t) < 20000, "output shortened"); + free(t); + char *short_s = xstrdup("small"); + char *t2 = tool_truncate_output(short_s); + CHECK(t2 && strcmp(t2, "small") == 0, "short text unchanged"); + free(t2); + free(long_s); + free(short_s); +} + +static void test_tool_list(void) { + const tool_entry *entries; + size_t n = tool_list_entries(&entries); + CHECK(n == 8, "eight tools registered"); + const char *want[8] = {"read_file", "write_file", "run_shell", + "use_skill", "search_memories", "embed", + "llm", "compact"}; + bool ok = true; + for (size_t i = 0; i < n; i++) + if (strcmp(entries[i].name, want[i]) != 0) + ok = false; + CHECK(ok, "tool names in order"); +} + +static void test_tool_params_schemas(void) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + const char *name = tool_builtin_name(i); + cJSON *schema = tool_params_json(name); + CHECK(schema != NULL, "schema non-NULL"); + const cJSON *stype = cJSON_GetObjectItem(schema, "type"); + CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0, + "schema type is object"); + const cJSON *props = cJSON_GetObjectItem(schema, "properties"); + CHECK(cJSON_IsObject(props), "properties is an object"); + if (!cJSON_IsObject(props)) { + cJSON_Delete(schema); + continue; + } + for (const cJSON *p = props->child; p; p = p->next) { + int duplicates = 0; + for (const cJSON *q = props->child; q; q = q->next) + if (p->string && q->string && strcmp(p->string, q->string) == 0) + duplicates++; + CHECK(duplicates == 1, "property key appears exactly once"); + CHECK(cJSON_HasObjectItem(p, "type"), "property carries a type"); + } + cJSON_Delete(schema); + } + + cJSON *rf = tool_params_json("read_file"); + const cJSON *path = cJSON_GetObjectItem( + cJSON_GetObjectItem(rf, "properties"), "path"); + CHECK(cJSON_IsObject(path), "read_file has a path property"); + const cJSON *ptype = path ? cJSON_GetObjectItem(path, "type") : NULL; + CHECK(cJSON_IsString(ptype) && strcmp(ptype->valuestring, "string") == 0, + "read_file path type is string"); + const cJSON *pd = path ? cJSON_GetObjectItem(path, "description") : NULL; + CHECK(cJSON_IsString(pd) && pd->valuestring[0] != 0, + "read_file path keeps its description"); + char *rfstr = cJSON_PrintUnformatted(rf); + CHECK_CONT(rfstr, "\"path\":{\"type\":\"string\",\"description\":", + "read_file schema serializes path property"); + free(rfstr); + cJSON_Delete(rf); +} + +static void test_tokenize(void) { + strlist t; + sl_init(&t); + tool_tokenize("Billing Notes (DB)", &t); + CHECK(t.len == 3, "three tokens"); + CHECK(t.len == 3 && strcmp(t.items[0], "billing") == 0, "token 1"); + CHECK(t.len == 3 && strcmp(t.items[1], "notes") == 0, "token 2"); + CHECK(t.len == 3 && strcmp(t.items[2], "db") == 0, "token 3"); + sl_free(&t); + strlist empty; + sl_init(&empty); + tool_tokenize(" ", &empty); + CHECK(empty.len == 0, "whitespace-only query has no tokens"); + sl_free(&empty); +} + +static void test_is_subsequence(void) { + CHECK(tool_is_subsequence("abc", "a1b2c3"), "scattered chars match"); + CHECK(!tool_is_subsequence("abc", "acb"), "out-of-order rejected"); + CHECK(tool_is_subsequence("bill", "billed"), "prefix subsequence"); + CHECK(!tool_is_subsequence("billing", "billed"), "longer needle rejected"); +} + +static void test_field_match_strength(void) { + const char *field = "billing is monthly"; + double word = tool_field_match_strength(field, "billing"); + double sub = tool_field_match_strength(field, "billin"); + double fuzzy = tool_field_match_strength(field, "billng"); + double none = tool_field_match_strength(field, "zzz"); + CHECK(word > sub, "word beats substring"); + CHECK(sub > fuzzy, "substring beats fuzzy"); + CHECK(fuzzy > none, "fuzzy beats no match"); + CHECK(none == 0.0, "no match is zero"); + CHECK(tool_field_match_strength("abc", "ac") == 0.0, + "2-char terms get no fuzzy credit"); + CHECK(tool_field_match_strength("billing", "billing") == 1.0, + "exact match is 1.0"); + CHECK(tool_field_match_strength("", "x") == 0.0, "empty field is zero"); +} + +static memory mk_mem(const char *id, const char *importance, const char *title, + const char **tags, size_t ntags, const char *summary, + const char *body) { + memory m; + m.id = xstrdup(id); + m.title = xstrdup(title); + m.importance = xstrdup(importance); + m.tags = NULL; + m.tag_count = 0; + for (size_t i = 0; i < ntags; i++) + m.tags = (char* * ) realloc(m.tags, (m.tag_count + 1) * sizeof(char *)), + m.tags[m.tag_count++] = xstrdup(tags[i]); + m.summary = xstrdup(summary); + m.body = xstrdup(body); + m.layer = LAYER_WORKSPACE; + return m; +} + +static void free_mem(memory *m) { + free(m->id); + free(m->title); + free(m->importance); + for (size_t i = 0; i < m->tag_count; i++) + free(m->tags[i]); + free(m->tags); + free(m->summary); + free(m->body); +} + +static void test_memory_relevance(void) { + strlist q_billing; + sl_init(&q_billing); + tool_tokenize("billing", &q_billing); + memory in_title = + mk_mem("a", "low", "Billing", NULL, 0, "", ""); + memory in_body = mk_mem("b", "low", "Other", NULL, 0, "", + "billing monthly"); + CHECK(tool_memory_relevance(&in_title, &q_billing) > + tool_memory_relevance(&in_body, &q_billing), + "title hit outranks body-only hit"); + strlist q_two; + sl_init(&q_two); + tool_tokenize("billing note", &q_two); + memory covers_both = mk_mem("c", "low", "Billing Note", NULL, 0, "", ""); + memory covers_one = mk_mem("d", "low", "Billing", NULL, 0, "", ""); + CHECK(tool_memory_relevance(&covers_both, &q_two) > + tool_memory_relevance(&covers_one, &q_two), + "covering both terms outranks covering one"); + memory unrelated = mk_mem("e", "low", "Unrelated", NULL, 0, "", ""); + CHECK(tool_memory_relevance(&unrelated, &q_billing) == 0.0, + "no match scores zero"); + free_mem(&in_title); + free_mem(&in_body); + free_mem(&covers_both); + free_mem(&covers_one); + free_mem(&unrelated); + sl_free(&q_billing); + sl_free(&q_two); +} + +static void test_search_memories_ranks_relevance(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("memrank"); + wfile(dir, ".agents/memories/important.md", + "---\nid: important\ntitle: Random\nimportance: high\n---\n" + "We mention billing here."); + wfile(dir, ".agents/memories/relevant.md", + "---\nid: relevant\ntitle: Billing\nimportance: low\n---\n" + "Details."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + char *err = NULL; + char *out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "search_memories"); + if (!out && err) + fprintf(stderr, " err: %s\n", err); + char *p_relevant = out ? strstr(out, "## Billing") : NULL; + char *p_important = out ? strstr(out, "## Random") : NULL; + CHECK(p_relevant && p_important && p_relevant < p_important, + "relevance outranks importance"); + free(out); + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_search_memories_empty_query(void) { + char *err = NULL; + char *out = tool_search_memories(NULL, " ", &err); + CHECK(out == NULL, "blank query fails"); + CHECK(err && strstr(err, "query must not be empty") != NULL, + "error message"); + free(err); +} + +static void test_use_skill_and_search_memories_workspace(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("ws"); + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "Step one. Step two."); + wfile(dir, ".agents/memories/note.md", + "---\nid: note\ntitle: Billing Note\nimportance: high\n" + "tags: billing, db\n---\nWe bill monthly."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + char *err = NULL; + char *out = tool_use_skill(&dot, "demo", &err); + CHECK(out != NULL, "use_skill"); + if (!out && err) + fprintf(stderr, " use_skill err: %s\n", err); + CHECK_CONT(out, "Demo Skill", "skill name"); + CHECK_CONT(out, "Step one. Step two.", "skill body"); + free(out); + err = NULL; + out = tool_use_skill(&dot, "nope", &err); + CHECK(out == NULL, "unknown skill fails"); + CHECK(err && strstr(err, "no skill") != NULL, "error names available"); + free(err); + err = NULL; + out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "search_memories hit"); + CHECK_CONT(out, "Billing Note", "memory title"); + CHECK_CONT(out, "importance: high", "importance line"); + CHECK_CONT(out, "tags: billing, db", "tags line"); + free(out); + err = NULL; + out = tool_search_memories(&dot, "zzz-qqq-xxx", &err); + CHECK(out != NULL, "no-match search returns a message"); + CHECK_CONT(out, "no memories matching", "no-match message"); + free(out); + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +/* Tools must serve the session's loaded `.agents` snapshot, not re-read + * the disk (AUDIT: no live mid-session reload). */ +static void test_tools_read_snapshot_not_disk(void) { + char *home = getcwd(NULL, 0); + char *dir = tmpdir("snap"); + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "ORIGINAL BODY."); + wfile(dir, ".agents/memories/note.md", + "---\nid: note\ntitle: Billing Note\nimportance: high\n" + "tags: billing\n---\nORIGINAL MEMORY."); + if (chdir(dir) != 0) + exit(1); + dotagents dot; + dotagents_load(&dot); + + /* Mutate the on-disk state after the snapshot was taken. */ + wfile(dir, ".agents/skills/demo/skill.md", + "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n" + "REPLACED BODY."); + wfile(dir, ".agents/memories/note.md", "entirely new memory"); + wfile(dir, ".agents/skills/fresh/skill.md", + "---\nid: fresh\nname: Fresh\ndescription: added later\n---\n" + "brand new skill."); + + char *err = NULL; + char *out = tool_use_skill(&dot, "demo", &err); + CHECK(out != NULL, "skill served from snapshot"); + CHECK_CONT(out, "ORIGINAL BODY.", "pre-mutation body intact"); + CHECK_NOT_CONT(out, "REPLACED BODY", "disk change not visible"); + free(out); + err = NULL; + out = tool_search_memories(&dot, "billing", &err); + CHECK(out != NULL, "memory served from snapshot"); + CHECK_CONT(out, "ORIGINAL MEMORY.", "pre-mutation memory body"); + CHECK_NOT_CONT(out, "entirely new memory", "new disk file not visible"); + free(out); + err = NULL; + out = tool_use_skill(&dot, "fresh", &err); + CHECK(out == NULL, "skill added after snapshot is unknown"); + CHECK(err != NULL, "unknown-skill error set"); + free(out); + free(err); + + dotagents_free(&dot); + if (chdir(home) != 0) + exit(1); + free(home); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_write_file_creates_parents(void) { + char *dir = tmpdir("wpar"); + char *p = xstrfmt("%s/deep/deeper/file.txt", dir); + char *err = NULL; + char *out = tool_write_file(p, "nested", &err); + CHECK(out != NULL, "write_file with new parents"); + CHECK_CONT(out, "wrote 6 bytes to", "byte count reported"); + free(out); + char *back = tool_read_file(p, 0, 0, &err); + CHECK(back && strcmp(back, "nested") == 0, "content roundtrip"); + free(back); + free(p); + char *rm = xstrfmt("rm -rf '%s'", dir); + system(rm); + free(rm); + free(dir); +} + +static void test_display_vtable(void) { + const tool_display *op; + + op = tool_display_for("run_shell"); + CHECK(op != NULL, "run_shell has a vtable entry"); + CHECK(op->render_call != NULL, "run_shell has a custom renderer"); + CHECK(op->dim_result, "run_shell results render muted"); + + char *line = op->render_call("{\"command\":\"ls -la src\"}"); + CHECK(line != NULL, "renderer returns a line"); + CHECK_CONT(line, "$ ", "renders a shell prompt line"); + CHECK_CONT(line, "ls -la src", "shell line carries the command"); + CHECK_NOT_CONT(line, "command", "raw JSON key does not leak"); + CHECK(line && line[0] == '\n' && line[strlen(line) - 1] == '\n', + "line framed with newlines"); + free(line); + + CHECK(tool_display_for("run_shell")->render_call(NULL) == NULL, + "NULL args -> generic fallback"); + CHECK(tool_display_for("run_shell")->render_call("{}") == NULL, + "missing command -> generic fallback"); + CHECK(tool_display_for("run_shell")->render_call("nope") == NULL, + "bad json -> generic fallback"); + + op = tool_display_for("read_file"); + CHECK(op && op->render_call == NULL && op->dim_result, + "read_file: generic display, muted result"); + op = tool_display_for("write_file"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "write_file: generic display, normal result"); + op = tool_display_for("use_skill"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "use_skill: generic display, normal result"); + op = tool_display_for("search_memories"); + CHECK(op && op->render_call == NULL && !op->dim_result, + "search_memories: generic display, normal result"); + + CHECK(tool_display_for("frobnicate") == NULL, "unknown tool -> NULL"); + CHECK(tool_display_for("mcp__srv__tool") == NULL, "mcp tool -> NULL"); + CHECK(tool_display_for(NULL) == NULL, "NULL name -> NULL"); +} + +/* ------------------------------------------------------------------ */ +/* embed tool */ +/* ------------------------------------------------------------------ */ + +static dotagents embed_dot(const char *model, const char *key, + const char *url) { + dotagents d; + memset(&d, 0, sizeof d); + fm_sl_init(&d.warnings); + d.models.default_name = xstrdup("emb"); + d.models.presets = (model_preset *) xmalloc(sizeof *d.models.presets); + memset(d.models.presets, 0, sizeof *d.models.presets); + d.models.presets[0].name = xstrdup("emb"); + d.models.presets[0].model = model ? xstrdup(model) : NULL; + d.models.presets[0].api_key = key ? xstrdup(key) : NULL; + d.models.presets[0].base_url = url ? xstrdup(url) : NULL; + d.models.preset_count = 1; + d.has_models = true; + return d; +} + +static config embed_cfg(const char *model, const char *key, const char *url) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + if (model) + c.model = xstrdup(model); + if (key) + c.api_key = xstrdup(key); + if (url) + c.base_url = xstrdup(url); + return c; +} + +static void free_embed_cfg(config *c) { + free(c->model); + free(c->api_key); + free(c->base_url); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + +static void test_embed_resolve_profile_values(void) { + config c = embed_cfg(NULL, NULL, NULL); + dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1"); + embed_target t; + char *err = NULL; + CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok"); + CHECK(t.model && strcmp(t.model, "emb-model") == 0, "preset model"); + CHECK(t.api_key && strcmp(t.api_key, "emb-key") == 0, "preset api_key"); + CHECK(t.base_url && strcmp(t.base_url, "https://emb.example/v1") == 0, + "preset base_url"); + embed_target_free(&t); + dotagents_free(&d); + free_embed_cfg(&c); +} + +static void test_embed_resolve_falls_back_to_top_level(void) { + config c = embed_cfg("top-model", "top-key", "https://top.example/v1"); + dotagents d = embed_dot(NULL, NULL, NULL); /* preset with empty fields */ + embed_target t; + char *err = NULL; + CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok"); + CHECK(t.model && strcmp(t.model, "top-model") == 0, "inherits model"); + CHECK(t.api_key && strcmp(t.api_key, "top-key") == 0, "inherits api_key"); + CHECK(t.base_url && strcmp(t.base_url, "https://top.example/v1") == 0, + "inherits base_url"); + embed_target_free(&t); + dotagents_free(&d); + free_embed_cfg(&c); +} + +static void test_embed_resolve_errors(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + embed_target t; + char *err = NULL; + + CHECK(tool_embed_resolve(&c, NULL, &t, &err) == -1 && err, + "no models.json -> error"); + CHECK(err && strstr(err, "not configured"), "not-configured message"); + free(err); + err = NULL; + + dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1"); + free(d.models.default_name); + d.models.default_name = xstrdup("ghost"); + CHECK(tool_embed_resolve(&c, &d, &t, &err) == -1 && err, + "unknown preset -> error"); + CHECK(err && strstr(err, "ghost"), "unknown-preset message"); + free(err); + err = NULL; + dotagents_free(&d); + + config c2 = embed_cfg(NULL, NULL, NULL); + dotagents d2 = embed_dot("emb-model", NULL, "https://emb.example/v1"); + err = NULL; + CHECK(tool_embed_resolve(&c2, &d2, &t, &err) == -1 && err, + "preset missing api_key -> error"); + CHECK(err && strstr(err, "api_key"), "missing-key message"); + free(err); + dotagents_free(&d2); + free_embed_cfg(&c2); + + sl_free(&c.shell_allowlist); + sl_free(&c.shell_denylist); +} + +static void test_embed_request_body(void) { + char *err = NULL; + char *req = tool_embed_request("emb-model", "hello \"world\"\n", &err); + CHECK(req && !err, "request built"); + CHECK_CONT(req, "\"model\":\"emb-model\"", "model in body"); + CHECK_CONT(req, "\"input\":\"hello \\\"world\\\"\\n\"", "input escaped"); + free(req); + free(err); + err = NULL; + CHECK(tool_embed_request("", "x", &err) == NULL, "empty model rejected"); + free(err); + err = NULL; + CHECK(tool_embed_request("m", "", &err) == NULL, "empty text rejected"); + free(err); +} + +static void test_embed_url(void) { + char *u = tool_embed_url("https://avois.org/v1/"); + CHECK(u && strcmp(u, "https://avois.org/v1/embeddings") == 0, + "trailing slash trimmed"); + free(u); + u = tool_embed_url("http://localhost:11434"); + CHECK(u && strcmp(u, "http://localhost:11434/embeddings") == 0, + "plain base"); + free(u); + u = tool_embed_url(""); + CHECK(u && strcmp(u, "https://api.openai.com/v1/embeddings") == 0, + "default base"); + free(u); +} + +static void test_embed_parse(void) { + char *vec = NULL; + char *err = NULL; + const char *body = + "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\"," + "\"embedding\":[0.1,-0.2,0.3],\"index\":0}],\"model\":\"emb\"}"; + CHECK(tool_embed_parse(body, &vec, &err) == 0, "parse ok"); + CHECK(vec, "vector non-NULL"); + /* Compare values, not serialization: float formatting is not stable + * across FP environments (valgrind shifts last ulps). */ + cJSON *v = vec ? cJSON_Parse(vec) : NULL; + CHECK(v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3, + "vector has 3 elements"); + if (v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3) { + CHECK(fabs(cJSON_GetArrayItem(v, 0)->valuedouble - 0.1) < 1e-9, + "v[0] ~= 0.1"); + CHECK(fabs(cJSON_GetArrayItem(v, 1)->valuedouble - (-0.2)) < 1e-9, + "v[1] ~= -0.2"); + CHECK(fabs(cJSON_GetArrayItem(v, 2)->valuedouble - 0.3) < 1e-9, + "v[2] ~= 0.3"); + cJSON_Delete(v); + } + CHECK(!err, "no error"); + free(vec); + vec = NULL; + + CHECK(tool_embed_parse("{\"data\":[]}", &vec, &err) == -1, + "empty data -> error"); + CHECK(err && strstr(err, "data[0].embedding"), "missing-array message"); + free(err); + err = NULL; + CHECK(tool_embed_parse("{\"data\":[{\"embedding\":[1,\"x\"]}]}", &vec, + &err) == -1, + "non-numeric element -> error"); + free(err); + err = NULL; + CHECK(tool_embed_parse("not json", &vec, &err) == -1, "bad JSON -> error"); + free(err); + err = NULL; + CHECK(tool_embed_parse("", &vec, &err) == -1, "empty body -> error"); + free(err); +} + +static void test_embed_tool_unset(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + char *err = NULL; + CHECK(tool_embed("text", &c, NULL, &err) == NULL, "unset -> NULL"); + CHECK(err && strstr(err, "not configured"), "unset message"); + free(err); + sl_free(&c.shell_allowlist); + sl_free(&c.shell_denylist); +} + +static config mkllmcfg(void) { + config c; + memset(&c, 0, sizeof c); + sl_init(&c.shell_allowlist); + sl_init(&c.shell_denylist); + c.provider = PROV_OPENAI; + c.model = xstrdup("test-model"); + c.base_url = xstrdup("http://127.0.0.1:9/v1"); + c.api_key = xstrdup("k"); + return c; +} + +static void freellmcfg(config *c) { + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); + free((void *)c->model); + free((void *)c->base_url); + free((void *)c->api_key); +} + +/* ------------------------------------------------------------------ */ +/* llm tool */ +/* ------------------------------------------------------------------ */ + +static void test_llm_tool_schemas(void) { + const char *names[] = {"llm", "compact"}; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) { + const char *name = names[i]; + cJSON *schema = tool_params_json(name); + CHECK(schema != NULL, "schema non-NULL"); + if (!schema) + continue; + const cJSON *stype = cJSON_GetObjectItem(schema, "type"); + CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0, + "schema type is object"); + CHECK(cJSON_IsObject(cJSON_GetObjectItem(schema, "properties")), + "properties is an object"); + CHECK(cJSON_IsArray(cJSON_GetObjectItem(schema, "required")), + "required is an array"); + cJSON_Delete(schema); + } + + /* llm: required `prompt`, and every documented parameter present. */ + cJSON *llm = tool_params_json("llm"); + const cJSON *req = cJSON_GetObjectItem(llm, "required"); + int req_has_prompt = 0; + if (cJSON_IsArray(req)) { + for (const cJSON *r = req->child; r; r = r->next) + if (cJSON_IsString(r) && strcmp(r->valuestring, "prompt") == 0) + req_has_prompt = 1; + } + CHECK(req_has_prompt, "llm requires prompt"); + const char *props[] = {"prompt", "model", "temperature", + "max_tokens", "reasoning_effort", "thinking_budget", + "response_schema", "embed"}; + const cJSON *llmprops = cJSON_GetObjectItem(llm, "properties"); + for (size_t i = 0; i < sizeof props / sizeof props[0]; i++) + CHECK(cJSON_HasObjectItem(llmprops, props[i]), props[i]); + /* reasoning_effort is an enum of the three allowed values. */ + const cJSON *re = cJSON_GetObjectItem(llmprops, "reasoning_effort"); + const cJSON *reenum = cJSON_GetObjectItem(re, "enum"); + CHECK(cJSON_IsArray(reenum) && cJSON_GetArraySize(reenum) == 3, + "reasoning_effort enum has 3 values"); + cJSON_Delete(llm); + + /* compact: takes no parameters. */ + cJSON *cmp = tool_params_json("compact"); + const cJSON *cmpreq = cJSON_GetObjectItem(cmp, "required"); + CHECK(cJSON_IsArray(cmpreq) && cJSON_GetArraySize(cmpreq) == 0, + "compact required is empty"); + const cJSON *cmpprops = cJSON_GetObjectItem(cmp, "properties"); + CHECK(cJSON_IsObject(cmpprops) && cmpprops->child == NULL, + "compact properties is empty"); + cJSON_Delete(cmp); +} + +static void test_llm_tool_requires_prompt(void) { + config c = mkllmcfg(); + llm_tool_call call; + memset(&call, 0, sizeof call); + char *err = NULL; + CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "no prompt -> NULL"); + CHECK(err && strstr(err, "prompt"), "names the prompt"); + free(err); + freellmcfg(&c); +} + +static void test_llm_tool_bad_schema(void) { + config c = mkllmcfg(); + llm_tool_call call; + memset(&call, 0, sizeof call); + call.prompt = "hi"; + call.response_schema = "not-json"; + char *err = NULL; + CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "bad schema -> NULL"); + CHECK(err && strstr(err, "response_schema"), "names the schema"); + free(err); + freellmcfg(&c); +} + +static void test_llm_tool_null_call(void) { + config c = mkllmcfg(); + char *err = NULL; + CHECK(tool_llm(NULL, &c, NULL, &err) == NULL, "null call -> NULL"); + CHECK(err && strstr(err, "prompt"), "names the prompt"); + free(err); + freellmcfg(&c); +} + +int main(void) { + test_read_write_roundtrip(); + test_read_file_missing_errors(); + test_read_file_empty_path_errors(); + test_read_file_too_large(); + test_read_file_binary_rejected(); + test_read_file_offset_limit(); + test_read_file_offset_after_unterminated_last_line(); + test_read_file_rejects_embedded_nul(); + test_write_file_rejects_missing_content(); + test_run_shell_success_output(); + test_run_shell_success_no_output(); + test_run_shell_failure_status(); + test_run_shell_stderr_section(); + test_run_shell_signal(); + test_run_shell_denylist_blocks(); + test_run_shell_allowlist_requires_match(); + test_run_shell_confirm_hook_aborts(); + test_run_shell_timeout_kills_process_group(); + test_run_shell_large_output_does_not_hang(); + test_truncate_output(); + test_tool_list(); + test_display_vtable(); + test_tool_params_schemas(); + test_tokenize(); + test_is_subsequence(); + test_field_match_strength(); + test_memory_relevance(); + test_search_memories_ranks_relevance(); + test_search_memories_empty_query(); + test_use_skill_and_search_memories_workspace(); + test_tools_read_snapshot_not_disk(); + test_write_file_creates_parents(); + test_embed_resolve_profile_values(); + test_embed_resolve_falls_back_to_top_level(); + test_embed_resolve_errors(); + test_embed_request_body(); + test_embed_url(); + test_embed_parse(); + test_embed_tool_unset(); + test_llm_tool_schemas(); + test_llm_tool_requires_prompt(); + test_llm_tool_bad_schema(); + test_llm_tool_null_call(); + + printf("%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/valgrind.supp b/valgrind.supp new file mode 100644 index 0000000..194c7ed --- /dev/null +++ b/valgrind.supp @@ -0,0 +1,115 @@ +# OpenSSL process-global state allocated during curl_global_init (TLS backend +# init: namemaps, providers, cipher tables). Retained until process exit; not +# a real leak. "Still reachable" only - real leaks (definite/indirect/possible) +# are still reported and fail the gate via --error-exitcode. +{ + openssl_crypto_malloc + Memcheck:Leak + match-leak-kinds: reachable + fun:malloc + ... + obj:*crypto* + ... +} + +{ + openssl_crypto_realloc + Memcheck:Leak + match-leak-kinds: reachable + fun:realloc + ... + obj:*crypto* + ... +} + +{ + openssl_crypto_posix_memalign + Memcheck:Leak + match-leak-kinds: reachable + fun:posix_memalign + ... + obj:*crypto* + ... +} + +{ + openssl_crypto_calloc + Memcheck:Leak + match-leak-kinds: reachable + fun:calloc + ... + obj:*crypto* + ... +} + +{ + openssl_crypto_aligned_alloc + Memcheck:Leak + match-leak-kinds: reachable + fun:aligned_alloc + ... + obj:*crypto* + ... +} + +{ + openssl_ssl_malloc + Memcheck:Leak + match-leak-kinds: reachable + fun:malloc + ... + obj:*ssl* + ... +} + +{ + openssl_ssl_realloc + Memcheck:Leak + match-leak-kinds: reachable + fun:realloc + ... + obj:*ssl* + ... +} + +{ + openssl_ssl_posix_memalign + Memcheck:Leak + match-leak-kinds: reachable + fun:posix_memalign + ... + obj:*ssl* + ... +} + +{ + openssl_ssl_calloc + Memcheck:Leak + match-leak-kinds: reachable + fun:calloc + ... + obj:*ssl* + ... +} + +{ + openssl_ssl_aligned_alloc + Memcheck:Leak + match-leak-kinds: reachable + fun:aligned_alloc + ... + obj:*ssl* + ... +} + +# Thread-local storage (Dtv) allocated by glibc for each pthread_create. +# Retained until process exit; not a real leak. "Possibly lost" only. +{ + pthread_tls + Memcheck:Leak + match-leak-kinds: possible + fun:calloc + ... + fun:allocate_stack + ... +} diff --git a/vendor/src/cJSON-1.7.19.tar.gz b/vendor/src/cJSON-1.7.19.tar.gz Binary files differnew file mode 100644 index 0000000..7f5a46b --- /dev/null +++ b/vendor/src/cJSON-1.7.19.tar.gz diff --git a/vendor/src/curl-8.10.1.tar.gz b/vendor/src/curl-8.10.1.tar.gz Binary files differnew file mode 100644 index 0000000..e47652e --- /dev/null +++ b/vendor/src/curl-8.10.1.tar.gz diff --git a/vendor/src/musl-1.2.5.tar.gz b/vendor/src/musl-1.2.5.tar.gz Binary files differnew file mode 100644 index 0000000..7af3adc --- /dev/null +++ b/vendor/src/musl-1.2.5.tar.gz diff --git a/vendor/src/nlohmann-json-3.12.0.tar.gz b/vendor/src/nlohmann-json-3.12.0.tar.gz Binary files differnew file mode 100644 index 0000000..f17f79f --- /dev/null +++ b/vendor/src/nlohmann-json-3.12.0.tar.gz diff --git a/vendor/src/openssl-3.3.2.tar.gz b/vendor/src/openssl-3.3.2.tar.gz Binary files differnew file mode 100644 index 0000000..eeccaf3 --- /dev/null +++ b/vendor/src/openssl-3.3.2.tar.gz diff --git a/vendor/src/zlib-1.3.1.tar.gz b/vendor/src/zlib-1.3.1.tar.gz Binary files differnew file mode 100644 index 0000000..53fa48b --- /dev/null +++ b/vendor/src/zlib-1.3.1.tar.gz |