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:
@@ -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 ]
|
||||
|
||||
Reference in New Issue
Block a user