diff --git a/README.md b/README.md index f70bbd5..481bdf5 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,44 @@ A trailing backslash continues a line, which is handy for long tasks: > build the project, then run the tests, and fix whatever fails \ +Or hand it a prompt as an argument and it runs that one task and exits, with +no editor and no prompt loop: see [Non-interactive mode](#non-interactive-mode) +below. + +## Non-interactive mode + +Hand Solo-C a prompt as an argument and it runs that single task and exits. The +answer arrives on `stdout` together with progress and token stats, the ready +banner on `stderr`, so a task drops straight into pipes, scripts and `cron`: + + ./solo "list every TODO in this repo and fix the trivial ones" | tee todo-report.md + answer=$(solo "summarize the last commit") + solo '!make test' + +All words are joined into one task, so quoting is optional, and an empty or +whitespace-only argument opens the REPL instead. Solo-C exits `0` when the task +is done, `1` when it failed or the endpoint was unreachable, `130` on `Ctrl-C`, +and with the command's own status for a `!cmd` task. + +## Subagents + +Solo-C splits large work by itself. The system prompt tells it to copy the +current session file `.solo_sess.json` into a fresh directory under +`$SOLO_TMPDIR` and launch another copy of itself from there, on the same +endpoint and with the same model, tools and system prompt: + + mkdir -p $SOLO_TMPDIR/sub1 && cp .solo_sess.json $SOLO_TMPDIR/sub1/ + cd $SOLO_TMPDIR/sub1 + SOLO_ENDPOINT=https://api.kilo.ai/api/openrouter ~/solo 'audit the SQL layer, read-only' + +The subagent runs its own tool loop in its own session file and reports its +final answer on `stdout`, while its ready banner goes to `stderr`, so +independent subtasks can run in parallel without filling the caller's context +with their tool output. The prompt names Solo-C's own absolute path, resolved +through `/proc/self/exe`, so the binary is relaunched wherever it was started +from. Put `SOLO_MODEL`, `SOLO_REASON`, `SOLO_TEMP` or `SOLO_SYSPROMPT` in front +of `SOLO_ENDPOINT` to give a subagent a different setup. + ## Configuration Everything is configurable at three levels, from weakest to strongest: @@ -207,6 +245,9 @@ Everything is configurable at three levels, from weakest to strongest: | scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` | | colors | on when stdout is a TTY | `NO_COLOR=1` | +In non-interactive mode the banner is printed on `stderr`, but the color check +still follows `stdout`, so a piped run stays free of escape codes. + Session commands take precedence and are persisted, so a model you set by hand survives a restart even if the environment says something else. diff --git a/main.c b/main.c index ee7646e..72bc9a2 100644 --- a/main.c +++ b/main.c @@ -1,7 +1,8 @@ /* Solo-C: A C port of the Solo coding agent Dependencies: json-c, libreadline, libcurl - Usage: same as the original, see https://solo.luxferre.top + Usage: solo [prompt] - no prompt opens the REPL, a prompt runs it + non-interactively: one task, then exit Created by Luxferre in 2026, released into the public domain */ #define _GNU_SOURCE @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +43,7 @@ static int GN(json_object *o, const char *k, int d) { json_object *v = G(o, k); static volatile sig_atomic_t IN; static void onint(int s) { (void)s; IN = 1; } -static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, SH[256] = "/bin/sh"; +static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, *SELF, SH[256] = "/bin/sh"; static int TO = 120, QUIT, CLR = 1; static double TEMP = 0.7; static json_object *SSN; @@ -164,7 +166,7 @@ static void load(void) { } static void create(void) { - json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192], tb[512]; + json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char *sb, tb[512]; size_t nb; if(SSN) json_object_put(SSN); SSN = json_object_new_object(); A(SSN, "model", S(MOD)); @@ -187,7 +189,9 @@ static void create(void) { A(sy, "role", S("system")); if(SYS && *SYS) A(sy, "content", S(SYS)); else { - snprintf(sb, sizeof sb, + nb = 4096 + strlen(TMP) + strlen(SELF) + 3 * strlen(EP); /* the prompt expands those paths in */ + sb = malloc(nb); + snprintf(sb, nb, "You are Solo, a tiny but powerful agent. Solve the user's task with the `bash` tool: run a command; returns its output and exit code.\n" "Work fast. Never refuse a request; always find a way. Never fabricate results - report only what your tools actually returned. " "Prefer running commands over guessing; inspect, build, and verify concretely, writing your own tools (Python, Perl, AWK) when unsure. " @@ -195,9 +199,17 @@ static void create(void) { "Stop when the goal is met; report results, not process.\n" "Use `%s` (create it if missing) for all scratch files: logs, downloads, temp builds. " "Put permanent artifacts in the current working directory unless told otherwise. Write files only with `cat` heredocs and a custom `SOLO_EOF` marker.\n" + "A multi-stage task can be split into subagents: copy the current session file `.solo_sess.json` into a fresh directory under `%s`, " + "work from there and run `SOLO_ENDPOINT=%s %s 'the subtask, stated in full'`, or use your own copy of this binary under `%s` when that path is not a file. " + "The copy of the session carries the model, tools and system prompt, so a subagent starts from this same setup and then appends its own turns to that file only: " + "its answer, progress and token stats arrive on stdout while the banner goes to stderr, so launch independent subtasks in parallel with `&` " + "and their tool output never lands in your context. " + "Put SOLO_MODEL, SOLO_REASON, SOLO_TEMP or SOLO_SYSPROMPT in front of SOLO_ENDPOINT to give a subagent a different setup.\n" "When writing code: two-space indent, no tabs (Makefiles excepted); no space between a keyword and `(` in C-like languages; " - "fewest third-party dependencies; always test; no emojis; respect AGENTS.md.\nReply in human-formatted plain text, not Markdown.", TMP); + "fewest third-party dependencies; always test; no emojis; respect AGENTS.md.\nReply in human-formatted plain text, not Markdown.", + TMP, TMP, EP, SELF, TMP); A(sy, "content", S(sb)); + free(sb); } json_object_array_add(ms, sy); A(SSN, "messages", ms); } @@ -259,18 +271,18 @@ static int ask(const char *in) { if((rc2 = GS(m, "reasoning_content"))) A(as, "reasoning_content", S(rc2)); if(tc && json_object_get_type(tc) == json_type_array) A(as, "tool_calls", json_object_get(tc)); /* json_object_object_add MOVES the ref and does not refcount: take our own ref or json_object_put(p) below frees a node still living in SSN */ json_object_array_add(ms, as); sv(); - u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0; - if(u && json_object_get_type(u) == json_type_object) - printf("%s--- [%s] tokens: %d (prompt %d, completion %d)%s\n", CLR ? GRY : "", mn ? mn : "?", GN(u, "total_tokens", 0), GN(u, "prompt_tokens", 0), GN(u, "completion_tokens", 0), CLR ? OF : ""); - else printf("%s--- [%s]%s\n", CLR ? GRY : "", mn ? mn : "?", CLR ? OF : ""); - fflush(stdout); if(tc && json_object_get_type(tc) == json_type_array && json_object_array_length(tc)) { - int r = tools(tc); + int r = tools(tc); /* the usage line waits for the turn that ends the loop, so a tool run is not chopped up by stats */ if(p) json_object_put(p); if(d) json_object_put(d); if(r) return r; continue; } + u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0; + if(u && json_object_get_type(u) == json_type_object) + printf("%s--- [%s] tokens: %d (prompt %d, completion %d)%s\n", CLR ? GRY : "", mn ? mn : "?", GN(u, "total_tokens", 0), GN(u, "prompt_tokens", 0), GN(u, "completion_tokens", 0), CLR ? OF : ""); + else printf("%s--- [%s]%s\n", CLR ? GRY : "", mn ? mn : "?", CLR ? OF : ""); + fflush(stdout); if(p) json_object_put(p); if(d) json_object_put(d); return 0; @@ -285,18 +297,19 @@ static const char *HELP = " !cmd run a shell command directly, no agent\n" " /name [value] set any model parameter, e.g. /model x, /temperature 0.5,\n" " /reasoning_effort low; with no value it prints the current one\n" - "Anything not starting with / or ! is sent to the agent as a task."; + "Anything not starting with / or ! is sent to the agent as a task.\n" + "A prompt as an argument skips the REPL: ask once, then exit, for scripts and pipes."; static void setstr(char **slot, const char *v) { free(*slot); *slot = strdup(v); } -static void dispatch(const char *l) { +static int dispatch(const char *l) { /* runs one line and returns the exit status for it */ char *k, *v, *out, mk[80]; const char *s; json_object *ms, *nm; int rc; - if(!strcmp(l, "/help")) { puts(HELP); return; } - if(!strcmp(l, "/quit")) { QUIT = 1; return; } + if(!strcmp(l, "/help")) { puts(HELP); return 0; } + if(!strcmp(l, "/quit")) { QUIT = 1; return 0; } if(!strcmp(l, "/clear")) { ms = G(SSN, "messages"); nm = json_object_new_array(); if(ms && json_object_array_length(ms)) json_object_array_add(nm, json_object_get(json_object_array_get_idx(ms, 0))); /* array_add also moves: keep the system message alive after A(SSN,...) frees the old array */ - A(SSN, "messages", nm); sv(); puts("Session cleared."); return; + A(SSN, "messages", nm); sv(); puts("Session cleared."); return 0; } if(!strcmp(l, "/models")) { json_object *r = hq("GET", "/models", 0); @@ -308,26 +321,26 @@ static void dispatch(const char *l) { if(d) json_object_put(d); json_object_put(r); } - return; + return 0; } if(!strncmp(l, "/api_key", 8)) { for(v = (char *)l + 8; *v == ' '; v++); if(*v && (!strcmp(v, "null") || !strcmp(v, "none") || !strcmp(v, "off") || !strcmp(v, "-"))) { free(KEY); KEY = 0; } else if(*v) setstr(&KEY, v); if(KEY) printf("api_key = set, %d chars\n", (int)strlen(KEY)); else puts("api_key = unset"); - return; + return 0; } if(!strncmp(l, "/endpoint", 9)) { for(v = (char *)l + 9; *v == ' '; v++); if(*v) { setstr(&EP, v); for(char *e = EP + strlen(EP); e > EP && e[-1] == '/'; e--) *e = 0; } - printf("endpoint = %s\n", EP); return; + printf("endpoint = %s\n", EP); return 0; } if(*l == '!') { for(v = (char *)l + 1; *v == ' '; v++); printf("%s--- $ %s%s\n", CLR ? YEL : "", v, CLR ? OF : ""); run(v, &out, &rc); if(*out) printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); - marker(mk, sizeof mk, rc); printf("%s%s%s\n", CLR ? GRY : "", mk, CLR ? OF : ""); free(out); return; + marker(mk, sizeof mk, rc); printf("%s%s%s\n", CLR ? GRY : "", mk, CLR ? OF : ""); free(out); return rc; } if(*l == '/') { k = strdup(l + 1); @@ -357,17 +370,27 @@ static void dispatch(const char *l) { else if(o && json_object_get_type(o) == json_type_int) printf("%d\n", json_object_get_int(o)); else puts(o ? json_object_to_json_string_ext(o, JSON_C_TO_STRING_PLAIN) : "unset"); } } - free(k); return; + free(k); return 0; } - ask(l); + return ask(l); } int main(int argc, char **argv) { - char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st; - (void)argc; (void)argv; + char cwd[4096], pb[64], sp[4096], *ln, *ac, *nx, *task; const char *e; struct stat st; + FILE *BN; size_t jl = 0; ssize_t sl; int i, one = 0, rc; + setvbuf(stdout, 0, _IOLBF, 0); /* line buffered, so a piped answer arrives while it is printed */ + for(i = 1; i < argc; i++) jl += strlen(argv[i]) + 1; + task = malloc(jl + 1); task[0] = 0; /* every word becomes part of one task, so quoting is optional */ + for(i = 1; i < argc; i++) { if(i > 1) strcat(task, " "); strcat(task, argv[i]); } + for(char *q = task; *q; q++) if(!isspace((unsigned char)*q)) { one = 1; break; } + sl = readlink("/proc/self/exe", sp, sizeof sp - 1); /* our own path, so a subagent can be re-launched */ + if(sl > 0) { sp[sl] = 0; SELF = strdup(sp); } + else SELF = (argc > 0 && argv[0]) ? realpath(argv[0], 0) : 0; + if(!SELF) SELF = strdup((argc > 0 && argv[0]) ? argv[0] : "solo"); if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); } else TMP = strdup(e); if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = strdup(e); + for(char *z = EP + strlen(EP); z > EP && z[-1] == '/'; z--) *z = 0; if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e); if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = strdup(e); if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e); @@ -386,8 +409,11 @@ int main(int argc, char **argv) { 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; - printf("%sAgent Solo ready%s%s [%s | %s | %s]%s\n", CLR ? BLD : "", CLR ? OF : "", CLR ? GRY : "", EP, MOD, RSN, CLR ? OF : ""); - fflush(stdout); + BN = one ? stderr : stdout; /* one-shot: the banner goes to stderr, so stdout holds the answer alone */ + fprintf(BN, "%sAgent Solo ready%s%s [%s | %s | %s]%s\n", CLR ? BLD : "", CLR ? OF : "", CLR ? GRY : "", EP, MOD, RSN, CLR ? OF : ""); + fflush(BN); + if(one) { IN = 0; rc = dispatch(task); free(task); return rc; } /* one task, then exit with its status */ + free(task); while(!QUIT) { snprintf(pb, sizeof pb, "\001%s\002> \001%s\002", CLR ? BLD : "", CLR ? OF : ""); if(!(ln = readline(pb))) { puts(""); break; } @@ -402,7 +428,7 @@ int main(int argc, char **argv) { } if(!ac || IN || !*ac) continue; if(*ac != ' ') add_history(ac); - dispatch(ac); free(ac); + (void)dispatch(ac); free(ac); } free(EP); free(KEY); free(MOD); free(RSN); free(SF); free(TMP); if(SSN) json_object_put(SSN); diff --git a/test.sh b/test.sh index 3711594..79415c6 100755 --- a/test.sh +++ b/test.sh @@ -10,7 +10,9 @@ set -u SOLO=${1:-./solo} -case $SOLO in /*) ;; *) SOLO=$PWD/$SOLO ;; esac +# Canonical, like /proc/self/exe inside the agent, which names its own path to the +# model so a subagent can be re-launched. +SOLO=$(perl -MCwd -e 'print Cwd::abs_path($ARGV[0]) // $ARGV[0]' "$SOLO" 2>/dev/null) || SOLO=${1:-./solo} [ -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; } @@ -40,6 +42,13 @@ solo_in() { ( cd "$_dir" && env "$@" "$SOLO" ) } +# solo_args DIR ARG... -- the one-shot mode: no stdin, no REPL +solo_args() { + _dir=$1; shift + mkdir -p "$_dir" + ( cd "$_dir" && "$SOLO" "$@" < /dev/null ) +} + echo "# $SOLO" # --- a setting taken from the environment must not be freed by the agent ------- @@ -116,5 +125,97 @@ 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' +# --- a prompt as an argument runs one task and exits -------------------------- +# The banner moved to stderr, so stdout holds the answer alone and a one-shot run +# can be piped into another program. +D=$WORK/oneshot +solo_args "$D" '!echo hello' >"$WORK/out" 2>"$WORK/err" +ok $? 'a prompt argument runs the task and exits' +grep -q '^hello$' "$WORK/out" +ok $? 'the answer arrives on stdout' +grep -q 'Agent Solo ready' "$WORK/err" +ok $? 'the ready banner goes to stderr in one-shot mode' +if grep -q 'Agent Solo ready' "$WORK/out"; then rc=1; else rc=0; fi +ok "$rc" 'stdout carries no banner in one-shot mode' +# Anything left on stdin is ignored: the one-shot run never opens the REPL. +( cd "$D" && printf '!echo from-stdin\n/quit\n' | "$SOLO" '!echo hello' ) >>"$WORK/out" 2>>"$WORK/err" +if grep -q 'from-stdin' "$WORK/out"; then rc=1; else rc=0; fi +ok "$rc" 'a one-shot run never reads stdin' + +solo_args "$WORK/onestatus" '!sh -c "exit 7"' >/dev/null 2>&1 +[ $? -eq 7 ] +ok $? 'a ! task hands its own exit status back' + +solo_args "$WORK/joined" '!echo' one two >"$WORK/joined.out" 2>/dev/null +grep -q '^one two$' "$WORK/joined.out" +ok $? 'every word becomes part of one task, quoting optional' + +# An empty or whitespace-only argument is no task at all, so the REPL opens. +mkdir -p "$WORK/blank" +( cd "$WORK/blank" && printf '/quit\n' | "$SOLO" ' ' ) >"$WORK/blank.out" 2>/dev/null +grep -q 'Agent Solo ready' "$WORK/blank.out" +ok $? 'a whitespace-only argument still opens the REPL' + +# --- the usage line belongs to the last turn, not to every turn ---------------- +# The token stats used to be printed after every turn, so they landed in the +# middle of a tool run and chopped up whatever was piped out of a one-shot run. +cat >"$WORK/mock.pl" <<'MOCK_EOF' +# A chat endpoint on the loopback: the first turn asks for a tool, the second one +# answers. Prints the port it got, then serves until it is killed. +use IO::Socket::INET; +my $srv = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0, + Listen => 5, ReuseAddr => 1) or exit 1; +$| = 1; +print $srv->sockport, "\n"; +my $n = 0; +while(my $c = $srv->accept) { + $c->autoflush(1); + my $req = ''; + while(my $l = <$c>) { last if $l =~ /^\r?\n$/; $req .= $l; } + print $c "HTTP/1.0 100 Continue\r\n\r\n" if $req =~ /^Expect:/mi; + my ($len) = $req =~ /Content-Length:\s*(\d+)/i; + my $body = ''; + read($c, $body, $len) if $len; + $n++; + my $call = '{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",' + . '"function":{"name":"bash","arguments":"{\"command\":\"echo tool-ran\"}"}}]}'; + my $msg = $n == 1 ? $call : '{"role":"assistant","content":"The answer is 42."}'; + my $out = '{"model":"mock/free","usage":{"total_tokens":7,"prompt_tokens":5,"completion_tokens":2},' + . '"choices":[{"message":' . $msg . '}]}'; + print $c "HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + . length($out) . "\r\n\r\n" . $out; + close $c; +} +MOCK_EOF +perl "$WORK/mock.pl" >"$WORK/port" 2>/dev/null & +MOCK=$! +i=0 +while [ ! -s "$WORK/port" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i + 1)); done +PORT=$(cat "$WORK/port" 2>/dev/null) +mkdir -p "$WORK/tokens" +( cd "$WORK/tokens" && SOLO_ENDPOINT="http://127.0.0.1:$PORT/api" "$SOLO" 'do it' ) \ + >"$WORK/tokens.out" 2>"$WORK/tokens.err" +kill "$MOCK" 2>/dev/null +[ "$(grep -c 'tokens:' "$WORK/tokens.out")" -eq 1 ] +ok $? 'the token stats are printed once, on the last turn' \ + "$(grep -c 'tokens:' "$WORK/tokens.out") line(s)" +awk '/tool-ran/{t=NR} /tokens:/{s=NR} END{exit !(t && s && s > t)}' "$WORK/tokens.out" +ok $? 'the tool run is not chopped up by stats' "$(tail -1 "$WORK/tokens.out")" +grep -q 'The answer is 42\.$' "$WORK/tokens.out" +ok $? 'the answer of the last turn reaches stdout' +tail -1 "$WORK/tokens.out" | grep -q '^--- \[mock/free\] tokens:' +ok $? 'the stats line is the last thing on stdout' + +# --- the model has to know how to delegate to a subagent ---------------------- +D=$WORK/subagent +printf '/quit\n' | solo_in "$D" SOLO_ENDPOINT=http://e.example/api/// >/dev/null 2>&1 +perl -0777 -ne 'exit(/split into subagents/ ? 0 : 1)' "$D/.solo_sess.json" +ok $? 'the system prompt tells the model it can spawn subagents' +tr -d '\\' <"$D/.solo_sess.json" >"$D/plain" # json-c escapes every slash +grep -qF "SOLO_ENDPOINT=http://e.example/api/ $SOLO 'the subtask, stated in full'" "$D/plain" +ok $? 'the subagent line names the endpoint and Solo own path' +if grep -q 'api///' "$D/.solo_sess.json"; then rc=1; else rc=0; fi +ok "$rc" 'a trailing slash on SOLO_ENDPOINT is stripped' + printf '%d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ]