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
+15 -1
View File
@@ -258,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 |
@@ -347,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
@@ -383,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.