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.
This commit is contained in:
Luxferre
2026-10-05 19:05:44 +03:00
parent fdd389e452
commit 7550c70df3
3 changed files with 164 additions and 23 deletions
+83
View File
@@ -282,5 +282,88 @@ 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 ]