Skip to content

AgentOS 2026.9.2

Choose a tag to compare

@github-actions github-actions released this 01 Sep 17:16
· 87 commits to main since this release
8cdd5a3

A release about numbers that were already there and nobody read — the price a marketplace of sellers is quoting right now, the dollars the router saved on every turn since it shipped — and about four gates that were checking something adjacent to the thing they were supposed to guard.

A provider where sellers compete on price

Surplus Intelligence (surplus) joins as a runtime provider. It is a two-sided marketplace: each request is routed to the cheapest healthy seller. Configuration is ordinary — an OpenAI-compatible provider with a buyer API key (SURPLUS_API_KEY, inf_…) against https://api.surplusintelligence.ai/v1. The x402/USDC and MPP per-request payment protocols it also offers are deliberately not wired up, so nothing crypto-related enters the dependency tree.

The interesting part is pricing. A static rate table works for a vendor that publishes a price list; it does not work for a market where the price moves with seller competition. So cost estimates come from the live public catalog instead — unauthenticated, OpenRouter-shaped rather than the flatter gateway shape, with rates in USD per token and an explicit supported_features array naming vision / reasoning / tools. The boot fetch doubles as the price seed and refreshes on its own TTL, with a bounded negative cache for when the catalog is unreachable. AGENTOS_SURPLUS_LIVE_PRICING=0 pins estimates back to the static table.

It ships its own router tier profile (deepseek-v4-flash, gpt-5.6-luna, glm-5.3, claude-opus-5, image glm-5.3-flash). Without one, the router would have silently fallen back to the OpenRouter tier table, whose namespaced ids (openai/gpt-5.6-luna) this marketplace does not serve. The image tier is glm-5.3-flash rather than OpenCAP's minimax-m3, because Surplus publishes minimax-m3 without vision.

The router has been writing down its savings since day one

Every turn wrote a SavingsTelemetry record to ~/.agentos/logs/decisions-*.jsonl. Nothing read it back. The existing reports covered routing quality (eval_report.md) and feature-extraction latency (benchmark_features.py) — neither said what the router saved in dollars.

agentos cost savings rolls that telemetry into a summary and a per-route breakdown, with --json, --csv, --start-date / --end-date, --log-dir, and --pdf for a branded one-page report. It reads the decision log directly, so it works with the gateway stopped.

