Skip to content

AgentOS 2026.9.13

Latest

Choose a tag to compare

@github-actions github-actions released this 13 Sep 07:54
· 4 commits to main since this release
7e56a56

A contributor-fix release. Thirty-four community PRs and one router default
change — most of this one is a single theme: code that quietly answered a
different question than the one asked. Targets resolved against the wrong
directory, keys compared against the wrong name, rows read before the write
that mattered, all with nothing in the log to say so.

Wrong-target resolution

The email channel's _resolve_target fell back to treating reply_to as a
mailbox whenever the thread it named was not in the in-memory routing cache.
But reply_to is the inbound thread key — an RFC 5322 Message-ID, which has a
mailbox's local@domain shape and a domain chosen by whoever sent the original
mail — so after a restart or LRU eviction the agent's reply was silently mailed
to that address. The fallback is gone: an unknown thread with no
metadata["to"] raises and logs email.send_unknown_thread. Scheduler and
heartbeat delivery, which had relied on the fallback for operator-configured
addresses, now pass metadata["to"] — but only when channel_id is a
configured recipient rather than a thread key, since naming a thread key as the
recipient would mail the Message-ID even on a cache hit. (#1570)

sensitive_target_in_command conflated the workspace boundary with the
command's execution directory: whenever workspace was passed, cwd was
discarded and relative targets were normalised against the workspace root. So
rm -rf config with workdir=~/.aws resolved to <workspace>/config, matched
no sensitive basename, and the hard block that "ordinary approval cannot
override" never fired; rm -rf .aws/config from $HOME and rm -rf ../.ssh
slipped past the whole-text scan the same way. The two notions are now kept
apart — cwd anchors a relative target, workspace measures "inside the
workspace" — and with no workspace configured the /root container exception
keeps working. (#1579)

One directory over, gate_action accepted cwd only when it was already
absolute and otherwise fell through to the workspace root. action_fingerprint
hashes cwd, so every relative workdir collapsed onto one fingerprint, and
for @sandboxed tools with a fixed argv_factory such as git_status that is
the only discriminator: post_denial_guard auto-denied a call in repoB as
REPEATED_SAME_INTENT after the human had denied repoA — a request the human
never saw. A relative cwd now joins lexically onto the workspace root,
mirroring shell._effective_workdir (#1595). The git tool had the same gap
from the other side — a relative workdir was returned unresolved, so
_run_git resolved it against the process CWD and inspected $PWD/<workdir>
whenever the gateway ran anywhere but the workspace (#1566).

read_spreadsheet tested the positional reading of sheet before the
exact-name match, so on a workbook whose sheets were ["Summary", "1"],
sheet="1" silently returned Summary, and every numeric sheet name — years,
step numbers, product codes — was unreachable. The exact-name match now wins;
positional selection is only outranked, never removed (#1569). In the same
tool, a CSV cell over Python's process-wide 131,072-character field limit — one
embedded JSON blob or base64 column — raised an unhandled _csv.Error. The
limit is raised to len(text) for the duration of the parse (never
sys.maxsize, so a malformed quote cannot swallow the rest of the file as one
field), restored in a finally, and taken under a lock because it is
process-global and the read runs on the shared executor (#1580).

grep_search's include glob ran fnmatch against the filename only, so
tests/*.py could never match and the tool answered "No matches" —
indistinguishable, to the agent, from "this code does not exist". It now
matches the filename first and then the path relative to the search base, and
**/ also matches zero directories. (#1571)

Approvals that could not be approved

_handle_slack_interactive compared the session key's channel segment against
SlackChannel.channel_id, a dataclass default of "slack" the registry never
overwrote, while session keys embed the entry name — so on any Slack entry
not literally named slack, every Approve/Deny click logged
slack.interactive_mismatch and the approval never resolved. SlackChannel
gains a name field, the registry's flat path passes entry.name to any
adapter that accepts one, and the check compares against it the way Discord,
Telegram and Teams compare against self.config.name (#1606). Discord had the
identical defect for a different reason: DiscordChannelConfig never declared
a name field for the registry to populate, so the handler compared against
the literal discord (#1600). Any multi-account setup has at least one entry
not named after its adapter.

ApprovalQueue.wait(approval_id, timeout=X) treated the caller's per-call
timeout as the approval's expiry, so a Web UI poll with timeout=10 on an
approval whose real lifespan was the 300s default permanently wrote
resolved = 1, approved = 0 after ten seconds, and the operator's later
Approve raised Approval already resolved. The approval is now denied only
once created_at + default_timeout has genuinely elapsed (#1568). The follow-up
fix compares both deadlines on the monotonic clock: on Windows the wall clock
ticks at ~15.6ms, so after a short monotonic wait time.time() could still
report the approval as younger than its lifespan and skip the deny — the
intermittent Windows CI failure on main.

execute_code passed command=code[:200] into the approval check, and that
string is what the human reviewing the approval sees — so a script whose first
200 characters were imports or a docstring presented as harmless while the
destructive statement that triggered the prompt was never shown. The
sensitive-access scan already ran over the full code; only the payload was
truncated. (#1567)

Transcripts, sessions and memory

persist_compaction_result derived the rows to replace from counts on the
live transcript, so a sessions.send appended between the agent loading its
history and the CompactionEvent being persisted fell into the overwritten
tail and vanished from both the live and the canonical transcript, its
message_id gone for good. TurnRunner._load_history now records a
TranscriptSnapshot — session id plus the message_id of the last row the
history was built from — and the manager rewrites and archives only rows
inside it; later rows are re-appended verbatim and the summary's
covered_through_id never reaches them. A snapshot whose session or anchor row
is gone is rejected and reported as a failed persist rather than written over
the newer transcript. (#1645)

Twenty per-session registries — task-runtime locks, stream replay buffers,
background shell sessions, approval elevations, usage scopes, the denial
ledger and more — were bare dicts keyed by a session id with no pop() on
session end and no ceiling, so a gateway serving many short sessions retained
one entry per session per registry for the life of the process. Fifteen
reports had produced twenty competing patches, each with its own eviction
policy; agentos.util.BoundedRegistry replaces them with one rule in two
shapes — session-scoped state dropped on the session's terminal event with an
LRU backstop, and time-scoped caches with TTL plus a ceiling — and
evict_session_runtime_state() sweeps every registry that can identify a
session. A value the site declares busy, such as a held asyncio.Lock or a
running background process, is never evicted on any path. Ceilings and TTL are
registry_session_max_entries, registry_cache_max_entries and
registry_cache_ttl_seconds. (#1131)

sessions.create was the one gateway write path that skipped
normalize_session_name, so raw ANSI/OSC bytes in a displayName reached
whatever terminal later rendered the session list, and a pasted multi-line
/new title broke the single-line shape list rows assume (#1618).
MemorySyncManager.sync() snapshotted has_pending() at the top, awaited
its indexing, and then unconditionally reset() the tracker — so session
activity recorded mid-sync, never covered by that sync's own work, was wiped
by its completion; the tracker now consumes only the snapshotted amount
(#1521). apply_patch to USER.md, memory.md or a nested
memory_source_dir now refreshes the memory snapshot the way write_file
already did — the patch tool kept its own copy of the classifier and knew only
MEMORY.md (#1625).

Providers

override_model(model, fallbacks=...) rebuilt the selector's chain but left
_index and the held breaker admission at positions computed against the old
one. A selector that resolve() had already moved onto a fallback then either
crashed with IndexError when the new chain was shorter, or silently kept
serving whatever now sat at the stale index without asking the breaker.
Auto-Pilot issues exactly this override on the live request path, so a tier
switch during a provider outage could take down the turn. The held position
now follows its provider into the rebuilt chain and resets to the primary
when the provider is gone (#1616). The circuit breaker, meanwhile, ignored
request-shaped failures (MODEL_NOT_FOUND, BAD_REQUEST, …) by design — but
when the ignored failure was the half-open probe, probe_started_at stayed
set and allow() blocked every other caller for a full cooldown window, up to
600s, parking a provider nothing had shown to be unhealthy. The probe slot is
now released so the next caller becomes the probe (#1602).

The c0 router tier on the bankr, opencap and surplus profiles defaults
to deepseek-v4.1-flash. All three gateways publish it at a 1M context and
384K max output. The openrouter profile deliberately stays on V4 Flash:
OpenRouter prices V4.1 Flash above openai/gpt-5.6-luna, so the cost-aware
override would hand every c0 turn to c1. Existing configs are not migrated.

Scheduler

run_job_now read the job, reserved it, then executed the row it had read
before the reservation — so an update() landing between the two reads
meant the operator who had just saved a change and clicked "run now" got the
old payload, prompt or timeout, and since handler_key derives from the
payload kind, an edit from an agent turn to a reminder dispatched to the old
handler (#1555). A structured cron schedule carrying schedule.timezone — the
alias the expression-shorthand path already honoured — was silently scheduled
in UTC; a job asked for at 09:00 Shanghai ran at 09:00 UTC (#1603). cron.remove
on an unknown id succeeded and the CLI invented {"removed": true}; it is now
NOT_FOUND like cron.status and cron.update (#1598). The cron-watchers
skill recorded every fresh id as seen while printing only fresh[:limit], so
with GitHub fetching 30 per page against a default limit of 10, one busy poll
could drop 20 items for good; only reported ids are committed now, drained
oldest-first (#1674).

Guards and the shell

_record_shell_denial's docstring promised a §8.3/§8.5 ledger record for every
shell-layer denial, but its only caller was the interactive approval path. The
four unconditional blocks — a denylisted binary, the sensitive-path block, the
workspace lockdown and the write-deny — raised or returned their envelope
without touching the ledger, so the most severe refusals were exactly the ones
missing from the audit trail. All four now record through a new
record_audit_denial, which keeps the per-fingerprint count and the purge and
deliberately leaves the §8.5 pause counter alone: that pause is permanent,
threshold 3, and gates every @sandboxed tool, so routing text-scan hits into
it would have let cat ~/.ssh/id_rsa three times lock echo hello for the
life of the session (#1513).

The Windows denylist listed del and rmdir but not rd, erase or
Remove-Item, and the warnlist entries that duplicated them were dead code
because the denylist is checked first. rd and erase are anchored to a
command position, optionally behind a cmd /c or powershell wrapper, so
record does not trip them (#1464). Background shell output decoded each
4096-byte chunk independently, so a CJK character or emoji straddling a
boundary came out as U+FFFD (#1535). TerminalChannel.receive() handed
sys.stdin to connect_read_pipe, which the Windows Proactor loop rejects
with WinError 6; it now reads through the executor there (#1575).

Tools and skills

apply_patch read with Path.read_text() and wrote with write_text(), so
the universal-newline translation held only in memory and a one-line patch to
a CRLF file came out as a whole-file diff with every untouched line converted
to LF — and the mirror image on Windows. Both ends now open with newline="",
context compares with rstrip("\r\n"), and an added line takes the file's own
ending (#1124). The same tool skipped a bare empty hunk line outright, though a
blank context line is written as "" at least as often as " ", producing a
spurious Context mismatch at the wrong line (#1577). Nullable unions spelled
{"type": ["null"]}, {"const": null} or {"enum": [null]} now collapse like
{"type": "null"} before a schema reaches a provider that rejects them
(#1573). Concurrent skill installs raced on the lockfile's load → mutate → save
cycle — 20 at once dropped 19 entries — and now go through
Lockfile.update() under an OS-level lock with an atomic save (#1557). Status
reactions left a rejected message carrying both ✅ and ❌ forever and leaked
one _active entry per rejection; failed() is now terminal (#1560).

In the bundled skills, pdf-toolkit set --tables-strategy on one axis only,
so text found nothing on the borderless tables it is documented for and
explicit crashed on every call — it is removed (#1673), and the docx
skill's replace_text never looked inside tables, where contract and invoice
fields usually live (#1653).

Full details for every entry are in
CHANGELOG.md.