Compare commits

...
6 Commits
Author SHA1 Message Date
Luxferre 7550c70df3 fix stale timeout, empty tool calls and trailing-space commands
Three defects from a review of the agent loop.

A session's tool schema is built by create(), and load() then replaced the
whole object with the one on disk, so a session saved under SOLO_TIMEOUT=42
went on telling the model the 42 s limit while run() enforced whatever the
environment said now. The schema moves into mkschema() and is rebuilt after
the load, so the description and the deadline always agree.

A tool call whose arguments held no command, which a flaky router or a
truncated stream can produce, was passed to the shell as an empty string.
The shell ran it as [exit 0], the agent asked again, and a peer that always
did this pinned the loop at full speed: a mock measuring 685 requests in
five seconds of the same call. Such a call is now answered with an error
instead of a shell run, command is declared required in the schema, and
three rounds in a row that run nothing end the turn. The same mock now
stops after three requests.

dispatch() read a line without trimming it, so "/help " was taken as the
generic parameter editor and wrote a help key into the session, "/quit "
did not quit, and "/temperature 0.5   " stored trailing spaces. A line
that starts with / or ! is right-trimmed before it is routed; a task keeps
its exact text, leading space included, so the model still sees what was
typed and a leading space still keeps the line out of the history.

Eleven offline regression tests cover the three, including a mock endpoint
that sends nothing but empty tool calls. make test: 42 passed, 0 failed.
2026-10-05 19:05:44 +03:00
Luxferre fdd389e452 fix Ctrl-C swallowing the next line
readline only redraws the prompt on Ctrl-C: the half-typed text
stays in its buffer and the interrupt flag outlives the prompt,
so the line typed next was silently discarded. Register a signal
event hook that empties the line, the way bash does, and clear
the flag around readline instead of eating the line: a Ctrl-C
during a command still aborts the turn, and one during a
multi-line task still abandons it. Two pty-based regression
tests cover the idle-prompt and mid-line cases.
2026-10-04 21:32:50 +03:00
Luxferre 141c6cc414 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.
2026-10-04 17:00:39 +03:00
Luxferre ecc48cd472 fix six defects and add an offline test suite
Four settings read from the environment (endpoint, model, reasoning,
scratch dir) pointed straight at getenv() memory instead of a copy,
and both setstr() and the cleanup at exit called free() on them.
Changing one of them from inside the session therefore freed the
environment block: "/endpoint http://x.example" with SOLO_ENDPOINT
set died inside the handler, and SOLO_TMPDIR killed the process on the
way out without a slash command at all. Reproduced on musl, both are
copies now, and the two post-load env re-reads go through setstr() so
they stop leaking the value create() already stored.

The generic parameter handler typed every number with
json_object_new_double, so "/max_tokens 4096" reached the endpoint as
4096.0 and "/seed 3" as 3.0. A number written without a decimal point
or an exponent now stays an integer, which is what the OpenAI schema
asks for; 0.7, .5 and 1e3 are still floats, and a word is still a
string.

The deadline for a command was time(0) + SOLO_TIMEOUT, and time()
counts whole seconds, so the effective limit was the timeout minus
whatever fraction of the second the command happened to start in.
Measured with SOLO_TIMEOUT=3, commands died after 2.38s, 2.47s, 2.68s
and 3.19s depending on the phase. rp() takes its deadline from
clock_gettime(CLOCK_MONOTONIC) now, which is also immune to the clock
being stepped underneath it.

A SOLO_TIMEOUT below 1 gave rp() a deadline already in the past, so
every command failed at once with "still running after 0s", and
atoi on anything unparseable lands there too. Below 1 now means no
limit. The API read, which passed time(0) + 1e9 to mean "never",
passes no deadline at all.

Only the child called setpgid(0,0), so a kill(-k, ...) issued before
the child reached it found no process group and was dropped, leaving
the command running as an orphan. A standalone harness confirms both
halves: without the parent claim the signal is lost and the child
runs to completion, with it the child dies. The parent claims the
group too, which is the belt to the child's braces.

hc() wrote the response envelope with one uncounted write(), so a
signal arriving mid-write truncated the JSON and the parent reported
"request failed" for a request that had in fact succeeded.

Both link steps write to <name>.new and rename it. Overwriting a
running executable fails with ETXTBSY, which is exactly what happens
when the agent rebuilds the binary it is itself running from, and a
failed link used to take the working binary with it. make clean now
removes the temp names as well.

test.sh holds 13 offline tests: no API key and no network, every line
handed to the agent starting with / or !, each test in its own
directory because the session file lives in the cwd. They cover the
four crash paths, integer/float/string typing, a staggered-start
sweep for the early timeout, SOLO_TIMEOUT=0 and a negative value,
process group cleanup after a timeout, and the tool description
quoting the real timeout. make test runs them. The README documents
the timeout semantics, the integer typing and the new build step.

