Commit Graph

50156 Commits

Author SHA1 Message Date
kshitijk4poor
4c8d4148ca test(aux): pin that a scoped runtime's key rotation stays out of the legacy mirrors
e9a77f4188's `_RUNTIME_MAIN_API_KEY == old` guard had no test: reverting it left every kept test
green, though rotating under scoped_runtime_main then raises KeyError 'base_url' (or, with the
.get() default, overwrites the turn's mirrors with the scoped copy). Extend the existing aux-route
test with the scoped rotate step; red with the guard removed (mirror model becomes 'm') and red on
the pre-guard code (KeyError), green on HEAD.

Fold finding C15 #2 (gate 2ab toothless guard).
2026-10-07 13:20:42 +05:30
kshitijk4poor
b6a19d6262 fix(anthropic): keep the fallback-restore snapshot's OAuth flag paired with its refreshed key
The credential refresh moved _primary_runtime["anthropic_api_key"] to the new token but left
_primary_runtime["is_anthropic_oauth"] at the old token's value, while self._is_anthropic_oauth is
recomputed from the new token. _build_anthropic_client_from_runtime restores the key and flag
together after a fallback, so a refresh that changed token type (Console key -> OAuth) would
restore the new key with the stale flag. Update the flag under the same identity guard.

Fold finding C15 #1 (gate quality review).
2026-10-07 13:20:42 +05:30
kshitijk4poor
03101500d0 refactor(compression): move _codex_compaction_cooldown_remaining into conversation_compression_codex to keep conversation_compression.py under its size cap
Code-health ratchet: the pre-compression credential refresh grew agent/conversation_compression.py
to 4591 lines, over its 4579 cap on main. Pure move (body byte-identical), late-imported at its one
call site; no test or doc references the old path.
2026-10-07 13:20:42 +05:30
kshitijk4poor
3ad035cb2e test(anthropic): compression before any request sends the refreshed token
Manual /compress and turn-start compaction run before the next main request, so the
holder sync inside the main-request refresh never fired and the summary call carried the
revoked token. Replaces the bare holder-move test (its assertions are now checked through
compress_context), keeping the stack at two tests. Red without the pre-compression refresh.

Co-authored-by: Atul <atul@trycoral.ai>
2026-10-07 13:20:42 +05:30
Atul
aa27574648 fix(agent): refresh a rotating Anthropic token before compressing
The main loop re-resolves the Anthropic OAuth token before each request, but a compression that runs
before the next request (manual /compress, preflight compaction) still summarised with the token the
last request used. compress_context now runs the same refresh first.

(cherry picked from commit 63e22d597340aa042c3c736062e1190e31a9f96e, agent/conversation_compression.py hunk only)
2026-10-07 13:20:42 +05:30
kshitijk4poor
0adafc01c9 chore: map atul-trycoral for salvage of #130334 2026-10-07 13:20:42 +05:30
kshitijk4poor
5018787e7b refactor(aux): move rotate_runtime_main_api_key into auxiliary_key_rotation to keep auxiliary_client.py under its size cap
Code-health ratchet: agent/auxiliary_client.py was 8421 > its 8407-line cap on main (+14) after this
stack added the key-rotation helper. Pure move of the existing function into a topical sibling;
the body is unchanged and it late-imports the facade's runtime-main state, so it still reads the
live legacy mirrors at call time. The only caller (client_lifecycle) now imports from the sibling.
2026-10-07 13:20:42 +05:30
kshitijk4poor
325c9a2f26 fix(aux): keep a scoped runtime's key rotation out of the legacy mirrors
scoped_runtime_main binds a normalized copy that drops empty fields, so rotating under it raised KeyError and would have written a scoped runtime into the mirrors. Refresh the mirrors only when they still hold the revoked key. Gate r2 quality finding; probe: KeyError 'base_url' before, clean after.
2026-10-07 13:20:42 +05:30
kshitijk4poor
d79480f128 fix(anthropic): rotate the published aux key in place so the turn thread sees it
Gate finding B15-1 (Medium): the pre-dispatch refresh runs inside the request
worker's copied Context (_context_thread_target -> contextvars.copy_context().run),
so re-calling _publish_runtime_main rebound the ContextVar only in that copy; the
turn thread's later `auto` aux calls (approvals, goal judge, plugin llm) still read
the revoked token. rotate_runtime_main_api_key mutates the already-published dict
(shared by both contexts) when it still holds the old key, then refreshes the
legacy mirrors. No cross-session leak: set_runtime_main builds a fresh dict per
turn, so the shared object is this turn's only.

The kept e2e test now runs the refresh on a thread under _context_thread_target;
red on the previous head, green here.
2026-10-07 13:20:42 +05:30
kshitijk4poor
e8f524d7ea test(anthropic): drop the stray third blank line after the aux-route test
Gate finding B15-2 (Low): three blank lines before TestOAuthFlagOnCredentialSwap
where the file uses two between classes.
2026-10-07 13:20:42 +05:30
kshitijk4poor
44c269f1eb fix(anthropic): republish the same-turn aux runtime after a token refresh
turn_context publishes the auxiliary main runtime at turn start, before the
first request can trigger the silent Anthropic refresh. Syncing the agent-side
holders alone left that already-published runtime on the revoked token, so
same-turn `auto` aux calls (approvals, goal judge, plugin llm) still 401d.
Re-call _publish_runtime_main when the published key is still the old token;
identity-gated so a fallback or other-route publish is never overwritten.

Also keep #121757's end-to-end aux-route test, rewritten to production
order (publish, then refresh, then read).

Co-authored-by: SoyMarketing <yo@soymarketing.org>
Co-authored-by: mahdi <mahdi.nikfallah@yarnhub.org>
2026-10-07 13:20:42 +05:30
mahdi
4b842d3f25 fix(anthropic): move every holder of the revoked token on credential refresh
Claude Code revokes the old OAuth token when it refreshes, but
_try_refresh_anthropic_client_credentials only updated _anthropic_api_key.
agent.api_key, context_compressor.api_key (sent as the aux main_runtime for
compression) and the _primary_runtime restore snapshot kept the revoked
token, so every compression summary 401d for the rest of the session.
Sync each holder that still carries the old token (identity-matched, so a
compressor or snapshot on another route keeps its own key).

Rebuilt by hand on main from #121859 (conflicted in the test file).

Fixes #121756

Co-authored-by: SoyMarketing <yo@soymarketing.org>
2026-10-07 13:20:42 +05:30
kshitijk4poor
a3f67a473d chore: map MNIK-YH and soymarketing for salvage of #121859/#121757
Attribution for the salvage of the Anthropic refresh holder-sync fix
(#121859 author, #121757 co-author).
2026-10-07 13:20:42 +05:30
kshitijk4poor
b278d6528f fix(line): a non-string channel secret or token reads as unset
_credentials coerced every value with str(), so YAML channel_secret: true
keyed the HMAC with the guessable "True" (likewise 123, lists, maps).
Read a value only when it is a string, matching the webhook adapter's
_is_usable_secret policy; scoped-env values stay first. (Lead fold: Sol
r4/r5 + quality lens, converged; probed: true/0 -> "" on head.)
2026-10-07 13:19:35 +05:30
kshitijk4poor
e8b53518ea fix(webhook): an all-NUL whsec_ key fails closed like the empty key
HMAC zero-pads its key, so a whsec_ secret that decodes to NUL bytes
(whsec_AA==) signs exactly like the empty key b"" the previous fold
refuses; the whitespace-only guard let it through. Strip NULs with the
ASCII whitespace before the emptiness check. (Lead fold, 2c lens Low.)
2026-10-07 13:19:35 +05:30
kshitijk4poor
b0ea19a9cd fix(webhook): a whsec_ Svix secret that decodes to an empty or blank key fails closed
_is_usable_secret only sees the encoded string, so "whsec_" (decodes to b"")
and "whsec_ICAg" (decodes to three spaces) passed it and keyed the Svix HMAC
with a public value: any caller could sign a request. Apply the same
blank-key rule to the decoded key before computing the HMAC.
2026-10-07 13:19:35 +05:30
kshitijk4poor
d28c513904 fix(raft): a null or blank bridge token is unset, not a guessable inbound token
`bridge_token:` with an empty YAML value became str(None) == "None" (and a
whitespace-only value stayed "   "). Both are truthy, so connect() skipped
the auto-generated token and _authorized() accepted any wake/activity POST
carrying that header. Same class as the null-secret fixes for LINE and
WhatsApp Cloud in this stack.
2026-10-07 13:19:35 +05:30
kshitijk4poor
a32358068f test(line): pin the blank-token refusal in the standalone sender
Reading the standalone-send token through _credentials (earlier in this
stack) strips it, but no test failed when that read was reverted: the only
standalone test used an absent token. A whitespace-only token now has to
stop at "missing token" before the push API.
2026-10-07 13:19:35 +05:30
kshitijk4poor
adf4a4b48c fix(sms): a non-string or blank standalone-send key fails closed instead of raising
PlatformConfig keeps a numeric YAML api_key as an int, so the .strip() this
stack added to _standalone_send raised AttributeError where origin/main
returned an error dict. Coerce through str(... or "") like the LINE reader,
and pin both the int key and a whitespace-only key (which also gives the
standalone strip a test that fails without it).
2026-10-07 13:19:35 +05:30
kshitijk4poor
53f84cc716 fix(whatsapp_cloud): keep a null app secret unset instead of keying HMAC with "None"
An empty YAML value (`app_secret:` / `verify_token:`) arrives as None, and
str(None) made the inbound signing key the literal "None". The truthiness
guard in _handle_webhook then accepted it, so anyone could sign a POST with
HMAC("None", body). Same class as the LINE null-secret fix in this stack;
`or ""` lets a null read as unset and reach the existing 503 refusal.
2026-10-07 13:19:35 +05:30
kshitijk4poor
ae083f8a22 fix(line): read the standalone-send token through _credentials and pin both strips
_standalone_send still read the access token raw, so a blank or newline-padded token went out as the Bearer header; reuse the hardened _credentials. Pin the token half of the null/blank guard and the env presence strip (both reverted green before). Gate r4 Lows.
2026-10-07 13:19:35 +05:30
kshitijk4poor
62df46fdc4 test(sms): pin the stripped Twilio token in the requirements check
Reverting only the check_sms_requirements strip left the suite green; also drop the dead 'or ""' after reads that already pass a default. Gate r3 Lows.
2026-10-07 13:19:35 +05:30
kshitijk4poor
c5caff330d fix(line): keep a null channel secret unset instead of keying HMAC with "None"
c92ed7e000 wrapped the extra fallback in str(), so a present-but-null config value (an empty YAML key) became the guessable literal "None" and connect passed. Fall through to "" before str(); strip the env presence check too so check_requirements agrees with connect. Gate r3 High (2ab, 2c, quality converge).
2026-10-07 13:19:35 +05:30
kshitijk4poor
caa63dfc44 test(webhook): pin the dynamic-route secret guard
Gate r2 findings 3 and 4. Mutating _dynamic_route_allowed to
`if not effective_secret:` stayed green, because no test reached the
hot-reload guard with a blank or non-string secret. The existing
parametrized connect case now asserts the dynamic-route guard rejects
the same secrets, and it goes red under that mutation (4/4). Also
rename the test and its param to match what they cover (unusable, not
only whitespace-only), and refresh the stale docstring to name
_is_usable_secret.
2026-10-07 13:19:35 +05:30
kshitijk4poor
84cb790e64 fix(sms): fail closed on a whitespace-only Twilio auth token
Gate r2 finding 2 (same class as the webhook blank-secret fix):
TWILIO_AUTH_TOKEN was read unstripped, so a whitespace-only token
passed check_sms_requirements and keyed the X-Twilio-Signature HMAC
with blanks, letting anyone forge inbound SMS. Strip the token at
every read (adapter init, standalone send, requirements check).

Stripping alone would leave an empty key, and HMAC with an empty key
is equally public (verified by probe: a signature forged with b''
was accepted). So _check_signature now refuses outright when no
auth token is set. This goes one line past the reviewer's FIX, which
only asked for the strip.
2026-10-07 13:19:35 +05:30
kshitijk4poor
a560ba0b08 fix(line): fail closed on a whitespace-only channel secret
Gate r2 finding 1 (same class as the webhook blank-secret fix):
_credentials returned LINE_CHANNEL_SECRET / extra['channel_secret']
unstripped, so a whitespace-only secret passed the `not channel_secret`
check at connect and in verify_line_signature, and the webhook HMAC
was keyed with blanks, making signatures forgeable. Strip both values
in _credentials (matching whatsapp_cloud) so a blank secret reads as
unset and connect refuses with config_missing.
2026-10-07 13:19:35 +05:30
kshitijk4poor
5a527de775 fix(webhook): say a non-string secret is rejected in the secret messages
Fold finding 2 (gate): _is_usable_secret now also rejects non-string
secrets (a YAML int like `secret: 123` fails closed), but the startup
ValueError, the dynamic-route warning, the per-request log and the
comment in _read_authenticated_body still said "no secret" / "missing
or empty". An operator with an int secret would be told it is absent.
All three now say "missing, blank, or not a string"; the connect()
test gains an int row and matches the new wording.
2026-10-07 13:19:35 +05:30
kshitijk4poor
6e6657e1ab refactor(webhook): keep the secret classifier private to the webhook adapter
Fold finding 1 (gate, 3 reviewers): is_usable_secret lived in the
experimental gateway/relay/auth.py, so every webhook import pulled in
the relay package (~2.5ms) for a one-line predicate only the webhook
adapter uses. Move it next to _hex_hmac as module-private
_is_usable_secret, revert both relay/auth.py hunks, and move the
parametrized classifier test into tests/gateway/test_webhook_adapter.py.
No behaviour change.
2026-10-07 13:19:35 +05:30
kshitijk4poor
e123550d43 test(webhook): pin the per-request blank-secret guard
The salvaged tests only covered connect(); extend the existing
missing-secret request test with a whitespace-only route secret signed
with that same secret, which BASE authenticated. Drop the
real-secret-accepted connect case, already covered elsewhere, to keep the
stack at two invariant tests.
2026-10-07 13:19:35 +05:30
Alex Honecker
8ec668b3cc fix(security): treat whitespace-only HMAC secrets as unset
A blank or whitespace-only secret now fails closed at webhook startup,
on the request path, and when a dynamic route is admitted.

(cherry picked from commit 05e918d52c41e905d88e6c0a0f55e7612f4d899d)
2026-10-07 13:19:35 +05:30
kshitijk4poor
1a7779d10e chore: map ahadvice for salvage of #102069
Attribute the cherry-picked webhook blank-secret fix to its author.
2026-10-07 13:19:35 +05:30
kshitijk4poor
149f39fc9f fix(photon): log the sent-text lookup failure instead of swallowing it
The code-health ratchet (BLE001) rejects a blind except Exception that
neither re-raises nor logs the traceback. The record path already logs
at debug with exc_info; give the lookup path the same.
2026-10-07 13:18:15 +05:30
kshitijk4poor
cb71296ebd fix(photon): a threaded reply to our bubble stays "own" after a restart
Cloud spectrum targets carry no direction and _sent_message_ids is
in-memory, so after a gateway restart a swipe-reply to one of our
bubbles hydrated the quoted text from the sent-text index but was
marked as not ours: the agent saw [Replying to: "..."] instead of
[Replying to your previous message: "..."]. Only our sends are indexed,
so an index hit is itself proof the target was ours.
2026-10-07 13:18:15 +05:30
David Uche
aba1f72ef2 fix(photon): record sent text at the _post_send choke point
Polls (clarify), the plain-text fallback resend and standalone attachment
sends reached the sidecar without recording what they sent, so a threaded
reply to those bubbles lost its quoted context. Record once in _post_send
(plus the standalone attachment leg, which has its own HTTP client) and
reuse _normalize_chat_key instead of a duplicate regex.
2026-10-07 13:18:15 +05:30
David Uche
348a68bb4e fix(photon): keep the user's words when they reply in an iMessage thread (#100663)
spectrum-ts 12.x delivers a threaded (swipe) reply as
{type: 'reply', content, target}. The sidecar had no branch for it, so
the agent received '[Photon content type not handled: reply]' and the
message text was lost.

- sidecar: normalize 'reply' to its inner content plus the target's id,
  direction and text
- adapter: unwrap it and set reply_to_message_id / reply_to_text /
  reply_to_is_own_message on the MessageEvent
- adapter: record outbound text (send, attachments, standalone send) in
  gateway.rich_sent_store and use it when spectrum can't hydrate the
  quoted text, which is the usual case for our own bubbles. DM GUIDs
  and bare E.164 share one key.
2026-10-07 13:18:15 +05:30
Teknium
05eecbcd97 chore(memory): remove the bundled openviking provider
OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.

The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
2026-10-06 23:13:01 -07:00
kshitijk4poor
72a346e8ea refactor(providers): one copy each of TokenHub's effort rule and the generic nested reasoning fallback
Review fold (round 1, simplify reuse + quality):
- tokenhub_effort() in agent/reasoning_effort.py: the tencent-tokenhub profile and the host-based
  branch base_url-only agents take used the same "high unless chosen, else clamp" expression in
  two places, so the two paths could drift apart.
- generic_nested_reasoning(): the OpenAI-compatible extra_body fallback auxiliary calls send when a
  profile declares no reasoning shape. The lmstudio profile's auxiliary branch was a line-for-line
  copy (it must produce it itself because overriding build_api_kwargs_extras turns the aux
  fallback off). Both now call the helper.
- agent/lmstudio_reasoning.py docstring names its real caller (the lmstudio profile).

Behaviour unchanged: 42/42 main-loop request cells and every auxiliary projection cell are
byte-identical to origin/main.
2026-10-07 11:27:35 +05:30
kshitijk4poor
d0c7bb9432 fix(providers): review fold for the LM Studio / TokenHub profiles
- Plugin hooks written against the older build_api_kwargs_extras keywords (no **context) no
  longer raise TypeError on every request: lmstudio_reasoning_options is forwarded only when the
  agent probed it (LM Studio). Pinned by TestProfileHookKeywordCompat, which fails if the field is
  passed unconditionally.
- tencent-tokenhub: drop base_url and supports_model_listing=False. With them, first-time
  `hermes model` setup reached the profile path and offered only the 3 curated Hy models instead
  of probing {base}/models as main does; without a profile base_url, setup and the /model picker
  take the same paths they took with no profile.
- One name for LM Studio's probed options end to end (lmstudio_reasoning_options), matching the
  other provider-prefixed hook keys (ollama_num_ctx, qwen_session_metadata).
- New base-url:github-models-chat snapshot cell (openai/o4-mini, asserted chat_completions): the
  gpt-5.5 cell goes to Responses and never reached the legacy GitHub reasoning branch.
- Comment/docstring truth: the host branches serve agents with no registered profile (not only
  base_url-only ones); the LM Studio effort omission is done by the profile.
2026-10-07 11:27:35 +05:30
kshitijk4poor
4c84ff744e refactor(providers): LM Studio and Tencent TokenHub get provider profiles; base_url-only agents get snapshot cells
lmstudio and tencent-tokenhub were the last chat-completions providers with no ProviderProfile,
so their requests were built by the legacy flag branch in ChatCompletionsTransport.build_kwargs
(name check for LM Studio, host check for TokenHub). They now have profiles that reproduce the
same wire through build_api_kwargs_extras:

- lmstudio: top-level reasoning_effort from the model's published allowed_options. The agent's
  cached probe is passed to the hook as reasoning_options; auxiliary calls (no probe) keep the
  generic nested reasoning they already sent. base_url is left empty on purpose: a loopback
  base_url would map every 127.0.0.1 / localhost endpoint to LM Studio in URL-to-provider
  inference and turn off local-server detection for Ollama, llama.cpp and vLLM. Model listing
  and the doctor probe stay off so the picker keeps LM Studio's own chat-model probe.
- tencent-tokenhub: top-level reasoning_effort clamped to low/medium/high, omitted when thinking
  is off, high when no level is chosen (default_reasoning_config on main-loop turns). Model
  listing and the doctor probe stay off so the picker keeps the curated Hy list.

The name-based LM Studio branch is removed from the legacy path. The host-based branches stay:
an agent built from a base_url alone (run_agent.py main, the batch runner, library callers)
carries provider="" and still reaches the legacy path. 11 base_url-only snapshot cells now pin
those host-detected requests (OpenRouter, Nous, Kimi, Kimi coding, TokenHub, GitHub Models,
NVIDIA NIM, Qwen portal, a localhost LM Studio port, Ollama, a generic endpoint) so the change
that retires the legacy path can prove it sends the same requests.

Deliberate deltas: model ids prefixed lmstudio: / tencent-tokenhub: are now stripped like every
other profiled provider's; TokenHub auxiliary calls with a reasoning config send top-level
reasoning_effort (the main-loop shape) instead of nested extra_body.reasoning.
2026-10-07 11:27:35 +05:30
kshitijk4poor
716bab6967 test: close the review gaps on the merged relay, classifier and prompt-cache tests
Review threads left open on three merged PRs, each a gap in what the tests actually guard:

- #133740 (relay usage merge): no checked-in test failed on the base. The merged fix keeps
  message_start's input/cache counts when the SDK's MessageDeltaUsage serializes them as null,
  but the existing message_delta test only sent output_tokens, so it passed with the old merge
  too. New test_anthropic_stream_accumulator_null_delta_usage_keeps_message_start_counts sends
  nulls for input and cache fields; it fails with the old `{**current_usage, **usage}` merge.
- #133767 (error-body table): the #49769 cell asserted the 402 would be reclassified as
  retryable. The fix that landed (#134157) keeps it `billing` and retries in the main loop's
  recovery chain, so the cell still XFAILed on main and would pass even if the retry broke.
  It now pins what the recovery depends on (billing + a parseable budget); the retry itself is
  driven end to end by test_affordable_402_main_loop.py.
- #133767 (prompt-cache contract): the marker check needed cache_control in only one request,
  so a later request that lost its markers passed. It now requires markers on every request.
  The module docstring claimed native Anthropic layout coverage the cells don't have; it now
  says envelope layout.
2026-10-07 11:26:41 +05:30
Teknium
a3ab152cd5 fix(tools): an image-bearing MCP result keeps its incomplete-data notice (review)
Greptile on a6b51f6ed4e (5/5, non-blocking): `_detect_upstream_elision`
only scans strings, so once an MCP result also carries an image it reaches
`make_tool_result_message` as a part list and an elided payload
("... 13 more items", has_more:true) lost the "data shown is INCOMPLETE"
notice. A part list is now judged by its first text part, and the notice
is appended there, inside the untrusted wrapper; the image is untouched.
2026-10-06 22:54:13 -07:00
Teknium
13eb190479 fix(tools): image-bearing tool results stay inside the turn budget; spills and refusals keep their content (review)
Greptile on b17398db089:
- `enforce_turn_budget` took `len(content)` of every tool message. An
  image-bearing result reaches it as a part list, so it counted parts,
  not characters: six 60K-char MCP results with screenshots totalled
  ~18 "chars" and nothing was spilled. Part lists now count their text
  characters and spill their largest text part (images stay inline).
- `_persist_multimodal_text_parts` spilled every text part under the same
  tool_call_id, so with a small `tool_budget.mcp_result_size_chars` the
  notes part overwrote the tool text's spill file. Each spilled part now
  gets its own id.
- When `vision.max_calls_per_image` refused every image, the refusal lines
  were appended after the handler's JSON closing brace. They now go into
  the envelope's `result`.
2026-10-06 22:54:13 -07:00
Teknium
a4465eb21d fix(mcp): the coordinate map survives spilling an oversized MCP tool text (review)
Greptile on 1c7dfff98e7: the scale notes sat at the end of the
envelope's single text part. When the tool text is over the result-size
limit, `_persist_multimodal_text_parts` spills that whole part to a file
and keeps only its head, so the resized screenshot stayed attached
without the "multiply coordinates by N" map and clicks would land on the
wrong pixel. The header and notes are now their own short text part,
which stays inline.
2026-10-06 22:54:13 -07:00
Teknium
62509bb87f fix(mcp): skipped MCP images free their slot, embeds keep the configured budget; capture calls the gate directly (review)
Greptile on 0ce63424a06:
- Only the first four images of a result were prepared; a damaged or
  already-in-context image among them hid a valid fifth. Images now
  prepare in waves sized to the free slots (bounded at 16 candidates), so
  a skip hands its slot to the next image.
- The attach check compared against the 20 MB hard cap, not
  `vision.embed_target_bytes`; a best-effort resize that bottomed out
  above a low budget rode history anyway. It now checks the configured
  target (clamped to the hard cap).
- `tools/computer_use/vision_routing.py` had become a one-line negation
  of `_native_tool_result_images`. The capture boundary calls the shared
  gate directly; the module and its tautological agreement test are gone,
  and the route-decision cases move into the capture-routing tests where
  they drive `_should_route_through_aux_vision` itself.
2026-10-06 22:54:13 -07:00
Teknium
775c5a91aa fix(mcp): MCP image prep on the vision pool keeps the caller's runtime context (review)
Greptile on 6c01b824fb: moving the prep onto `_vision_cpu_executor`
dropped the calling session's ContextVars. The active runtime is
context-local (`set_runtime_main`), so a pool thread fell back to the
process default and `_supported_media_types` no longer saw a managed
llama.cpp runtime: a small WebP was attached unchanged, and stb_image
cannot decode it (the model confabulates). Profile-scoped vision settings
had the same gap. Each job now runs in `contextvars.copy_context().run`,
as the delegation and MCP-loop paths already do.
2026-10-06 22:54:13 -07:00
Teknium
3f3df8d4d7 fix(mcp): MCP images honour vision.max_calls_per_image and prep on the vision pool (review)
Greptile re-review of 6771a0e681:
- A polled MCP screenshot tool re-caches identical pixels under a new
  path on every call, so each copy was embedded and re-sent on every later
  request, past the per-image cap (3 in delegated children by default).
  Each attachment now reserves through `repeat_refusal`, keyed on the
  resized data URL (same pixels, same key); a refused image keeps its
  MEDIA: path with an "already in context" line.
- Decode/resize ran on the tool thread, so a parallel tool batch of
  image-heavy MCP calls could decode many large images at once on a shared
  gateway. Prep now runs on the bounded `_vision_cpu_executor`
  (auxiliary.vision.max_concurrency), as vision_analyze's encodes do.
2026-10-06 22:54:13 -07:00
Teknium
f070912aa4 fix(vision): MCP embeds keep both caps and their coordinate map; capture route follows config live (review)
Independent review follow-up:
- The shared resizer is best-effort: its 64 px short-edge floor keeps a
  60000x64 image at 60000 px, and the MCP path checked only bytes, so an
  over-limit part could enter history (providers reject >8000 px per
  turn and the reactive shrink cannot fix it). The attach path now
  refuses any image whose output long edge is still above 1568 px; the
  result stays the MEDIA: text.
- A resized screenshot lost its coordinate mapping. The MCP path now
  passes scale_out and lists `_build_scale_note` per MEDIA: path, as
  vision_analyze does, so a cua-driver-over-MCP click lands on the screen
  pixel the model saw.
- computer_use cached its route per (home, provider, model) for the
  process, so editing `image_input_mode` (or the aux backend) never
  reached the next capture. The cache is gone: the decision reads the
  signature-cached `load_config_readonly` per capture. Pre-existing on
  main (12ad13ddca), fixed here because this PR claims every image tool
  honours the setting; the perf test that pinned the stale cache becomes
  the "config change applies without restart" invariant.
2026-10-06 22:54:13 -07:00
Teknium
e20c4afe2a fix(mcp): never attach an undecodable MCP image; registry-dispatch tests (review)
Review follow-up on the MCP native-image path (Greptile):
- A JPEG/WebP/GIF with a valid header over a truncated pixel stream passes
  both the cache's magic-byte check and the sniff, and one undecodable part
  makes the provider reject the whole request. The cached file is now
  fully decoded (`_validate_raster_image_decodable`, as vision_analyze)
  before it is attached; on failure the result stays the MEDIA: text.
- tools/AGENTS.md names the multimodal envelope as the one sanctioned
  non-string handler return, routed through `_native_tool_result_images`.
- The invariant tests register the fake server with
  `_register_server_tools` and call `registry.dispatch`, so dropping
  `native_images=True` at registration goes red. A truncated JPEG case
  pins the decode check (red when the check is removed).
2026-10-06 22:54:13 -07:00
Teknium
50632786f6 chore: map contributor email for @sarahwu185 (salvage #131374) 2026-10-06 22:54:13 -07:00
Teknium
d78d031f34 fix(vision): computer_use captures honour image_input_mode and catalog text-only models
The capture route only checked "does the provider carry tool-result
media", never `agent.image_input_mode` or a catalog verdict that the
model cannot see. deepseek-v3.2 on OpenRouter therefore went native, the
agent backstop swapped the screenshot for a "switch to a vision-capable
model" error, and the configured auxiliary vision describer never ran;
`image_input_mode: text` was ignored for screenshots too.

`tools.vision_tools._native_tool_result_images(provider, model, cfg)` is
now the one gate for every tool that hands the model pixels
(vision_analyze, browser screenshots, computer_use captures, MCP images):
image routing resolves to native AND the route accepts tool-result
images. The capture route is its negation, which drops three local
helpers that duplicated image_routing. Proxy aliases the catalog does
not know still go native under `image_input_mode: native` (#115248's
case); `auto` sends them to aux, matching vision_analyze.

Tests: test_computer_use_vision_routing.py is rewritten to drive the
real gate (the old cases patched the removed helpers). Changed
assertions: "whitelisted provider + catalog-unknown model" now requires
image_input_mode: native (it carried that cfg already); new cases pin
catalog text-only -> aux and image_input_mode: text -> aux.
2026-10-06 22:54:13 -07:00