Commit Graph
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 494bee8b77 Solo-C: a tiny coding agent in a single C file
main.c is the whole agent: a readline REPL that talks to any OpenAI-compatible
/chat/completions endpoint and exposes exactly one tool, bash(command). Commands
run through the user's shell with stdout and stderr merged, a hard timeout, and
a process-group kill; the output and an exit marker go back to the model until
it answers without calling a tool.

make static-deps builds curl and c-ares from source into static-deps/, so
`make static` can link a fully static binary on distros that get in the way: the
distro libcurl.a drags in libpsl, brotli, zstd and nghttp2, whose static objects
are GCC 14 LTO bytecode a newer linker cannot read, and a minimal system often
ships no libcares.a at all.

Two details worth keeping in mind when editing mkstaticdeps.sh. curl 8.22 takes
--enable-ares=PREFIX rather than the older --with-cares, and it only warns
about options it does not recognise, so the wrong spelling turns off async DNS
in silence instead of failing. And link order is load-bearing: libcares.a has
to come after libcurl.a or ares_* stays undefined.
2026-10-03 09:15:02 +03:00