mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
* fix(agents): classify web_fetch error pages as errors, not successful evidence
* fix(agents): classify 502/503/504 error shells as transient, not internal
Per review: a gateway error page is the try-a-different-source case, so
502/503/504 reason phrases now map to transient (warn, then escalate)
while 500/501 stay internal (stop). This mirrors _ERROR_RULES' own split
and removes the two phrases that classified differently through the
shell table than through _classify_error_text ("service temporarily
unavailable", "gateway timeout") — now pinned by a cross-path
consistency test over the live table plus a chain-level test that a 503
page warns and escalates instead of blocking web_fetch on first sight.
Also per review, _classify_error_shell returns a copy of the category
attrs instead of the rules-table dict, matching _classify_error_text.
Dropped "gone" from the phrase table: a single-word reason phrase
collides with legitimate one-word document titles ("# Gone"), and 410
error pages are rare while such titles are not; negative controls record
the decision.
* test(agents): pin crawl4ai's recorded renderer shape; note web_capture asymmetry
Per review: the title rule assumes every web_fetch provider renders
title-first. Eight of the nine providers hold by construction
(f"# {title}" or the shared Article.to_markdown()); crawl4ai renders
server-side, so its generator output (crawl4ai 0.9.2, fit and raw) was
measured over the same corpus and recorded as fixtures driven through
the real tool and middleware chain. A comment on _PAGE_CONTENT_TOOL_NAMES
records that web_capture's absence is intentional: its dead-target signal
is the provider warning suffix, which belongs to the provider boundary.
305 lines
14 KiB
Python
305 lines
14 KiB
Python
"""Unified tool result semantics for structured signal production.
|
|
|
|
Every tool result that passes through ToolErrorHandlingMiddleware gets a
|
|
``deerflow_tool_meta`` entry in additional_kwargs. Downstream consumers
|
|
(ToolProgressMiddleware, etc.) read this key instead of parsing text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
from langchain_core.messages import ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
TOOL_META_KEY = "deerflow_tool_meta"
|
|
|
|
_ERROR_PREFIX = "Error:"
|
|
_PARTIAL_MARKERS = (
|
|
"partial results",
|
|
"limited results",
|
|
"truncated",
|
|
"results may be incomplete",
|
|
# Tools that return status="success" with a no-results body (instead of status="error")
|
|
# must still be caught by stagnation detection so the model is prompted to try a different query.
|
|
"no results found",
|
|
"no content found",
|
|
"no images found",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ToolResultMeta:
|
|
status: Literal["success", "error", "partial_success"]
|
|
error_type: str | None
|
|
recoverable_by_model: bool
|
|
recommended_next_action: Literal["continue", "rewrite_query", "try_alternative", "summarize", "stop"]
|
|
source: Literal["exception", "tool_return", "content_analysis", "progress_middleware"]
|
|
|
|
|
|
_ERROR_RULES: list[tuple[list[str], dict[str, object]]] = [
|
|
(
|
|
["401", "403", "unauthorized", "authentication", "invalid api key"],
|
|
{"error_type": "auth", "recoverable_by_model": False, "recommended_next_action": "stop"},
|
|
),
|
|
(
|
|
["rate limit", "rate limited", "rate_limit"],
|
|
{"error_type": "rate_limited", "recoverable_by_model": False, "recommended_next_action": "summarize"},
|
|
),
|
|
(
|
|
["timeout", "timed out", "connection", "network error", "temporarily unavailable"],
|
|
{"error_type": "transient", "recoverable_by_model": False, "recommended_next_action": "try_alternative"},
|
|
),
|
|
(
|
|
["not configured", "not installed", "missing required", "disabled", "no api key"],
|
|
{"error_type": "config", "recoverable_by_model": False, "recommended_next_action": "stop"},
|
|
),
|
|
(
|
|
["permission denied", "access denied", "path traversal", "forbidden"],
|
|
{"error_type": "permission", "recoverable_by_model": True, "recommended_next_action": "try_alternative"},
|
|
),
|
|
(
|
|
["no results found", "no content found", "no images found", "no results"],
|
|
{"error_type": "no_results", "recoverable_by_model": True, "recommended_next_action": "rewrite_query"},
|
|
),
|
|
(
|
|
["not found", "no such file", "does not exist", "404"],
|
|
{"error_type": "not_found", "recoverable_by_model": True, "recommended_next_action": "rewrite_query"},
|
|
),
|
|
(
|
|
["unexpected error", "internal error", "500"],
|
|
{"error_type": "internal", "recoverable_by_model": False, "recommended_next_action": "stop"},
|
|
),
|
|
]
|
|
|
|
_UNKNOWN_ERROR: dict[str, object] = {
|
|
"error_type": "unknown",
|
|
"recoverable_by_model": True,
|
|
"recommended_next_action": "try_alternative",
|
|
}
|
|
|
|
# Tool names whose result content is a *rendered remote page* rather than a tool's
|
|
# own message. Name-based, mirroring ToolResultSanitizationMiddleware's
|
|
# _REMOTE_CONTENT_TOOL_NAMES: the first-party fetch providers all normalize to
|
|
# ``web_fetch`` (see community/*/tools.py), so the gate stays provider-agnostic.
|
|
# The gate is required because normalize_tool_message() runs for *every* tool —
|
|
# a short "not found" line is legitimate output from many of them.
|
|
# ``web_capture`` is deliberately absent: its result is a tool message about an
|
|
# artifact ("Captured screenshot: <path> (warning: ...)"), not a rendered page, so
|
|
# a title rule cannot apply. A dead-target capture still yields the artifact plus a
|
|
# model-visible warning; stamping it belongs to the provider boundary (#4239).
|
|
_PAGE_CONTENT_TOOL_NAMES: frozenset[str] = frozenset({"web_fetch"})
|
|
|
|
# Category attributes reused by the error-shell path, indexed by the error_type
|
|
# _ERROR_RULES already declares. Derived rather than duplicated so a shell can
|
|
# never drift from the recoverable/next-action contract of its own category.
|
|
_ATTRS_BY_ERROR_TYPE: dict[str, dict[str, object]] = {str(attrs["error_type"]): attrs for _keywords, attrs in _ERROR_RULES}
|
|
|
|
# Reason phrases (RFC 9110 §15 plus the wording real servers ship) mapped onto the
|
|
# error_type they already have in _ERROR_RULES. Restricted to the statuses a fetch
|
|
# actually lands on as a rendered page. The 5xx split mirrors _ERROR_RULES' own:
|
|
# 500/501 sit with its "500"/"internal error" keywords (internal → stop), while
|
|
# 502/503/504 sit with its "timeout"/"temporarily unavailable" keywords (transient
|
|
# → try_alternative) — a gateway error is the try-a-different-source case, and the
|
|
# same words must not classify differently here than through _classify_error_text.
|
|
_ERROR_SHELL_PHRASES: dict[str, str] = {
|
|
"unauthorized": "auth",
|
|
"proxy authentication required": "auth",
|
|
"forbidden": "permission",
|
|
"access denied": "permission",
|
|
"permission denied": "permission",
|
|
"not found": "not_found",
|
|
"too many requests": "rate_limited",
|
|
"internal server error": "internal",
|
|
"not implemented": "internal",
|
|
"bad gateway": "transient",
|
|
"service unavailable": "transient",
|
|
"service temporarily unavailable": "transient",
|
|
"gateway timeout": "transient",
|
|
}
|
|
|
|
# Generic subject nouns a server may prefix onto the reason phrase ("Page not found",
|
|
# IIS's "404 - File or directory not found."). Stripped from the *front* only, so any
|
|
# word left over after the phrase still rejects the title.
|
|
_STATUS_TITLE_FILLER: frozenset[str] = frozenset({"http", "error", "page", "the", "file", "or", "directory", "url", "resource"})
|
|
|
|
|
|
# Pre-compiled at module load from _ERROR_RULES. Anchoring bare numeric codes (401, 403, 404,
|
|
# 500) to word boundaries prevents substring hits on unrelated numbers like "took 500ms".
|
|
# Computed here (after _ERROR_RULES) so the set is authoritative and thread-safe — no lazy
|
|
# writes on the hot classification path.
|
|
_NUMERIC_KW_RE: dict[str, re.Pattern[str]] = {kw: re.compile(rf"\b{kw}\b") for rule_keywords, _ in _ERROR_RULES for kw in rule_keywords if kw.isdigit()}
|
|
|
|
_SEMANTIC_ZERO_ERROR_STRINGS: frozenset[str] = frozenset({"none", "null", "false", "no", "ok", "success", "n/a", ""})
|
|
|
|
|
|
def _extract_json_error_text(content: str) -> str | None:
|
|
"""Return the error string from a JSON-wrapped error like {"error": "...", "query": "..."}.
|
|
|
|
Returns None when the ``error`` field is falsy (JSON null / 0 / false / empty
|
|
string) or is a sentinel string that conventionally means "no error" (e.g.
|
|
``"none"``, ``"null"``, ``"false"``). This prevents tools that return
|
|
``{"error": "none", "results": [...]}`` on success from being misclassified
|
|
as errors.
|
|
"""
|
|
try:
|
|
data = json.loads(content)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
error = data.get("error") if isinstance(data, dict) else None
|
|
if not error:
|
|
return None
|
|
if isinstance(error, str) and error.lower().strip() in _SEMANTIC_ZERO_ERROR_STRINGS:
|
|
return None
|
|
# Serialize non-string values to JSON so _classify_error_text sees a predictable
|
|
# format (e.g. {"error": 404} → "404", {"error": [...]} → "[...]") instead of
|
|
# Python repr which can spuriously match keyword rules like "missing required".
|
|
return error if isinstance(error, str) else json.dumps(error)
|
|
|
|
|
|
def _match_keyword(kw: str, lower: str) -> bool:
|
|
"""Match a keyword against lowercased text, using word boundaries for numeric codes."""
|
|
if kw.isdigit():
|
|
return bool(_NUMERIC_KW_RE[kw].search(lower))
|
|
return kw in lower
|
|
|
|
|
|
def _classify_error_text(text: str) -> dict[str, object]:
|
|
lower = text.lower()
|
|
for keywords, attrs in _ERROR_RULES:
|
|
if any(_match_keyword(kw, lower) for kw in keywords):
|
|
return {**attrs}
|
|
return {**_UNKNOWN_ERROR}
|
|
|
|
|
|
def _classify_error_shell(msg: ToolMessage, content: str) -> dict[str, object] | None:
|
|
"""Return category attributes when a fetched page is an HTTP error page.
|
|
|
|
A fetch of a missing URL succeeds at the transport layer, so none of the branches
|
|
above apply and the server's error page reaches the model stamped
|
|
``status="success"`` — counted as evidence it never contained (issue #4273).
|
|
|
|
The signal is the *extracted title*: error pages from nginx / Apache / IIS /
|
|
Cloudflare all render with the status line as their heading and only server
|
|
boilerplate as the body ("# 404 Not Found" over "nginx/1.24.0"). Matching is by
|
|
equality after normalization, never substring, so a document merely *about* a
|
|
status keeps its title's other words and is rejected: "404 Ways to Cook Rice" and
|
|
"Not Found: a short history of the 404" both survive as successes.
|
|
|
|
Content length deliberately plays no part — measured against real error pages it
|
|
does not separate (an IIS 404 renders to 193 chars, a genuine article to 202).
|
|
|
|
This is the provider-agnostic fallback only. The authoritative signal is the
|
|
provider's own status code, which stays at the web_fetch boundary (Browserless
|
|
surfaces ``X-Response-Code`` per #4239); a page whose title is not a status line
|
|
remains that layer's job.
|
|
"""
|
|
if msg.name not in _PAGE_CONTENT_TOOL_NAMES:
|
|
return None
|
|
title = next((line for line in content.splitlines() if line.strip()), "")
|
|
phrase = _as_status_line(title.lstrip("#").strip())
|
|
error_type = _ERROR_SHELL_PHRASES.get(phrase) if phrase else None
|
|
return {**_ATTRS_BY_ERROR_TYPE[error_type]} if error_type else None
|
|
|
|
|
|
def _as_status_line(title: str) -> str | None:
|
|
"""Reduce a page title to its bare reason phrase, or None if it carries content.
|
|
|
|
"404 Not Found" -> "not found"; "404 - File or directory not found." -> "not found";
|
|
"404 Ways to Cook Rice" -> "ways to cook rice" (words survive, so it is a document).
|
|
"""
|
|
words = re.sub(r"[^0-9a-z]+", " ", title.lower()).split()
|
|
# Strip leading status codes and generic nouns in either order — servers write both
|
|
# "404 - File or directory not found" and "HTTP Error 404 - Not Found".
|
|
while words and (words[0] in _STATUS_TITLE_FILLER or (len(words[0]) == 3 and words[0].isdigit() and 400 <= int(words[0]) <= 599)):
|
|
words = words[1:]
|
|
return " ".join(words) or None
|
|
|
|
|
|
def _make_meta(*, status: str, source: str, error_type: str | None = None, recoverable_by_model: bool = True, recommended_next_action: str = "continue") -> dict[str, object]:
|
|
return {
|
|
"status": status,
|
|
"error_type": error_type,
|
|
"recoverable_by_model": recoverable_by_model,
|
|
"recommended_next_action": recommended_next_action,
|
|
"source": source,
|
|
}
|
|
|
|
|
|
def stamp_exception_meta(msg: ToolMessage, exc_info: str) -> ToolMessage:
|
|
"""Stamp deerflow_tool_meta with source='exception' onto an exception-derived ToolMessage.
|
|
|
|
Unlike normalize_tool_message (which preserves existing stamps), this function always
|
|
overwrites any pre-existing TOOL_META_KEY entry. Exception-derived classification is
|
|
more authoritative than a tool's own return-time stamp.
|
|
"""
|
|
attrs = _classify_error_text(exc_info)
|
|
updated_kwargs = dict(msg.additional_kwargs or {})
|
|
updated_kwargs[TOOL_META_KEY] = _make_meta(status="error", source="exception", **attrs)
|
|
msg.additional_kwargs = updated_kwargs
|
|
return msg
|
|
|
|
|
|
def normalize_tool_message(msg: ToolMessage) -> ToolMessage:
|
|
"""Attach deerflow_tool_meta to a ToolMessage if not already present."""
|
|
existing = (msg.additional_kwargs or {}).get(TOOL_META_KEY)
|
|
if existing is not None:
|
|
return msg
|
|
|
|
content = msg.content if isinstance(msg.content, str) else ""
|
|
# Pre-compute once; reused by the partial-success marker check below to avoid calling
|
|
# content.lower() once per _PARTIAL_MARKERS entry inside the generator.
|
|
content_lower = content.lower()
|
|
|
|
# Non-standard error: tool returned status="error" without the "Error:" prefix convention.
|
|
# (Actual exceptions from ToolErrorHandlingMiddleware are pre-stamped by stamp_exception_meta
|
|
# and exit early above — they never reach this branch.)
|
|
# Try JSON extraction first so classification uses only the "error" field value, not
|
|
# keywords that appear incidentally in other JSON fields (e.g. "query").
|
|
if msg.status == "error" and not content.startswith(_ERROR_PREFIX):
|
|
json_error = _extract_json_error_text(content)
|
|
if json_error is not None:
|
|
attrs = _classify_error_text(json_error)
|
|
else:
|
|
# Determine whether content is a JSON object that simply has no 'error' key.
|
|
# If so, do NOT classify from the raw JSON string — incidental field values
|
|
# (e.g. {"user_id": 401}) would spuriously match keyword rules and hard-block
|
|
# the tool. Classify raw text only when the content is not valid JSON.
|
|
try:
|
|
is_json_dict = isinstance(json.loads(content), dict)
|
|
except (json.JSONDecodeError, ValueError):
|
|
is_json_dict = False
|
|
attrs = {**_UNKNOWN_ERROR} if is_json_dict else _classify_error_text(content)
|
|
meta = _make_meta(status="error", source="tool_return", **attrs)
|
|
elif content.startswith(_ERROR_PREFIX):
|
|
attrs = _classify_error_text(content[len(_ERROR_PREFIX) :])
|
|
meta = _make_meta(status="error", source="tool_return", **attrs)
|
|
elif (json_error := _extract_json_error_text(content)) is not None:
|
|
attrs = _classify_error_text(json_error)
|
|
meta = _make_meta(status="error", source="tool_return", **attrs)
|
|
elif (shell_attrs := _classify_error_shell(msg, content)) is not None:
|
|
meta = _make_meta(status="error", source="content_analysis", **shell_attrs)
|
|
elif any(m in content_lower for m in _PARTIAL_MARKERS):
|
|
meta = _make_meta(
|
|
status="partial_success",
|
|
source="content_analysis",
|
|
recommended_next_action="rewrite_query",
|
|
)
|
|
else:
|
|
meta = _make_meta(status="success", source="content_analysis")
|
|
|
|
updated_kwargs = dict(msg.additional_kwargs or {})
|
|
updated_kwargs[TOOL_META_KEY] = meta
|
|
msg.additional_kwargs = updated_kwargs
|
|
return msg
|
|
|
|
|
|
def normalize_tool_result(result: ToolMessage | Command) -> ToolMessage | Command:
|
|
"""Normalize a tool result, handling Command wrappers transparently."""
|
|
if isinstance(result, ToolMessage):
|
|
return normalize_tool_message(result)
|
|
return result
|