The number is reported for what it is. Despite its name, routing_savings_usd_estimated_vs_baseline is not measured against the sibling baseline_model field: it is the input-price delta between the routed model and the most expensive model configured in [router.tiers], times input tokens, clamped at zero. baseline_model records the model the request arrived with. So the report labels that column Requested, names the comparison as the top tier, and prints on the page that only input tokens are priced. Tool-result projection, short-reply enforcement, prompt-cache hits and thinking mode are all excluded, so the figure stays attributable to the router — a floor, not a full-turn saving. Without saying that plainly, the rows where routed_model == baseline_model read as nonsense; on real logs they are the largest single savings row. (#788)

Four gates that checked the wrong thing

  • Trusted-proxy auth checked a header substring, not the peer. auth.mode = "trusted-proxy" admitted any request whose client-supplied X-Forwarded-For merely contained the configured proxy string. The real transport peer was never consulted, so any network peer could send X-Forwarded-For: <proxy> and walk in with full Control RPC access. The gate now requires request.client.host to be in the trusted-proxy set; once that passes, XFF is honoured downstream for client identity, which is the entire point of the mode (nginx, Caddy and ALB all set it on every forwarded request). The trust logic is now a single shared peer_is_trusted_proxy helper used by AuthMiddleware, RateLimitMiddleware and the RPC resolve_auth layer — which previously had no trusted-proxy branch at all — so the gates cannot drift apart. (#568)
  • Cron webhooks were checked for scheme and hostname, not destination. validate_webhook_url let a cron job POST its run output — model output and tool results — to AWS IMDS, the GCP or Azure metadata service, or anything else in the link-local range, and hand the response back as the delivery result. The shared metadata floor http_request already uses now applies on create, on update, and at delivery. Localhost hooks keep working. (#574)
  • web_fetch capped what the model saw, not what it downloaded. The whole response body was buffered into memory before max_chars ran — max_chars truncates the display, not the transfer — so one chunked response with no content-length, or a lying one, could exhaust process memory and take the agent or gateway down. The 30-second timeout bounds time, not bytes. The response is now streamed and reading stops at AGENTOS_WEB_FETCH_DOWNLOAD_LIMIT (default 1 MiB), reporting truncated=True at the cap. The response is closed only after the redirect Location header is read, so a 3xx with no Location no longer fails against a closed stream. (#502)
  • The approval cache parsed the first rm, not every rm. It used re.search, which stops at the first match, so rm A; rm -rf / had its second invocation skipped entirely — the destructive target never reached the intent scan. Every rm is now tokenized independently with re.finditer, capture stopping at shell separators. Regression tests pin both layers: every separator ends an rm invocation, and a sensitive read in a later segment is still refused at the tool boundary by exec_command's whole-command scan. (#512, #676)

Alongside them, GET /api/approvals loses its rate-limit exemption. The endpoint serializes every pending exec/plugin approval — command, argv and params — and takes a SQLite read on each call, so the carve-out let any caller past the auth gate poll it at unlimited rate: continuous observation of pending tool-call arguments, plus enough read pressure to stall the approval and chat pipeline. It is counted in a dedicated per-IP bucket rather than the shared /api/* one, because the Web UI polls it every 1.5s and the shared 100/min default would have 429'd operators out of their own approval queue; the cap is AGENTOS_RATE_APPROVALS_MAX_REQUESTS, default 300 per window. (#569)

And the proxy names are no longer writable through any AgentOS surface. set_env_var — and the Web UI, agentos env set, and the gateway RPC — could write AGENTOS_LLM_PROXY, which every provider client applies, letting an agent or a prompt injection reaching one route all model traffic through a proxy of its choosing and read the Authorization header off it. AGENTOS_LLM_PROXY, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY and AGENTOS_TRUST_ENV now join the write denylist, matched in any casing: the proxy readers lower-case every name they find, so denying only the two conventional spellings would have left Http_Proxy as an equivalent way in. Values exported in the shell or hand-written into ~/.agentos/.env keep working. (#550)

Two more land from the same family: provider base_url values arriving through onboarding.provider.configure are now validated as absolute http(s) URLs that are not cloud metadata endpoints or private/link-local addresses — including the inet_aton spellings like http://2852039166/ that reach the metadata service without looking like an address (#551) — and the SSRF guard's DNS-rebinding TOCTOU is closed by a validating httpcore backend that resolves at connect time and connects to the address it just checked, so a short-TTL domain can no longer answer public for the guard and 169.254.169.254 for the socket (#516).

Overflow errors that ended the turn instead of compacting it

Both Gemini and Anthropic report context overflow in wording no marker matched, so both fell through to the status_code == 400 branch and surfaced as BAD_REQUEST — the turn died where COMPACT_AND_RETRY would have saved it. Gemini's actual message is the input token count (X) exceeds the maximum number of tokens allowed (Y) (#657); Anthropic's are prompt_too_long, exceed context limit, request_too_large and request size exceeds (#613). Both now classify as CONTEXT_OVERFLOW and compact.

Fixes

  • The email channel checks Reply-To against the sender allowlist. The From address was checked against the fail-closed allowed_senders list; Reply-To — equally attacker-controlled on an admitted message — was taken verbatim as the reply target, so an allowlisted sender could redirect the agent's answer, tool output included, to any mailbox. It now runs through the same allowlist and falls back to the From address when off-list, rather than rejecting the whole message. The target is re-checked when the outbound reply is built, so a stale or tampered thread cache cannot reintroduce an off-list recipient.
  • Agent-initiated email sends work at all. _resolve_target read only metadata["to"] and the in-memory inbound-thread table, so the built-in message tool (which writes metadata["recipient"]) and scheduler/heartbeat delivery (which passes the bare address as reply_to) both raised ValueError: email.send has no recipient for reply_to. Recipients now resolve in order: metadata["to"], metadata["recipient"], the thread cache, then reply_to when it parses as an address — and a fresh outbound mail with no thread gets a real subject instead of Re: (no subject). (#598)
  • OpenCAP's router tiers track OpenCAP's catalog. The profile was cloned from Bankr's with the provider string swapped, so OpenCAP silently inherited Bankr's release cadence; the two gateways publish overlapping but different catalogs. It is now its own table, c2 moves to glm-5.3 (1.31M context), and five newly published models — glm-5.3, glm-5.3-flash, grok-4.6, kimi-k3, muse-spark-1.2 — are declared in the registry with real context windows, output caps and rack rates instead of falling through to the generic $3/$15 default. A thinking_level on a GLM tier also reaches the wire again: the capability gate reported supports_reasoning=False for everything but DeepSeek V4, so the c2 default's declared level was dropped even though GLM 5.x reasons by default. And the offline vision fallback now recognizes gpt-5.6-*, glm-5.3-flash and muse-spark-*, so a failed catalog fetch no longer leaves image turns with nowhere to route.
  • robinhood-rwa-addresses stops answering a company question with an impersonator. Robinhood Chain is permissionless and the public token list carries both kinds: two entries are named "GameStop" with symbol GME, and the lookup stripped the • Robinhood Token suffix — the only thing telling them apart — before ranking, so which address came back was down to list order. Asking for NET returned the "NetNet" community token above Cloudflare. The lookup now matches Stock Tokens only (opt back in with --include-community), tags every match with isStockToken, and reports a stock_tokens count. (#745)
  • MemorySyncManager preserves watched-file mtimes. It now passes the filesystem mtime its watcher already captured to LongTermMemoryStore.index_file(), for both watched memory files and knowledge-base documents, so persisted freshness and retrieval recency track the source file rather than the moment the sync ran — matching the direct ingestion path, and without an extra stat. (#649)
  • The scheduler cancels startup catch-up tasks on shutdown. They were fired and never retained, so a shutdown mid-catch-up left them running against a closing runtime instead of being cancelled and awaited with the regular timer tasks. (#655)
  • web_fetch honours the response charset. The body was decoded as hard-coded UTF-8, so ISO-8859-1, Shift_JIS and GBK pages reached the model as runs of U+FFFD. It now uses the charset the server advertised, snapshotted inside the client block before any body read — the same shape http_request already used.
  • The @sandboxed decorator derives a valid argv for git_diff. The argv_factory produced a command line the sandbox could not run, so the tool failed under sandboxing rather than being inspected and allowed. (#614)
  • The Ollama "model not found" branch spells out its grouping. "model not found" in text or ("pull" in text and "model" in text) — behaviour unchanged, since and already bound tighter, but the intent no longer rests on implicit precedence, and the branch is covered by tests. (#582)

Also new

Three bundled skills. gmgn-wallet-analysis and gmgn-wallet-score answer "should I follow this trader" — four pass/fail gates, current holdings and buys, the copy window in seconds and a size cap; then track record, a latency/slippage/gas copy-tradeability backtest, and developer reputation for wallets that mostly launch tokens. The bundled set could previously only gesture at that with raw gmgn-portfolio fields. (Upstream's gmgn-wallet-score frontmatter is not valid YAML — an unquoted : inside description — so its description is folded into a block scalar here; without that the loader drops the skill silently.)

robinhood-chain-stocks reads tokenized-stock state straight from Robinhood Chain (chainId 4663) over JSON-RPC: Chainlink USD price, the ERC-8056 uiMultiplier() corporate-action ratio, oraclePaused(), total supply, and wallet balances with their USD value. Read-only by construction — only eth_call, never a signature or a key. Feed addresses resolve from Chainlink's reference-data directory rather than being hardcoded, prices carry ageSeconds and a stale flag so a market-closed quote is not read as current, and authenticity is reported three ways (verified, disproven by a revert, or unverified because the node was unreachable) so a network fault is never presented as proof that a genuine listing is fake.

Also: the default skills-prompt budget (skills.max_skills_prompt_chars) rises from 24,000 to 26,000 characters. The shipped skill set's own descriptions had grown past the old ceiling, which would have dropped full-mode installs to a narrower render. It is a cap, so installs already under it send no more than before, and configs that set the value themselves are untouched.

Thanks to @keyKQ, @iamhaniofficial, @Carlys17, @BunnyTeddy, @Preciousuche, @Tiktokaiagent, @s97472091-pixel, @tejajakarulloh and @kyveni.