fix(llm): stop sending Anthropic empty text blocks / cache_control-on-empty (provider_unavailable empties)

Empty tool output was sealed/sent as {"type":"text","text":"","cache_control":{...}} →
Anthropic 400 "cache_control cannot be set for empty text blocks" (non-retryable
bad_request) → reroute replays the same malformed transcript → task dies as
provider_unavailable with an empty final answer. Separately the direct-lane normalizer
dropped stop_reason, so successful responses could look like finish_reason=null.

- loop.py seal_task_transcript: placeholder when the sealed tool output is empty.
- llm.py _build_anthropic_messages: new _sanitize_anthropic_tool_result_content (drop
  empty text blocks, keep image/document blocks, placeholder only if otherwise empty;
  covers scalar "" and list []).
- llm.py _copy_messages_with_cache_policy: never attach cache_control to an empty text
  block (content-preserving removal — safe for all lanes).
- llm.py _normalize_anthropic_response: surface Anthropic stop_reason.
- tests/test_anthropic_empty_block_fix.py: 9 focused regression cases.

Smoke (live API): control payload (empty text + cache_control) reproduces the 400; the
converted empty-tool-result transcript returns 200. Triad+scope reviewed: scope clean,
code findings resolved (the produced_output retry guard was reverted as a no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-28 03:15:52 +03:00
parent 79bfd236d7
commit d32f703d1e
9 changed files with 176 additions and 10 deletions

View file

@ -7,7 +7,7 @@
[![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://github.com/razzant/ouroboros/releases)
[![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)](https://github.com/razzant/ouroboros/releases)
[![OuroborosHub](https://img.shields.io/badge/OuroborosHub-skills%20marketplace-8A2BE2.svg)](https://github.com/razzant/OuroborosHub)
[![Version 6.50.3](https://img.shields.io/badge/version-6.50.3-green.svg)](VERSION)
[![Version 6.50.4](https://img.shields.io/badge/version-6.50.4-green.svg)](VERSION)
A self-modifying AI agent that writes its own code, rewrites its own mind, and evolves autonomously. Born February 16, 2026.
@ -527,6 +527,7 @@ the contribution guide only routes to those sources.
| Version | Date | Description |
|---------|------|-------------|
| 6.50.4 | 2026-06-28 | **fix(llm): stop sending Anthropic empty text blocks / cache_control-on-empty.** An empty tool output was sealed/sent as `{"type":"text","text":"","cache_control":{...}}`, causing Anthropic HTTP 400 `cache_control cannot be set for empty text blocks` (classified non-retryable `bad_request`) → the reroute replayed the same malformed transcript → the whole task died as `provider_unavailable` with an empty answer. Fixes at the source (`seal_task_transcript` placeholders an empty cache anchor), at the Anthropic tool-result construction (`_sanitize_anthropic_tool_result_content` drops empty text blocks, preserves image/document blocks, and substitutes a placeholder only when the result is otherwise empty — covering scalar `""` and list `[]`), and a content-preserving guard in the shared cache-policy chokepoint (never attach `cache_control` to an empty text block). The direct-lane normalizer now surfaces Anthropic `stop_reason` so a successful response is not misread as a `finish_reason=null` glitch. Regression tests cover all four surfaces; smoke-verified against the live API (control reproduces the 400, the converted empty-tool-result transcript returns 200). |
| 6.50.3 | 2026-06-28 | **feat: TB2.1 capability + swarm-affordance hardening.** Verification: `verify_and_record` gains an `expected_match` mode (`substring` default · `exact` · `exact_line` · `json_equals`) recorded in the host-attested receipt, and the task-acceptance reviewer now demands metric-grounded evidence — existence/substring-only is insufficient, with a public-information-only anti-cheat boundary. Telemetry honesty: an unrecovered access-policy block (`resource_policy_blocked` / `resource_constraint_blocked`) on a READ-ONLY exploratory tool is demoted to an `ignored_tool_errors` axis instead of degrading the turn. Swarm affordances: a per-task `swarm_efficiency` rollup (subagent_count / wave_count / Σ inter-wave latency / lanes_used) for fan-out tasks; `schedule_subagent` surfaces effective lane(s); `wait_task` emits a burst/absorb advisory when other children are still in flight; the descriptions steer burst+absorb and cooperative-multi-builder (`external_workspace`, omit `write_root`) versus genesis. UX: a one-shot no-op-attempt finalization nudge (declared `expected_output` + zero effects + no FINAL ANSWER) and a preserve-your-work rule. |
| 6.50.2 | 2026-06-27 | **fix(scope-review): preserve blocking scope-review authority while handling false-window overflow.** The scope reviewer window probe now uses the actual scope-review slot route instead of the active main route, recognizes direct-provider and explicit OpenRouter spellings of the shipped `openai/gpt-5.5` reviewer, and only downgrades opaque gateway 400s to `budget_exceeded` when independent size evidence points to real oversize. Off-default reviewers without Capability Evidence now surface advisory-only under the 1M floor until owner-acked, instead of inheriting false 1M trust. Regression tests cover direct/default reviewer identity, local-main plus remote-scope routing, and large non-oversize provider errors staying fail-closed. |
| 6.50.1 | 2026-06-27 | **docs(prompt): sync resident SYSTEM.md with the v6.50.0 reconciliation layer.** The system prompt now points agents at the live `schedule_subagent` schema for capability declarations, names structured `delegation_constraint` back-pressure and parent-only `override_delegation_constraint`, and calls out the runtime filesystem affordance map without duplicating schema-only directive details. No release tag, build, or stable promotion was produced for this patch-only branch update. |
@ -535,9 +536,7 @@ the contribution guide only routes to those sources.
| 6.48.0 | 2026-06-27 | **feat: Anthropic same-model provider failover keeps reasoning continuity (OpenRouter resilience).** A replayed-reasoning OpenRouter request no longer pins `provider.allow_fallbacks=false` for Anthropic-family models: Anthropic thinking-block signatures are cross-platform compatible (Anthropic API / Bedrock / Vertex / Azure — confirmed against Anthropic's extended-thinking docs and a live same-model OpenRouter replay probe), so a same-model failover preserves reasoning continuity and no longer surfaces one upstream's rate-limit when a healthy sibling endpoint could serve the turn. Non-Anthropic families (e.g. OpenAI / Gemini encrypted reasoning) keep the conservative pin, and the reactive signature-rejection `400` strip-and-retry stays the safety net for every family. |
| 6.47.1 | 2026-06-26 | **fix: verify-before-done observability + ledger grounding-status accounting (v6.47.0 fix-forward).** The verification ledger's `has_failures` summary no longer mis-reads a SUCCESSFUL grounding receipt as a failure: the `observed` (artifact_observation) and `declared` (honest no_visible_machine_contract) statuses join the success allow-list via the `_RECEIPT_GROUNDING_STATUSES` SSOT, in both `build_verification_ledger` and `refresh_verification_ledger_artifacts`. The `receipt_absent` / `expected_output_ungrounded` objective-axis warning now reaches the `task_eval` / `task_metrics` event stream (where day-one monitoring reads it), not only the stored task_result: the flag is applied once in `emit_task_results` before the events emit, and the single flagged `loop_outcome` is threaded into `_store_task_result` (single source, no second derive). Devtools: a GAIA `--sample-id` re-run selector for replaying a chosen subset of tasks, and root-anchored `.gitignore` entries so the in-repo solver's downloaded attachments / OCR scratch stop polluting the working tree. |
| 6.47.0 | 2026-06-26 | **feat: verify-before-done flagship, deep cooperative subagents, workspace-aware code-intel, and the skill-publish desync fix.** Reliability: a new core `verify_and_record` tool runs the agent's declared verification check on the host and writes a durable host-attested receipt (contract kinds: visible_verifier / explicit_command / explicit_metric / artifact_observation / no_visible_machine_contract); a one-shot verify-before-done nudge fires when a turn produced real effects but recorded no grounding, and a BINARY `receipt_absent` (FR3) / `expected_output_ungrounded` (M2 zero-grounding, gated on the typed `expected_output`) objective-axis warning surfaces an unverified-but-clean turn WITHOUT ever downgrading it (anti-oscillation); a deadline-flush prompt salvages a time-boxed deliverable at the tightest milestone. Capability: `query_code` runs read-only structured code intelligence over an external `user_files` target (e.g. a benchmark `/app`) with search_code-shape guards and bounded symlink-safe structural walks; external-workspace HOST process commands scrub the Ouroboros repo from `PYTHONPATH` so a target's `import web`/`server` can't shadow-import the agent (a `docker_exec` backend already runs in the container's own environment); the artifact-audit nudge is now effect-based; deep COOPERATIVE acting subagents build together in ONE host-minted shared git tree (external_workspace), with a depth-aware reservation so a wide first level can't starve depth. UX: skill→hub publish eligibility is one SSOT predicate the gateway serializes (`submit_hub`) and the Skills card renders — a no-blocker (clean OR advisory-only warnings) review now enables Submit in the UI, ending the clean-only desync. Bench: the SWE-bench Pro grader prints a RAW Pass@1 headline and keeps all contamination/benchmark-defect analysis in a separate diagnostic `CONTAMINATION_AUDIT.md` that never adjusts the score (gold is never shown to the solver). New surface: `verify_and_record`, `ouroboros/skill_publish_eligibility.py`, `submit_hub` gateway field. |
| 6.46.0 | 2026-06-25 | **feat: GAIA-forensic hardening — generalizable runtime fixes surfaced by a benchmark audit, plus an empirical context-window probe.** Runtime: budget exhaustion now TERMINALIZES undispatched queued tasks (`fail_tasks`, `reason_code=budget_exhausted` + event/notify) instead of a silent never-dispatched PENDING freeze; `search_code` gains a wall-clock deadline across the directory walk AND the rg-batch loop (`OUROBOROS_SEARCH_CODE_WALL_SEC`); `web_search` gains a backend pin (`OUROBOROS_WEBSEARCH_BACKEND`, `ddgs` now a dependency with 429 backoff) so a fixed-model run can force pure retrieval; a config-SSOT `OUROBOROS_OR_PROVIDER` (resilience/repro presets) routes OpenRouter providers for rate-limit resilience without losing prompt-cache warmth, generalized behind a `supports_message_cache_control` predicate. Answer integrity: a tool-count-stamped latch recovers a produced `FINAL ANSWER` whose marker a later round DROPS (a deliberate post-review marker — a genuine correction — stays authoritative, and new tool work invalidates a now-stale latch), and acceptance review on the advisory task surface stops over-failing a grounded answer (majority-not-single-FAIL, no solved→DEGRADED on empty coach) so review can ground but never single-vote-DOWNGRADE a correct answer — the P3 commit/scope immune gate is untouched. Security: `user_files` honors `OUROBOROS_USER_FILES_ROOT` (jailed scratch home), and the authoritative observability blob is redacted by default (`OUROBOROS_OBSERVABILITY_KEEP_RAW` opt-in) with a broadened secret matcher. Capability: a generative context-window probe empirically confirms a route's ≥1M window from a FREE over-window reject (only on the explicit Max toggle/save, never from a possibly-paid 200), plus a cloud.ru route base_url fix. Bench: the GAIA adapter stages attachments robustly, injects the `FINAL ANSWER:` protocol, crash-isolates per-sample timeouts, and exposes per-config knobs. |
| 6.45.5 | 2026-06-24 | **fix: CI expectation compatibility for credential-aware tool gating.** Clear inherited timeout env in shell timeout tests, seed web-search availability where resource-gate behavior is under test, and accept central `CAPABILITY_UNAVAILABLE` for Claude Code auth-gate expectations. |
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows, the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
| 6.46.0 | 2026-06-25 | **feat: GAIA-forensic hardening — generalizable runtime fixes surfaced by a benchmark audit, plus an empirical context-window probe.** Runtime: budget exhaustion now TERMINALIZES undispatched queued tasks (`fail_tasks`, `reason_code=budget_exhausted` + event/notify) instead of a silent never-dispatched PENDING freeze; `search_code` gains a wall-clock deadline across the directory walk AND the rg-batch loop (`OUROBOROS_SEARCH_CODE_WALL_SEC`); `web_search` gains a backend pin (`OUROBOROS_WEBSEARCH_BACKEND`, `ddgs` now a dependency with 429 backoff) so a fixed-model run can force pure retrieval; a config-SSOT `OUROBOROS_OR_PROVIDER` (resilience/repro presets) routes OpenRouter providers for rate-limit resilience without losing prompt-cache warmth, generalized behind a `supports_message_cache_control` predicate. Answer integrity: a tool-count-stamped latch recovers a produced `FINAL ANSWER` whose marker a later round DROPS (a deliberate post-review marker — a genuine correction — stays authoritative, and new tool work invalidates a now-stale latch), and acceptance review on the advisory task surface stops over-failing a grounded answer (majority-not-single-FAIL, no solved→DEGRADED on empty coach) so review can ground but never single-vote-DOWNGRADE a correct answer — the P3 commit/scope immune gate is untouched. Security: `user_files` honors `OUROBOROS_USER_FILES_ROOT` (jailed scratch home), and the authoritative observability blob is redacted by default (`OUROBOROS_OBSERVABILITY_KEEP_RAW` opt-in) with a broadened secret matcher. Capability: a generative context-window probe empirically confirms a route's ≥1M window from a FREE over-window reject (only on the explicit Max toggle/save, never from a possibly-paid 200), plus a cloud.ru route base_url fix. Bench: the GAIA adapter stages attachments robustly, injects the `FINAL ANSWER:` protocol, crash-isolates per-sample timeouts, and exposes per-config knobs. |Older releases are preserved in Git tags and GitHub releases. Older 6.x rows, the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
---

View file

@ -1 +1 @@
6.50.3
6.50.4

View file

@ -1,4 +1,4 @@
# Ouroboros v6.50.3 — Architecture & Reference
# Ouroboros v6.50.4 — Architecture & Reference
This file is NOT a changelog. Version history lives in README.md, git tags, and commit log.

View file

@ -851,7 +851,17 @@ class LLMClient:
else:
for block in content:
if isinstance(block, dict):
if allow_message_cache_control and isinstance(block.get("cache_control"), dict):
# Anthropic 400s on cache_control set for an EMPTY text block;
# only cache a text block that actually has text (image/tool
# blocks keep their cache_control). Pure removal of an invalid
# cache_control — never rewrites content, so all lanes are safe.
empty_text = (
block.get("type") == "text"
and not str(block.get("text") or "").strip()
)
if (allow_message_cache_control
and isinstance(block.get("cache_control"), dict)
and not empty_text):
block["cache_control"] = {"type": "ephemeral"}
else:
block.pop("cache_control", None)
@ -1721,6 +1731,26 @@ class LLMClient:
blocks.append(normalized)
return blocks
@staticmethod
def _sanitize_anthropic_tool_result_content(content: Any) -> Any:
"""Anthropic rejects empty tool_result content (and 400s on cache_control set
for an empty text block). Drop empty text blocks, KEEP non-empty / non-text
(image/document/search) blocks, and substitute a single placeholder only when
the whole tool result would otherwise be empty (scalar ``""`` or list ``[]``)."""
placeholder = "(no tool output)"
if isinstance(content, list):
cleaned = [
b for b in content
if not (
isinstance(b, dict)
and str(b.get("type") or "") == "text"
and not str(b.get("text") or "").strip()
)
]
return cleaned if cleaned else placeholder
text = "" if content is None else str(content)
return text if text.strip() else placeholder
def _build_anthropic_messages(
self,
messages: List[Dict[str, Any]],
@ -1781,6 +1811,7 @@ class LLMClient:
)[0]["content"]
else:
tool_result_content = self._stringify_anthropic_content(raw_content)
tool_result_content = self._sanitize_anthropic_tool_result_content(tool_result_content)
self._coalesce_anthropic_message(
anthropic_messages,
"user",
@ -1927,6 +1958,12 @@ class LLMClient:
}
if tool_calls:
message["tool_calls"] = tool_calls
# Anthropic always returns stop_reason on success; surface it so the empty-
# response classifier isn't blind on the direct lane (otherwise every direct
# response looks like a finish_reason=null transient glitch).
stop_reason = resp_dict.get("stop_reason")
if stop_reason:
message["stop_reason"] = str(stop_reason)
return message, usage
def _chat_anthropic(

View file

@ -1152,6 +1152,10 @@ def seal_task_transcript(
candidate = messages[seal_candidate_idx]
plain_text = str(candidate.get("content", ""))
if not plain_text.strip():
# Anthropic 400s on cache_control attached to an empty text block; never seal
# an empty tool output as the cache anchor (turns the whole task unanswerable).
plain_text = "(no tool output)"
candidate["content"] = [
{
"type": "text",

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ouroboros"
version = "6.50.3"
version = "6.50.4"
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
readme = "README.md"
license = {text = "MIT"}

View file

@ -0,0 +1,126 @@
"""Regression tests for the Anthropic empty-text-block / cache_control-on-empty fix.
Anthropic 400s on ``cache_control cannot be set for empty text blocks`` (and rejects
empty tool_result content). An empty tool output sealed as
``{"type":"text","text":"","cache_control":{...}}`` used to make the whole task die as
``provider_unavailable`` with an empty answer. These cover the four touched surfaces:
- ``seal_task_transcript`` (never seal an empty cache anchor)
- ``LLMClient._sanitize_anthropic_tool_result_content`` (empty -> placeholder, keep blocks)
- ``LLMClient._copy_messages_with_cache_policy`` (no cache_control on empty text)
- ``LLMClient._normalize_anthropic_response`` (surface Anthropic stop_reason)
"""
from ouroboros.loop import seal_task_transcript
from ouroboros.llm import LLMClient
# ── seal_task_transcript ──────────────────────────────────────────────
def _build(n_tools: int, empty_idx: int) -> list:
msgs = [{"role": "user", "content": "q"}]
for i in range(n_tools):
msgs.append({"role": "assistant", "content": f"a{i}"})
msgs.append({"role": "tool", "tool_call_id": f"t{i}",
"content": "" if i == empty_idx else "x" * 50})
return msgs
def test_seal_empty_tool_output_uses_placeholder():
# keep_active=2, 4 tools -> sealed candidate is tool index 1 (the empty one).
msgs = _build(4, empty_idx=1)
seal_task_transcript(msgs, keep_active=2, min_prefix_tokens=0)
sealed = [m for m in msgs if m.get("role") == "tool" and isinstance(m.get("content"), list)]
assert len(sealed) == 1, "exactly one cache boundary expected"
blk = sealed[0]["content"][0]
assert blk["cache_control"] == {"type": "ephemeral"}
# the bug: text=="" with cache_control -> Anthropic 400. Must be a non-empty placeholder.
assert blk["text"].strip(), "sealed cache anchor must never be an empty text block"
assert blk["text"] == "(no tool output)"
def test_seal_nonempty_tool_output_preserved():
msgs = _build(4, empty_idx=99) # none empty
seal_task_transcript(msgs, keep_active=2, min_prefix_tokens=0)
sealed = [m for m in msgs if m.get("role") == "tool" and isinstance(m.get("content"), list)]
assert len(sealed) == 1
assert sealed[0]["content"][0]["text"] == "x" * 50 # real content untouched
# ── _sanitize_anthropic_tool_result_content ───────────────────────────
def test_sanitize_empty_scalar_and_list_become_placeholder():
S = LLMClient._sanitize_anthropic_tool_result_content
assert S("") == "(no tool output)"
assert S(" ") == "(no tool output)"
assert S(None) == "(no tool output)"
assert S([]) == "(no tool output)"
assert S([{"type": "text", "text": ""}]) == "(no tool output)"
assert S([{"type": "text", "text": " "}]) == "(no tool output)"
def test_sanitize_keeps_real_content_and_nontext_blocks():
S = LLMClient._sanitize_anthropic_tool_result_content
assert S("hello") == "hello"
# empty text dropped, real text kept
assert S([{"type": "text", "text": "x"}, {"type": "text", "text": ""}]) == [{"type": "text", "text": "x"}]
# image / non-text blocks preserved even with no text
img = [{"type": "image", "source": {"type": "base64", "data": "..."}}]
assert S(img) == img
# ── _copy_messages_with_cache_policy ──────────────────────────────────
def test_cache_policy_drops_cache_control_on_empty_text_block():
msg = {"role": "tool", "content": [
{"type": "text", "text": "", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "real", "cache_control": {"type": "ephemeral"}},
]}
out = LLMClient._copy_messages_with_cache_policy(
[msg], allow_message_cache_control=True, flatten_tool_content_blocks=False
)[0]["content"]
empty_blk, real_blk = out[0], out[1]
assert "cache_control" not in empty_blk, "empty text block must not carry cache_control"
assert real_blk.get("cache_control") == {"type": "ephemeral"}, "non-empty text keeps cache_control"
def test_cache_policy_keeps_cache_control_on_image_block():
msg = {"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "data": "x"}, "cache_control": {"type": "ephemeral"}},
]}
out = LLMClient._copy_messages_with_cache_policy(
[msg], allow_message_cache_control=True, flatten_tool_content_blocks=False
)[0]["content"]
assert out[0].get("cache_control") == {"type": "ephemeral"}, "image block cache_control preserved"
# ── _normalize_anthropic_response (stop_reason surfaced) ───────────────
def test_normalize_surfaces_stop_reason():
client = LLMClient.__new__(LLMClient) # converter helper is pure; skip heavy __init__
resp = {"content": [{"type": "text", "text": "hi"}], "stop_reason": "end_turn",
"usage": {"input_tokens": 0, "output_tokens": 0}}
message, _usage = client._normalize_anthropic_response(resp, {"resolved_model": "m"})
assert message.get("stop_reason") == "end_turn"
def test_normalize_empty_response_still_surfaces_stop_reason():
client = LLMClient.__new__(LLMClient)
resp = {"content": [], "stop_reason": "max_tokens", "usage": {"input_tokens": 0, "output_tokens": 0}}
message, _usage = client._normalize_anthropic_response(resp, {"resolved_model": "m"})
assert message.get("stop_reason") == "max_tokens"
def test_normalize_without_stop_reason_omits_key():
client = LLMClient.__new__(LLMClient)
resp = {"content": [{"type": "text", "text": "hi"}], "usage": {"input_tokens": 0, "output_tokens": 0}}
message, _usage = client._normalize_anthropic_response(resp, {"resolved_model": "m"})
assert "stop_reason" not in message
if __name__ == "__main__": # allow `python tests/test_anthropic_empty_block_fix.py`
import sys
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for fn in fns:
try:
fn(); print(f"PASS {fn.__name__}")
except Exception as e: # noqa: BLE001
failed += 1; print(f"FAIL {fn.__name__}: {type(e).__name__}: {e}")
print(f"\n{len(fns)-failed}/{len(fns)} passed")
sys.exit(1 if failed else 0)

View file

@ -370,4 +370,4 @@
* @property {boolean=} ok
*/
export const GATEWAY_CONTRACT_VERSION = '6.50.3';
export const GATEWAY_CONTRACT_VERSION = '6.50.4';

View file

@ -1,6 +1,6 @@
{
"name": "ouroboros-web",
"version": "6.50.3",
"version": "6.50.4",
"private": true,
"type": "module",
"description": "Ouroboros browser UI package boundary",