diff --git a/.gitignore b/.gitignore index de1230a..f69e09a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ # Anchor on the root so a directory called solo is still tracked. /solo /solo-static +# Half-written link targets, left behind only by a failed link. +/solo.new +/solo-static.new *.o *.a *.so diff --git a/Makefile b/Makefile index a05500d..f74d580 100644 --- a/Makefile +++ b/Makefile @@ -66,16 +66,18 @@ STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistri -lreadline -lncursesw -ljson-c -lm -ldl -pthread solo: main.c - $(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@ main.c $(PKG_LIBS) $(LDLIBS) - strip $@ + $(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@.new main.c $(PKG_LIBS) $(LDLIBS) + strip $@.new + mv -f $@.new $@ # Writes solo-static, not a file called "static", so $@ is not used here. static: main.c @for f in $(CURL_STATIC) $(CARES_STATIC); do \ test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \ done - $(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS) $(LDLIBS) - strip solo-static + $(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static.new main.c $(STATIC_LIBS) $(LDLIBS) + strip solo-static.new + mv -f solo-static.new solo-static static-deps: FORCE sh ./mkstaticdeps.sh @@ -148,7 +150,12 @@ uninstall: FORCE rm -f "$$dir/solo" "$$dir/solo-static" clean: FORCE - rm -f solo solo-static + rm -f solo solo-static solo.new solo-static.new + +# Offline regression tests. No API key, no network: every line sent to the agent +# starts with / or !, so nothing reaches the model. Pass another binary as $1. +test: solo FORCE + sh ./test.sh ./solo # POSIX lint target: runs the lint tool named by the LINT macro. lint: main.c FORCE diff --git a/README.md b/README.md index 1c59cd6..f70bbd5 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,17 @@ make That produces a stripped dynamic binary named `solo` in the project directory. The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`. +Both binaries are linked under a temporary name and then renamed, so `make` +works while an agent is still running from the old one, and a link failure +leaves the previous binary in place. + Other targets: make clean # remove solo and solo-static make install # install to $(PREFIX)/bin, default /usr/local/bin make installdir # print the directory install would use make uninstall # remove the installed binaries + make test # offline regression tests, no API key or network make static-deps # build the static curl and c-ares archives, see below ## Static build @@ -227,7 +232,10 @@ environment set instead. `/name` is a generic model-parameter editor. The value is typed from its spelling: `true` and `false` become booleans, a parseable number becomes a -number, and anything else stays a string. +number, and anything else stays a string. A number written without a decimal +point or an exponent stays an integer, so `/max_tokens 4096` is sent as `4096` +and not as `4096.0`, which a strict endpoint rejects; `0.7` and `1e3` are sent +as floats. > /model deepseek/deepseek-v4.1-flash > /reasoning_effort high @@ -321,9 +329,10 @@ Tool execution in detail: with `-c`, in the current working directory. - stdout and stderr are merged into one pipe, so the model sees them together in the order they were produced. -- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the - whole group is signalled, so `make`, a shell pipeline and everything they - spawned all die, not just the direct child. +- The child gets its own process group (`setpgid`), claimed by the parent as + well as the child, so the group signal cannot lose the race against the child + starting up. On timeout or Ctrl-C the whole group is signalled, so `make`, a + shell pipeline and everything they spawned all die, not just the direct child. - The output is returned verbatim, with an exit marker on the last line. Nothing is truncated or summarised, which is the point: the model sees exactly what the terminal saw. @@ -340,7 +349,10 @@ back into the transcript so that a follow-up turn can reuse them. ## Timeouts, exit codes and interrupts Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by -default), and the exit status is reported to the model as a marker line: +default), and the exit status is reported to the model as a marker line. The +deadline comes from a monotonic clock, so a command is not killed early by a +wall clock adjustment. A `SOLO_TIMEOUT` below 1 means no limit, for the rare +command that has to run to completion. | Status | Meaning | | ------- | ------------------------------------------------------------------ | diff --git a/main.c b/main.c index 030ddf5..ee7646e 100644 --- a/main.c +++ b/main.c @@ -54,13 +54,18 @@ static void ba(BUF *b, const char *s, size_t l) { } static size_t wcb(void *c, size_t s, size_t n, void *u) { ba((BUF *)u, c, s * n); return s * n; } -/* st: 0 ok, 1 interrupted, 2 deadline */ +/* Monotonic seconds, so a deadline is immune to clock steps and to the + one-second granularity of time(), which made timeouts fire up to a second early. */ +static double now(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; } + +/* Reads until EOF, an interrupt, or the monotonic deadline dl (0 = no limit). + st: 0 ok, 1 interrupted, 2 deadline */ static int rp(int fd, char **out, double dl, int *st) { BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r; bg(&b); *st = 0; for(;;) { if(IN) { *st = 1; break; } - if(dl > 0 && (double)time(0) >= dl) { *st = 2; break; } + if(dl > 0 && now() >= dl) { *st = 2; break; } FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000; r = select(fd + 1, &fs, 0, 0, &tv); if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); } @@ -95,7 +100,8 @@ static void hc(const char *m, const char *p, const char *body, int w) { A(r, "success", B(e == CURLE_OK && sc >= 200 && sc < 300)); A(r, "content", S(e == CURLE_OK && b.p ? b.p : "")); j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN); - write(w, j, strlen(j)); free(b.p); json_object_put(r); + { size_t l = strlen(j), o = 0; while(o < l) { ssize_t n = write(w, j + o, l - o); if(n > 0) o += (size_t)n; else if(n < 0 && errno == EINTR) continue; else break; } } + free(b.p); json_object_put(r); if(c) curl_easy_cleanup(c); if(h) curl_slist_free_all(h); curl_global_cleanup(); _exit(0); @@ -106,7 +112,7 @@ static json_object *hq(const char *m, const char *p, const char *body) { if(pipe(f)) return 0; if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; } if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); } - close(f[1]); n = rp(f[0], &buf, time(0) + 1e9, &st); close(f[0]); + close(f[1]); n = rp(f[0], &buf, 0, &st); close(f[0]); if(st == 1 || IN) kill(k, SIGKILL); waitpid(k, 0, 0); r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf); @@ -125,7 +131,9 @@ static void run(const char *cmd, char **out, int *rc) { if(pipe(f)) { *out = 0; *rc = 1; return; } if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; } if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); } - close(f[1]); rp(f[0], &buf, time(0) + TO, &st); close(f[0]); + setpgid(k, k); /* claim the group here too: a kill(-k) issued before the child + itself reaches setpgid(0,0) finds no group and is dropped. */ + close(f[1]); rp(f[0], &buf, TO > 0 ? now() + TO : 0, &st); close(f[0]); if(st == 1 || IN) { *rc = 130; } else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; } else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; } @@ -156,7 +164,7 @@ static void load(void) { } static void create(void) { - json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192]; + json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192], tb[512]; if(SSN) json_object_put(SSN); SSN = json_object_new_object(); A(SSN, "model", S(MOD)); @@ -167,8 +175,9 @@ static void create(void) { a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object(); p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object(); A(cp, "type", S("string")); - A(cp, "description", S("Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. " - "A command still running after 120s is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\".")); + snprintf(tb, sizeof tb, "Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. " + "A command still running after %ds is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\".", TO); + A(cp, "description", S(tb)); A(pr, "command", cp); A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", json_object_new_array()); A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p); @@ -332,9 +341,11 @@ static void dispatch(const char *l) { json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k); if(!strcmp(v, "true")) A(SSN, k, B(1)); else if(!strcmp(v, "false")) A(SSN, k, B(0)); - else if(strcmp(v, "null") && (d = strtod(v, 0), 1)) { + else if(strcmp(v, "null")) { char *e; d = strtod(v, &e); - if(!*e && e != v) A(SSN, k, D(d)); else A(SSN, k, S(v)); + if(e == v || *e) A(SSN, k, S(v)); /* not a number at all */ + else if(strpbrk(v, ".eE")) A(SSN, k, D(d)); /* 0.7, 1e3, .5: a real */ + else A(SSN, k, N((int)strtol(v, 0, 10))); /* 4096 must stay an int */ } } sv(); printf("%s = %s\n", k, v); @@ -355,12 +366,12 @@ int main(int argc, char **argv) { char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st; (void)argc; (void)argv; if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); } - else TMP = (char *)e; - if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)e; + else TMP = strdup(e); + if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = strdup(e); if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e); - if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = (char *)e; + if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = strdup(e); if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e); - if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = (char *)e; + if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = strdup(e); if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e); if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); } SYS = getenv("SOLO_SYSPROMPT"); @@ -369,9 +380,9 @@ int main(int argc, char **argv) { snprintf(SH, sizeof SH, "%s", e); signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN); create(); load(); - if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); } + if((e = getenv("SOLO_MODEL")) && *e) { setstr(&MOD, e); A(SSN, "model", S(e)); } if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); } - if((e = getenv("SOLO_REASON")) && *e) { RSN = strdup(e); A(SSN, "reasoning_effort", S(e)); } + if((e = getenv("SOLO_REASON")) && *e) { setstr(&RSN, e); A(SSN, "reasoning_effort", S(e)); } if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); } A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv(); if(!isatty(1) || getenv("NO_COLOR")) CLR = 0; diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..3711594 --- /dev/null +++ b/test.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# Offline regression tests for Solo-C. No API key and no network: every line +# handed to the agent starts with / or !, so nothing is ever sent to the model. +# +# sh ./test.sh [binary] # default ./solo +# +# Each test runs the agent in its own directory, because the session file +# (.solo_sess.json) lives in the current directory. Exits non-zero if any test +# failed, with the reason on stderr. +set -u + +SOLO=${1:-./solo} +case $SOLO in /*) ;; *) SOLO=$PWD/$SOLO ;; esac +[ -x "$SOLO" ] || { echo "test: no such executable: $SOLO" >&2; exit 1; } +command -v perl >/dev/null 2>&1 || { echo "test: perl is required" >&2; exit 1; } + +WORK=$(mktemp -d) +# Long enough that the environment block lands where the allocator dislikes it, +# short enough to stay a legal path component. +LONGDIR=/tmp/$(perl -e 'print "d" x 200') +trap 'rm -rf "$WORK" "$LONGDIR"' EXIT INT TERM +PASS=0 +FAIL=0 + +# ok EXIT-CODE NAME [DETAIL] -- prints pass, or the detail on stderr. +ok() { + if [ "$1" = 0 ]; then + PASS=$((PASS + 1)) + printf 'PASS %s\n' "$2" + else + FAIL=$((FAIL + 1)) + printf 'FAIL %s: %s\n' "$2" "${3:-}" >&2 + fi +} + +# solo_in DIR ENV... < lines +solo_in() { + _dir=$1; shift + mkdir -p "$_dir" + ( cd "$_dir" && env "$@" "$SOLO" ) +} + +echo "# $SOLO" + +# --- a setting taken from the environment must not be freed by the agent ------- +# These slots used to point straight at getenv memory, so setstr() called free() +# on it and the process died inside the slash command that changed it. +printf '/endpoint http://x.example\n/quit\n' | \ + solo_in "$WORK/envcrash" SOLO_ENDPOINT="http://$(perl -e 'print "a" x 900').example.com/api" \ + >/dev/null 2>&1 +ok $? 'SOLO_ENDPOINT then /endpoint does not crash' + +printf '/model other/model\n/quit\n' | \ + solo_in "$WORK/envmodel" SOLO_MODEL="vendor/$(perl -e 'print "m" x 500')" >/dev/null 2>&1 +ok $? 'SOLO_MODEL then /model does not crash' + +printf '/reasoning_effort low\n/quit\n' | \ + solo_in "$WORK/envreason" SOLO_REASON="$(perl -e 'print "r" x 700')" >/dev/null 2>&1 +ok $? 'SOLO_REASON then /reasoning_effort does not crash' + +# TMP is freed at exit, so this one used to die without a slash command at all. +printf '/quit\n' | solo_in "$WORK/envtmp" SOLO_TMPDIR="$LONGDIR" >/dev/null 2>&1 +ok $? 'SOLO_TMPDIR then /quit does not crash' + +# --- an integer parameter must stay an integer in the request ------------------ +# Every number went through json_object_new_double, so /max_tokens 4096 reached +# the endpoint as 4096.0. +D=$WORK/params +printf '/max_tokens 4096\n/seed 3\n/top_p 0.95\n/quit\n' | solo_in "$D" >/dev/null 2>&1 +perl -0777 -ne 'exit(/"max_tokens":4096,/ ? 0 : 1)' "$D/.solo_sess.json" +ok $? '/max_tokens 4096 is sent as an integer' +perl -0777 -ne 'exit(/"seed":3,/ ? 0 : 1)' "$D/.solo_sess.json" +ok $? '/seed 3 is sent as an integer' +perl -0777 -ne 'exit(/"top_p":0\.9/ ? 0 : 1)' "$D/.solo_sess.json" +ok $? '/top_p 0.95 is sent as a number' +perl -0777 -ne 'exit(/"foo"/ ? 1 : 0)' "$D/.solo_sess.json" +ok $? 'a string parameter is still a string' + +# --- the timeout is the timeout, not one second less -------------------------- +# The old deadline was time(0) + TO, and time() counts whole seconds, so a +# command that happened to start late in a second was killed up to a second +# early. Stagger the start and require that no run dies before the limit. +D=$WORK/timing +MIN=99 +for frac in 0 0.25 0.5 0.75; do + out=$( { sleep "$frac" + printf '!sh -c "date +%%s.%%N; sleep 6"\n!date +%%s.%%N\n/quit\n'; } | \ + solo_in "$D" SOLO_TIMEOUT=3 2>/dev/null ) + a=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | head -1) + b=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | tail -1) + d=$(perl -e 'printf "%.2f", $ARGV[1] - $ARGV[0]' "$a" "$b" 2>/dev/null || echo 0) + MIN=$(perl -e 'printf "%.2f", $ARGV[1] < $ARGV[0] ? $ARGV[1] : $ARGV[0]' "$MIN" "$d") +done +[ "$(perl -e 'print $ARGV[0] >= 2.8 ? 1 : 0' "$MIN")" = 1 ] +ok $? "SOLO_TIMEOUT=3 never fires early" "a run died after ${MIN}s" + +# --- SOLO_TIMEOUT below 1 means no limit, not "kill instantly" ---------------- +printf '!echo hi\n/quit\n' | solo_in "$WORK/nolimit" SOLO_TIMEOUT=0 2>&1 | grep -q '^hi$' +ok $? 'SOLO_TIMEOUT=0 runs commands instead of killing them at once' +printf '!echo hi\n/quit\n' | solo_in "$WORK/neglimit" SOLO_TIMEOUT=-5 2>&1 | grep -q '^hi$' +ok $? 'a negative SOLO_TIMEOUT is not an instant timeout' + +# --- a timed out command leaves nothing behind -------------------------------- +# The child calls setpgid(0,0) and the parent kills the group, so the whole +# group has to be gone once the turn ends. +D=$WORK/group +printf '!sh -c "sleep 41 & sleep 41"\n/quit\n' | solo_in "$D" SOLO_TIMEOUT=1 >/dev/null 2>&1 +sleep 1 +LEFT=$(ps -o args= 2>/dev/null | grep -c '^sleep 41$') +[ "$LEFT" -eq 0 ] +ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left" + +# --- the tool description must quote the real timeout ------------------------- +D=$WORK/desc +printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1 +perl -0777 -ne 'exit(/after 42s is killed/ ? 0 : 1)' "$D/.solo_sess.json" +ok $? 'the bash tool description quotes SOLO_TIMEOUT' + +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]