A contributor-fix release. Twenty-six community PRs and one new CLI flag —
most of this one is a single theme: operations that reported success while
writing to the wrong place, or writing nothing at all.
Writes that landed somewhere else
The Microsoft Teams adapter's edit() and delete() resolved a conversation
reference with next(iter(self._references.values())) — whichever conversation
happened to be cached first — and ignored message_id entirely. With a single
conversation cached the bug is invisible; with two, an edit or a delete lands
on someone else's thread and reports success. send() and send_streaming()
in the same file already resolved by key, and Discord's adapter already tracked
message_id -> channel_id for exactly this reason. Both destructive paths now
consult a mapping the send paths record, falling back to most recent rather
than oldest for an untracked id. (#1494)
sessions.truncate read its bound with no validation at all:
max_messages = (params or {}).get("maxMessages", 20)bool is a subclass of int, so maxMessages: false arrived as 0 and wiped
the entire transcript while answering ok: true; true kept only the newest
message; and a string reached the session manager's own < 0 check, raised
TypeError, and came back through the dispatcher's catch-all as a raw
INTERNAL_ERROR carrying the Python error string. The guard now runs before
anything destructive and raises ValueError, which the RPC registry already
maps to INVALID_REQUEST. maxMessages: 0 stays valid — that is the
intentional wipe, and it is already gated by the checkpoint/force check. (#1371)
projects.update had the same unvalidated-bool shape on expectedUpdatedAt,
where a boolean was compared rather than rejected. (#1261)
write_file did not record a workspace write when it overwrote an existing
file, so the artifact-delivery path saw a turn that had produced nothing and
the new content never reached the user. (#1205)
Document skills that reported success and changed something else
Every bug in this section exits 0.
docx replace_text joined every run in a paragraph, ran the replacement over
the whole string, wrote the result into runs[0] and emptied the rest. A run
is where Word stores character formatting, so every character inherited run 0's
formatting and the others survived as empty shells: bold, italic, underline,
font, size and colour were discarded for the entire paragraph when one word
needed changing. Nothing errors and the text reads correctly, so the document
looks right and is wrong when opened — against a skill that promises the
opposite, "in-place edit-by-run (preserves styles)". Every character now stays
with the run it came from, and a replacement is written into the run owning the
first character of its match, which also covers a find spanning runs. The
resulting paragraph text is unchanged: still a plain str.replace on the
joined runs. (#1447)
xlsx set_cell wrote through ws.cell(row=..., column=..., value=...), and
openpyxl's helper ends with if value is not None: cell.value = value. An
explicit {"value": null} therefore only read the cell — the previous value
survived, the script still counted the edit and exited 0 reporting
{"applied": 1}, and a caller had no way to detect the no-op short of
reopening the workbook. Assignment now goes through the property. Because
op.get("value") collapsed a missing key and an explicit null to the same
None, making null mean "clear" would have turned a typo into silent data
loss; a sentinel separates the two, so an op with no value key is skipped and
not counted. 0, false and "" were already written and still are. (#1260)
xlsx as_text implemented itself as return "'" + value, but Excel's leading
apostrophe is an input-mode escape, not cell content. The cell held '=hello
where the caller asked for =hello, so Excel and LibreOffice rendered a stray
apostrophe, len() was off by one, and the skill's own inspect_xlsx read the
quoted string back. The flag was also consulted only on the startswith("=")
branch, so the ISO-8601 coercion ran regardless and a timestamp could not be
stored as text. openpyxl models both halves already: as_text now suppresses
the datetime coercion, leaves the value untouched, sets data_type = "s" and
carries the apostrophe as quotePrefix — and it consumes a leading apostrophe
when escaping a formula, so the two documented spellings "=hello" and
"'=hello" land on the same cell. Scoped to '=, so 'tis keeps its
apostrophe. (#1358)
Rounding out the document skills: xlsx accepts inspector-style string merge
ranges alongside the dictionary specs it already took (#1250), and pptx text
extraction preserves paragraph boundaries, table cells included, instead of
running them together (#1430).
Names mangled before they were used
str.lstrip("./") takes a character set, not a prefix, so it strips every
leading . and / — including the dot that makes a dotfile. In the write
policy it ran over both the deny pattern and the candidate, so a rule written
as .env* normalised to env* and blocked environment.md, envoy.yaml and
env_setup.py along with the .env files it was meant to protect. (The
direction is over-blocking, not a bypass.) In the skill tools it mangled the
requested name before read_resource ever saw it, so a resource called
.eslintrc.json was looked up as eslintrc.json and reported missing. (#1244)
The IDENTITY.md emphasis stripper used _{1,3}(.*?)_{1,3} with no boundary
condition, so any two underscores on a line paired up as a delimiter run:
snake_case_bot was stored as snakecasebot, and an agent named
my_agent_name was told its name is myagentname. CommonMark disallows
intra-word emphasis with _ — a _ flanked by alphanumerics on both sides can
neither open nor close — and the pattern now carries that condition. The
asterisk branch is deliberately unchanged, because a*b*c really is emphasis.
(#1428)
Channels and transports
Telegram's _render_inline substituted [text](url) into <a href="url"> and
only then ran the inline passes for **, __, ~~ and *. Those match
anywhere in the string, so a URL carrying them was rewritten inside the
attribute — foo__bar__baz became foo<b>bar</b>baz — and Telegram rejected
the whole message with "can't find end tag of href", losing the reply rather
than degrading it. The URL is now parked behind a placeholder for those passes,
in the same shape the file already uses for code spans; the link text stays
exposed, because [**bold**](url) is meant to render bold. _plain_inline had
the same hazard with a worse outcome — it strips markers with str.replace, so
a label linked to foo__bar__baz pointed at foobarbaz, a link that does not
resolve with nothing to signal it. (#1435)
Discord slash-command reconstruction filtered options on truthiness, so option
values of 0 and False were silently dropped, and it never descended into
subcommand or subcommand-group options, so their names were lost entirely:
/temperature value: 0 arrived as /temperature, /verbose enabled: False as
/verbose, and /agentos status as /agentos. Every leaf value is now
appended as-is and nested options are walked into the command path. (#1229)
The MCP stdio client let concurrent tool calls on one server read from the same
asyncio.StreamReader, raising RuntimeError: readuntil() called while another coroutine is already waiting for incoming data — so any multi-tool turn
touching a single stdio server could fail. _send_request and
_send_notification now hold an asyncio.Lock. (#1462)
The email channel bounds IMAP fetch and parse retries: a permanently oversized
message is quarantined immediately, a transient failure gets a bounded number
of attempts before quarantine, and the per-UID attempt counter is pruned to the
current poll's UNSEEN set each cycle so it cannot grow without bound over the
connection's lifetime. (#1209)
Operator-facing
agentos upgrade on Windows failed with "Access is denied" and afterwards
agentos was gone from PATH. The lock is one AgentOS creates itself:
GatewayLifecycleManager spawns the managed gateway as sys.executable, which
inside a uv tool venv is ...\use-agent-os\Scripts\python.exe, and Windows
will not let uv tool install --force replace a file a live process holds
open — so the rebuild is refused and can leave the tool directory
half-replaced. The command restarted the gateway after the upgrade, which is
right on POSIX and backwards on Windows. On Windows only, a running managed
gateway is now stopped first and started again afterwards with the same bounded
version verification; if the upgrade fails or times out, the gateway that was
stopped comes back on the previous version rather than staying down.
--no-restart still opts out, and an "Access is denied" failure now names the
recovery. (#1365)
browser.max_sessions was not enforced when the cap was lowered. Three
behaviours combined: _evict_if_over_cap is reached only when a new session is
created, configure_browser dropped live sessions for a cdp_port or
enabled change but not for max_sessions, and reusing a session refreshes
last_used_at so the idle reaper never took it. Sessions that kept being used
therefore stayed over the new cap indefinitely. Each managed session is a
Chromium process, and lowering this number is exactly how an operator relieves
memory pressure — so the config changed and nothing changed on the box, with no
warning that the new limit was not in force. The reconfigure path now trims to
the cap, evicting oldest-idle and logging
browser.session_evicted_on_reconfigure. (#1498)
SupervisorRegistry.get_or_start tore down the previous supervisor inside
self._lock. CDPSupervisor.stop() reaches _WebSocketTransport.stop(),
bounded at 5s on the close call plus 5s on the thread join — and that branch
runs exactly when the connection is dead or its URL changed, the case that pays
both in full. The registry is process-wide and get, stop and stop_all
share the lock, so one wedged socket stalled every other browser path: dialogs,
eval, _drop_session, the idle reaper, gateway teardown. Measured with a
transport whose stop() sleeps 3s, an unrelated get() blocked 2.70s; after
the change, 0.00s. The file had already established the rule — start() is
deliberately called outside the lock — and get_or_start was the one place
that did not follow it. (#1496)
OllamaProvider handed cfg.timeout (120s by default) to httpx as a single
timeout, so the connect phase got the whole request budget, and it surfaced
failures verbatim: "Request error: All connection attempts failed", or a raw
404 carrying Ollama's own JSON. Neither tells an operator what to do. The
connect phase is now bounded at 5s while the read timeout stays at
cfg.timeout — Ollama is a local daemon, so a connection nobody accepts in
five seconds means it is not there, while a first token can legitimately wait
for a model to load. Connect failures name the base URL and ollama serve; a
404 whose body mentions the model says to ollama pull it. Both messages keep
the words classify_provider_error keys on, so TRANSPORT_TRANSIENT and
MODEL_NOT_FOUND classification is preserved. (#1366)
Bare day-of-week step expressions diverged from croniter for every start value
but 0. _parse_field's N/M branch built range(N, hi + 1, M) with
hi = 7, the alias-inclusive bound — so 7/2 gave {0} and 6/2 gave {6}
where both should be {0,2,4,6}, and 1/3 included a spurious Sunday.
croniter treats 7 as a pure input alias for 0 rather than an eighth slot: it
rewrites a bare N/M to N-6/M, aliases a 7 start to 0, and expands to the
whole field stepped when the start resolves to the true max. That is now
replicated exactly, scoped to the bare-value day-of-week branch; explicit
ranges and every other field are untouched. (#1501)
web_fetch returned more data for a smaller request.
_resolve_effective_max_chars() returned None for any max_chars below the
documented minimum of 100, and _apply_max_chars() reads None as unlimited,
so max_chars=1 came back as the entire untruncated page. Values below 100 are
now clamped up to it, matching the tool schema and the pattern already used for
the env-configured default. (#1400)
/file and /image split unquoted input at the first whitespace, so a path
pasted from a file manager was truncated at its first space and
/file /tmp/data set.csv summarise this reported File not found: /tmp/data.
The unquoted branch now scans whitespace word spans shortest-first for the
first existing regular file and keeps the remaining words as the prompt, with
the longest existing prefix as a fallback so a spaced path still resolves when
a shorter token only matches a directory. When nothing exists on disk the first
token is kept, so a genuinely missing path reports its usual error. Quoted
paths are unaffected. (#1228)
Two hot paths that discarded their own work
_persist_user_message decided one boolean with
not bool(await get_transcript(key)). SessionManager.get_transcript defaults
limit to None, SessionStorage.get_transcript turns that into LIMIT -1,
and a TranscriptEntry is built per row — so the session's entire history was
read and deserialised to compute not bool(...). Measured on a 5,000-entry
session: 135.12ms unbounded against 4.39ms for a single row. This runs on every
user message, so the cost grew with the conversation the user was still having.
The bound goes through the shared signature probe, since several session
managers accept the key alone and would raise TypeError on an unconditional
keyword. (#1368)
SessionSourceIndexer.sync read every session's full transcript, ran the
redaction pass over every entry and rendered the whole document — for every
session up to max_sessions, default 1000 — before index_file hashed the
result and returned 0 for anything unchanged. All of that work was discarded.
Measured at 377ms discarded per sync over 50 sessions × 200 messages, roughly
7.5s at the default cap; sync runs on session start, on the timer, on watch
events and ahead of memory searches, and one new message in one session is
enough to pay it for all of them. The mtime index_file records comes from
updated_at, which is on the session row already, so one batched
store.get_file_mtimes query now decides which sessions need reading.
append_message touches updated_at on every append, so a session that gained
a message always compares newer and is never skipped. (#1432)
New surface
agentos --version prints the installed version and exits. The CLI had no way
to report its own version: the flag failed with No such option: --version,
there was no version command, and the only top-level options were
--install-completion, --show-completion and --help — so the version was
reachable only from outside the tool, via uv tool list or pip show. The
value comes from the existing importlib.metadata resolution in
agentos/__init__.py, so there is no second source of truth to drift. (#1364)
Slack webhook accounts after the first now auto-derive
/slack/events/<account_name>. ChannelManager.collect_webhook_routes()
called create_webhook_route() without arguments and every adapter defaulted
to /slack/events, so with more than one webhook account enabled Starlette
dispatched only to the first matching route and the rest failed signature
verification on events meant for them. Deriving a path for every unset entry
would have re-pathed an operator's already-configured account, so the first
enabled webhook account keeps /slack/events and only the ones after it derive
a suffix. Duplicate or colliding paths across channel entries are now rejected
at gateway startup with an error naming both conflicting entries, instead of
one adapter silently never receiving events. (#1022)
Install
uv tool install --python 3.12 "use-agent-os[recommended]==2026.9.10"
Full Changelog: v2026.9.9...v2026.9.10