mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* feat(middleware): add structured tool result meta and tool-progress state machine
feat:
- Add tool_result_meta.py: ToolResultMeta dataclass (status/error_type/retryable/
recoverable_by_model/recommended_next_action/source) + normalize_tool_result and
stamp_exception_meta utilities; classifies every ToolMessage regardless of path
- Add ToolProgressMiddleware: per-(thread_id, tool_name) state machine ACTIVE →
WARNED (hint injected as HumanMessage) → BLOCKED (call short-circuited); Jaccard
near-duplicate detection for repeated successful results; auth/config/internal
errors bypass WARNED and go directly to BLOCKED; LRU-bounded thread state store
- Add ToolProgressConfig: all thresholds configurable (stagnation_threshold,
warn_escalation_count, jaccard_similarity_threshold, exempt_tools, etc.);
disabled by default (enabled: false)
- Wire ToolProgressMiddleware as outer wrapper around ToolErrorHandlingMiddleware
in _build_runtime_middlewares so it receives results already carrying
deerflow_tool_meta
fix:
- ToolErrorHandlingMiddleware now calls stamp_exception_meta on exception path and
normalize_tool_result on success path so every ToolMessage carries deerflow_tool_meta
test:
- Add test_tool_result_meta.py: 26 cases covering all classification paths,
stamp_exception_meta, and normalize_tool_result Command passthrough
- Add test_tool_progress_middleware.py: 27 cases including full async paths,
Jaccard duplicate detection, LRU eviction, hint injection, and malformed meta
passthrough
- Extend test_tool_error_handling_middleware.py: middleware ordering invariant and
meta stamping on exception
docs:
- Add tool_progress section to config.example.yaml with all fields and descriptions
- Update CLAUDE.md middleware chain documentation (entries 8-9)
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
* fix(middleware): recoverable errors stay WARNED; fix auth keyword shadowing
fix:
- WARNED is terminal for recoverable_by_model=True errors (no_results, not_found,
permission); hint re-injected on each problem call instead of escalating to
BLOCKED, so the model can retry with different parameters (e.g. fresh query,
new URL) without being hard-blocked by a prior stagnation count.
Non-recoverable (rate_limited, transient) still escalate WARNED → BLOCKED
after warn_escalation_count more problems; auth/config/internal remain
immediately BLOCKED.
- Remove bare "api key" keyword from auth classification rule so "no api key
configured" correctly classifies as config (not auth), producing the accurate
block-reason text for the model.
docs:
- CLAUDE.md: document all three ToolProgressMiddleware transition paths
- config.example.yaml: update inline state-machine comment to match new paths
test:
- test_recoverable_errors_stay_warned_indefinitely: WARNED never escalates for
recoverable errors regardless of how many problem calls accumulate
- test_recoverable_error_re_injects_hint_past_escalation: hints continue past
the escalation zone for recoverable errors
- test_no_api_key_is_config_not_auth: regression guard for keyword shadowing fix
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
* fix(tool_result_meta): add JSON error extraction and fix source classification
fix:
- Fix non-standard error path: source was "exception" but should be "tool_return"
- Add _extract_json_error_text to isolate JSON error fields from noisy JSON bodies
(e.g. Brave Search {"error": "...", "query": "..."} — query keywords no longer
pollute error classification)
- Add success-path JSON extraction to catch tools that return HTTP 200 with a JSON
error body (status="success" but {"error": "API key not configured"})
- Add _SEMANTIC_ZERO_ERROR_STRINGS frozenset to suppress false positives from tools
that use {"error": "none"} / {"error": "null"} / {"error": "ok"} as success signals
- Document that stamp_exception_meta always overwrites existing TOOL_META_KEY
(exception-derived classification is authoritative over tool return-time stamps)
test:
- Add parametrized regression tests for all semantic-zero error strings
- Add tests for non-standard error path source field
- Add tests for JSON error extraction (nonstd, success-path, numeric, falsy values)
- Correct test comment for test_no_api_key_is_config_not_auth
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
* fix(tool_progress_middleware): fix 6 bugs, add terminal guard and structured logging
fix:
- H1: fix exempt_tools empty-set silently ignored — use `is not None` instead of
truthiness check so ToolProgressConfig(exempt_tools=set()) correctly disables all
exemptions
- Fix _get_block_reason creating phantom LRU entries via _get_state (write path);
now uses dict.get + explicit move_to_end on read path only
- Fix _pending memory leak: LRU eviction of _phase_states now synchronously removes
all (evicted_thread, *) keys from _pending
- Fix _assess_and_transition missing terminal guard for blocked state — a recoverable
error result could silently demote blocked → warned in concurrent-race scenarios;
early return preserves terminal semantics
- Fix recent_word_sets window: stored [-5:] but is_near_duplicate only compared [-3:];
align to [-3:] and change type list→tuple (prevents accidental in-place mutation
across dataclasses.replace shallow copies)
- Fix _format_hint missing "success" key and "continue" action: Jaccard near-duplicate
results produced the generic fallback instead of a specific actionable message
feat:
- Add structured state-transition logging (ACTIVE/WARNED/BLOCKED transitions, blocked
intercepts, hint injection debug log)
test:
- Add regression tests for all 6 bug fixes (H1, phantom LRU, pending leak, terminal
guard, window alignment, format_hint near-dup)
- Add Jaccard near-threshold boundary test (7/9 vs 8/9 Jaccard)
- Add production min_words=10 skip test for short content
- Add exempt_tools empty-set and None round-trip tests
- Add _augment_request deduplication test
- Add before_agent current-run preservation test
- Add structured logging tests (WARNED/BLOCKED/ACTIVE/intercepted/debug)
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
* chore(config): remove unused backward-compat fields from ToolProgressConfig
Remove max_calls_per_intent and window_size fields that were marked
"Retained for backward compatibility; not used by the current state machine"
when the state machine was introduced. Pydantic v2 ignores unknown fields
by default, so existing config.yaml files with these keys remain valid.
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
* fix(tool_progress): address PR review and multi-agent review findings
fix:
- Remove <80-char length gate for partial_success; only _PARTIAL_MARKERS now
- Add word-boundary regex for numeric HTTP codes (401/403/404/500) to avoid
false positives like "500ms" or "4010 rows" triggering hard-block
- Add "task" to default exempt_tools (delegation primitive, not a search tool)
- Remove move_to_end() from _get_block_reason read path; blocked threads were
permanently warm in LRU, starving active threads of eviction slots
- Add _reset_blocked_states in before_agent: scope BLOCKED and WARNED states
to a single run; clear recent_word_sets so stale Jaccard windows don't cause
false near-duplicate detections in the next run
- Compute word_set() lazily (only for success results); cap content at 8192
chars to bound memory and CPU cost on large tool results
- Remove unused retryable field from ToolResultMeta (no consumer existed)
- Add isinstance-based ordering guard and warning log for missing meta
- Fix JSON-without-error-key fallback: use _UNKNOWN_ERROR instead of
classifying incidental field values (e.g. {"user_id": 401} → auth → stop)
- Fix _extract_json_error_text: use json.dumps for dict/list error fields
instead of str() which produced Python repr matching config rules spuriously
- Add "no results found"/"no content found"/"no images found" to _PARTIAL_MARKERS
so success responses with empty results trigger stagnation detection
- Fix immediate-block path to increment consecutive_problems (was left at 0)
- Fix _queue_assessment: skip phantom _pending entries for evicted threads
- Bump config_version 13→16 (upstream added 14/15; tool_progress is additive)
test:
- Update test_short_content_is_partial → test_short_terse_success_is_not_partial
- Add parametrized test_numeric_keyword_word_boundary (8 positive + negative cases)
- Add test_before_agent_resets_blocked_states_for_new_run (strengthened assertions)
- Add test_before_agent_resets_warned_states_for_new_run
- Add test_missing_meta_on_non_exempt_tool_emits_warning
- Add test_middleware_ordering_guard_raises_when_progress_is_inner
- Add test_auth_error_immediately_blocked asserts consecutive_problems == 1
- Add tests for JSON-without-error-key, dict error field, no-results partial_success
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tool_progress): address second PR review — perf, architecture doc, concurrency note
fix:
- Extract content.lower() once before _PARTIAL_MARKERS check in normalize_tool_message;
previously computed up to 7× per call inside the generator (once per marker)
docs:
- Add division-of-labor paragraph to ToolProgressMiddleware module docstring explaining
coexistence with LoopDetectionMiddleware: result-quality guard (per-tool BLOCK) vs
call-pattern guard (whole-turn hard-stop); no shared state, no double-stop risk
- Add threading.Lock comment explaining why asyncio.Lock is not used (short critical
sections, must also protect sync wrap_tool_call path from subagent executor threads)
- Update backend/CLAUDE.md entry 8 with division-of-labor summary; fix entry 9
(remove stale retryable field reference, add missing recoverable_by_model/source)
test:
- Add test_tool_progress_and_loop_detection_coexist_without_interfering: drives both
middlewares to WARNED state simultaneously, verifies independent state, independent
hint queues, and no cross-contamination; uses snapshot copy for final assertion
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tool_progress): reset all tool states at run boundary; fix semantic-zero test validity
fix:
- _reset_run_states (formerly _reset_blocked_states) drops the phase filter and
resets all tracked (thread, tool) pairs unconditionally at before_agent; ACTIVE
tools with sub-threshold consecutive_problems or cached recent_word_sets no longer
bleed into the next run, preventing spurious WARNED transitions on clean R2 calls
- test_normalize_json_semantic_zero_error_string_not_treated_as_error: replace
{error_value!r} f-string (produces invalid JSON with single quotes) with
json.dumps so _extract_json_error_text actually parses the payload and the
_SEMANTIC_ZERO_ERROR_STRINGS guard is exercised, not bypassed at json.loads
test:
- add test_before_agent_resets_active_state_consecutive_problems_and_word_sets to
lock the ACTIVE-phase run-boundary reset: drives tool to active/cp=1/ws≠() in R1,
asserts both fields are zero/empty after before_agent fires for R2
* docs(tool_progress): document intentional per-run reset vs LoopDetection thread-scoped retention
Addresses reviewer observation in PR #3601 that _reset_run_states diverges
from LoopDetectionMiddleware's cross-run scoping policy without explanation.
Expands the _reset_run_states docstring to record the intentional design
choice: ToolProgressMiddleware resets per-run because result-quality errors
(rate_limited, transient) are time-bound and may resolve between turns —
retaining stale counters would risk false-positive BLOCKED calls.
LoopDetectionMiddleware retains history across runs because call-pattern
loops are time-invariant. The divergence is by design, not oversight.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(middleware): restore ReadBeforeWriteMiddleware as outermost write gate
A merge conflict resolution had accidentally placed ReadBeforeWriteMiddleware
after ToolErrorHandlingMiddleware (inner), reversing the original intent from
b81334cc where it was the outermost write gate before ToolErrorHandling.
fix:
- Restore ReadBeforeWriteMiddleware to outer position: ReadBeforeWrite →
ToolProgress → ToolErrorHandling. Blocked writes now return immediately
without consuming a ToolProgress slot.
- Add normalize_tool_result call on blocked ToolMessages so they carry
deerflow_tool_meta (recoverable_by_model=True) even though they bypass
ToolErrorHandlingMiddleware.
test:
- Add test_blocked_write_has_deerflow_tool_meta (sync + async) to lock the
normalize_tool_result behavior on blocked writes.
- Fix chain order assertions in TestChainWiring and
test_build_lead_runtime_middlewares_chain_order_matches_agents_md.
docs:
- Renumber AGENTS.md items: 10→ReadBeforeWrite, 11→ToolProgress,
12→ToolErrorHandling; update descriptions to reflect outermost-gate design.
- Fix stale cross-reference: LoopDetectionMiddleware (item 23) → (item 25).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
474 lines
18 KiB
Python
474 lines
18 KiB
Python
"""Tests for tool_result_meta normalization logic."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from langchain_core.messages import ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.agents.middlewares.tool_result_meta import (
|
|
TOOL_META_KEY,
|
|
ToolResultMeta,
|
|
normalize_tool_message,
|
|
normalize_tool_result,
|
|
stamp_exception_meta,
|
|
)
|
|
|
|
|
|
def _make_msg(content: str, *, status: str = "success", kwargs: dict[str, object] | None = None) -> ToolMessage:
|
|
return ToolMessage(
|
|
content=content,
|
|
tool_call_id="tc-1",
|
|
name="test_tool",
|
|
status=status,
|
|
additional_kwargs=kwargs or {},
|
|
)
|
|
|
|
|
|
def _meta(msg: ToolMessage) -> dict[str, object]:
|
|
return msg.additional_kwargs[TOOL_META_KEY]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Already-stamped messages are not overwritten
|
|
|
|
|
|
def test_existing_meta_is_preserved():
|
|
existing = {"status": "success", "source": "custom"}
|
|
msg = _make_msg("hello", kwargs={TOOL_META_KEY: existing})
|
|
result = normalize_tool_message(msg)
|
|
assert result.additional_kwargs[TOOL_META_KEY] is existing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error prefix (tool_return path)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"snippet,expected_type",
|
|
[
|
|
("Error: 401 unauthorized", "auth"),
|
|
("Error: permission denied for path", "permission"),
|
|
("Error: 429 rate limit exceeded", "rate_limited"),
|
|
("Error: connection timeout", "transient"),
|
|
("Error: tool not configured", "config"),
|
|
("Error: no results found for query", "no_results"),
|
|
("Error: file not found", "not_found"),
|
|
("Error: internal error 500", "internal"),
|
|
("Error: something completely unexpected happened", "unknown"),
|
|
],
|
|
)
|
|
def test_error_prefix_classification(snippet: str, expected_type: str):
|
|
msg = _make_msg(snippet, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == expected_type
|
|
assert m["source"] == "tool_return"
|
|
|
|
|
|
def test_auth_error_is_unrecoverable_and_stop():
|
|
msg = _make_msg("Error: invalid api key", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["recoverable_by_model"] is False
|
|
assert m["recommended_next_action"] == "stop"
|
|
|
|
|
|
def test_no_api_key_is_config_not_auth():
|
|
# Distinguish "missing key" (config) from "wrong key" (auth):
|
|
# - auth rule keyword: "invalid api key" (key provided but rejected)
|
|
# - config rule keyword: "no api key" (key not set at all)
|
|
# The two phrases do not overlap, so rule order does not affect this particular
|
|
# case. This test documents the semantic distinction — a missing API key is a
|
|
# configuration issue, not an authentication failure.
|
|
msg = _make_msg("Error: no api key configured", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["error_type"] == "config", "missing API key is a config issue, not auth"
|
|
assert m["recommended_next_action"] == "stop"
|
|
assert m["recoverable_by_model"] is False
|
|
|
|
|
|
def test_rate_limited_error_suggests_summarize():
|
|
msg = _make_msg("Error: rate limited", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["recommended_next_action"] == "summarize"
|
|
assert m["recoverable_by_model"] is False
|
|
|
|
|
|
def test_no_results_suggests_rewrite_query():
|
|
msg = _make_msg("Error: no results found", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["recoverable_by_model"] is True
|
|
assert m["recommended_next_action"] == "rewrite_query"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-standard error path (status="error", no "Error:" prefix)
|
|
|
|
|
|
def test_nonstd_error_status_classifies_from_content():
|
|
# Tools that return status="error" without the "Error:" prefix are tool_return, not exception.
|
|
# Actual exceptions are pre-stamped by stamp_exception_meta and exit normalize_tool_message early.
|
|
msg = _make_msg("ConnectionError: connection refused", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["source"] == "tool_return"
|
|
assert m["error_type"] == "transient"
|
|
|
|
|
|
def test_nonstd_error_status_timeout_content():
|
|
msg = _make_msg("timeout occurred", status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["source"] == "tool_return"
|
|
assert m["error_type"] == "transient"
|
|
|
|
|
|
def test_nonstd_error_status_json_classifies_from_error_field():
|
|
# When status="error" and content is JSON, classification must use only the "error"
|
|
# field value — not keywords that appear in other fields like "query".
|
|
content = '{"error": "api limit exceeded", "query": "connection test timeout"}'
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["source"] == "tool_return"
|
|
# "connection" and "timeout" in query must not trigger transient; the error field
|
|
# "api limit exceeded" doesn't match any rule → unknown.
|
|
assert m["error_type"] == "unknown"
|
|
|
|
|
|
def test_nonstd_error_status_json_no_error_key_is_unknown():
|
|
# JSON with no 'error' key must NOT be classified from other field values.
|
|
# Previously, {"message": "connection refused"} would be passed to _classify_error_text
|
|
# and match the transient rule via "connection"; now the full JSON is treated as unknown.
|
|
content = '{"message": "connection refused"}'
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "unknown", "JSON dict with no 'error' key must resolve to unknown"
|
|
|
|
|
|
def test_nonstd_error_status_json_no_error_key_with_dangerous_field_is_unknown():
|
|
# {"user_id": 401} previously triggered auth stop; must now be unknown.
|
|
content = '{"user_id": 401, "action": "login"}'
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "unknown"
|
|
assert m["recommended_next_action"] != "stop", "spurious 401 in non-error field must not trigger stop"
|
|
|
|
|
|
def test_nonstd_error_status_non_json_content_still_classified():
|
|
# Plain text (not JSON) with status="error" must still be classified from content.
|
|
content = "connection refused: remote host unreachable"
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "transient"
|
|
|
|
|
|
def test_json_error_field_dict_is_serialized_not_repr():
|
|
# FastAPI-style: {"error": [{"loc": ["body"], "msg": "missing required field"}]}
|
|
# str() would produce Python repr containing 'missing required' → config → stop.
|
|
# json.dumps produces a clean JSON string that should not spuriously match.
|
|
import json as _json
|
|
|
|
error_val = [{"loc": ["body"], "msg": "missing required field"}]
|
|
content = _json.dumps({"error": error_val})
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
# The JSON-serialized error list contains "missing required field" which IS in the config rule.
|
|
# This is correct classification (the validation error IS a config-class problem).
|
|
# The key requirement is that we're classifying from the error field value, not repr noise.
|
|
assert m["error_type"] == "config"
|
|
|
|
|
|
def test_no_results_success_response_is_partial_success():
|
|
# Tools that return status="success" with "no results found" content must be treated as
|
|
# partial_success so ToolProgressMiddleware can detect stagnation.
|
|
for phrase in ("no results found", "No Content Found here", "no images found for query"):
|
|
msg = _make_msg(phrase, status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "partial_success", f"expected partial_success for: {phrase!r}"
|
|
assert m["recommended_next_action"] == "rewrite_query"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Partial success detection
|
|
|
|
|
|
def test_partial_markers_detected():
|
|
for marker in ("partial results available", "limited results returned", "truncated output", "results may be incomplete"):
|
|
msg = _make_msg(f"Here are some {marker} from the search.", status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "partial_success", f"expected partial_success for: {marker}"
|
|
assert m["recommended_next_action"] == "rewrite_query"
|
|
|
|
|
|
def test_short_terse_success_is_not_partial():
|
|
# "Ok." is a valid, complete success response from mutation tools like write_file/str_replace.
|
|
# partial_success is now gated only on _PARTIAL_MARKERS, not content length.
|
|
msg = _make_msg("Ok.", status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "success"
|
|
assert m["source"] == "content_analysis"
|
|
|
|
|
|
def test_empty_content_is_not_partial():
|
|
# Empty content has no partial markers, so it falls through to success.
|
|
msg = _make_msg("", status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
# Empty content falls through to success (no partial markers)
|
|
assert m["status"] == "success"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Success path
|
|
|
|
|
|
def test_substantial_content_is_success():
|
|
content = "A" * 200
|
|
msg = _make_msg(content, status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "success"
|
|
assert m["source"] == "content_analysis"
|
|
assert m["recommended_next_action"] == "continue"
|
|
assert m["error_type"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ToolResultMeta dataclass round-trip
|
|
|
|
|
|
def test_tool_result_meta_from_dict():
|
|
msg = _make_msg("A" * 200)
|
|
result = normalize_tool_message(msg)
|
|
meta_dict = _meta(result)
|
|
meta = ToolResultMeta(**meta_dict)
|
|
assert meta.status == "success"
|
|
assert meta.error_type is None
|
|
assert meta.recommended_next_action == "continue"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stamp_exception_meta
|
|
|
|
|
|
def test_stamp_exception_meta_classifies_from_exc_info_not_content():
|
|
# Content says "no results" but exc_info says "connection refused" —
|
|
# stamp_exception_meta must use exc_info, producing transient, not no_results.
|
|
msg = _make_msg("Error: no results found", status="error")
|
|
result = stamp_exception_meta(msg, "ConnectionError: connection refused")
|
|
m = _meta(result)
|
|
assert m["source"] == "exception"
|
|
assert m["error_type"] == "transient"
|
|
|
|
|
|
def test_stamp_exception_meta_overwrites_existing_meta():
|
|
pre_existing = {TOOL_META_KEY: {"source": "tool_return", "error_type": "unknown"}}
|
|
msg = _make_msg("Error: no results found", status="error", kwargs=pre_existing)
|
|
result = stamp_exception_meta(msg, "PermissionError: access denied")
|
|
m = _meta(result)
|
|
assert m["source"] == "exception"
|
|
assert m["error_type"] == "permission"
|
|
|
|
|
|
def test_stamp_exception_meta_preserves_other_additional_kwargs():
|
|
msg = _make_msg("irrelevant", status="error", kwargs={"subagent_status": "running"})
|
|
result = stamp_exception_meta(msg, "TimeoutError: timed out")
|
|
assert result.additional_kwargs["subagent_status"] == "running"
|
|
assert TOOL_META_KEY in result.additional_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# normalize_tool_result handles Command wrappers
|
|
|
|
|
|
def test_normalize_tool_result_passthrough_command():
|
|
cmd = Command(goto="next_node")
|
|
result = normalize_tool_result(cmd)
|
|
assert result is cmd
|
|
|
|
|
|
def test_normalize_tool_result_stamps_tool_message():
|
|
msg = _make_msg("A" * 200)
|
|
result = normalize_tool_result(msg)
|
|
assert isinstance(result, ToolMessage)
|
|
assert TOOL_META_KEY in result.additional_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON-wrapped error detection
|
|
|
|
|
|
def test_normalize_json_error_config_classified_as_error():
|
|
content = '{"error": "BRAVE_SEARCH_API_KEY is not configured", "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "config"
|
|
assert m["source"] == "tool_return"
|
|
|
|
|
|
def test_normalize_json_error_no_results_classified_correctly():
|
|
content = '{"error": "No results found", "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "no_results"
|
|
assert m["recoverable_by_model"] is True
|
|
|
|
|
|
def test_normalize_json_null_error_not_treated_as_error():
|
|
content = '{"error": null, "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] != "error"
|
|
|
|
|
|
def test_normalize_json_no_error_key_not_treated_as_error():
|
|
content = '{"results": [{"title": "page one", "url": "https://example.com/one", "content": "summary one"}], "total": 1}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "success"
|
|
|
|
|
|
def test_normalize_malformed_json_not_treated_as_error():
|
|
content = '{"error": "broken json'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] != "error"
|
|
|
|
|
|
def test_normalize_json_error_with_leading_whitespace():
|
|
content = ' {"error": "No results found", "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "no_results"
|
|
|
|
|
|
def test_normalize_json_numeric_error_classified_correctly():
|
|
content = '{"error": 404, "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "not_found"
|
|
|
|
|
|
def test_normalize_json_zero_error_not_treated_as_error():
|
|
content = '{"error": 0, "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] != "error"
|
|
|
|
|
|
def test_normalize_json_false_error_not_treated_as_error():
|
|
content = '{"error": false, "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] != "error"
|
|
|
|
|
|
def test_normalize_json_boolean_true_error_classified_as_unknown():
|
|
"""Boolean True in the error field means 'an error occurred' and must be classified.
|
|
|
|
str(True) = "True" which matches no keyword rule, so the result is error/unknown.
|
|
This is intentional: a boolean True error is a real error with no further detail.
|
|
"""
|
|
content = '{"error": true, "query": "test"}'
|
|
msg = _make_msg(content)
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == "unknown" # str(True)="True" matches no keyword rule
|
|
assert m["recoverable_by_model"] is True
|
|
assert m["recommended_next_action"] == "try_alternative"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# M2 regression: semantic-zero error strings must NOT be treated as errors
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error_value",
|
|
["none", "None", "NONE", "null", "Null", "false", "False", "no", "ok", "success", "n/a", ""],
|
|
)
|
|
def test_normalize_json_semantic_zero_error_string_not_treated_as_error(error_value: str):
|
|
"""M2 regression: error field containing a conventional 'no-error' string must not trigger misclassification.
|
|
|
|
Tools sometimes return {"error": "none", "results": [...]} on success.
|
|
The string "none" is truthy in Python, so without this guard the message
|
|
would have been classified as error (unknown), inflating stagnation counters.
|
|
|
|
Note: the empty-string case ("") is handled by the falsy guard (`if not error: return None`)
|
|
in _extract_json_error_text rather than by _SEMANTIC_ZERO_ERROR_STRINGS. Both paths produce
|
|
the same outcome (no misclassification), but the mechanism differs from the other cases here.
|
|
"""
|
|
content = json.dumps({"error": error_value, "results": ["item1", "item2", "item3"]})
|
|
msg = _make_msg(content, status="success")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] != "error", f'error="{error_value}" should not be treated as an error; got status={m["status"]!r}'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Numeric keyword word-boundary matching (_match_keyword)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content, expected_error_type",
|
|
[
|
|
# Positive: numeric code at a word boundary → correct classification
|
|
("Error: HTTP 500 Internal Server Error", "internal"),
|
|
("Error: returned status 500", "internal"),
|
|
("Error: 401 Unauthorized", "auth"),
|
|
("Error: 404 Not Found", "not_found"),
|
|
# Negative: numeric code embedded inside a longer token → must resolve to "unknown".
|
|
# Use exact "unknown" assertions so any future rule additions that accidentally
|
|
# absorb these strings are caught (a broad exclusion list would miss new matches).
|
|
("Error: took 500ms to respond", "unknown"),
|
|
("Error: query returned 4010 rows", "unknown"),
|
|
("Error: batch 401A failed", "unknown"),
|
|
("Error: response contained 5000 items", "unknown"),
|
|
],
|
|
)
|
|
def test_numeric_keyword_word_boundary(content: str, expected_error_type: str):
|
|
"""Numeric HTTP codes must match only at word boundaries to avoid false positives.
|
|
|
|
'500ms', '4010', '401A', '5000' must not trigger internal/auth/not_found rules.
|
|
Negative cases assert exactly 'unknown' so future rule additions that accidentally
|
|
absorb these strings are caught — a broad exclusion-list assertion would not be.
|
|
"""
|
|
msg = _make_msg(content, status="error")
|
|
result = normalize_tool_message(msg)
|
|
m = _meta(result)
|
|
assert m["status"] == "error"
|
|
assert m["error_type"] == expected_error_type, f"{content!r} → expected {expected_error_type!r}, got {m['error_type']!r}"
|