Verified: 13 passed, 0 failed against the new binary, and 8 of the
13 fail against the previous one, which is what makes them regression
tests rather than decoration. Still silent under -Wall -Wextra and
under -Wshadow -Wformat=2 -Wcast-align -Wwrite-strings
-Wstrict-prototypes -Wmissing-prototypes -Wredundant-decls -Wundef.
Built and tested on Alpine gcc 15.2 (musl) with libcurl 8.22.0,
json-c 0.19 and readline 8.3.
2026-10-04 06:06:06 +00:00
Luxferre c0d0b2633d fix wrapping of input lines that overflow the terminal width
The prompt passed to readline() contained raw ANSI SGR sequences
("\e[1;37m" and "\e[0m") with no marker for readline to exclude them
from its column accounting, so readline counted those 9 bytes as
printable columns. While typing this was invisible, but once the line
wrapped, readline's display model carried one extra (phantom) row. On
accept, _rl_update_final() then took its "remove the final xterm
line-wrap flag" branch, moving the cursor up one row and repainting the
final character over the end of the previous row. The multi-line
continuation loop made it obvious: the "... " prompt was printed on top
of the wrapped tail and the continued line landed one row too high.

Wrapping the escape sequences in readline's \001 / \002 non-printing
markers makes their width 0, which removes the phantom row. The joined
text was already correct; only the rendering was wrong.

