POLYROB v0.10.0
[0.10.0] — 2026-08-11
Two new capability surfaces — user-declared LLM providers and on-chain token
operations — on top of a large correctness and honesty pass across the agent
loop, billing, security gates, and the provider/credential UX.
Added
- On-chain token sight (
defi_data,DEFI_DATA_ENABLED, default off): a
read-only tool giving the agent eyes on Base —token_resolve(ranked
candidate contracts for a ticker),token_info(on-chain identity + price +
liquidity + a safety screen),price,portfolio(own holdings, USD-valued)
andcontract_read(raweth_call). No signer is constructed and nothing is
broadcast, so it cannot move value. Two rules run through the whole tier:
an address is the only identity —token_info/price/contract_read
reject a ticker outright, andtoken_resolvereturns every candidate and
never picks, because binding a symbol to a contract is the primary injection
surface for an agent that reads web pages; and unknown is never zero — an
unreadable price rendersunknown, an unreachable safety screen renders
UNSCREENED(never "safe"), a balance that failed to read is listed
separately as unknown, and a partial portfolio scan says outright which
addresses it looked at and that anything outside that set is invisible. Only
high-confidence prices enter a portfolio total, so an attacker-seeded pool
cannot inflate the headline figure an owner reads. Token metadata is pinned
for verified tokens and frozen on first sight otherwise, with any later
divergence surfaced asmetadata_changedrather than accepted. Results are
untrusted-wrapped (a token'sname/symbolare chosen by whoever deployed
the contract), andportfolioalone is gated while a session is
correspondent-tainted. AnALCHEMY_API_KEYupgrades holdings enumeration
from an honest partial scan to a complete index; it is never required. - On-chain transfers behind a transaction guard (
defi_trade,
DEFI_TRADE_ENABLED, default off): the first verb that can move real
funds —transfer, withdry_run=trueby default. Every call routes through
a single choke point (core/wallet/tx_guard.py) that simulates the
transaction, measures its observed asset and allowance deltas, and asserts
them against a declared intent — refusing on any disagreement or any probe
failure. The bound is not "we only wrote safe verbs", which stops being a
security property the moment the agent supplies its own calldata. Nine
ordered, fail-closed gates run before anything is signed: owner kill-switch,
turn origin (a forged, self-wake, delegation-result, delegated-leaf or
autonomous turn can never reach a money verb, and an unprovable origin
refuses), structural checks, an RPC-trust gate that refuses to arm on the
shared public endpoint, simulation trustworthiness, the delta assertion (an
undeclared allowance grant refuses outright — a hidden approve is the one
effect a USD cap cannot bound, because the drain happens in a later
transaction), pricing (an unpriceable outflow refuses), the per-transaction
ceiling plus rolling daily caps and replay guard, and finally the approval
lane: aboveDEFI_AUTONOMOUS_MAX_USD(default$25) the call returns
lane=owner_queueand does not execute. Result rendering is honest by
construction — a refusal says NOT SENT, a reverted receipt says the transfer
did not happen but gas was spent, and a receipt that never arrived says
BROADCAST BUT NOT CONFIRMED and warns against blind retry. The signing
perimeter is deliberately narrow: transaction signing refuses a transaction
with nochainId(EIP-155 replay exposure), and typed-data signing is kept
off the money path entirely, since a signed permit is not a transaction and
would bypass simulation, deltas, caps and audit.defi_tradeis classified
money + high-impact + delegation-blocked, so it is explicit-grant-only and
the agent cannot self-serve it; it is ANDed with — never a replacement for —
the existing wallet caps, kill-switch andowner_queueapproval lane. - Declarative LLM provider registry (proposal 024 P0,
LLM_PROVIDER_REGISTRY, default on): oneProviderSpectable
(modules/llm/provider_spec.py) now describes every LLM provider — identity,
credential shape, transport, base URL, capabilities — and the thirteen
historical hand-maintained provider lists (profiles,PROVIDER_CONFIG,
schema-generator routing, native-tools list, key→provider detection, fallback
hierarchy, the OpenAI-compat model map, …) are derivations of it. Users can
declare NEW providers with zero code in~/.polyrob/providers.yaml
(LLM_CUSTOM_PROVIDERS): any OpenAI-compatible endpoint (Ollama, LM Studio,
vLLM, llama.cpp, LiteLLM, Groq, Together, Fireworks, corporate gateways) or
Anthropic-compatible endpoint (z.ai GLM Coding Plan, incl. bearer auth) —
served by two generic spec-parameterized clients. Declared models join the
model registry (ownership routing through the OpenAI-compat surface and
check_provider_model); thepolyrob modelpicker UI joins in the L1.5
surface wave. Byte-identical
with the flag off or no user file (pinned by a dual-mode characterization
suite + a provider-list ratchet test);providers.yamlis treated as a
credential-equivalent file (denied to agent file tools — it redirects the
agent's inference endpoint), andauth.jsonstores are name-denied
everywhere in preparation for 024 L1. - LLM credential layer (proposal 024 L1,
LLM_AUTH_STORE_ENABLED, default
off):core/llm_auth/— a durable credential store at
~/.polyrob/auth.json(0600O_EXCLcreate, cross-processflock, atomic
replace), the singleresolve_credentialoracle (env key → OAuth entry →
consent-tagged borrowed entry → no-credential sentinel), credential
health (ok/rate_limited/exhausted,last_status_at-reconciled so
a lost exhaustion marker can never resurrect a spent subscription), and an
auth error taxonomy (relogin_requiredvs throttle). Fail-closed tenancy:
the store only serves on a single-ownerPOLYROB_LOCALdeployment. The
credential layer is agent-unreachable by construction (pinned by tests), and
both secret scrubbers now redact JWT-shaped OAuth tokens via one shared
pattern. OAuth connect flows (L2) and surface un-blinding (L1.5) are the
next phases.
Changed
- Provider and credential UX is honest end-to-end. A 15-finding evaluation
of provider setup and usage flows closed two outright blockers and thirteen
smaller lies.polyrob serveused to import a repo-root module that is in no
wheel, so every installedpolyrob servedied with aModuleNotFoundError
instead of the intended no-key refusal — the server entry point now lives in
the package and gates before importing it. Keyless-by-design providers (a
local Ollama, the documented rail) were excluded from the gating oracles, so
a keyless box was refused even when explicitly asked for it; they are now
first-class. Beyond that:polyrob doctorandpolyrob modelderive status
from one vocabulary (present/malformed/missing/no key needed)
instead of contradicting themselves between a table and its footer; a
rejectedproviders.yamlrow is queryable state indoctorrather than a
transient log warning;polyrob initno longer prompts for the API key of a
keyless provider;polyrob run -p <unknown>prints the known-provider list
instead of a traceback;GET /v1/modelsderives from the spec registry so
declared providers are discoverable by OpenAI SDK clients; error messages
name the session's actual provider (a-p zai-codingfailure no longer
reports "provider openrouter failed" or "from AnthropicCompatClient"); and
remedies point atpolyrob doctor/init/config set/providers.yaml
rather than a repo-relative env path that does not exist on an installed box.
POLYROB_<PROVIDER>_MODELis now a registered catalog row, and the
credential-surface write refusal moved into the config oracle so any remote
surface inherits it. - Repo-wide duplication and dead-code sweep. A verified audit removed
several thousand lines of unreachable surface — a retired second billing path
that re-implemented the markup math, an unused A2A client, dead LLM client
and manager methods (including a third "which provider owns this model"
implementation), dead database/memory modules and ~20 zero-caller methods,
orphaned telemetry models, and a 346-line duplicate Markdown formatter — and
consolidated the survivors onto single sources of truth: one bool/float env
parser battery, one secret-scrub sequence, one x402 database/telemetry
helper, one payments network table, one persistent-backend cache, one
surface-command envelope behind the sevenpolyrob <surface>commands, one
sidecar-DB path resolver, and one FTS query builder. Behaviour-preserving
except where the duplication was itself the bug (see Fixed/Security).
Fixed
- Credit death is fail-fast again. The billing block's deliberate
InsufficientCreditsErrorwas being absorbed by the generic exception
boundaries guarding the native-tools → structured-output → plain-call →
manual-parse chain, so each absorbed raise bought another billable provider
call: one out-of-credits step could make up to four paid calls before the
credit sentinel saw it. A timeout at the outer boundary was likewise answered
by starting another full-timeout call. - A stray "billing" or "402" in an application exception no longer halts the
session. Both branches that stop the agent classified any exception by bare
substring match on its message, so a database error on the shipped
billing_failurestable, or a 500 on a path containing/402, killed the
run with "PERMANENT ERROR — check API configuration" and pointed the operator
at their API keys for a bug in application code. Both branches are now
type-gated to the LLM error family, matching the sentinel that already was. - Provider fallback now updates every model source of truth. After an
automatic failover the session still named the failed provider for billing
(corrupting per-provider spend and provider-health data), context compaction
still ran against the dead client, and token budgets were never re-derived.
Fallback now routes through the same adoption path as the deliberate
hot-swap. - Tool results could be cross-wired to the wrong call. When identity
pairing was unavailable, the positional fallback walked calls and results
with the same index — but a call dropped by pre-execution validation stays in
the call list and contributes no result, shifting every later call by one. A
tool message could be built from a different call's output while the call
that actually ran was reported to the model as "not executed", with no error
surfaced anywhere. - Per-session reflection state no longer leaks across tenants. The memory
manager is a container singleton, but the reflection model and its billing
identity were assigned straight onto it — last-writer-wins, so one tenant's
reflection ran on another tenant's aux model and the usage record was written
against the wrong user, session and agent. - The tool-free-response counter is actually consecutive. It was never
reset on a productive step, so it counted empty responses over the whole life
of the agent: a model that emitted one tool-free step at step 3 and another
at step 44 drew a "2 consecutive steps" escalation and was scolded for
something it had not done, andALLOWED_REASONING_TURNSdegenerated from
"one planning turn per run" into "one per two empty responses ever". - Two background paths no longer block the shared event loop. The skill
curator's tick and the every-10-steps memory save both did synchronous SQLite
and filesystem work inline on the loop that also serves live chat, API, goal
and cron sessions — under write contention one curator tick could stall every
session in the process for seconds. Both now run off-thread. GET /api/admin/users/searchworks again. It was registered after
/users/{user_id}, so FastAPI matched the path-param route first and every
search landed inget_user("search"). Route order is now pinned by a test.- Judge-model calls whose structured output failed to bind went unbilled
even though the provider ran and charged for them; the<environment>block's
"Tools loaded this session" line had never rendered (it read an attribute the
agent does not have); a browser-state exception left aNonethat downstream
code dereferenced unconditionally; feed mirroring of memory reads/writes had
raised aTypeErroron every call since it shipped (swallowed at debug
level); and a lingering inline copy of the streaming fallback on the billing
path was converted to the shared helper.
Security
- Four policy gates that matched nothing are live again. Container-tool
actions register namespaced as{tool_id}_{action}, but four policy sets
listed the bare verb — and an exact-match gate keyed on a name the runtime
never emits fires never, while every unit test passed because the tests
asserted the same fiction. Consequences:PAYMENT_APPROVAL_MODEhad never
gated invoice creation under either mode; the scoped tainted-reply
exemption was dead for email; and the redundant name entries protecting
high-impact verbs (code execution, deploy/undeploy, the x402 ledger reads)
were absent. All four sets now carry runtime names, and an action-name parity
ratchet fails the build if a gate ever again names a verb the runtime cannot
emit. - Correspondent taint is cleared on delivery, not on submit. The clear ran
at the top of message submission, before the checks that decide whether the
message is even accepted — so three rejection paths (unknown agent id, and
either queue full) re-opened every high-impact tool while the session still
held untrusted third-party data and no owner turn had entered. Anyone able to
make the queue reject could open the gate without the owner doing anything.
The clear now happens at the drain point, where the message provably enters
the turn. - One ordered secret-scrub battery. The logging filter's private copy of
the substitution sequence had dropped the JWT rung, so OAuth access and
refresh tokens survived into log lines — and the fast-path gate skipped a
bare JWT entirely. The persisted-content scrubber, the logging filter and the
CLI display scrubber now all delegate to one sequence. - An RPC provider error is UNKNOWN, never a confirmed zero. The JSON-RPC
helper returnedresultunconditionally, so an{"error": …}body mapped to
0. A rate-limited endpoint therefore reported a confirmed$0.00wallet
balance; worse, the x402 treasury scan swallowed the failure and returned
"nothing there", advancing its checkpoint past an unread block range — and
because the checkpoint refuses to regress, a payer's real transfer in that
range was lost permanently, without even an unmatched-payment notice. The
scan now distinguishes "not scanned" from "scanned, empty" and holds its
checkpoint so the next tick retries. Operators can pin a real endpoint per
chain withDEFI_EVM_RPC_<CHAIN>. - MCP HTTP health checks are SSRF-validated. The connectivity probe built a
raw session against a user-supplied URL with no validation and redirect
following; it now uses the same validate-and-pin path as every other MCP HTTP
hop, with redirects disabled. - Workspace confinement no longer accepts sibling directories. The path
validator'srealpath().startswith(root)check passed/tmp/ws-evilfor a
root of/tmp/ws; it now delegates to the shared containment helper. - Two browser lifecycle methods and a context manager pair were each defined
twice in one class body (Python keeps the last definition, so the first pair
was silently dead), a duplicate money gate in the usage tracker was deleted,
andsupports_native_toolsnow answers from the provider spec first — the
hardcoded substring list had been shadowing it and contradicting the spec.
What's Changed
- release: POLYROB 0.10.0 by @themontreal in #10
Full Changelog: v0.9.0...v0.10.0