A contributor-fix release. Eighteen community PRs and one small web-UI
surface — most of this one is a single theme: output that vanished without an
error. Replies, tool calls, transcripts and wake signals that were lost while
everything reported fine.
Replies that never arrived
TelegramChannel.send() posted the full payload with no length check, and
DiscordChannel.send() assigned payload["content"] straight from the
message. A final reply over Telegram's 4096 rendered characters or Discord's
2000 either failed the API call or was rejected server-side — and the final
reply is the one output the user is actually waiting for. This is reachable
whenever streaming is off (a final_only stream policy). Telegram already owned
the splitter, _split_for_limit, and send_streaming used it; send() just
never called it. The cut-point algorithm now lives in a shared
agentos.channels._util.split_text_for_limit so Discord reuses it instead of
growing a second splitter that drifts. It also refuses to cut inside a fenced
code block: an odd number of fences before the cut means one is open, so the
cut backs up until each half's fences balance — Telegram rejects a message
whose entities do not parse, which would turn a length problem into a delivery
failure. Reply context goes on the right chunk (reply reference first, embeds
and keyboards last), and Discord's interaction-response path sends its first
chunk through the interaction PATCH and the overflow as channel follow-ups
rather than dropping it. (#1544)
The Slack adapter kept one _last_thread_ts on the channel instance for every
conversation on the account. parse_event overwrote it on every inbound event
carrying a thread_ts, and send fell back to it for any outgoing message
with no anchor of its own — so a scheduled delivery, a heartbeat or a proactive
notification landed in whichever conversation had spoken most recently. The
fallback is removed rather than scoped: a reply that needs a thread already
carries one, derived from the specific inbound message by build_reply_message
/ streaming_reply_kwargs, and anything else belongs in the channel
un-threaded. One visible change worth naming: with reply_in_thread=True, an
anchorless send that previously happened to land in the last-seen thread now
posts to the channel. (#1543)
Telegram blockquotes were parsed one line at a time with a strict "> " prefix
and each wrapped in its own <blockquote>, so a multiline quote rendered as a
stack of disjoint bubbles, and CommonMark's bare > paragraph separator did
not match at all and leaked a raw > into the message. The marker is now
CommonMark's — up to three leading spaces, >, at most one optional space —
and consecutive matching lines, empty ones included, are gathered into a single
tag. (#1532)
Tool calls that were hidden and then not run
_synthesize_text_tool_events only extracted a text-encoded tool call when
contains_minimax_protocol() saw the literal <minimax:tool_call> wrapper.
But engine.tool_text_compat — the layer that scrubs this markup from what the
user sees — already recognised several other real variants: the <tvoe_calls>
typo-wrapper, DSML's pipe-prefixed tags, and a bare <invoke> with no wrapper.
For all of those the leak suppressor hid the protocol, so nothing looked wrong,
and the write_file or create_xlsx simply never ran. minimax_compat.py
becomes text_tool_protocol.py and keys on a well-formed <invoke name="…">
… </invoke> pair rather than the wrapper around it, accepting DSML's pipe in
ASCII or fullwidth form and honouring its string="false" parameter marker by
JSON-decoding the body, so create_xlsx receives a list of rows rather than an
escaped string. Synthesis still fires only when no structured tool call
arrived, and any name the turn did not offer is still dropped. (#1514)
Transcripts and tasks on the reset path
_rotate_session_id called _archive_session_identity, discarded its return
value, and unconditionally deleted the transcript and summaries. The archiver
catches every exception and returns False on any I/O failure — full disk,
permissions, a bad archive path — so a transient write failure produced no
exception, no log and no archive, immediately followed by an irreversible
delete of the only copy of the data the archive was meant to protect. False
had been carrying two meanings, "nothing to archive" and "the write failed".
The destructive path now passes require_success=True, under which a write
failure raises and the reset stops with a clear error; an empty session still
returns False and rotates normally, and the non-destructive
rotate_session_id_archive_only stays best-effort. (#1539)
In the same neighbourhood, _drain_task_runtime_for_session's final drain
loop wrapped the whole for in one try/except TimeoutError, so the first
active task to exceed _RESET_RUNTIME_CANCEL_DRAIN_SECONDS aborted the loop
and every remaining task was never waited on — reset and delete then touched
storage while those tasks could still be running against it. The timeout now
sits inside the loop, the way the settle loop just above it already did, and
the warning carries undrained_count. (#1538)
The Telegram adapter also drops _known_sender_profiles, a per-sender map
written on every inbound update and every unpaired-DM pairing request and read
nowhere — unbounded growth driven by any user who messages a public bot. It is
deleted rather than capped; the profile still flows into
pairing_store.request(), its one real consumer. (#1542)
Scheduler
ops.update, ops.pause and ops.resume each do get → mutate → save, and
_execute_save's upsert wrote the reservation columns unconditionally. When a
lock-free reserve_due_job claim landed between an ops caller's get and its
save, the caller wrote its pre-reservation snapshot back over the claim.
Both consequences were silent: apply_reserved_result no longer recognised the
token and dropped the finished run's result, and the row looked free, so the
next tick reserved and ran the same job again alongside the run still in
flight. save() now takes write_reservation (default True, so the
reservation protocol's own writes are unchanged) and the four ops sites pass
False; scheduled_run_at is in the excluded set because that is exactly what
clear_reservation resets. (#1537)
Both SchedulerTimer._loop and HeartbeatLoop._loop cleared _nudge_event
before waiting on it, so a nudge() that arrived during _tick() was erased
the moment the loop came round to wait. In the heartbeat loop — 30-minute
default interval, request_now() exposed over RPC as the cron wake hook — a
requested heartbeat stalled silently until the interval expired. The clear now
runs after the wake, and SchedulerTimer.stop() sets the event the way
HeartbeatLoop.stop() already did. (#1526)
_PERMANENT_ERROR_PATTERNS matched bare status codes by substring, so "403"
matched the 4033 in Request timed out after 4033ms. Permanent runs before
transient, so it won over the explicit "timed out" signature, and
_apply_result_state set the job to DISABLED with no retry — one network
blip whose message carried a three-digit millisecond count permanently
disabled a healthy recurring job. Bare codes are now matched as whole numbers
with a digit-based guard rather than \b, because . is not a word character
and 12.403 seconds would otherwise still read as a 403; report-403.sh no
longer disables its own job, while got 403. and http_403 still classify as
permanent. (#1519)
Guards
Memory redaction anchored its keyword on \b, and _ is a word character, so
reset_token, csrf_token, device_token, push_token and
verification_token passed their values through unmasked — on the path
memory_save and the session indexer run before writing durable memory. The
keyword may now be preceded by up to four qualifier_ / qualifier- segments
but must still sit immediately before the separator, so token_count does not
match and sellToken stays an asset name. The chain is bounded on purpose:
every - is a word boundary, and an unbounded chain measured 22 s on one
100 KB line of 8f3a- repeats, 13 ms bounded, on a path that runs per
transcript message. (#1517)
> /dev/null 2>&1 was refused under workspace lockdown. _sensitive_shell_block
strips null redirections before it scans, but _shell_write_targets — the only
feed into the lockdown check — ran against the raw command, and /dev/null is
under no lockdown root. The scanner now strips the redirections first and drops
/dev/null from the result afterwards, which is what covers | tee /dev/null,
where the sink is an argument rather than a redirection. A real target beside a
null sink is still reported and still blocked. (#1545)
_extract_rm_targets matched \brm\b anywhere in a command, so
grep -rn "rm" /etc/passwd extracted delete /etc/passwd and hit the /etc
hard block — the one documented as surviving user approval, so the operator
could not approve past a false positive on a read-only command. Anchoring to a
command position was measured and rejected: it misses sudo rm, env FOO=1 rm, time rm and xargs rm. Instead a quoted span is data until something
runs it. _command_spans returns the unquoted text plus any quoted span
introduced by a shell-invoking command — sh/bash/zsh/dash/ash/ksh
with a -c flag found anywhere in its option prefix, and ssh — so
sh -c "rm -rf /etc/passwd" keeps its hard block while echo "rm -rf /"
does not. (#1349)
browser.allowed_domains compared entries against urlparse(url).hostname
after only lowercasing and trimming them, so .example.com, *.example.com,
https://example.com and example.com/ could never equal a host and the
allowlist matched nothing — failing closed, but with a refusal that named the
very domain the operator had just allowlisted. Every entry is now reduced to
the hostname it means, duplicate spellings collapse, and an entry that cannot
be a hostname is refused at config time naming the accepted format, rather
than dropped. The match itself is unchanged. (#1478)
Shell denial recording built its SandboxRequest with an empty env and
honoured only an absolute workdir, so the fingerprint written to the §8.3
ledger and used for the §8.5 purge never matched the one produced at execution
through gate_action. It now resolves workdir the way exec_command does
and populates env through build_subprocess_env. (#1562)
read_spreadsheet keys .xlsx rows by their real r number instead of
padding a list up to it, so a sparse sheet with data at row 1 and row 5000
reports and paginates against the real row numbers, the tool's own
continuation offsets reach row 5000 instead of stalling in the gap, and a
crafted or corrupt row index costs nothing. (#1149)
Web UI
The chat composer's route picker now names the model a turn actually ran on
while routing is automatic — Auto · c2 · glm-5.2 rather than the bare
Auto · image_model tier key — and shows the image route badge only when a
pin actually exists to be bypassed (#1631). It also lists the tiers an image
turn is routed to, as plain text below the pinnable list, since the router
picks the vision route before holds are consulted and pinning one would install
a hold that never takes effect; router.hold.get gained a separate
imageTiers list for it (#1632).
Full details for every entry are in
CHANGELOG.md.