Compare commits
8
Commits
5852a9732b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7550c70df3 | ||
|
|
fdd389e452 | ||
|
|
141c6cc414 | ||
|
|
ecc48cd472 | ||
|
|
c0d0b2633d | ||
|
|
9513780a02 | ||
|
|
e2140c2fd7 | ||
|
|
94172c22b0 |
@@ -3,6 +3,9 @@
|
||||
# Anchor on the root so a directory called solo is still tracked.
|
||||
/solo
|
||||
/solo-static
|
||||
# Half-written link targets, left behind only by a failed link.
|
||||
/solo.new
|
||||
/solo-static.new
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2 -std=c99 -march=native $(shell pkg-config --cflags libcurl json-c readline)
|
||||
LDLIBS = $(shell pkg-config --libs libcurl json-c readline)
|
||||
# Makefile for Solo-C.
|
||||
#
|
||||
# Written for the POSIX make utility (IEEE Std 1003.1, XCU make) and a POSIX
|
||||
# shell (/bin/sh), so it works with any conforming make and no GNU-only
|
||||
# features. Deliberately avoided:
|
||||
#
|
||||
# $(shell ...) not POSIX - pkg-config is run by the recipe shell
|
||||
# ?= not POSIX - defaults are plain "=" and are overridden on
|
||||
# the command line, e.g. make CC=clang CFLAGS=-O1
|
||||
# .PHONY not POSIX - see the FORCE target at the bottom
|
||||
# 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
|
||||
# $(NAME) (POSIX also accepts ${NAME}), and the only automatic macro used is
|
||||
# $@, the name of the target. Prerequisite names are written out: $< is POSIX
|
||||
# for inference rules only, and "$< is the first prerequisite" in a plain rule
|
||||
# is a GNU extension that even BusyBox make lacks.
|
||||
|
||||
SHELL = /bin/sh
|
||||
|
||||
# Standard macros. POSIX defaults CC to c99; Solo-C uses the system compiler
|
||||
# driver "cc" instead, which is the compiler a platform actually ships
|
||||
# (gcc, clang, musl-gcc, ...). Every macro here can be overridden from the
|
||||
# command line: make CC=clang, make CFLAGS=-O1, make LDLIBS=-lcurl.
|
||||
CC = cc
|
||||
CFLAGS = -Wall -Wextra -O2 -std=c99 -march=native
|
||||
LDFLAGS =
|
||||
LDLIBS =
|
||||
LINT = lint
|
||||
LINTFLAGS =
|
||||
LINTLIBS = -lc
|
||||
|
||||
# Link-time discovery of the shared dependencies. POSIX make cannot run
|
||||
# commands while reading a makefile, so the flags are collected by the shell
|
||||
# that runs the recipe instead of by $(shell ...) while parsing.
|
||||
PKG_CONFIG = pkg-config
|
||||
PKGS = libcurl json-c readline
|
||||
# $$(...) survives expansion of the macro and reaches the shell as $(...).
|
||||
PKG_CFLAGS = $$($(PKG_CONFIG) --cflags $(PKGS))
|
||||
PKG_LIBS = $$($(PKG_CONFIG) --libs $(PKGS))
|
||||
|
||||
# Fully static binary.
|
||||
#
|
||||
@@ -21,41 +60,109 @@ LDLIBS = $(shell pkg-config --libs libcurl json-c readline)
|
||||
#
|
||||
# Archive order matters: libcurl.a pulls in ares_* symbols, so libcares.a has to
|
||||
# come after it on the link line.
|
||||
CURL_STATIC ?= static-deps/libcurl.a
|
||||
CARES_STATIC ?= static-deps/libcares.a
|
||||
CURL_STATIC = static-deps/libcurl.a
|
||||
CARES_STATIC = static-deps/libcares.a
|
||||
STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistring \
|
||||
-lreadline -lncursesw -ljson-c -lm -ldl -pthread
|
||||
|
||||
solo: main.c
|
||||
$(CC) $(CFLAGS) -o solo main.c $(LDLIBS)
|
||||
strip solo
|
||||
$(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@.new main.c $(PKG_LIBS) $(LDLIBS)
|
||||
strip $@.new
|
||||
mv -f $@.new $@
|
||||
|
||||
# Writes solo-static, not a file called "static", so $@ is not used here.
|
||||
static: main.c
|
||||
@for f in $(CURL_STATIC) $(CARES_STATIC); do \
|
||||
test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \
|
||||
done
|
||||
$(CC) $(CFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS)
|
||||
strip solo-static
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static.new main.c $(STATIC_LIBS) $(LDLIBS)
|
||||
strip solo-static.new
|
||||
mv -f solo-static.new solo-static
|
||||
|
||||
static-deps:
|
||||
static-deps: FORCE
|
||||
sh ./mkstaticdeps.sh
|
||||
|
||||
# Rebuild both archives from scratch, ignoring what is already there.
|
||||
static-deps-force:
|
||||
static-deps-force: FORCE
|
||||
FORCE=1 sh ./mkstaticdeps.sh
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
# Install paths. Every one of these can be set on the command line, so the
|
||||
# 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
|
||||
BINDIR = $(PREFIX)/bin
|
||||
INSTALL ?= install
|
||||
DIR =
|
||||
INSTALL = install
|
||||
|
||||
install: solo
|
||||
$(INSTALL) -d $(DESTDIR)$(BINDIR)
|
||||
$(INSTALL) -m 755 solo $(DESTDIR)$(BINDIR)/solo
|
||||
# The installation directory, as a shell snippet that prints it on stdout.
|
||||
#
|
||||
# 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"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/solo $(DESTDIR)$(BINDIR)/solo-static
|
||||
# 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
|
||||
|
||||
clean:
|
||||
rm -f solo solo-static
|
||||
# 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"
|
||||
|
||||
.PHONY: clean static static-deps static-deps-force install uninstall
|
||||
# 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
|
||||
@dir=$$($(INSTALL_DIR)); \
|
||||
echo "removing from $$dir"; \
|
||||
rm -f "$$dir/solo" "$$dir/solo-static"
|
||||
|
||||
clean: FORCE
|
||||
rm -f solo solo-static solo.new solo-static.new
|
||||
|
||||
# Offline regression tests. No API key, no network: every line sent to the agent
|
||||
# starts with / or !, so nothing reaches the model. Pass another binary as $1.
|
||||
test: solo FORCE
|
||||
sh ./test.sh ./solo
|
||||
|
||||
# POSIX lint target: runs the lint tool named by the LINT macro.
|
||||
lint: main.c FORCE
|
||||
$(LINT) $(LINTFLAGS) $(LINTLIBS) main.c
|
||||
|
||||
# POSIX make has no .PHONY special target. Any target that depends on FORCE is
|
||||
# always out of date, because FORCE never creates a file, so its recipe always
|
||||
# runs. Keep this target last: the first target in the file is the default goal.
|
||||
FORCE:
|
||||
@:
|
||||
|
||||
@@ -16,7 +16,7 @@ beyond libcurl, json-c and readline.
|
||||
Build-time:
|
||||
|
||||
- A C99 compiler (GCC or Clang; built and tested with GCC 14)
|
||||
- GNU make
|
||||
- Make
|
||||
- `pkg-config`
|
||||
- libcurl, json-c and GNU readline development headers
|
||||
|
||||
@@ -43,11 +43,17 @@ make
|
||||
That produces a stripped dynamic binary named `solo` in the project directory.
|
||||
The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`.
|
||||
|
||||
Both binaries are linked under a temporary name and then renamed, so `make`
|
||||
works while an agent is still running from the old one, and a link failure
|
||||
leaves the previous binary in place.
|
||||
|
||||
Other targets:
|
||||
|
||||
make clean # remove solo and solo-static
|
||||
make install # install to $(PREFIX)/bin, default /usr/local/bin
|
||||
make installdir # print the directory install would use
|
||||
make uninstall # remove the installed binaries
|
||||
make test # offline regression tests, no API key or network
|
||||
make static-deps # build the static curl and c-ares archives, see below
|
||||
|
||||
## Static build
|
||||
@@ -131,6 +137,24 @@ two problem archives are built from source here.
|
||||
|
||||
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
|
||||
|
||||
./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 \
|
||||
|
||||
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:
|
||||
@@ -183,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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| Command | Effect |
|
||||
@@ -208,7 +278,10 @@ environment set instead.
|
||||
|
||||
`/name` is a generic model-parameter editor. The value is typed from its
|
||||
spelling: `true` and `false` become booleans, a parseable number becomes a
|
||||
number, and anything else stays a string.
|
||||
number, and anything else stays a string. A number written without a decimal
|
||||
point or an exponent stays an integer, so `/max_tokens 4096` is sent as `4096`
|
||||
and not as `4096.0`, which a strict endpoint rejects; `0.7` and `1e3` are sent
|
||||
as floats.
|
||||
|
||||
> /model deepseek/deepseek-v4.1-flash
|
||||
> /reasoning_effort high
|
||||
@@ -279,7 +352,10 @@ The loop, in order:
|
||||
|
||||
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
|
||||
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.
|
||||
4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the
|
||||
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.
|
||||
- stdout and stderr are merged into one pipe, so the model sees them together in
|
||||
the order they were produced.
|
||||
- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the
|
||||
whole group is signalled, so `make`, a shell pipeline and everything they
|
||||
spawned all die, not just the direct child.
|
||||
- The child gets its own process group (`setpgid`), claimed by the parent as
|
||||
well as the child, so the group signal cannot lose the race against the child
|
||||
starting up. On timeout or Ctrl-C the whole group is signalled, so `make`, a
|
||||
shell pipeline and everything they spawned all die, not just the direct child.
|
||||
- The output is returned verbatim, with an exit marker on the last line. Nothing
|
||||
is truncated or summarised, which is the point: the model sees exactly what
|
||||
the terminal saw.
|
||||
@@ -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
|
||||
|
||||
`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
|
||||
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.
|
||||
@@ -321,7 +404,10 @@ back into the transcript so that a follow-up turn can reuse them.
|
||||
## Timeouts, exit codes and interrupts
|
||||
|
||||
Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by
|
||||
default), and the exit status is reported to the model as a marker line:
|
||||
default), and the exit status is reported to the model as a marker line. The
|
||||
deadline comes from a monotonic clock, so a command is not killed early by a
|
||||
wall clock adjustment. A `SOLO_TIMEOUT` below 1 means no limit, for the rare
|
||||
command that has to run to completion.
|
||||
|
||||
| Status | Meaning |
|
||||
| ------- | ------------------------------------------------------------------ |
|
||||
@@ -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. |
|
||||
| Waiting on the API | The request is abandoned and the turn ends. |
|
||||
| Part way through a line | The half-typed line is thrown away. |
|
||||
| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. |
|
||||
| Part way through a line | The half-typed line is cleared; the next line runs normally. |
|
||||
| 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
|
||||
next command vanishes without a word. Press it twice and the second one goes
|
||||
through, so it is self-correcting in practice.
|
||||
The half-typed line is cleared by Solo itself: readline only redraws the
|
||||
prompt on Ctrl-C and leaves the text in its buffer, so Solo registers a
|
||||
signal hook that empties the line, the way bash does.
|
||||
|
||||
## Example session
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 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 double TEMP = 0.7;
|
||||
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; }
|
||||
|
||||
/* st: 0 ok, 1 interrupted, 2 deadline */
|
||||
/* Monotonic seconds, so a deadline is immune to clock steps and to the
|
||||
one-second granularity of time(), which made timeouts fire up to a second early. */
|
||||
static double now(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; }
|
||||
|
||||
/* Reads until EOF, an interrupt, or the monotonic deadline dl (0 = no limit).
|
||||
st: 0 ok, 1 interrupted, 2 deadline */
|
||||
static int rp(int fd, char **out, double dl, int *st) {
|
||||
BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r;
|
||||
bg(&b); *st = 0;
|
||||
for(;;) {
|
||||
if(IN) { *st = 1; break; }
|
||||
if(dl > 0 && (double)time(0) >= dl) { *st = 2; break; }
|
||||
if(dl > 0 && now() >= dl) { *st = 2; break; }
|
||||
FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000;
|
||||
r = select(fd + 1, &fs, 0, 0, &tv);
|
||||
if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); }
|
||||
@@ -95,7 +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, "content", S(e == CURLE_OK && b.p ? b.p : ""));
|
||||
j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN);
|
||||
write(w, j, strlen(j)); free(b.p); json_object_put(r);
|
||||
{ size_t l = strlen(j), o = 0; while(o < l) { ssize_t n = write(w, j + o, l - o); if(n > 0) o += (size_t)n; else if(n < 0 && errno == EINTR) continue; else break; } }
|
||||
free(b.p); json_object_put(r);
|
||||
if(c) curl_easy_cleanup(c);
|
||||
if(h) curl_slist_free_all(h);
|
||||
curl_global_cleanup(); _exit(0);
|
||||
@@ -106,7 +124,7 @@ static json_object *hq(const char *m, const char *p, const char *body) {
|
||||
if(pipe(f)) return 0;
|
||||
if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; }
|
||||
if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); }
|
||||
close(f[1]); n = rp(f[0], &buf, time(0) + 1e9, &st); close(f[0]);
|
||||
close(f[1]); n = rp(f[0], &buf, 0, &st); close(f[0]);
|
||||
if(st == 1 || IN) kill(k, SIGKILL);
|
||||
waitpid(k, 0, 0);
|
||||
r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf);
|
||||
@@ -125,7 +143,9 @@ static void run(const char *cmd, char **out, int *rc) {
|
||||
if(pipe(f)) { *out = 0; *rc = 1; return; }
|
||||
if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; }
|
||||
if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); }
|
||||
close(f[1]); rp(f[0], &buf, time(0) + TO, &st); close(f[0]);
|
||||
setpgid(k, k); /* claim the group here too: a kill(-k) issued before the child
|
||||
itself reaches setpgid(0,0) finds no group and is dropped. */
|
||||
close(f[1]); rp(f[0], &buf, TO > 0 ? now() + TO : 0, &st); close(f[0]);
|
||||
if(st == 1 || IN) { *rc = 130; }
|
||||
else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; }
|
||||
else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; }
|
||||
@@ -155,8 +175,29 @@ static void load(void) {
|
||||
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) {
|
||||
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);
|
||||
SSN = json_object_new_object();
|
||||
A(SSN, "model", S(MOD));
|
||||
@@ -164,21 +205,14 @@ static void create(void) {
|
||||
A(SSN, "temperature", D(TEMP));
|
||||
A(SSN, "stream", B(0));
|
||||
A(SSN, "tool_choice", S("auto"));
|
||||
a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object();
|
||||
p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object();
|
||||
A(cp, "type", S("string"));
|
||||
A(cp, "description", S("Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. "
|
||||
"A command still running after 120s is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\"."));
|
||||
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);
|
||||
mkschema();
|
||||
ms = json_object_new_array(); sy = json_object_new_object();
|
||||
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. "
|
||||
@@ -186,23 +220,43 @@ 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);
|
||||
}
|
||||
|
||||
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;
|
||||
*ran = 0;
|
||||
if(!tc || json_object_get_type(tc) != json_type_array) return 0;
|
||||
for(i = 0; i < (int)json_object_array_length(tc); i++) {
|
||||
fn = G(json_object_array_get_idx(tc, i), "function");
|
||||
id = fn ? GS(json_object_array_get_idx(tc, i), "id") : 0;
|
||||
s = fn ? GS(fn, "arguments") : 0; cmd = strdup("");
|
||||
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 */
|
||||
s = fn ? GS(fn, "arguments") : 0; cmd = 0;
|
||||
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);
|
||||
run(cmd, &out, &rc);
|
||||
(*ran)++;
|
||||
}
|
||||
printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); fflush(stdout);
|
||||
ms = G(SSN, "messages");
|
||||
if(ms) {
|
||||
@@ -221,6 +275,7 @@ static int tools(json_object *tc) {
|
||||
|
||||
static int ask(const char *in) {
|
||||
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;
|
||||
as = json_object_new_object(); A(as, "role", S("user")); A(as, "content", S(in));
|
||||
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(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();
|
||||
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;
|
||||
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);
|
||||
if(p) json_object_put(p);
|
||||
if(d) json_object_put(d);
|
||||
if(r) return r;
|
||||
continue;
|
||||
}
|
||||
if(p) json_object_put(p);
|
||||
if(d) json_object_put(d);
|
||||
return 0;
|
||||
@@ -276,18 +337,24 @@ 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 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;
|
||||
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);
|
||||
@@ -299,26 +366,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++);
|
||||
for(v = 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++);
|
||||
for(v = 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++);
|
||||
for(v = 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);
|
||||
@@ -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);
|
||||
if(!strcmp(v, "true")) A(SSN, k, B(1));
|
||||
else if(!strcmp(v, "false")) A(SSN, k, B(0));
|
||||
else if(strcmp(v, "null") && (d = strtod(v, 0), 1)) {
|
||||
else if(strcmp(v, "null")) {
|
||||
char *e; d = strtod(v, &e);
|
||||
if(!*e && e != v) A(SSN, k, D(d)); else A(SSN, k, S(v));
|
||||
if(e == v || *e) A(SSN, k, S(v)); /* not a number at all */
|
||||
else if(strpbrk(v, ".eE")) A(SSN, k, D(d)); /* 0.7, 1e3, .5: a real */
|
||||
else A(SSN, k, N((int)strtol(v, 0, 10))); /* 4096 must stay an int */
|
||||
}
|
||||
}
|
||||
sv(); printf("%s = %s\n", k, v);
|
||||
@@ -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 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) {
|
||||
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 = (char *)e;
|
||||
if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)e;
|
||||
else TMP = strdup(e);
|
||||
if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = strdup(e);
|
||||
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 = (char *)e;
|
||||
if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = strdup(e);
|
||||
if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e);
|
||||
if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = (char *)e;
|
||||
if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = strdup(e);
|
||||
if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e);
|
||||
if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); }
|
||||
SYS = getenv("SOLO_SYSPROMPT");
|
||||
@@ -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";
|
||||
snprintf(SH, sizeof SH, "%s", e);
|
||||
signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN);
|
||||
create(); load();
|
||||
if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); }
|
||||
rl_signal_event_hook = ci_hook;
|
||||
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_REASON")) && *e) { RSN = strdup(e); A(SSN, "reasoning_effort", S(e)); }
|
||||
if((e = getenv("SOLO_REASON")) && *e) { setstr(&RSN, e); A(SSN, "reasoning_effort", S(e)); }
|
||||
if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); }
|
||||
A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv();
|
||||
if(!isatty(1) || getenv("NO_COLOR")) CLR = 0;
|
||||
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, "%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(IN) { IN = 0; free(ln); continue; }
|
||||
IN = 0; /* a Ctrl-C during the line only cleared that line, not this turn */
|
||||
ac = ln;
|
||||
for(;;) {
|
||||
size_t l = strlen(ac);
|
||||
@@ -391,7 +484,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);
|
||||
|
||||
@@ -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 ]
|
||||
Reference in New Issue
Block a user