Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1823,7 +1823,9 @@ async def _fetch(
# seconds on a slow resolver, and one process serves every session's turn.
is_valid, error_msg = await asyncio.to_thread(_judge_fetch_target, url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
# The same rule as the handlers below: most of these reasons name the
# hostname, and a reader whose every target is refused is one cause.
return json.dumps({"error": "URL validation failed", "detail": error_msg, "url": url}, ensure_ascii=False)

# "no explicit proxy" is not "direct": with trust_env on, httpx
# still honours HTTP(S)_PROXY from the environment.
Expand Down
37 changes: 24 additions & 13 deletions raven/agent/tools/media_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,21 @@ async def _chat(self, payload: dict[str, Any]) -> dict[str, Any]:
return message

def _format_http_error(self, e: httpx.HTTPStatusError) -> str:
# The status, not the body: ``failure_class`` keys the loop's tool-failure
# streak on this envelope's ``error`` alone, and a vendor body carries a
# request id, so interpolating it splits one dead endpoint into a class per
# call and the stop-repeating nudge never fires. The body moves to ``detail``,
# which keeps it in front of the model and inside ``is_hard_tool_failure``'s
# transient-marker scan. The same rule governs every envelope below.
body = e.response.text[:400]
hint = ""
answer: dict[str, Any] = {"error": f"HTTP {e.response.status_code}", "detail": body}
if e.response.status_code == 403:
hint = (
" | Request denied (HTTP 403). You can route media calls through a "
answer["hint"] = (
"Request denied (HTTP 403). You can route media calls through a "
"proxy via tools.media.proxy (or HTTPS_PROXY) and retry."
)
logger.error("{} HTTP {}: {}", self.name, e.response.status_code, body)
return json.dumps({"error": f"HTTP {e.response.status_code}: {body}{hint}"}, ensure_ascii=False)
return json.dumps(answer, ensure_ascii=False)


_IMAGE_API_MODELS = (
Expand Down Expand Up @@ -558,7 +564,7 @@ async def execute(
)
blank = next((i for i, spec in enumerate(wanted) if not str(spec.get("prompt") or "").strip()), None)
if blank is not None:
return json.dumps({"error": f"the picture at index {blank} has no prompt"}, ensure_ascii=False)
return json.dumps({"error": "a picture has no prompt", "index": blank}, ensure_ascii=False)
if len(wanted) == 1:
return await self._generate_one(wanted[0], _safe_stem(wanted[0].get("filename")), model, output_dir)
stems = _distinct_stems(wanted)
Expand Down Expand Up @@ -598,7 +604,12 @@ def _batch_answer(self, wanted: list[dict[str, Any]], answers: list[str | BaseEx
paths.extend(reply["paths"])
else:
item["error"] = reply.get("error") or "no image returned"
for key in ("note", "finish_reason", "retryable"):
# Every key ``error`` was split into travels with it: the reason a
# picture failed, the proxy a 403 can be routed through, the
# reference that was refused. ``error`` carries only the class now,
# so a key left behind here is a batched picture told strictly less
# than the single call is.
for key in ("detail", "hint", "url", "note", "finish_reason", "retryable"):
if reply.get(key) is not None:
item[key] = reply[key]
if reply.get("model"):
Expand Down Expand Up @@ -640,7 +651,7 @@ async def _generate_one(self, spec: dict[str, Any], stem: str, model: str | None
for ref in images[:6]:
content.append(self._image_part(ref))
except OSError as e:
return json.dumps({"error": f"could not read input image: {e}"}, ensure_ascii=False)
return json.dumps({"error": "could not read input image", "detail": str(e)}, ensure_ascii=False)
else:
content = prompt

Expand All @@ -664,7 +675,7 @@ async def _generate_one(self, spec: dict[str, Any], stem: str, model: str | None
return self._format_http_error(e)
except Exception as e:
logger.error("image_generate error: {}", e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
return json.dumps({"error": type(e).__name__, "detail": str(e)}, ensure_ascii=False)

out_images = msg.get("images") or []
if not out_images:
Expand Down Expand Up @@ -720,7 +731,7 @@ async def _via_images_api(
# edits form takes the bytes behind it.
refs = [self._image_part(ref) for ref in (images or [])[:16]]
except OSError as e:
return json.dumps({"error": f"could not read input image: {e}"}, ensure_ascii=False)
return json.dumps({"error": "could not read input image", "detail": str(e)}, ensure_ascii=False)
try:
async with httpx.AsyncClient(proxy=self._proxy, timeout=300.0) as client:
if openrouter:
Expand All @@ -745,7 +756,7 @@ async def _via_images_api(
fetched = await guarded_fetch(client, url, what="image reference")
if fetched is None:
return json.dumps(
{"error": f"input image {url} refused: not a fetchable public target"},
{"error": "input image refused: not a fetchable public target", "url": url},
ensure_ascii=False,
)
fetched.raise_for_status()
Expand All @@ -772,7 +783,7 @@ async def _via_images_api(
return self._format_http_error(e)
except Exception as e:
logger.error("image_generate error: {}", e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
return json.dumps({"error": type(e).__name__, "detail": str(e)}, ensure_ascii=False)
paths: list[str] = []
for item in items:
encoded = item.get("b64_json") if isinstance(item, dict) else None
Expand Down Expand Up @@ -866,7 +877,7 @@ async def execute(
return self._format_http_error(e)
except Exception as e:
logger.error("text_to_speech error: {}", e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
return json.dumps({"error": type(e).__name__, "detail": str(e)}, ensure_ascii=False)

if not pcm:
return json.dumps(
Expand Down Expand Up @@ -1107,7 +1118,7 @@ async def execute(
return self._format_http_error(e)
except Exception as e:
logger.error("video_generate error: {}", e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
return json.dumps({"error": type(e).__name__, "detail": str(e)}, ensure_ascii=False)

path = self._output_path("mp4")
path.write_bytes(data)
Expand Down
4 changes: 3 additions & 1 deletion raven/agent/tools/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,9 @@ async def execute(self, url: str, extractMode: str = "markdown", maxChars: int |
max_chars = maxChars or self.max_chars
is_valid, error_msg = validate_url_target(url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
# The same rule as the handlers below: most of these reasons name the
# hostname, and a reader whose every target is refused is one cause.
return json.dumps({"error": "URL validation failed", "detail": error_msg, "url": url}, ensure_ascii=False)

try:
logger.debug("WebFetch[{}]: {}", self.provider, "proxy enabled" if self.proxy else "direct connection")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_agent_loop_tool_loop_break.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def workspace():
# by the textual tests: the payload starts with "{" and spells the key
# '"error":' rather than "error:".
('{"error": "Jina API key not configured", "url": "https://a.example"}', True),
('{"error": "URL validation failed: blocked host", "url": "https://a.example"}', True),
('{"error": "URL validation failed", "detail": "blocked host", "url": "https://a.example"}', True),
('{"url": "https://a.example", "text": "the page", "length": 9}', False),
# Transient markers are tested before the envelope, so a retryable failure
# stays retryable however it is wrapped.
Expand All @@ -76,7 +76,7 @@ def test_different_envelope_failures_are_different_classes():
every envelope classified alike, a blocked URL followed by a reader's HTTP refusal
would fire the stop-repeating nudge at a model that had changed both its cause and
its approach -- the case this whole function exists to keep apart."""
blocked = '{"error": "URL validation failed: blocked host", "url": "https://a.example"}'
blocked = '{"error": "URL validation failed", "detail": "blocked host", "url": "https://a.example"}'
refused = '{"error": "Jina answered HTTP 403", "url": "https://b.example"}'
keyless = '{"error": "Jina API key not configured. Set it in the config", "url": "https://c.example"}'

Expand Down
20 changes: 20 additions & 0 deletions tests/test_agent_tools_web_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,26 @@ async def test_two_vendor_refusals_stay_two_streak_classes(monkeypatch: pytest.M
assert failure_class(spent) != failure_class(blocked)


async def test_one_blocked_reason_on_two_hosts_is_one_streak_class() -> None:
"""The gate in front of the reader, which the two tests above never reach.

Every reason this gate composes names the address it refused, so the reason
in the classification key gave a model walking an internal range one class
per address: the streak never reached the break threshold and the nudge that
would have sent it somewhere else never fired. The gate runs before any
transport, so nothing here needs a reader.
"""
envelopes = [
await WebFetchTool(api_key="k", provider="jina").execute(u)
for u in ("http://10.0.0.1/p", "http://192.168.1.1/p")
]

assert all(is_hard_tool_failure(raw) for raw in envelopes)
assert len({failure_class(raw) for raw in envelopes}) == 1
# The address the model has to stop reaching for is still in front of it.
assert "10.0.0.1" in json.loads(envelopes[0])["detail"]


def test_every_reader_is_covered_by_the_fetch_table() -> None:
assert set(_FETCH_CASES) == set(FETCH_PROVIDERS)

Expand Down
17 changes: 17 additions & 0 deletions tests/test_agents_research_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,23 @@ async def test_a_private_address_is_still_refused(monkeypatch):
assert "URL validation failed" in answer


@pytest.mark.asyncio
async def test_one_blocked_reason_on_two_hosts_is_one_streak_class(monkeypatch):
"""This copy of the reader carries the same gate, and drifting from the host's
is what let the envelope fix land on one of them last time. Every reason the
gate composes names the address, so the reason in the classification key gave
a model walking an internal range one class per address and the stop-repeating
nudge never came."""
_patch_client(monkeypatch, _PageTransport())
set_current_session("t")

envelopes = [await WebFetchTool().execute(url=u) for u in ("http://10.0.0.1/p", "http://192.168.1.1/p")]

assert all(is_hard_tool_failure(raw) for raw in envelopes)
assert len({failure_class(raw) for raw in envelopes}) == 1
assert "10.0.0.1" in json.loads(envelopes[0])["detail"]


@pytest.mark.asyncio
async def test_a_non_http_scheme_is_still_refused(monkeypatch):
_patch_client(monkeypatch, _PageTransport())
Expand Down
Loading
Loading