* fix(gateway): persist agent request transcript media
Route inline and offloaded agent.request images through the canonical user-turn transcript recorder across embedded, CLI, and ACP runtimes. Share ordered media persistence with chat.send and cover media-only empty-reply turns.
Co-authored-by: Petros Dhespollari <info@peterdsp.dev>
* fix(gateway): avoid transcript media shadowing
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(anthropic): resolve thinking as disabled when legacy budget is below 1024
When adjustMaxTokensForThinking collapses the thinking budget below the
Anthropic minimum (1024), option resolution now sets thinkingEnabled to
false instead of always forcing it to true. This keeps every downstream
consumer (payload, replay, temperature, tool-choice) consistent — they
all see the same disabled state instead of an enabled flag with a
missing or API-rejected thinking block.
|| → ?? in both builders is defensive: the resolution layer already
prevents invalid budgets from reaching the builder through the normal
path, but ?? preserves an explicit zero when the builder is called
directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(anthropic): guard raw streamAnthropic builder path against sub-minimum budgets
Add budget guards in both Anthropic payload builders so direct
streamAnthropic and bundled-plugin callers (e.g. Mantle) that bypass
option resolution also get the disabled-state rule instead of producing
API-rejected { type: "enabled", budget_tokens: < 1024 } requests.
Add proof-anthropic-thinking-budget.mts driving real production
functions with terminal output and negative control.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(anthropic): normalize legacy thinking budgets
Co-authored-by: Pick-cat <huang.ting3@xydigit.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(media): allow aws-sdk auth mode for image and audio/video paths
Media understanding tools failed for amazon-bedrock deployments using
auth.mode "aws-sdk" (BYOK via role/SSO/profile creds). Each path called
requireApiKey, which throws on the empty-key sentinel before the AWS SDK
credential chain can resolve creds at call time.
The image path (image.ts) resolves auth via getApiKeyForModel; the
audio/video paths route through resolveProviderExecutionAuth. Both now
mirror the chat path's allowMissingApiKeyModes allowance: when the
resolved key is empty and the mode is aws-sdk, execute keyless and let
the SDK resolve credentials. The image path skips setRuntimeApiKey so no
empty-string secret is persisted; audio/video return the kind:"none"
execution auth so the runner bypasses key rotation.
Closes#72031
* fix(media): cover Bedrock PDF SDK auth
* fix(pdf): register plugin completion streams
* fix(media): recognize Bedrock SDK tool auth
* chore: move release note to PR
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
The active user turn was decorated at request-build time with an inbound
metadata block (Conversation info / reply-target / sender / forwarded /
chat-history), but the session store persists the bare text and the LLM boundary
strips that block from historical replay. So a user message's serialized bytes
changed retroactively — decorated as the active turn, bare as history — on every
turn, invalidating any prefix/exact-match provider prompt cache from that point
onward, every turn. (#90811 fixed the timestamp half of this same asymmetry.)
Rework of the earlier "strip the active turn" approach (which lost model-visible
context — ClawSweeper's P1) into one that keeps the context AND recovers the
cache cost:
- Route current-turn inbound metadata out of the user prompt and into the hidden
runtime-context custom message (the mechanism from openclaw#89428), and
relocate that carrier to the ABSOLUTE TAIL of the wire request (after the
active user turn and any tool-call scaffolding). The user turn stays bare and
byte-identical in both the active and historical positions; the volatile
carrier vanishes next turn exactly where the assistant reply begins anyway, so
the request is an append-only prefix-extension through the active user turn.
- A durable marker (UserMessage.runtimeContextCarrier, set by convertToLlm from
the carrier's details) lets the Anthropic SDK transport, the managed Anthropic
transport (anthropic-transport-stream / anthropic-payload-policy), and
OpenAI-completions skip the carrier when selecting the deepest cache_control
breakpoint, keeping the anchor on the last stable user turn.
Storage stays BARE (invariant preserved); runtime-only turns (room events) keep
their existing inline behavior, which is byte-stable because room-event/system
context is not strip-eligible; legacy bare-stored sessions are unchanged.
Reviewed with adversarial gpt-5.5/codex sweeps (correctness, byte-identity,
compatibility, test adequacy): found + fixed the managed Anthropic transport
cache-anchor path and added the missing OpenAI-completions/managed-transport
breakpoint tests; a theoretical runtime-only mutation was verified unreachable
and pinned with a test. Final sweep clean.
Co-authored-by: openclaw#89428 (runtime-context handoff approach)
buildOpenAIClientHeaders only skipped generating a session_id header
when the exact lowercase key was already present, so a caller-supplied
Session_ID or SESSION_ID header would end up on the wire alongside our
generated one instead of winning per the caller-wins header contract.
Check all resolved header keys case-insensitively before injecting.
The ChatGPT backend routes requests by session_id (codex-cli sends it); without it each request lands on an arbitrary machine and the prompt cache misses. Measured on live traffic: 56.0% -> 81.8% TRUE cache hit rate (12-turn A/B, identical prompts).
* fix(diagnostics-otel): surface error message on run/harness error spans
Errored openclaw.run / openclaw.harness.run spans only carried a
low-cardinality errorCategory (or a hardcoded "error"), so trace UIs
showed "outcome error" with no message. Thread the redacted error
message through run.completed / harness.run.completed / harness.run.error
onto an openclaw.error span attribute + span status, mirroring
recordWebhookError. The raw message stays off metric attrs to preserve
cardinality; support-bundle redaction covers the new error field by name.
* fix(diagnostics-otel): harden run failure telemetry
Co-authored-by: Alex Knight <aknight@atlassian.com>
* test(diagnostics-otel): assert wire-level redaction
* chore(changelog): defer release note to release process
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Behavior: a plugin before_agent_finalize hook that never resolves could previously freeze an agent run forever after a successful compaction retry, with no errors and no recovery from a gateway restart; this was the frozen-runner mechanism behind #84777. before_agent_finalize now has the same 15s default budget as sibling modifying hooks and fails open with the original final answer, converting the freeze into a bounded delay.
Surface: plugin hook runner defaults (src/plugins/hooks.ts), docs/plugins/hooks.md.
Refs #84777.
Tracks per-skill usage from the skill.used diagnostic event (trusted-only
delivery, file-scoped identity), sweeps workshop-created skills daily from
gateway maintenance (active -> stale 30d -> archived 90d, pinned bypass,
restore-only unarchive, files never touched), filters archived skills from
snapshots fail-open, reports workspace-scoped overlap candidates, and adds
openclaw skills curator CLI, additive gateway methods, and a warn-only
doctor finding. Zero new config keys; SQLite/Kysely state only.
* fix(agent-model): omit synthesized maxTokens fallback (Fixes#98295)
The configured-fallback resolver used DEFAULT_CONTEXT_TOKENS (200_000) as
the last-resort value for model.maxTokens when no configured, provider,
or bundled-catalog value was known. For strict OpenAI-compatible providers,
model.maxTokens is forwarded as max_completion_tokens on every request,
so the synthesized 200k value became a wire-level output cap that exceeded
the provider ceiling and produced HTTP 400 (Param Incorrect) on providers
like Xiaomi MiMo (131_072 max) — reported for mimo-v2.5 / mimo-v2.5-pro
added via 'models add' on a custom provider entry.
The sibling applyConfiguredProviderOverrides path already omits maxTokens
when nothing resolves; align resolveConfiguredFallbackModel with that
behavior. When the transport sees an undefined model.maxTokens, it omits
max_completion_tokens entirely and the provider applies its own default,
which is the correct behavior when the user has configured no output limit.
contextWindow retains DEFAULT_CONTEXT_TOKENS as its local budgeting
fallback (it is not shipped on the wire).
* test(agents): prove unknown output cap omission
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Compaction failure and timeout recovery can no longer leave a session transcript assistant-last and wedged after restoring pre-compaction state.
Transcript-continuation failures now carry a typed agent-core error code and no longer demote the active model through model fallback scoring.
Surface: embedded agent runner compaction recovery, model fallback classification, packages/agent-core.
Refs #100312. Includes regression coverage for #99943.
Wedged model calls could hold an agent session lane for the normalized max timer duration when the user disabled the run timeout. In production that path resolves to MAX_TIMER_TIMEOUT_MS, so a zero-progress model call could block sibling work for about 24.8 days instead of failing through the normal terminal outcome path.
Cap the lane watchdog at the default agent deadline plus the existing grace window while preserving explicit shorter run timeouts. The disabled/max-timeout case now releases the lane at the default 48h deadline instead of inheriting the timer sentinel.
Refs #97588.
Refs #94650.
* fix(web-shared): bound response.text() fallback to honor maxBytes
readResponseText's .text() fallback path ignored the maxBytes option,
reading the full body unbounded. When a foreign Response lacks a body
stream (no getReader) and no arrayBuffer(), the .text() call was the
last resort — but maxBytes was never applied.
- Honor maxBytes in the .text() fallback: truncate + set truncated=true
- Under-cap responses pass through unchanged
- No maxBytes set → original behavior preserved (regression safe)
* fix(web-shared): use byte-level truncation in .text() fallback
Replace text.length/text.slice (character-level) with TextEncoder/
TextDecoder (byte-level) so the maxBytes cap is respected at byte
granularity, consistent with the bounded-stream path at lines 240-298.
- bytes.byteLength > maxBytes instead of text.length > maxBytes
- TextDecoder().decode(bytes.slice(0, maxBytes)) for byte-safe truncation
- bytesRead reports actual bytes, not character count
* fix(web-shared): refuse unbounded .text() when maxBytes is set
When maxBytes is set the caller expects bounded memory. The .text()
fallback path buffers the full body before any post-read check,
defeating the cap. Fail-closed by returning empty with truncated=true
instead of calling .text() unbounded.
Without maxBytes .text() is safe — the caller accepts full allocation.
* fix(web-shared): byte-level truncation in .text() fallback with maxBytes
The .text() fallback used text.length (characters) and text.slice
(char-level) against a byte-level maxBytes cap. Use TextEncoder/
TextDecoder for byte-accurate comparison and truncation.
- bytes.byteLength > maxBytes instead of text.length > maxBytes
- TextDecoder().decode(bytes.slice(0, maxBytes)) for byte-safe truncation
- bytesRead reports actual byte count, not character count
* fix(web): fail closed on unbounded response fallbacks
* test(web): use streaming response fixtures
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Budget context engine assembly against the reserve and rendered prompt
pressure, and carry the preflight estimated prompt tokens, prompt budget,
and overflow tokens into the outer overflow recovery loop so compaction
engines compact against the prompt OpenClaw actually rendered instead of
a minimally over-budget guess.
* fix(edit): show candidate lines on oldText match failure
When edit tool cannot find oldText, include up to 3 near-match candidate
lines with similarity scores and line numbers. Caps input size (MAX_LINES
3000, MAX_LINE_LEN 200) to avoid unbounded CPU on large files. Reuses
existing levenshteinDistance helper.
Closes#97032
* fix(edit): use toSorted instead of sort for candidate ranking
Fixes lint violation: unicorn(no-array-sort)
* fix(edit): keep candidate truncation branch-compatible
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
sessions.patch becomes a params-aware dynamic-scope method: operator.write
now authorizes patches that touch only user-level chat-organization fields
(label, category, pinned, archived, unread); every other field, mixed
patches, and unknown keys keep requiring operator.admin (fail closed).
This unblocks the session controls shipped in #100814 for the Android app,
whose bounded operator session intentionally never requests operator.admin.
Also: session list ordering gains a deterministic key tiebreaker for equal
pinnedAt/updatedAt (stable offset paging and prompt-cache friendliness), a
user-assigned label now beats stored channel-derived display names in the
row projection so renames survive refreshes, and subagent spawn pins its
gateway calls to admin via the params-aware least-privilege resolver
instead of the static admin-only method check (#59428 contract preserved).
Refs #100712
* [AI] fix(agents): classify HTTP 429 overloaded bodies as overloaded, not rate_limit
Reorder overloaded check before rate_limit across all three classification paths:
- classifyFailoverClassificationFromHttpStatus (HTTP status path)
- classifyFailoverClassificationFromMessage (message text path)
- formatRateLimitOrOverloadedErrorCopy (user-facing copy path)
Previously, isRateLimitErrorMessage matched the bare '429' token in
overloaded-worded 429 bodies (e.g. z.ai code 1305), misclassifying them
as rate_limit and showing the wrong user-facing error message. 503/499
already check overloaded before defaulting; 429 now follows the same pattern.
Related to #98101
* [AI] fix(agents): put MODEL_CAPACITY_ERROR_RE before overloaded in user-facing copy
MODEL_CAPACITY_ERROR_RE is the most specific overloaded variant
('selected model is at capacity') and must be checked before the
broader isOverloadedErrorMessage matcher (which also covers model at
capacity), otherwise it's shadowed with a less specific message.
Related to #98101
* fix(agents): preserve overload copy for rate-limited responses
* fix(agents): retain provider retry guidance
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(auth): preserve auth profile order when copying store to spawned agent
* fix(auth): preserve copied profile order
Co-authored-by: Vivek Behani <vivekbehani@me.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(openai): stop double-prefixing SSE bodies mislabeled as JSON
OpenAI-compatible gateways that stream real SSE (data: {...}) but label the
response application/json hit the streaming JSON-wrap fallback, which re-prefixed
each frame as 'data: data: {...}' and broke JSON.parse in the OpenAI SDK. Sniff
JSON-labeled streaming bodies and relabel genuine SSE as text/event-stream so the
SSE sanitizer parses them verbatim.
* test(openai): prove mislabeled SSE stays streaming
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>