Verified at 40 columns with a 67 character line and at 80 columns with
a 147 character line, both the lengths that triggered the corruption.
2026-10-03 19:18:12 +03:00
Luxferre 9513780a02 install target custom 2026-10-03 13:37:26 +03:00
5 changed files with 695 additions and 84 deletions
+3
View File
@@ -3,6 +3,9 @@
# Anchor on the root so a directory called solo is still tracked. # Anchor on the root so a directory called solo is still tracked.
/solo /solo
/solo-static /solo-static
# Half-written link targets, left behind only by a failed link.
/solo.new
/solo-static.new
*.o *.o
*.a *.a
*.so *.so
+70 -11
View File
@@ -9,6 +9,8 @@
# the command line, e.g. make CC=clang CFLAGS=-O1 # the command line, e.g. make CC=clang CFLAGS=-O1
# .PHONY not POSIX - see the FORCE target at the bottom # .PHONY not POSIX - see the FORCE target at the bottom
# target-specific variables, ifeq, wildcard, -include, ... all non-POSIX # target-specific variables, ifeq, wildcard, -include, ... all non-POSIX
# $(patsubst ...) not POSIX - so the install directory is resolved by the
# recipe shell, see INSTALL_DIR below
# #
# Rule syntax used here: recipe lines start with a TAB, macros are written # Rule syntax used here: recipe lines start with a TAB, macros are written
# $(NAME) (POSIX also accepts ${NAME}), and the only automatic macro used is # $(NAME) (POSIX also accepts ${NAME}), and the only automatic macro used is
@@ -64,16 +66,18 @@ STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistri
-lreadline -lncursesw -ljson-c -lm -ldl -pthread -lreadline -lncursesw -ljson-c -lm -ldl -pthread
solo: main.c solo: main.c
$(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@ main.c $(PKG_LIBS) $(LDLIBS) $(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@.new main.c $(PKG_LIBS) $(LDLIBS)
strip $@ strip $@.new
mv -f $@.new $@
# Writes solo-static, not a file called "static", so $@ is not used here. # Writes solo-static, not a file called "static", so $@ is not used here.
static: main.c static: main.c
@for f in $(CURL_STATIC) $(CARES_STATIC); do \ @for f in $(CURL_STATIC) $(CARES_STATIC); do \
test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \ test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \
done done
$(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS) $(LDLIBS) $(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static.new main.c $(STATIC_LIBS) $(LDLIBS)
strip solo-static strip solo-static.new
mv -f solo-static.new solo-static
static-deps: FORCE static-deps: FORCE
sh ./mkstaticdeps.sh sh ./mkstaticdeps.sh
@@ -82,21 +86,76 @@ static-deps: FORCE
static-deps-force: FORCE static-deps-force: FORCE
FORCE=1 sh ./mkstaticdeps.sh FORCE=1 sh ./mkstaticdeps.sh
# Install paths. DESTDIR and PREFIX may be set on the command line: # Install paths. Every one of these can be set on the command line, so the
# make install DESTDIR=/tmp/pkg PREFIX=/usr # install target takes any location you name:
#
# make install # $(DESTDIR)$(BINDIR) = /usr/local/bin
# make install PREFIX=$HOME/.local # ~/.local/bin
# make install BINDIR=/opt/solo/bin # an explicit directory
# make install DIR=/opt/solo/bin # the same, spelled in one macro
# make install DIR=~/.local/bin # a leading ~ is expanded, see below
# make install DIR=/usr/bin INSTALL="install -C"
# make install DESTDIR=/tmp/pkg DIR=/usr/bin # staged, for packaging
#
# DIR wins over BINDIR when both are given; DESTDIR is prepended either way, so
# "make install DESTDIR=/tmp/pkg DIR=/usr/bin" stages into /tmp/pkg/usr/bin.
DESTDIR =
PREFIX = /usr/local PREFIX = /usr/local
BINDIR = $(PREFIX)/bin BINDIR = $(PREFIX)/bin
DIR =
INSTALL = install INSTALL = install
install: solo FORCE # The installation directory, as a shell snippet that prints it on stdout.
$(INSTALL) -d $(DESTDIR)$(BINDIR) #
$(INSTALL) -m 755 solo $(DESTDIR)$(BINDIR)/solo # POSIX make has no string functions, so "DIR if set, else BINDIR" and the
# leading ~ are handled by the recipe shell instead of by the makefile. Both
# install and uninstall use this one snippet, so they always agree on the path.
# $$(...) survives expansion of the macro and reaches the shell as $(...).
INSTALL_DIR = d="$(DIR)"; \
test -n "$$d" || d="$(BINDIR)"; \
case "$$d" in \
'~') d="$$HOME";; \
'~/'*) d="$$HOME/$$(printf '%s' "$$d" | cut -c3-)";; \
esac; \
printf '%s%s\n' "$(DESTDIR)" "$$d"
# Install solo, and solo-static too when it has been built. Prints the location
# it used, so a script can see where the binaries went.
install: solo FORCE
@dir=$$($(INSTALL_DIR)); \
echo "installing into $$dir"; \
$(INSTALL) -d "$$dir"; \
$(INSTALL) -m 755 solo "$$dir/solo"; \
if test -f solo-static; then \
$(INSTALL) -m 755 solo-static "$$dir/solo-static"; \
fi
# Install the static binary only; fails if it has not been built yet.
install-static: static FORCE
@dir=$$($(INSTALL_DIR)); \
echo "installing solo-static into $$dir"; \
$(INSTALL) -d "$$dir"; \
$(INSTALL) -m 755 solo-static "$$dir/solo-static"
# Prints the installation directory that install would use, and nothing else.
installdir: FORCE
@dir=$$($(INSTALL_DIR)); echo "$$dir"
# Removes the binaries from the location resolved the same way, so
# "make install DIR=~/.local/bin" is undone by
# "make uninstall DIR=~/.local/bin".
uninstall: FORCE uninstall: FORCE
rm -f $(DESTDIR)$(BINDIR)/solo $(DESTDIR)$(BINDIR)/solo-static @dir=$$($(INSTALL_DIR)); \
echo "removing from $$dir"; \
rm -f "$$dir/solo" "$$dir/solo-static"
clean: FORCE 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. # POSIX lint target: runs the lint tool named by the LINT macro.
lint: main.c FORCE lint: main.c FORCE
+98 -11
View File
@@ -43,11 +43,17 @@ make
That produces a stripped dynamic binary named `solo` in the project directory. That produces a stripped dynamic binary named `solo` in the project directory.
The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`. 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: Other targets:
make clean # remove solo and solo-static make clean # remove solo and solo-static
make install # install to $(PREFIX)/bin, default /usr/local/bin 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 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 make static-deps # build the static curl and c-ares archives, see below
## Static build ## Static build
@@ -131,6 +137,24 @@ two problem archives are built from source here.
Remember to put `~/.local/bin` on your `PATH` if you install there. Remember to put `~/.local/bin` on your `PATH` if you install there.
`install` takes any location you name, so nothing has to be patched to install
somewhere unusual:
make install BINDIR=/opt/solo/bin # an explicit directory
make install DIR=/opt/solo/bin # the same, spelled in one macro
make install DIR=~/.local/bin # a leading ~ is expanded by the shell
make install DIR="$HOME/bin" # spaces in the path are fine
make install INSTALL="install -C" # a different install program
make installdir DIR=/opt/solo/bin # prints /opt/solo/bin, installs nothing
`DIR` wins over `BINDIR`, and `DESTDIR` is prepended to either, so
`make install DESTDIR=/tmp/pkg DIR=/usr/bin` stages into `/tmp/pkg/usr/bin`.
`make uninstall` resolves the location the same way, so pass the same macros to
undo an install: `make uninstall DIR=~/.local/bin`.
`solo-static` is installed alongside `solo` when it has been built
(`make static`). To install only that one, use `make install-static`.
## Run ## Run
./solo ./solo
@@ -163,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 \ > 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 ## Configuration
Everything is configurable at three levels, from weakest to strongest: Everything is configurable at three levels, from weakest to strongest:
@@ -183,6 +245,9 @@ Everything is configurable at three levels, from weakest to strongest:
| scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` | | scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` |
| colors | on when stdout is a TTY | `NO_COLOR=1` | | 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 Session commands take precedence and are persisted, so a model you set by hand
survives a restart even if the environment says something else. survives a restart even if the environment says something else.
@@ -193,6 +258,11 @@ below, but it only writes a `timeout` field into the saved session, which the
API ignores and which does not change the command timeout. Restart with the API ignores and which does not change the command timeout. Restart with the
environment set instead. environment set instead.
The timeout is not persisted with the session: the tool description, which
quotes it, is rebuilt from the current environment on every start, so a session
saved under `SOLO_TIMEOUT=42` and reopened under `SOLO_TIMEOUT=99` describes and
enforces 99 seconds.
## Slash commands ## Slash commands
| Command | Effect | | Command | Effect |
@@ -208,7 +278,10 @@ environment set instead.
`/name` is a generic model-parameter editor. The value is typed from its `/name` is a generic model-parameter editor. The value is typed from its
spelling: `true` and `false` become booleans, a parseable number becomes a 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 > /model deepseek/deepseek-v4.1-flash
> /reasoning_effort high > /reasoning_effort high
@@ -279,7 +352,10 @@ The loop, in order:
1. `readline` reads one line. A trailing `\` continues it on the next line. 1. `readline` reads one line. A trailing `\` continues it on the next line.
2. `dispatch` routes the line: a slash command changes settings, `!` runs a shell 2. `dispatch` routes the line: a slash command changes settings, `!` runs a shell
command locally, anything else becomes a task. command locally, anything else becomes a task. The line is right-trimmed
first, so `/quit ` and `/help ` are still commands and a value such as
`/temperature 0.5 ` carries no trailing spaces; a task keeps its exact
text, leading space included.
3. A task is appended to the message list as a `user` message and saved. 3. A task is appended to the message list as a `user` message and saved.
4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the 4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the
transport-level result as `{"status", "success", "content"}` and writing it transport-level result as `{"status", "success", "content"}` and writing it
@@ -302,9 +378,10 @@ Tool execution in detail:
with `-c`, in the current working directory. with `-c`, in the current working directory.
- stdout and stderr are merged into one pipe, so the model sees them together in - stdout and stderr are merged into one pipe, so the model sees them together in
the order they were produced. the order they were produced.
- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the - The child gets its own process group (`setpgid`), claimed by the parent as
whole group is signalled, so `make`, a shell pipeline and everything they well as the child, so the group signal cannot lose the race against the child
spawned all die, not just the direct 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 - 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 is truncated or summarised, which is the point: the model sees exactly what
the terminal saw. the terminal saw.
@@ -314,6 +391,12 @@ The request is a plain non-streaming `POST /chat/completions` with
bash(command: string) -> combined stdout/stderr plus an exit marker bash(command: string) -> combined stdout/stderr plus an exit marker
`command` is required by the schema. A tool call that arrives without a usable
one, which happens with a flaky router or a truncated stream, is answered with
an error instead of being passed to the shell as an empty string, and three
rounds in a row with nothing to run end the turn rather than loop on a peer that
never sends a command.
Reasoning fields are handled defensively, because providers disagree on the Reasoning fields are handled defensively, because providers disagree on the
name: `reasoning` and `reasoning_content` are both read, and both are stored name: `reasoning` and `reasoning_content` are both read, and both are stored
back into the transcript so that a follow-up turn can reuse them. back into the transcript so that a follow-up turn can reuse them.
@@ -321,7 +404,10 @@ back into the transcript so that a follow-up turn can reuse them.
## Timeouts, exit codes and interrupts ## Timeouts, exit codes and interrupts
Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by 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 | | Status | Meaning |
| ------- | ------------------------------------------------------------------ | | ------- | ------------------------------------------------------------------ |
@@ -344,12 +430,13 @@ where you are when you press it:
| -------------------- | ------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------- |
| Running a command | The process group is killed, exit 130 is reported, the turn ends. | | Running a command | The process group is killed, exit 130 is reported, the turn ends. |
| Waiting on the API | The request is abandoned and the turn ends. | | Waiting on the API | The request is abandoned and the turn ends. |
| Part way through a line | The half-typed line is thrown away. | | Part way through a line | The half-typed line is cleared; the next line runs normally. |
| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. | | Mid way through a multi-line task | The whole task is abandoned. |
| Sitting at the prompt | Solo survives and stays usable; the next line runs normally. |
That last case is the one wart: press Ctrl-C once at an idle prompt and your The half-typed line is cleared by Solo itself: readline only redraws the
next command vanishes without a word. Press it twice and the second one goes prompt on Ctrl-C and leaves the text in its buffer, so Solo registers a
through, so it is self-correcting in practice. signal hook that empties the line, the way bash does.
## Example session ## Example session
+153 -60
View File
@@ -1,7 +1,8 @@
/* /*
Solo-C: A C port of the Solo coding agent Solo-C: A C port of the Solo coding agent
Dependencies: json-c, libreadline, libcurl 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 Created by Luxferre in 2026, released into the public domain
*/ */
#define _GNU_SOURCE #define _GNU_SOURCE
@@ -11,6 +12,7 @@
#include <unistd.h> #include <unistd.h>
#include <signal.h> #include <signal.h>
#include <errno.h> #include <errno.h>
#include <ctype.h>
#include <time.h> #include <time.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/wait.h> #include <sys/wait.h>
@@ -40,8 +42,18 @@ static int GN(json_object *o, const char *k, int d) { json_object *v = G(o, k);
static volatile sig_atomic_t IN; static volatile sig_atomic_t IN;
static void onint(int s) { (void)s; IN = 1; } static void onint(int s) { (void)s; IN = 1; }
/* readline calls this from its input loop, in a safe context, after it has
dealt with a signal caught while it was reading a line. A Ctrl-C only
redraws the prompt: the half-typed text stays in the buffer, so clear it
here, the way bash does. The flag is left set on purpose; the prompt loop
clears it, so a Ctrl-C during a command still aborts the turn, and one
during a multi-line task still abandons it. */
static int ci_hook(void) {
if(IN) { rl_replace_line("", 1); rl_redisplay(); }
return 0;
}
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 int TO = 120, QUIT, CLR = 1;
static double TEMP = 0.7; static double TEMP = 0.7;
static json_object *SSN; static json_object *SSN;
@@ -54,13 +66,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; } 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) { static int rp(int fd, char **out, double dl, int *st) {
BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r; BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r;
bg(&b); *st = 0; bg(&b); *st = 0;
for(;;) { for(;;) {
if(IN) { *st = 1; break; } 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; FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000;
r = select(fd + 1, &fs, 0, 0, &tv); 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); } if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); }
@@ -95,7 +112,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, "success", B(e == CURLE_OK && sc >= 200 && sc < 300));
A(r, "content", S(e == CURLE_OK && b.p ? b.p : "")); A(r, "content", S(e == CURLE_OK && b.p ? b.p : ""));
j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN); 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(c) curl_easy_cleanup(c);
if(h) curl_slist_free_all(h); if(h) curl_slist_free_all(h);
curl_global_cleanup(); _exit(0); curl_global_cleanup(); _exit(0);
@@ -106,7 +124,7 @@ static json_object *hq(const char *m, const char *p, const char *body) {
if(pipe(f)) return 0; if(pipe(f)) return 0;
if((k = fork()) < 0) { close(f[0]); close(f[1]); 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); } 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); if(st == 1 || IN) kill(k, SIGKILL);
waitpid(k, 0, 0); waitpid(k, 0, 0);
r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf); r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf);
@@ -125,7 +143,9 @@ static void run(const char *cmd, char **out, int *rc) {
if(pipe(f)) { *out = 0; *rc = 1; return; } 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 = 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); } 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; } if(st == 1 || IN) { *rc = 130; }
else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; } 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; } else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; }
@@ -155,8 +175,29 @@ static void load(void) {
json_object_put(SSN); SSN = o; json_object_put(SSN); SSN = o;
} }
/* Builds the single tool the agent exposes and stores it in the session. Kept
apart from create() because the description quotes the timeout, so it has to
be rebuilt after load(): a session saved under one SOLO_TIMEOUT would
otherwise keep telling the model the old limit while run() enforced the new
one. */
static void mkschema(void) {
json_object *a, *o, *f, *p, *pr, *cp, *rq; char tb[512];
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"));
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);
rq = json_object_new_array(); json_object_array_add(rq, S("command")); /* command is required, so an endpoint cannot quietly send an empty call */
A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", rq);
A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p);
A(o, "type", S("function")); A(o, "function", f);
json_object_array_add(a, o); A(SSN, "tools", a);
}
static void create(void) { static void create(void) {
json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192]; json_object *ms, *sy; char *sb; size_t nb;
if(SSN) json_object_put(SSN); if(SSN) json_object_put(SSN);
SSN = json_object_new_object(); SSN = json_object_new_object();
A(SSN, "model", S(MOD)); A(SSN, "model", S(MOD));
@@ -164,21 +205,14 @@ static void create(void) {
A(SSN, "temperature", D(TEMP)); A(SSN, "temperature", D(TEMP));
A(SSN, "stream", B(0)); A(SSN, "stream", B(0));
A(SSN, "tool_choice", S("auto")); A(SSN, "tool_choice", S("auto"));
a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object(); mkschema();
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 &\"."));
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);
A(o, "type", S("function")); A(o, "function", f);
json_object_array_add(a, o); A(SSN, "tools", a);
ms = json_object_new_array(); sy = json_object_new_object(); ms = json_object_new_array(); sy = json_object_new_object();
A(sy, "role", S("system")); A(sy, "role", S("system"));
if(SYS && *SYS) A(sy, "content", S(SYS)); if(SYS && *SYS) A(sy, "content", S(SYS));
else { 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" "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. " "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. " "Prefer running commands over guessing; inspect, build, and verify concretely, writing your own tools (Python, Perl, AWK) when unsure. "
@@ -186,23 +220,43 @@ static void create(void) {
"Stop when the goal is met; report results, not process.\n" "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. " "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" "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; " "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)); A(sy, "content", S(sb));
free(sb);
} }
json_object_array_add(ms, sy); A(SSN, "messages", ms); json_object_array_add(ms, sy); A(SSN, "messages", ms);
} }
static int tools(json_object *tc) { /* ran is set to the number of commands actually executed, so the caller can see
a round that ran nothing at all and stop a peer that keeps sending empty
tool calls instead of looping on it forever. */
static int tools(json_object *tc, int *ran) {
json_object *fn, *ar, *ms, *tm; const char *id, *cs, *s; char *cmd, *out, *cb, mk[80]; int i, rc; size_t l; json_object *fn, *ar, *ms, *tm; const char *id, *cs, *s; char *cmd, *out, *cb, mk[80]; int i, rc; size_t l;
*ran = 0;
if(!tc || json_object_get_type(tc) != json_type_array) return 0; if(!tc || json_object_get_type(tc) != json_type_array) return 0;
for(i = 0; i < (int)json_object_array_length(tc); i++) { for(i = 0; i < (int)json_object_array_length(tc); i++) {
fn = G(json_object_array_get_idx(tc, i), "function"); fn = G(json_object_array_get_idx(tc, i), "function");
id = fn ? GS(json_object_array_get_idx(tc, i), "id") : 0; id = fn ? GS(json_object_array_get_idx(tc, i), "id") : 0;
s = fn ? GS(fn, "arguments") : 0; cmd = strdup(""); s = fn ? GS(fn, "arguments") : 0; cmd = 0;
if(s) { ar = json_tokener_parse(s); cs = ar ? GS(ar, "command") : 0; if(cs) { free(cmd); cmd = strdup(cs); } if(ar) json_object_put(ar); } /* copy before the put: cmd must outlive ar */ if(s) { ar = json_tokener_parse(s); cs = ar ? GS(ar, "command") : 0; if(cs) cmd = strdup(cs); if(ar) json_object_put(ar); } /* copy before the put: cmd must outlive ar */
if(cmd) { char *q = cmd; while(*q && isspace((unsigned char)*q)) q++; if(!*q) { free(cmd); cmd = 0; } } /* a blank command is no command */
if(!cmd) {
printf("%s--- tool %s: no command in the arguments%s\n", CLR ? YEL : "", id ? id : "", CLR ? OF : "");
out = strdup("error: this tool call carried no usable \"command\" argument, so nothing ran. Call bash again with a command, e.g. {\"command\": \"ls -l\"}.");
rc = 1;
} else {
printf("%s--- tool %s: $ %s%s\n", CLR ? YEL : "", id ? id : "", cmd, CLR ? OF : ""); fflush(stdout); printf("%s--- tool %s: $ %s%s\n", CLR ? YEL : "", id ? id : "", cmd, CLR ? OF : ""); fflush(stdout);
run(cmd, &out, &rc); run(cmd, &out, &rc);
(*ran)++;
}
printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); fflush(stdout); printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); fflush(stdout);
ms = G(SSN, "messages"); ms = G(SSN, "messages");
if(ms) { if(ms) {
@@ -221,6 +275,7 @@ static int tools(json_object *tc) {
static int ask(const char *in) { static int ask(const char *in) {
json_object *ms, *d, *p, *ch, *m, *as, *tc, *u; const char *raw, *co, *rs, *rc2, *mn; json_object *ms, *d, *p, *ch, *m, *as, *tc, *u; const char *raw, *co, *rs, *rc2, *mn;
int empty = 0;
if(!(ms = G(SSN, "messages"))) return 1; if(!(ms = G(SSN, "messages"))) return 1;
as = json_object_new_object(); A(as, "role", S("user")); A(as, "content", S(in)); as = json_object_new_object(); A(as, "role", S("user")); A(as, "content", S(in));
json_object_array_add(ms, as); sv(); json_object_array_add(ms, as); sv();
@@ -250,18 +305,24 @@ static int ask(const char *in) {
if((rc2 = GS(m, "reasoning_content"))) A(as, "reasoning_content", S(rc2)); 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 */ 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(); json_object_array_add(ms, as); sv();
if(tc && json_object_get_type(tc) == json_type_array && json_object_array_length(tc)) {
int r, ran; /* the usage line waits for the turn that ends the loop, so a tool run is not chopped up by stats */
r = tools(tc, &ran);
if(p) json_object_put(p);
if(d) json_object_put(d);
if(r) return r;
if(ran) empty = 0;
else if(++empty >= 3) { /* three rounds in a row with nothing to run: feed the errors back was useless, so give up rather than spin */
puts("no command in three consecutive tool rounds, stopping the turn");
return 1;
}
continue;
}
u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0; u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0;
if(u && json_object_get_type(u) == json_type_object) 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 : ""); 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 : ""); else printf("%s--- [%s]%s\n", CLR ? GRY : "", mn ? mn : "?", CLR ? OF : "");
fflush(stdout); fflush(stdout);
if(tc && json_object_get_type(tc) == json_type_array && json_object_array_length(tc)) {
int r = tools(tc);
if(p) json_object_put(p);
if(d) json_object_put(d);
if(r) return r;
continue;
}
if(p) json_object_put(p); if(p) json_object_put(p);
if(d) json_object_put(d); if(d) json_object_put(d);
return 0; return 0;
@@ -276,18 +337,24 @@ static const char *HELP =
" !cmd run a shell command directly, no agent\n" " !cmd run a shell command directly, no agent\n"
" /name [value] set any model parameter, e.g. /model x, /temperature 0.5,\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" " /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 setstr(char **slot, const char *v) { free(*slot); *slot = strdup(v); }
static void dispatch(const char *l) { static void rtrim(char *s) { size_t n = strlen(s); while(n && isspace((unsigned char)s[n - 1])) s[--n] = 0; }
/* Runs the command lines (a leading / or !) of one already right-trimmed line.
The trim happens in dispatch(), because "/help " and "/quit " must stay
commands rather than being read as a parameter named help or quit. */
static int command(char *l) {
char *k, *v, *out, mk[80]; const char *s; json_object *ms, *nm; int rc; 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, "/help")) { puts(HELP); return 0; }
if(!strcmp(l, "/quit")) { QUIT = 1; return; } if(!strcmp(l, "/quit")) { QUIT = 1; return 0; }
if(!strcmp(l, "/clear")) { if(!strcmp(l, "/clear")) {
ms = G(SSN, "messages"); nm = json_object_new_array(); 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 */ 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")) { if(!strcmp(l, "/models")) {
json_object *r = hq("GET", "/models", 0); json_object *r = hq("GET", "/models", 0);
@@ -299,26 +366,26 @@ static void dispatch(const char *l) {
if(d) json_object_put(d); if(d) json_object_put(d);
json_object_put(r); json_object_put(r);
} }
return; return 0;
} }
if(!strncmp(l, "/api_key", 8)) { if(!strncmp(l, "/api_key", 8)) {
for(v = (char *)l + 8; *v == ' '; v++); for(v = l + 8; *v == ' '; v++);
if(*v && (!strcmp(v, "null") || !strcmp(v, "none") || !strcmp(v, "off") || !strcmp(v, "-"))) { free(KEY); KEY = 0; } if(*v && (!strcmp(v, "null") || !strcmp(v, "none") || !strcmp(v, "off") || !strcmp(v, "-"))) { free(KEY); KEY = 0; }
else if(*v) setstr(&KEY, v); else if(*v) setstr(&KEY, v);
if(KEY) printf("api_key = set, %d chars\n", (int)strlen(KEY)); else puts("api_key = unset"); 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)) { if(!strncmp(l, "/endpoint", 9)) {
for(v = (char *)l + 9; *v == ' '; v++); for(v = l + 9; *v == ' '; v++);
if(*v) { setstr(&EP, v); for(char *e = EP + strlen(EP); e > EP && e[-1] == '/'; e--) *e = 0; } 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 == '!') { if(*l == '!') {
for(v = (char *)l + 1; *v == ' '; v++); for(v = l + 1; *v == ' '; v++);
printf("%s--- $ %s%s\n", CLR ? YEL : "", v, CLR ? OF : ""); printf("%s--- $ %s%s\n", CLR ? YEL : "", v, CLR ? OF : "");
run(v, &out, &rc); run(v, &out, &rc);
if(*out) printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); 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 == '/') { if(*l == '/') {
k = strdup(l + 1); k = strdup(l + 1);
@@ -332,9 +399,11 @@ static void dispatch(const char *l) {
json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k); 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)); if(!strcmp(v, "true")) A(SSN, k, B(1));
else if(!strcmp(v, "false")) A(SSN, k, B(0)); 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); 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); sv(); printf("%s = %s\n", k, v);
@@ -346,21 +415,40 @@ 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 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"); } 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 0;
}
/* Runs one line and returns the exit status for it. A task keeps its exact
text, including a leading space that keeps it out of the history. */
static int dispatch(const char *l) {
char *cl; int rc;
if(*l != '/' && *l != '!') return ask(l);
cl = strdup(l); rtrim(cl); rc = command(cl); free(cl);
return rc;
} }
int main(int argc, char **argv) { int main(int argc, char **argv) {
char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st; char cwd[4096], pb[64], sp[4096], *ln, *ac, *nx, *task; const char *e; struct stat st;
(void)argc; (void)argv; 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); } 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; else TMP = strdup(e);
if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)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_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_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((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); } if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); }
SYS = getenv("SOLO_SYSPROMPT"); SYS = getenv("SOLO_SYSPROMPT");
@@ -368,19 +456,24 @@ int main(int argc, char **argv) {
if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash"; if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash";
snprintf(SH, sizeof SH, "%s", e); snprintf(SH, sizeof SH, "%s", e);
signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN); signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN);
create(); load(); rl_signal_event_hook = ci_hook;
if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); } create(); load(); mkschema(); /* a restored session carries the schema of the run that saved it: rebuild it, so the timeout in the description matches the one run() enforces */
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_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)); } 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(); A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv();
if(!isatty(1) || getenv("NO_COLOR")) CLR = 0; 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 : ""); BN = one ? stderr : stdout; /* one-shot: the banner goes to stderr, so stdout holds the answer alone */
fflush(stdout); 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) { while(!QUIT) {
snprintf(pb, sizeof pb, "%s> %s", CLR ? BLD : "", CLR ? OF : ""); snprintf(pb, sizeof pb, "\001%s\002> \001%s\002", CLR ? BLD : "", CLR ? OF : "");
IN = 0; /* a Ctrl-C during a command must not reach the line about to be typed */
if(!(ln = readline(pb))) { puts(""); break; } if(!(ln = readline(pb))) { puts(""); break; }
if(IN) { IN = 0; free(ln); continue; } IN = 0; /* a Ctrl-C during the line only cleared that line, not this turn */
ac = ln; ac = ln;
for(;;) { for(;;) {
size_t l = strlen(ac); size_t l = strlen(ac);
@@ -391,7 +484,7 @@ int main(int argc, char **argv) {
} }
if(!ac || IN || !*ac) continue; if(!ac || IN || !*ac) continue;
if(*ac != ' ') add_history(ac); 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); free(EP); free(KEY); free(MOD); free(RSN); free(SF); free(TMP);
if(SSN) json_object_put(SSN); if(SSN) json_object_put(SSN);
Executable
+369
View File
@@ -0,0 +1,369 @@
#!/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}
# 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; }
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" )
}
# 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 -------
# 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"
# --- Ctrl-C must not eat the next line --------------------------------
# readline only redraws the prompt on Ctrl-C: the half-typed text
# stays in its buffer and the interrupt flag outlives the prompt.
# Both used to swallow the next command silently. This needs a real
# tty, so the agent runs on a pty driven by a small helper.
D=$WORK/ctrlc
cat >"$WORK/ptyrun.c" <<'PTY_EOF'
/* Drive PROG on a pty from a script on stdin. A line "INT" sends
SIGINT, a line "RAW text" writes text without a newline, and any
other line is written with a newline appended. A short pause
follows every step. Everything PROG prints is relayed to stdout. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <pty.h>
int main(int argc, char **argv) {
int m, st; pid_t k; char buf[4096], line[4096]; struct timeval tv; fd_set fs;
if(argc < 2) { fprintf(stderr, "usage: ptyrun PROG [ARG...] < script\n"); return 2; }
k = forkpty(&m, 0, 0, 0);
if(k < 0) { perror("forkpty"); return 2; }
if(!k) { execvp(argv[1], argv + 1); _exit(127); }
fcntl(m, F_SETFL, O_NONBLOCK);
while(fgets(line, sizeof line, stdin)) {
size_t l = strlen(line);
while(l && (line[l - 1] == '\n' || line[l - 1] == '\r')) line[--l] = 0;
usleep(400000);
if(!strcmp(line, "INT")) { kill(k, SIGINT); continue; }
if(!strncmp(line, "RAW ", 4)) { if(write(m, line + 4, strlen(line + 4)) < 0) break; continue; }
if(write(m, line, strlen(line)) < 0 || write(m, "\n", 1) < 0) break;
}
for(;;) {
FD_ZERO(&fs); FD_SET(m, &fs); tv.tv_sec = 10; tv.tv_usec = 0;
if(select(m + 1, &fs, 0, 0, &tv) <= 0) break;
st = read(m, buf, sizeof buf);
if(st <= 0) break;
if(write(1, buf, st) < 0) break;
}
kill(k, SIGKILL);
waitpid(k, &st, 0);
return 0;
}
PTY_EOF
# forkpty lives in libc since glibc 2.34; older systems need -lutil.
cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" 2>/dev/null \
|| cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" -lutil 2>/dev/null
[ -x "$WORK/ptyrun" ] || { echo "test: the pty helper did not build" >&2; exit 1; }
mkdir -p "$D"
( cd "$D" && printf 'INT\n!echo PTY_CI\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
>"$WORK/ctrlc.out" 2>&1
grep -q -- '--- \$ echo PTY_CI' "$WORK/ctrlc.out"
ok $? 'a command typed after Ctrl-C at the prompt still runs'
( cd "$D" && printf 'RAW echo partial\nINT\n!echo PTY_FRESH\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
>"$WORK/ctrlc2.out" 2>&1
grep -q -- '--- \$ echo PTY_FRESH' "$WORK/ctrlc2.out"
ok $? 'a command typed after Ctrl-C mid-line still runs'
if grep -q 'partialPTY_FRESH\|partial!echo' "$WORK/ctrlc2.out"; then rc=1; else rc=0; fi
ok "$rc" 'Ctrl-C mid-line clears the half-typed text'
# --- 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'
# --- 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'
# --- a command line keeps its meaning when it ends in spaces -------------------
# dispatch() right-trims before it looks at the line, so "/help " and "/quit "
# stay commands instead of being read as a parameter named help or quit, and a
# value such as "/temperature 0.5 " does not gain trailing spaces. A task, on
# the other hand, keeps every character, because the model should see what was
# typed and a leading space still keeps the line out of the history.
D=$WORK/trim
printf '/help \n/quit \n' | solo_in "$D" 2>&1 | grep -q '^Commands:'
ok $? '/help with a trailing space prints help'
if grep -qE '"(help|quit)"' "$D/.solo_sess.json"; then rc=1; else rc=0; fi
ok "$rc" '"/help " and "/quit " do not become session parameters'
if printf '/quit \n!echo SURVIVED\n' | solo_in "$D" 2>&1 | grep -q SURVIVED; then rc=1; else rc=0; fi
ok "$rc" '/quit with a trailing space exits instead of being ignored'
D=$WORK/trimval
printf '/temperature 0.5 \n/quit\n' | solo_in "$D" >/dev/null 2>&1
perl -0777 -ne 'exit(/"temperature":0\.5,/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? 'a trailing space does not land in a parameter value'
# --- a resumed session must not keep the old timeout in the tool description ---
# create() builds the schema, then load() replaced the whole object with the one
# on disk, so a session saved under SOLO_TIMEOUT=42 went on describing 42 s while
# run() enforced whatever the environment said now. The schema is rebuilt after
# the load, so the two always agree.
D=$WORK/timeoutdesc
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 $? 'SOLO_TIMEOUT=42 reaches the tool description'
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=99 >/dev/null 2>&1
perl -0777 -ne 'exit(/after 99s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? 'a rerun with another SOLO_TIMEOUT rebuilds the description'
if grep -q 'after 42s' "$D/.solo_sess.json"; then rc=1; else rc=0; fi
ok "$rc" 'the stale timeout is gone from the resumed session'
perl -0777 -ne 'exit(/"required":\s*\[\s*"command"/s ? 0 : 1)' "$D/.solo_sess.json"
ok $? 'the bash tool declares command as required'
# --- an empty tool call must not spin the agent -------------------------------
# A peer that answers with a function call whose arguments hold no command used
# to be fed an empty string, which the shell ran as [exit 0]: the agent then
# asked again, forever. Such a call is now answered with an error, and three
# rounds in a row with nothing to run end the turn instead of the loop.
cat >"$WORK/emptycmd.pl" <<'EMPTY_EOF'
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++;
open(my $f, '>>', $ARGV[0]); print $f "1\n"; close $f;
my $msg = '{"role":"assistant","content":"c","tool_calls":[{"id":"a","type":"function",'
. '"function":{"name":"bash","arguments":"not json"}}]}';
my $out = '{"model":"mock/free","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;
}
EMPTY_EOF
COUNT=$WORK/emptycmd.count
: >"$COUNT"
perl "$WORK/emptycmd.pl" "$COUNT" >"$WORK/emptycmd.port" 2>/dev/null &
EMPTY=$!
i=0
while [ ! -s "$WORK/emptycmd.port" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i + 1)); done
EPORT=$(cat "$WORK/emptycmd.port" 2>/dev/null)
mkdir -p "$WORK/emptycmd"
( cd "$WORK/emptycmd" && SOLO_ENDPOINT="http://127.0.0.1:$EPORT/api" "$SOLO" 'do it' ) \
>"$WORK/emptycmd.out" 2>"$WORK/emptycmd.err"
ERC=$?
kill "$EMPTY" 2>/dev/null
[ "$ERC" -ne 124 ]
ok $? 'a peer sending only empty tool calls does not hang the turn' "exit $ERC"
[ "$(wc -l <"$COUNT")" -le 4 ]
ok $? 'the empty-call loop is cut off after a few rounds' "$(wc -l <"$COUNT") request(s)"
grep -q 'carried no usable "command" argument' "$WORK/emptycmd.out"
ok $? 'the empty tool call is answered with an error, not a shell run'
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]