ouroboros/tests/test_loop_image_attach.py
Ouroboros 62f87cc94c Merge upstream ouroboros db6d7cf8 into the v7 line: absorb PR #609 net-resilience and PR #614 update letter
Second absorption of the frozen upstream line (23ab428f..db6d7cf8: 89
commits, 47 files) on top of the rc.10 hotfix tip, by the F2 rules (S1
upstream body in the owning leaf, S2 hand-merge, S3 only with proof;
retired 7.0 surfaces never return):

- PR #609 net-resilience: interactive transport-wait episodes bounded by
  the task idle timeout with the typed task_incident/toast_once pair, a
  bounded paid repeat after a typed post-dispatch transport death with a
  round-keyed record that fences every other send, the shutdown-aware
  supervisor crash counter and bounded lifespan join, Darwin keepalive
  tuning. loop_transport/transport_custody/net_transport/loop_llm_call
  land verbatim (same shapes on both sides); the loop.py deltas are
  relocated into the v7 leaves (loop_round_limits, loop_model_call,
  loop_delivery, loop_forced_finalization, loop_nudges, loop_messages,
  loop_budget) with bodies AST-equal to upstream modulo the call-time
  handles; _emit_overflow_retry_skipped stays a public helper (the v7
  facade contract) and upstream's nested _skipped delegates to it.
- PR #614 update letter: ouroboros/update_letter.py and its web module
  land verbatim; the new OUROBOROS_UPDATE_LETTER_TIMEOUT_SEC key and
  get_update_letter_timeout_sec live in their v7 owners
  (settings_defaults.py, runtime_limits.py, re-exported by config.py);
  _supervisor_stop lives in server_process.py beside the restart events;
  docs/PERSISTENCE.md gains the state/update_letter.json row and the
  inventory pin moves to 286.
- Tests: the relocated run_llm_loop tests take the emit_progress
  incident keyword (every one-argument progress fake in tests/ swept, a
  gap upstream itself left in test_tree_cost_ceiling); the official-update
  runtime-section test lands in tests/test_context.py; _MOVED_OWNERS
  registers the relocated getter.
- Docs: ARCHITECTURE/DEVELOPMENT hunks land on the upstream text; the two
  legacy timeout rows upstream's context still carries stay retired (7.0).
- Size ratchet regenerated; the band rationale for tests/test_update_letter.py
  is carried verbatim from upstream.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-04 22:53:15 +00:00

98 lines
4.9 KiB
Python

"""The image auto-attach seam of the loop's tool-result processing.
Split out of ``tests/test_loop_misc.py`` when that module was divided by
theme; every moved block is verbatim.
"""
from __future__ import annotations
def test_tool_results_carrying_auto_attach_image_get_the_image_same_round(tmp_path, monkeypatch):
"""A result whose JSON offers `auto_attach_image` (the unix_computer_use screenshot)
must have its image attached in the SAME round, through the same implementation
view_image uses — removing the mandatory second round per observation that consumed
~21% of the round budget on computer-use benches (v6.81.0 OSWorld: 3,830 view_image
rounds after 3,893 screenshots). Failure is strictly non-fatal: a bad path must not
turn a successful screenshot into a failed tool call."""
import json as _json
from types import SimpleNamespace
from ouroboros.loop_tool_execution import process_tool_results
attached = []
def fake_attach(ctx, path):
attached.append(path)
ctx.messages.append({"role": "user", "content": [{"type": "image_url"}]})
return True, "attached"
import ouroboros.tools.vision as vision
monkeypatch.setattr(vision, "attach_local_image_to_context", fake_attach)
messages: list = []
tools = SimpleNamespace(_ctx=SimpleNamespace(messages=messages, drive_root=str(tmp_path)))
ok_result = _json.dumps({"ok": True, "path": "/x/shot.png",
"auto_attach_image": "/x/shot.png"})
rows = [
{"fn_name": "ext_1_r_unix_computer_use_screenshot", "tool_call_id": "c1",
"result": ok_result, "is_error": False, "args_for_log": {}, "tool_args": {},
"result_meta": {}},
# An ERROR result never attaches, even if the field is present.
{"fn_name": "ext_1_r_unix_computer_use_screenshot", "tool_call_id": "c2",
"result": ok_result, "is_error": True, "args_for_log": {}, "tool_args": {},
"result_meta": {}},
# A result without the field never attaches.
{"fn_name": "read_file", "tool_call_id": "c3",
"result": "plain text", "is_error": False, "args_for_log": {}, "tool_args": {},
"result_meta": {}},
]
# An MCP-shaped result carrying the field must NOT attach: the capability is
# defined for first-party extension tools; MCP results are untrusted
# server-supplied data that must not drive automatic context mutation.
rows.append({"fn_name": "mcp__someserver__screenshot", "tool_call_id": "c9",
"result": ok_result, "is_error": False, "args_for_log": {},
"tool_args": {}, "result_meta": {}})
errors = process_tool_results(rows, messages, {"tool_calls": []},
emit_progress=lambda _m, *, incident=None: None, tools=tools)
assert attached == ["/x/shot.png"], "exactly the opted-in successful result attaches"
assert errors == 1
# Ordering: in a multi-result round the image lands AFTER the round's complete
# tool-message block, never between two tool messages answering one assistant
# turn — contiguity by construction, not by transport repair.
roles = [m["role"] for m in messages]
first_image = roles.index("user")
assert roles[:first_image] == ["tool"] * 4, roles
# Attachment failure stays non-fatal and the tool result survives untouched.
monkeypatch.setattr(vision, "attach_local_image_to_context",
lambda ctx, path: (_ for _ in ()).throw(RuntimeError("boom")))
messages2: list = []
tools2 = SimpleNamespace(_ctx=SimpleNamespace(messages=messages2, drive_root=str(tmp_path)))
errors2 = process_tool_results(
[dict(rows[0], tool_call_id="c4")], messages2, {"tool_calls": []},
emit_progress=lambda _m, *, incident=None: None, tools=tools2)
assert errors2 == 0 and messages2[0]["role"] == "tool"
# Legacy callers without `tools` keep exactly the old behavior.
errors3 = process_tool_results(
[dict(rows[0], tool_call_id="c5")], [], {"tool_calls": []},
emit_progress=lambda _m, *, incident=None: None)
assert errors3 == 0
def test_undecodable_image_fails_the_attach_not_the_provider_call():
"""A truncated PNG passes header checks; forwarding its bytes used to become a
non-retryable provider 400 rounds later (5 task deaths in the v6.81.1 OSWorld
run). The payload builder must raise at build time so the attach seam maps it
to a tool-visible warning instead."""
import io
import pytest
from PIL import Image
from ouroboros.tools import vision
buf = io.BytesIO()
Image.new("RGB", (32, 16), (1, 2, 3)).save(buf, format="PNG")
good = buf.getvalue()
corrupt = good[:40] + b"\x00" * 400
with pytest.raises(ValueError, match="IMAGE_UNDECODABLE"):
vision._downscale_image_for_vlm(corrupt, "image/png")
out, mime = vision._downscale_image_for_vlm(good, "image/png")
assert out == good and mime == "image/png"