add non-interactive mode and the subagent delegation prompt

Port the two latest changes of the Perl original.

Non-interactive mode: a prompt as an argument runs that one task and exits.
Every word is joined into a single task, so quoting is optional, and an empty
or whitespace-only argument still opens the REPL. The ready banner moves to
stderr so stdout carries the answer, progress and token stats alone, stdout
is line buffered so a piped run arrives as it is written, and the process
exits with the task status: the command's own for !cmd, 1 on failure, 130 on
Ctrl-C. dispatch() returns an int for that.

Subagent delegation: the system prompt now tells the model it can split a
multi-stage task by copying .solo_sess.json into a fresh directory under the
scratch dir and re-launching this binary from there with the same endpoint,
model, tools and prompt, which the copy carries over. The prompt names our
own path, resolved through /proc/self/exe, and notes that a subagent answers
on stdout while its banner goes to stderr, so independent subtasks run in
parallel with &. The prompt buffer is now sized from the expanded paths, and
trailing slashes are stripped from SOLO_ENDPOINT as in the original, so the
command in the prompt is always well formed.

Also print the token stats only on the turn that ends the tool loop, as the
original does: they used to land in the middle of a tool run and chop up
whatever was piped out of a one-shot run.

Tests go to 28, all offline: 15 new ones, including a loopback mock endpoint
that drives a tool call and checks where the stats land.
This commit is contained in:
Luxferre
2026-10-04 17:00:39 +03:00
parent ecc48cd472
commit 141c6cc414
3 changed files with 196 additions and 28 deletions
+41
View File
@@ -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.
+53 -27
View File
@@ -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 <unistd.h>
#include <signal.h>
#include <errno.h>
#include <ctype.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/wait.h>
@@ -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);
+102 -1
View File
@@ -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 ]