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>
1479 lines
54 KiB
Python
1479 lines
54 KiB
Python
"""Tests for ToolProgressMiddleware state machine (RFC #3177)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from langchain_core.messages import HumanMessage, ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.agents.middlewares.tool_progress_middleware import (
|
|
ToolProgressMiddleware,
|
|
is_near_duplicate,
|
|
word_set,
|
|
)
|
|
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
|
|
|
|
def _make_runtime(thread_id: str = "t1", run_id: str = "r1") -> MagicMock:
|
|
rt = MagicMock()
|
|
rt.context = {"thread_id": thread_id, "run_id": run_id}
|
|
return rt
|
|
|
|
|
|
def _make_tool_request(tool_name: str = "web_search", *, runtime: MagicMock | None = None) -> SimpleNamespace:
|
|
rt = runtime or _make_runtime()
|
|
return SimpleNamespace(
|
|
tool_call={"name": tool_name, "id": f"tc-{tool_name}"},
|
|
runtime=rt,
|
|
)
|
|
|
|
|
|
def _meta_kwargs(
|
|
*,
|
|
status: str = "success",
|
|
error_type: str | None = None,
|
|
recoverable_by_model: bool = True,
|
|
recommended_next_action: str = "continue",
|
|
source: str = "content_analysis",
|
|
) -> dict[str, object]:
|
|
return {
|
|
TOOL_META_KEY: {
|
|
"status": status,
|
|
"error_type": error_type,
|
|
"recoverable_by_model": recoverable_by_model,
|
|
"recommended_next_action": recommended_next_action,
|
|
"source": source,
|
|
}
|
|
}
|
|
|
|
|
|
def _make_tool_message(
|
|
content: str = "A" * 200,
|
|
*,
|
|
tool_name: str = "web_search",
|
|
meta_kwargs: dict[str, object] | None = None,
|
|
) -> ToolMessage:
|
|
return ToolMessage(
|
|
content=content,
|
|
tool_call_id=f"tc-{tool_name}",
|
|
name=tool_name,
|
|
status="success",
|
|
additional_kwargs=meta_kwargs or _meta_kwargs(),
|
|
)
|
|
|
|
|
|
def _make_non_recoverable_error_message(
|
|
content: str = "Error: rate limited",
|
|
*,
|
|
tool_name: str = "web_search",
|
|
error_type: str = "rate_limited",
|
|
recommended_next_action: str = "summarize",
|
|
) -> ToolMessage:
|
|
"""Non-recoverable stagnation error (recoverable_by_model=False, non-stop).
|
|
Unlike auth/config, these go through the stagnation counter, but should
|
|
still reach BLOCKED because the model cannot fix them by retrying.
|
|
"""
|
|
return ToolMessage(
|
|
content=content,
|
|
tool_call_id=f"tc-{tool_name}",
|
|
name=tool_name,
|
|
status="error",
|
|
additional_kwargs=_meta_kwargs(
|
|
status="error",
|
|
error_type=error_type,
|
|
recoverable_by_model=False,
|
|
recommended_next_action=recommended_next_action,
|
|
),
|
|
)
|
|
|
|
|
|
def _make_error_message(
|
|
content: str = "Error: no results found",
|
|
*,
|
|
tool_name: str = "web_search",
|
|
error_type: str = "no_results",
|
|
recoverable_by_model: bool = True,
|
|
recommended_next_action: str = "rewrite_query",
|
|
) -> ToolMessage:
|
|
return ToolMessage(
|
|
content=content,
|
|
tool_call_id=f"tc-{tool_name}",
|
|
name=tool_name,
|
|
status="error",
|
|
additional_kwargs=_meta_kwargs(
|
|
status="error",
|
|
error_type=error_type,
|
|
recoverable_by_model=recoverable_by_model,
|
|
recommended_next_action=recommended_next_action,
|
|
),
|
|
)
|
|
|
|
|
|
def _make_model_request(messages: list, runtime: MagicMock) -> MagicMock:
|
|
req = MagicMock()
|
|
req.messages = list(messages)
|
|
req.runtime = runtime
|
|
|
|
def _override(**kw) -> MagicMock:
|
|
updated = MagicMock()
|
|
updated.messages = kw.get("messages", req.messages)
|
|
updated.runtime = runtime
|
|
updated.override = req.override
|
|
return updated
|
|
|
|
req.override = _override
|
|
return req
|
|
|
|
|
|
def _make_mw(**kwargs) -> ToolProgressMiddleware:
|
|
defaults = {
|
|
"stagnation_threshold": 3,
|
|
"warn_escalation_count": 2,
|
|
"inject_assessment": True,
|
|
"jaccard_threshold": 0.8,
|
|
"min_words": 5,
|
|
}
|
|
defaults.update(kwargs)
|
|
return ToolProgressMiddleware(**defaults)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests: word_set and is_near_duplicate
|
|
|
|
|
|
def test_word_set_extracts_words_ge_3():
|
|
ws = word_set("go quick brown fox")
|
|
assert "go" not in ws
|
|
assert "quick" in ws
|
|
assert "brown" in ws
|
|
assert "fox" in ws
|
|
|
|
|
|
def test_is_near_duplicate_above_threshold():
|
|
ws1 = frozenset("quick brown fox jumps over lazy dog".split())
|
|
ws2 = frozenset("quick brown fox jumps over lazy dog".split())
|
|
assert is_near_duplicate(ws2, [ws1], threshold=0.8, min_words=5)
|
|
|
|
|
|
def test_is_near_duplicate_near_threshold():
|
|
# ws1 has 8 words; ws2 shares 7 of them and adds 1 new word.
|
|
# intersection=7, union=9 → Jaccard = 7/9 ≈ 0.778 < 0.8 → NOT duplicate.
|
|
# ws3 shares all 8 original words and adds 1 new word.
|
|
# intersection=8, union=9 → Jaccard = 8/9 ≈ 0.889 >= 0.8 → IS duplicate.
|
|
base = frozenset("alpha bravo charlie delta echo foxtrot golf hotel".split())
|
|
nearly_below = frozenset("alpha bravo charlie delta echo foxtrot golf india".split()) # 7/9 ≈ 0.778
|
|
nearly_above = frozenset("alpha bravo charlie delta echo foxtrot golf hotel india".split()) # 8/9 ≈ 0.889
|
|
assert not is_near_duplicate(nearly_below, [base], threshold=0.8, min_words=5)
|
|
assert is_near_duplicate(nearly_above, [base], threshold=0.8, min_words=5)
|
|
|
|
|
|
def test_is_near_duplicate_below_threshold():
|
|
ws1 = frozenset("apple banana cherry delta echo".split())
|
|
ws2 = frozenset("xray yankee zulu alpha bravo".split())
|
|
assert not is_near_duplicate(ws2, [ws1], threshold=0.8, min_words=5)
|
|
|
|
|
|
def test_is_near_duplicate_too_short_skips_check():
|
|
ws1 = frozenset("apple".split())
|
|
ws2 = frozenset("apple".split())
|
|
# min_words=5 but len==1, so not a duplicate
|
|
assert not is_near_duplicate(ws2, [ws1], threshold=0.8, min_words=5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 1: Normal call → no hint, phase stays active
|
|
|
|
|
|
def test_normal_call_no_hint_phase_active():
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
msg = _make_tool_message("A" * 300)
|
|
|
|
def handler(_r):
|
|
return msg
|
|
|
|
result = mw.wrap_tool_call(req, handler)
|
|
|
|
assert result is msg
|
|
assert mw._phase_states["t1"]["web_search"].phase == "active"
|
|
assert mw._phase_states["t1"]["web_search"].consecutive_problems == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 2: consecutive no_results → hint injected, phase=warned
|
|
|
|
|
|
def test_repeated_no_results_reaches_warned():
|
|
mw = _make_mw(stagnation_threshold=2)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
def handler(_r):
|
|
return error_msg
|
|
|
|
# stagnation_threshold=2, so the second problem call tips into warned
|
|
mw.wrap_tool_call(req, handler)
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "warned"
|
|
assert state.consecutive_problems == 2
|
|
|
|
# Hint should be queued
|
|
hints = mw._drain_pending(rt)
|
|
assert len(hints) == 1
|
|
assert "PROGRESS HINT" in hints[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 3: Non-recoverable errors escalate warned → blocked
|
|
|
|
|
|
def test_warned_to_blocked_after_escalation():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=2)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
# Non-recoverable error (rate_limited): model cannot fix this by retrying,
|
|
# so stagnation should escalate to BLOCKED.
|
|
error_msg = _make_non_recoverable_error_message()
|
|
|
|
def handler(_r):
|
|
return error_msg
|
|
|
|
for _ in range(4):
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "blocked"
|
|
assert state.block_reason is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 4: Blocked tool is front-gate intercepted (handler NOT called)
|
|
|
|
|
|
def test_blocked_tool_is_intercepted_without_calling_handler():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
# Non-recoverable error: stagnation escalates to BLOCKED, handler is never called.
|
|
error_msg = _make_non_recoverable_error_message()
|
|
call_count = [0]
|
|
|
|
def handler(r):
|
|
call_count[0] += 1
|
|
return error_msg
|
|
|
|
# 2 calls → warned + 1 more = blocked
|
|
for _ in range(3):
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
assert mw._phase_states["t1"]["web_search"].phase == "blocked"
|
|
call_count_before = call_count[0]
|
|
|
|
# Next call should be intercepted
|
|
result = mw.wrap_tool_call(req, handler)
|
|
|
|
assert call_count[0] == call_count_before
|
|
assert isinstance(result, ToolMessage)
|
|
assert "[TOOL_BLOCKED]" in result.content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 4b: Recoverable errors never escalate to BLOCKED — WARNED is terminal
|
|
|
|
|
|
def test_recoverable_errors_stay_warned_indefinitely():
|
|
# stagnation_threshold=2, warn_escalation_count=1 → would block at call 3 for
|
|
# non-recoverable errors, but recoverable errors must stay in WARNED forever.
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message() # recoverable_by_model=True
|
|
|
|
def handler(_r):
|
|
return error_msg
|
|
|
|
# 10 calls — well past the threshold+escalation
|
|
for _ in range(10):
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "warned", "recoverable errors must never escalate to BLOCKED"
|
|
assert state.consecutive_problems == 10
|
|
|
|
|
|
def test_recoverable_error_re_injects_hint_past_escalation():
|
|
# After crossing threshold+escalation for a recoverable error, each additional
|
|
# problem call should still queue a hint.
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1, inject_assessment=True)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
def handler(_r):
|
|
return error_msg
|
|
|
|
# Reach warned (call 2) and past escalation (call 3+)
|
|
for _ in range(4):
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
# All hints from call 2 onward should have been queued (capped at _MAX_PENDING_PER_RUN=3).
|
|
# >= 2 proves that at least one hint was queued *inside* the escalation zone (calls 3+),
|
|
# not just the initial WARNED hint at call 2.
|
|
hints = mw._drain_pending(rt)
|
|
assert len(hints) >= 2
|
|
assert all("PROGRESS HINT" in h for h in hints)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 5: Auth error → immediately blocked (no warned phase)
|
|
|
|
|
|
def test_auth_error_immediately_blocked():
|
|
mw = _make_mw(stagnation_threshold=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
auth_msg = _make_error_message(
|
|
error_type="auth",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="stop",
|
|
)
|
|
|
|
def handler(_r):
|
|
return auth_msg
|
|
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "blocked"
|
|
assert "auth" in state.block_reason.lower() or "Authentication" in state.block_reason
|
|
# consecutive_problems must be 1 (not 0) even on immediate-block paths so diagnostic
|
|
# logs and future consumers see a consistent non-zero count after a failed call.
|
|
assert state.consecutive_problems == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 6: Valid result after problems resets to active
|
|
|
|
|
|
def test_valid_result_after_problems_resets_to_active():
|
|
mw = _make_mw(stagnation_threshold=3, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
good_msg = _make_tool_message("A" * 300)
|
|
|
|
def handler_error(_r):
|
|
return error_msg
|
|
|
|
def handler_good(_r):
|
|
return good_msg
|
|
|
|
mw.wrap_tool_call(req, handler_error)
|
|
mw.wrap_tool_call(req, handler_error)
|
|
mw.wrap_tool_call(req, handler_error)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "warned"
|
|
|
|
# Good result resets
|
|
mw.wrap_tool_call(req, handler_good)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "active"
|
|
assert state.consecutive_problems == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 7: Two different tools have independent states
|
|
|
|
|
|
def test_two_tools_have_independent_states():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req_search = _make_tool_request("web_search", runtime=rt)
|
|
req_read = _make_tool_request("read_file", runtime=rt)
|
|
|
|
# Non-recoverable errors so web_search escalates to BLOCKED.
|
|
error_search = _make_non_recoverable_error_message(tool_name="web_search")
|
|
error_read = _make_error_message(tool_name="read_file")
|
|
|
|
# Drive web_search to BLOCKED (2 → warned, 1 more → blocked)
|
|
for _ in range(3):
|
|
mw.wrap_tool_call(req_search, lambda r: error_search)
|
|
|
|
assert mw._phase_states["t1"]["web_search"].phase == "blocked"
|
|
|
|
# read_file should still be active — independent state per tool name
|
|
mw.wrap_tool_call(req_read, lambda r: error_read)
|
|
assert mw._phase_states["t1"]["read_file"].phase == "active"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 8: Jaccard near-duplicate result counts as problem
|
|
|
|
|
|
def test_jaccard_near_duplicate_counts_as_problem():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5, jaccard_threshold=0.8, min_words=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
|
|
# First call: good unique content (establishes baseline)
|
|
words = "apple banana cherry delta echo foxtrot golf hotel india juliet"
|
|
msg1 = _make_tool_message(words)
|
|
mw.wrap_tool_call(req, lambda r: msg1)
|
|
|
|
# Second call: exact same content (Jaccard = 1.0) → near-duplicate → problem count goes up
|
|
msg2 = _make_tool_message(words)
|
|
mw.wrap_tool_call(req, lambda r: msg2)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.consecutive_problems >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 9: Different Jaccard content does NOT count as problem
|
|
|
|
|
|
def test_jaccard_different_content_not_a_problem():
|
|
mw = _make_mw(stagnation_threshold=3, warn_escalation_count=5, jaccard_threshold=0.8, min_words=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
|
|
words1 = "apple banana cherry delta echo foxtrot golf hotel india juliet"
|
|
words2 = "xray yankee zulu alpha bravo charlie sierra tango uniform victor"
|
|
msg1 = _make_tool_message(words1)
|
|
msg2 = _make_tool_message(words2)
|
|
|
|
mw.wrap_tool_call(req, lambda r: msg1)
|
|
mw.wrap_tool_call(req, lambda r: msg2)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.consecutive_problems == 0
|
|
assert state.phase == "active"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 9b: production default min_words=10 skips Jaccard for short content
|
|
|
|
|
|
def test_jaccard_skipped_when_content_below_production_min_words():
|
|
"""Production default min_words=10 must skip Jaccard for content with 6-9 unique words.
|
|
|
|
_make_mw() uses min_words=5 to make most tests easier to set up. This test
|
|
uses the production default (min_words=10) to verify that short but repeated
|
|
content does NOT count as a near-duplicate stagnation problem.
|
|
"""
|
|
mw = ToolProgressMiddleware(
|
|
stagnation_threshold=3,
|
|
warn_escalation_count=2,
|
|
jaccard_threshold=0.8,
|
|
min_words=10, # production default
|
|
)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
|
|
# 7 unique words — above min_words=5 but below production min_words=10.
|
|
# With min_words=10 the Jaccard check is skipped → never a problem → phase stays active.
|
|
words = "apple banana cherry delta echo foxtrot golf"
|
|
msg = _make_tool_message(words)
|
|
|
|
for _ in range(5):
|
|
mw.wrap_tool_call(req, lambda r: msg)
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "active", "7-word repeated content must not trigger stagnation with production min_words=10"
|
|
assert state.consecutive_problems == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 10: exempt_tools are not tracked
|
|
|
|
|
|
def test_exempt_tools_not_tracked():
|
|
mw = _make_mw(stagnation_threshold=1, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request("ask_clarification", runtime=rt)
|
|
error_msg = _make_error_message(tool_name="ask_clarification")
|
|
|
|
def handler(_r):
|
|
return error_msg
|
|
|
|
for _ in range(5):
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
assert "ask_clarification" not in mw._phase_states.get("t1", {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 11: before_agent clears stale pending hints from previous runs
|
|
|
|
|
|
def test_before_agent_clears_stale_pending():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt_run1 = _make_runtime(thread_id="t1", run_id="old-run")
|
|
rt_run2 = _make_runtime(thread_id="t1", run_id="new-run")
|
|
req = _make_tool_request(runtime=rt_run1)
|
|
error_msg = _make_error_message()
|
|
|
|
# Produce a hint for old-run
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
|
|
mw._drain_pending(rt_run1)
|
|
# Re-queue manually to simulate pending state
|
|
mw._queue_assessment(rt_run1, "old hint")
|
|
|
|
# before_agent with new-run should clear the old-run's pending hints
|
|
state_mock = MagicMock()
|
|
mw.before_agent(state_mock, rt_run2)
|
|
|
|
# Old pending should be gone
|
|
leftovers = mw._pending.get(("t1", "old-run"), [])
|
|
assert leftovers == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_abefore_agent_clears_stale_pending():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt_run1 = _make_runtime(thread_id="t1", run_id="old-run")
|
|
rt_run2 = _make_runtime(thread_id="t1", run_id="new-run")
|
|
req = _make_tool_request(runtime=rt_run1)
|
|
error_msg = _make_error_message()
|
|
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw._drain_pending(rt_run1)
|
|
mw._queue_assessment(rt_run1, "old hint")
|
|
|
|
state_mock = MagicMock()
|
|
await mw.abefore_agent(state_mock, rt_run2)
|
|
|
|
leftovers = mw._pending.get(("t1", "old-run"), [])
|
|
assert leftovers == []
|
|
|
|
|
|
def test_before_agent_preserves_current_run_hints():
|
|
# _clear_stale_pending deletes keys where thread_id matches but run_id differs.
|
|
# Hints for the *current* run must not be evicted.
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime(thread_id="t1", run_id="current-run")
|
|
# _queue_assessment guards against phantom entries by checking _phase_states; seed the
|
|
# thread so the direct call below isn't silently dropped by the L1 guard.
|
|
mw._phase_states["t1"] = {}
|
|
mw._queue_assessment(rt, "current hint")
|
|
|
|
state_mock = MagicMock()
|
|
mw.before_agent(state_mock, rt)
|
|
|
|
preserved = mw._pending.get(("t1", "current-run"), [])
|
|
assert preserved == ["current hint"]
|
|
|
|
|
|
def test_before_agent_resets_blocked_states_for_new_run():
|
|
"""BLOCKED and WARNED tool states must both be cleared at the start of a new run.
|
|
|
|
A tool BLOCKED in run R1 must not silently remain blocked in R2.
|
|
A tool WARNED in R1 must not carry its consecutive_problems count into R2
|
|
(the model has not seen the warning context, so it would be hard-blocked
|
|
without ever receiving a hint in the current session).
|
|
recent_word_sets must also be cleared so stale Jaccard windows don't cause
|
|
false near-duplicate detections on the first success call of the new run.
|
|
"""
|
|
mw = _make_mw(stagnation_threshold=1, warn_escalation_count=1)
|
|
rt_run1 = _make_runtime(thread_id="t1", run_id="run-1")
|
|
rt_run2 = _make_runtime(thread_id="t1", run_id="run-2")
|
|
req = _make_tool_request(runtime=rt_run1)
|
|
|
|
# Drive the tool to BLOCKED via auth error (immediate block, no WARN stage)
|
|
auth_msg = ToolMessage(
|
|
content="Error: invalid api key",
|
|
tool_call_id="tc-web_search",
|
|
name="web_search",
|
|
status="error",
|
|
additional_kwargs=_meta_kwargs(
|
|
status="error",
|
|
error_type="auth",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="stop",
|
|
),
|
|
)
|
|
mw.wrap_tool_call(req, lambda _r: auth_msg)
|
|
assert mw._phase_states["t1"]["web_search"].phase == "blocked"
|
|
|
|
# Simulate start of run 2
|
|
state_mock = MagicMock()
|
|
mw.before_agent(state_mock, rt_run2)
|
|
|
|
# _reset_run_states always replaces the entry in-place; it is never None.
|
|
tool_state = mw._phase_states.get("t1", {}).get("web_search")
|
|
assert tool_state is not None
|
|
assert tool_state.phase == "active"
|
|
assert tool_state.consecutive_problems == 0
|
|
assert tool_state.block_reason is None
|
|
assert tool_state.recent_word_sets == ()
|
|
|
|
|
|
def test_before_agent_resets_warned_states_for_new_run():
|
|
"""WARNED tool state must also be cleared by before_agent.
|
|
|
|
A tool with phase='warned' and accumulated consecutive_problems at end of run R1
|
|
must not carry that count into R2; the model has no warning context and would
|
|
be hard-blocked after just a few calls without receiving a hint.
|
|
"""
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt_run1 = _make_runtime(thread_id="t1", run_id="run-1")
|
|
rt_run2 = _make_runtime(thread_id="t1", run_id="run-2")
|
|
req = _make_tool_request(runtime=rt_run1)
|
|
error_msg = _make_error_message()
|
|
|
|
# Drive to WARNED (stagnation_threshold=2 means 2 problems → warned)
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
assert mw._phase_states["t1"]["web_search"].phase == "warned"
|
|
assert mw._phase_states["t1"]["web_search"].consecutive_problems == 2
|
|
|
|
state_mock = MagicMock()
|
|
mw.before_agent(state_mock, rt_run2)
|
|
|
|
tool_state = mw._phase_states.get("t1", {}).get("web_search")
|
|
assert tool_state is not None
|
|
assert tool_state.phase == "active"
|
|
assert tool_state.consecutive_problems == 0
|
|
assert tool_state.recent_word_sets == ()
|
|
|
|
|
|
def test_before_agent_resets_active_state_consecutive_problems_and_word_sets():
|
|
"""ACTIVE tools with sub-threshold problems must also be cleaned at run boundaries.
|
|
|
|
An ACTIVE tool (phase never left 'active') can exit a run with non-zero
|
|
consecutive_problems and non-empty recent_word_sets. If _reset_run_states only
|
|
touched BLOCKED/WARNED tools, the counter from R1 would bleed into R2: a single
|
|
problem on R2's first call could then trip WARNED against stale R1 context that
|
|
the model has never seen.
|
|
"""
|
|
# stagnation_threshold=3 so two errors keep the tool ACTIVE.
|
|
mw = _make_mw(stagnation_threshold=3, warn_escalation_count=5)
|
|
rt_run1 = _make_runtime(thread_id="t1", run_id="run-1")
|
|
rt_run2 = _make_runtime(thread_id="t1", run_id="run-2")
|
|
req = _make_tool_request(runtime=rt_run1)
|
|
|
|
# Two successes → recent_word_sets grows.
|
|
success_a = _make_tool_message("alpha beta gamma delta epsilon zeta eta theta iota kappa")
|
|
success_b = _make_tool_message("lambda mu nu xi omicron pi rho sigma tau upsilon phi chi")
|
|
mw.wrap_tool_call(req, lambda _r: success_a)
|
|
mw.wrap_tool_call(req, lambda _r: success_b)
|
|
|
|
# One recoverable error → consecutive_problems=1, phase stays ACTIVE.
|
|
error_msg = _make_error_message()
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
state_r1 = mw._phase_states.get("t1", {}).get("web_search")
|
|
assert state_r1 is not None
|
|
assert state_r1.phase == "active"
|
|
assert state_r1.consecutive_problems == 1
|
|
assert len(state_r1.recent_word_sets) > 0
|
|
|
|
# Start of run 2: all per-run state must be cleared.
|
|
state_mock = MagicMock()
|
|
mw.before_agent(state_mock, rt_run2)
|
|
|
|
state_r2 = mw._phase_states.get("t1", {}).get("web_search")
|
|
assert state_r2 is not None
|
|
assert state_r2.phase == "active"
|
|
assert state_r2.consecutive_problems == 0
|
|
assert state_r2.recent_word_sets == ()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario 12: LRU eviction when max_tracked_threads exceeded
|
|
|
|
|
|
def test_get_block_reason_does_not_create_phantom_entries():
|
|
# _get_block_reason is called on every wrap_tool_call before the handler.
|
|
# It must not insert an empty entry for new threads (which could prematurely
|
|
# evict another thread's WARNED state via LRU).
|
|
mw = _make_mw(max_tracked_threads=2, stagnation_threshold=2)
|
|
rt_a = _make_runtime(thread_id="thread-a")
|
|
rt_b = _make_runtime(thread_id="thread-b")
|
|
rt_c = _make_runtime(thread_id="thread-c")
|
|
|
|
req_a = _make_tool_request(runtime=rt_a)
|
|
error_msg = _make_error_message()
|
|
|
|
# Drive thread-a to WARNED state (needs 2 error calls with threshold=2).
|
|
mw.wrap_tool_call(req_a, lambda r: error_msg)
|
|
mw.wrap_tool_call(req_a, lambda r: error_msg)
|
|
assert mw._phase_states["thread-a"]["web_search"].phase == "warned"
|
|
|
|
# Drive thread-b so it has a real entry too.
|
|
req_b = _make_tool_request(runtime=rt_b)
|
|
good_msg = _make_tool_message("A" * 300)
|
|
mw.wrap_tool_call(req_b, lambda r: good_msg)
|
|
assert "thread-b" in mw._phase_states
|
|
|
|
# Now thread-c makes its very first call. max_tracked_threads=2, so adding
|
|
# thread-c must evict one of {thread-a, thread-b} — but the eviction must
|
|
# only happen in _update_state_from_result (the write path), not in
|
|
# _get_block_reason (the read path that runs first).
|
|
# After wrap_tool_call completes, the two survivors should be thread-b and
|
|
# thread-c (thread-a is oldest because thread-b was accessed most recently).
|
|
req_c = _make_tool_request(tool_name="read_file", runtime=rt_c)
|
|
mw.wrap_tool_call(req_c, lambda r: good_msg)
|
|
|
|
# thread-c must now have a real entry (not an empty phantom).
|
|
assert "thread-c" in mw._phase_states
|
|
assert mw._phase_states["thread-c"].get("read_file") is not None
|
|
|
|
# No more than max_tracked_threads entries should exist.
|
|
assert len(mw._phase_states) <= 2
|
|
|
|
|
|
def test_lru_eviction_of_oldest_thread():
|
|
mw = _make_mw(max_tracked_threads=2)
|
|
error_msg = _make_error_message()
|
|
|
|
for i in range(3):
|
|
rt = _make_runtime(thread_id=f"thread-{i}")
|
|
req = _make_tool_request(runtime=rt)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
|
|
assert len(mw._phase_states) == 2
|
|
# thread-0 should have been evicted (oldest); thread-1 and thread-2 remain
|
|
assert "thread-0" not in mw._phase_states
|
|
assert "thread-1" in mw._phase_states
|
|
assert "thread-2" in mw._phase_states
|
|
|
|
|
|
def test_pending_evicted_with_phase_states_on_lru_overflow():
|
|
"""M1 regression: _pending keys for evicted threads must be cleaned up.
|
|
|
|
When _phase_states evicts a thread via LRU, any pending hint entries
|
|
for that thread must also be removed so _pending cannot grow unboundedly.
|
|
"""
|
|
mw = _make_mw(max_tracked_threads=2, stagnation_threshold=2)
|
|
error_msg = _make_error_message()
|
|
|
|
# Thread-0: produce a hint (reach WARNED) so it has a pending entry.
|
|
rt0 = _make_runtime(thread_id="thread-0", run_id="run-0")
|
|
req0 = _make_tool_request(runtime=rt0)
|
|
mw.wrap_tool_call(req0, lambda r: error_msg)
|
|
mw.wrap_tool_call(req0, lambda r: error_msg)
|
|
# Verify thread-0 has a pending hint.
|
|
assert len(mw._pending.get(("thread-0", "run-0"), [])) >= 1
|
|
|
|
# Thread-1: occupy the second slot.
|
|
rt1 = _make_runtime(thread_id="thread-1", run_id="run-1")
|
|
req1 = _make_tool_request(runtime=rt1)
|
|
good_msg = _make_tool_message("A" * 300)
|
|
mw.wrap_tool_call(req1, lambda r: good_msg)
|
|
|
|
# Thread-2: adding this forces LRU eviction of thread-0.
|
|
rt2 = _make_runtime(thread_id="thread-2", run_id="run-2")
|
|
req2 = _make_tool_request(runtime=rt2)
|
|
mw.wrap_tool_call(req2, lambda r: good_msg)
|
|
|
|
# thread-0 must be evicted from phase_states.
|
|
assert "thread-0" not in mw._phase_states
|
|
|
|
# The pending entry for thread-0 must also be gone (no memory leak).
|
|
assert ("thread-0", "run-0") not in mw._pending, "_pending entry for evicted thread-0 should have been cleaned up"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hint injection via wrap_model_call
|
|
|
|
|
|
def test_hint_injected_into_model_call():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
# Trigger hint
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
|
|
model_req = _make_model_request([], rt)
|
|
captured_messages = []
|
|
|
|
def model_handler(r):
|
|
captured_messages.extend(r.messages)
|
|
return MagicMock()
|
|
|
|
mw.wrap_model_call(model_req, model_handler)
|
|
|
|
assert any(isinstance(m, HumanMessage) for m in captured_messages)
|
|
hint_msgs = [m for m in captured_messages if isinstance(m, HumanMessage)]
|
|
assert any("PROGRESS HINT" in m.content for m in hint_msgs)
|
|
|
|
|
|
def test_partial_success_hint_is_specific_not_generic():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
partial_msg = ToolMessage(
|
|
content="Here are some partial results from the search.",
|
|
tool_call_id="tc-web_search",
|
|
name="web_search",
|
|
status="success",
|
|
additional_kwargs=_meta_kwargs(
|
|
status="partial_success",
|
|
recommended_next_action="rewrite_query",
|
|
),
|
|
)
|
|
|
|
def handler(_r):
|
|
return partial_msg
|
|
|
|
mw.wrap_tool_call(req, handler)
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
hints = mw._drain_pending(rt)
|
|
assert len(hints) == 1
|
|
assert "incomplete results" in hints[0].lower()
|
|
assert "not producing new information" not in hints[0]
|
|
|
|
|
|
def test_jaccard_near_dup_hint_is_specific_and_actionable():
|
|
"""Near-duplicate success hint must be specific (not generic fallback) and include action guidance.
|
|
|
|
Before the fix, status='success'/error_type=None fell through to the generic fallback
|
|
'[PROGRESS HINT] The tool is not producing new information.' with no action suffix
|
|
(recommended_next_action='continue' was absent from action_map). The fix adds a
|
|
'success' key to the base dict and a 'continue' key to the action_map.
|
|
"""
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5, jaccard_threshold=0.8, min_words=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
|
|
# First call: good unique content to seed recent_word_sets.
|
|
words = "apple banana cherry delta echo foxtrot golf hotel india juliet"
|
|
good_msg = _make_tool_message(words)
|
|
mw.wrap_tool_call(req, lambda r: good_msg)
|
|
|
|
# Second and third calls: exact same content → near-duplicate → stagnation_threshold=2 → WARNED.
|
|
dup_msg = _make_tool_message(words)
|
|
|
|
def handler(_r):
|
|
return dup_msg
|
|
|
|
mw.wrap_tool_call(req, handler)
|
|
mw.wrap_tool_call(req, handler)
|
|
|
|
hints = mw._drain_pending(rt)
|
|
assert len(hints) == 1
|
|
hint = hints[0]
|
|
# Must contain a specific near-dup message, not the generic fallback.
|
|
assert "duplicate" in hint.lower(), f"expected 'duplicate' in hint, got: {hint!r}"
|
|
# Must include an actionable suggestion (from action_map["continue"]).
|
|
assert "rephras" in hint.lower() or "different" in hint.lower(), f"expected action guidance in hint, got: {hint!r}"
|
|
|
|
|
|
def test_no_hint_when_inject_assessment_disabled():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5, inject_assessment=False)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
|
|
hints = mw._drain_pending(rt)
|
|
assert hints == []
|
|
|
|
|
|
def test_augment_request_deduplicates_identical_hints():
|
|
"""L2: _augment_request must deduplicate identical hint strings via dict.fromkeys.
|
|
|
|
If the same hint text appears multiple times in the queue (e.g. two successive
|
|
no_results errors produce identical hint strings), only one copy should be
|
|
injected into the model message.
|
|
"""
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5, inject_assessment=True)
|
|
rt = _make_runtime()
|
|
|
|
# _queue_assessment guards against phantom entries; seed the thread so the direct
|
|
# calls below aren't dropped by the L1 guard.
|
|
mw._phase_states["t1"] = {}
|
|
# Manually queue two identical hints to simulate duplicates.
|
|
mw._queue_assessment(rt, "[PROGRESS HINT] same hint text")
|
|
mw._queue_assessment(rt, "[PROGRESS HINT] same hint text")
|
|
|
|
model_req = _make_model_request([], rt)
|
|
captured: list = []
|
|
|
|
def model_handler(r):
|
|
captured.extend(r.messages)
|
|
return MagicMock()
|
|
|
|
mw.wrap_model_call(model_req, model_handler)
|
|
|
|
hint_msgs = [m for m in captured if isinstance(m, HumanMessage)]
|
|
assert len(hint_msgs) == 1
|
|
# The single injected message must contain the hint exactly once.
|
|
assert hint_msgs[0].content.count("[PROGRESS HINT] same hint text") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# L1: _assess_and_transition called with already-blocked state is idempotent
|
|
|
|
|
|
def test_assess_and_transition_blocked_state_immediate_stop_is_idempotent():
|
|
"""L1: _assess_and_transition must handle an already-blocked state without error.
|
|
|
|
The docstring states the immediate-block branch re-applies idempotently.
|
|
This test verifies that re-entering with a blocked state + stop-action meta
|
|
stays blocked and does not corrupt the block_reason.
|
|
"""
|
|
from deerflow.agents.middlewares.tool_progress_middleware import ToolPhaseState
|
|
|
|
mw = _make_mw()
|
|
blocked_state = ToolPhaseState(
|
|
phase="blocked",
|
|
consecutive_problems=5,
|
|
block_reason="Authentication failure — this tool cannot be used.",
|
|
)
|
|
auth_meta_kwargs = _meta_kwargs(
|
|
status="error",
|
|
error_type="auth",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="stop",
|
|
)[TOOL_META_KEY]
|
|
from deerflow.agents.middlewares.tool_result_meta import ToolResultMeta
|
|
|
|
auth_meta = ToolResultMeta(**auth_meta_kwargs)
|
|
|
|
new_state, hint = mw._assess_and_transition(blocked_state, auth_meta, "")
|
|
|
|
assert new_state.phase == "blocked"
|
|
assert new_state.block_reason is not None
|
|
assert hint is None # no hint on immediate block path
|
|
|
|
|
|
def test_assess_and_transition_blocked_state_non_stop_increments_count():
|
|
"""L1: A blocked state receiving a non-stop problem increments counter, stays blocked.
|
|
|
|
Simulates a concurrent race where two threads both process results for the
|
|
same tool: the second thread's _assess_and_transition receives a stale
|
|
'blocked' snapshot. The result must remain blocked.
|
|
"""
|
|
from deerflow.agents.middlewares.tool_progress_middleware import ToolPhaseState
|
|
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
blocked_state = ToolPhaseState(
|
|
phase="blocked",
|
|
consecutive_problems=3,
|
|
block_reason="Repeated rate-limiting — summarize current findings and proceed.",
|
|
)
|
|
rate_meta_kwargs = _meta_kwargs(
|
|
status="error",
|
|
error_type="rate_limited",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="summarize",
|
|
)[TOOL_META_KEY]
|
|
from deerflow.agents.middlewares.tool_result_meta import ToolResultMeta
|
|
|
|
rate_meta = ToolResultMeta(**rate_meta_kwargs)
|
|
|
|
new_state, _hint = mw._assess_and_transition(blocked_state, rate_meta, "")
|
|
|
|
# Must stay blocked (not regress to warned or active).
|
|
assert new_state.phase == "blocked"
|
|
# Counter must NOT be incremented: blocked is terminal, state returned unchanged.
|
|
assert new_state.consecutive_problems == 3
|
|
|
|
|
|
def test_assess_and_transition_blocked_recoverable_does_not_regress_to_warned():
|
|
"""L1: A blocked state with recoverable errors must not silently regress to warned.
|
|
|
|
Before the fix, _assess_and_transition had no guard for already-blocked states.
|
|
A recoverable error arriving on a blocked state (concurrent race) would take
|
|
the `warned` branch because recoverable_by_model=True, demoting the phase from
|
|
blocked back to warned. This test locks the fixed behavior.
|
|
"""
|
|
from deerflow.agents.middlewares.tool_progress_middleware import ToolPhaseState
|
|
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
blocked_state = ToolPhaseState(
|
|
phase="blocked",
|
|
consecutive_problems=5,
|
|
block_reason="Repeated no-results — rewrite your query or try a different tool.",
|
|
)
|
|
# Recoverable no_results error (would normally only WARN, never block on its own)
|
|
no_results_meta_kwargs = _meta_kwargs(
|
|
status="error",
|
|
error_type="no_results",
|
|
recoverable_by_model=True,
|
|
recommended_next_action="rewrite_query",
|
|
)[TOOL_META_KEY]
|
|
from deerflow.agents.middlewares.tool_result_meta import ToolResultMeta
|
|
|
|
no_results_meta = ToolResultMeta(**no_results_meta_kwargs)
|
|
|
|
new_state, hint = mw._assess_and_transition(blocked_state, no_results_meta, "")
|
|
|
|
assert new_state.phase == "blocked", "blocked must not regress to warned even when the new error is recoverable"
|
|
assert hint is None
|
|
assert new_state is blocked_state # exact same object returned (no copy)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool without runtime attribute is passed through
|
|
|
|
|
|
def test_no_runtime_passthrough():
|
|
mw = _make_mw()
|
|
req = SimpleNamespace(tool_call={"name": "web_search", "id": "tc-1"})
|
|
# No runtime attribute
|
|
msg = _make_tool_message()
|
|
|
|
def handler(_r):
|
|
return msg
|
|
|
|
result = mw.wrap_tool_call(req, handler)
|
|
assert result is msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command results are passed through unchanged
|
|
|
|
|
|
def test_command_result_passthrough():
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
cmd = Command(goto="some_node")
|
|
|
|
def handler(_r):
|
|
return cmd
|
|
|
|
result = mw.wrap_tool_call(req, handler)
|
|
assert result is cmd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_config round-trip
|
|
|
|
|
|
def test_from_config():
|
|
from deerflow.config.tool_progress_config import ToolProgressConfig
|
|
|
|
cfg = ToolProgressConfig(
|
|
enabled=True,
|
|
stagnation_threshold=4,
|
|
warn_escalation_count=3,
|
|
jaccard_similarity_threshold=0.7,
|
|
min_word_count_for_similarity=8,
|
|
)
|
|
mw = ToolProgressMiddleware.from_config(cfg)
|
|
assert mw._stagnation_threshold == 4
|
|
assert mw._warn_escalation == 3
|
|
assert mw._jaccard_threshold == pytest.approx(0.7)
|
|
assert mw._min_words == 8
|
|
|
|
|
|
def test_from_config_empty_exempt_tools_clears_exemptions():
|
|
"""Empty exempt_tools in config must produce an empty set, not the default fallback.
|
|
|
|
H1 regression: `exempt_tools or {default}` would silently ignore an empty set
|
|
because set() is falsy in Python. The fix uses `is not None` so an explicit
|
|
empty set from config actually disables all exemptions.
|
|
"""
|
|
from deerflow.config.tool_progress_config import ToolProgressConfig
|
|
|
|
cfg = ToolProgressConfig(enabled=True, exempt_tools=set())
|
|
mw = ToolProgressMiddleware.from_config(cfg)
|
|
assert mw._exempt_tools == set(), "empty exempt_tools in config must clear all exemptions, not fall back to defaults"
|
|
|
|
|
|
def test_exempt_tools_none_uses_defaults():
|
|
"""None exempt_tools in __init__ must use the built-in default set."""
|
|
mw = ToolProgressMiddleware(exempt_tools=None)
|
|
assert "ask_clarification" in mw._exempt_tools
|
|
assert "write_todos" in mw._exempt_tools
|
|
assert "present_files" in mw._exempt_tools
|
|
|
|
|
|
def test_from_config_default_exempt_tools_round_trip():
|
|
"""Default exempt_tools from config must match the __init__ default."""
|
|
from deerflow.config.tool_progress_config import ToolProgressConfig
|
|
|
|
cfg = ToolProgressConfig(enabled=True)
|
|
mw = ToolProgressMiddleware.from_config(cfg)
|
|
assert mw._exempt_tools == {"ask_clarification", "write_todos", "present_files", "task"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defensive meta parsing: malformed dicts must not crash the middleware
|
|
|
|
|
|
def test_wrap_tool_call_malformed_meta_passthrough():
|
|
"""Malformed deerflow_tool_meta dict must not crash the middleware."""
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
bad_msg = ToolMessage(
|
|
content="some content",
|
|
tool_call_id="tc-web_search",
|
|
name="web_search",
|
|
status="success",
|
|
additional_kwargs={TOOL_META_KEY: {"unexpected_field": True}},
|
|
)
|
|
|
|
def handler(_r):
|
|
return bad_msg
|
|
|
|
result = mw.wrap_tool_call(req, handler)
|
|
|
|
assert result is bad_msg
|
|
assert mw._phase_states.get("t1", {}).get("web_search") is None
|
|
|
|
|
|
def test_missing_meta_on_non_exempt_tool_emits_warning(caplog):
|
|
"""When deerflow_tool_meta is completely absent for a non-exempt tool,
|
|
the middleware must emit a warning pointing to the likely ordering misconfiguration.
|
|
"""
|
|
import logging
|
|
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
no_meta_msg = ToolMessage(
|
|
content="some content",
|
|
tool_call_id="tc-web_search",
|
|
name="web_search",
|
|
status="success",
|
|
additional_kwargs={}, # no TOOL_META_KEY at all
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.agents.middlewares.tool_progress_middleware"):
|
|
mw.wrap_tool_call(req, lambda _r: no_meta_msg)
|
|
|
|
assert any("deerflow_tool_meta missing" in r.message for r in caplog.records), "Expected a warning about missing meta for non-exempt tool"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async path: awrap_tool_call mirrors sync path
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_normal_passthrough():
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
msg = _make_tool_message("A" * 300)
|
|
|
|
result = await mw.awrap_tool_call(req, AsyncMock(return_value=msg))
|
|
|
|
assert result is msg
|
|
assert mw._phase_states["t1"]["web_search"].phase == "active"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_blocked_intercepted_without_calling_handler():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
# Non-recoverable error: stagnation escalates to BLOCKED.
|
|
error_msg = _make_non_recoverable_error_message()
|
|
call_count = [0]
|
|
|
|
async def handler(r):
|
|
call_count[0] += 1
|
|
return error_msg
|
|
|
|
# 3 calls: 2 → warned, 1 more → blocked
|
|
for _ in range(3):
|
|
await mw.awrap_tool_call(req, handler)
|
|
|
|
assert mw._phase_states["t1"]["web_search"].phase == "blocked"
|
|
before = call_count[0]
|
|
|
|
result = await mw.awrap_tool_call(req, handler)
|
|
|
|
assert call_count[0] == before
|
|
assert isinstance(result, ToolMessage)
|
|
assert "[TOOL_BLOCKED]" in result.content
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_auth_error_immediately_blocked():
|
|
mw = _make_mw(stagnation_threshold=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
auth_msg = _make_error_message(
|
|
error_type="auth",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="stop",
|
|
)
|
|
|
|
await mw.awrap_tool_call(req, AsyncMock(return_value=auth_msg))
|
|
|
|
state = mw._phase_states["t1"]["web_search"]
|
|
assert state.phase == "blocked"
|
|
assert state.block_reason is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_no_runtime_passthrough():
|
|
mw = _make_mw()
|
|
req = SimpleNamespace(tool_call={"name": "web_search", "id": "tc-1"})
|
|
msg = _make_tool_message()
|
|
|
|
result = await mw.awrap_tool_call(req, AsyncMock(return_value=msg))
|
|
|
|
assert result is msg
|
|
assert "t1" not in mw._phase_states
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_command_result_passthrough():
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
cmd = Command(goto="some_node")
|
|
|
|
result = await mw.awrap_tool_call(req, AsyncMock(return_value=cmd))
|
|
|
|
assert result is cmd
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_tool_call_malformed_meta_passthrough():
|
|
"""Malformed deerflow_tool_meta dict must not crash the middleware."""
|
|
mw = _make_mw()
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
bad_msg = ToolMessage(
|
|
content="some content",
|
|
tool_call_id="tc-web_search",
|
|
name="web_search",
|
|
status="success",
|
|
additional_kwargs={TOOL_META_KEY: {"unexpected_field": True}},
|
|
)
|
|
|
|
result = await mw.awrap_tool_call(req, AsyncMock(return_value=bad_msg))
|
|
|
|
assert result is bad_msg
|
|
# No state was tracked — malformed meta is silently skipped
|
|
assert mw._phase_states.get("t1", {}).get("web_search") is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_awrap_model_call_drains_and_injects_hints():
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
# Trigger hint via sync path (state machine is shared)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
mw.wrap_tool_call(req, lambda r: error_msg)
|
|
|
|
model_req = _make_model_request([], rt)
|
|
captured: list = []
|
|
|
|
async def model_handler(r):
|
|
captured.extend(r.messages)
|
|
return MagicMock()
|
|
|
|
await mw.awrap_model_call(model_req, model_handler)
|
|
|
|
hint_msgs = [m for m in captured if isinstance(m, HumanMessage)]
|
|
assert any("PROGRESS HINT" in m.content for m in hint_msgs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging behavior
|
|
|
|
_MW_LOGGER = "deerflow.agents.middlewares.tool_progress_middleware"
|
|
|
|
|
|
def test_log_active_to_warned_emits_info(caplog):
|
|
mw = _make_mw(stagnation_threshold=2)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
with caplog.at_level(logging.INFO, logger=_MW_LOGGER):
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
info_records = [r for r in caplog.records if r.levelname == "INFO" and "WARNED" in r.message]
|
|
assert len(info_records) == 1
|
|
assert "web_search" in info_records[0].message
|
|
|
|
|
|
def test_log_immediate_block_emits_warning(caplog):
|
|
mw = _make_mw(stagnation_threshold=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
auth_msg = _make_error_message(
|
|
error_type="auth",
|
|
recoverable_by_model=False,
|
|
recommended_next_action="stop",
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING, logger=_MW_LOGGER):
|
|
mw.wrap_tool_call(req, lambda _r: auth_msg)
|
|
|
|
warning_records = [r for r in caplog.records if r.levelname == "WARNING" and "BLOCKED" in r.message]
|
|
assert len(warning_records) == 1
|
|
assert "web_search" in warning_records[0].message
|
|
|
|
|
|
def test_log_escalation_block_emits_warning(caplog):
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=2)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_non_recoverable_error_message()
|
|
|
|
with caplog.at_level(logging.WARNING, logger=_MW_LOGGER):
|
|
for _ in range(4):
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
warning_records = [r for r in caplog.records if r.levelname == "WARNING" and "BLOCKED" in r.message]
|
|
assert len(warning_records) == 1
|
|
|
|
|
|
def test_log_blocked_call_intercepted_emits_info(caplog):
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=1)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_non_recoverable_error_message()
|
|
|
|
for _ in range(3):
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
with caplog.at_level(logging.INFO, logger=_MW_LOGGER):
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
intercepted = [r for r in caplog.records if "intercepted" in r.message and "web_search" in r.message]
|
|
assert len(intercepted) == 1
|
|
|
|
|
|
def test_log_warned_to_active_reset_emits_info(caplog):
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
good_msg = _make_tool_message("A" * 300)
|
|
|
|
# Drive to WARNED
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
with caplog.at_level(logging.INFO, logger=_MW_LOGGER):
|
|
mw.wrap_tool_call(req, lambda _r: good_msg)
|
|
|
|
reset_records = [r for r in caplog.records if r.levelname == "INFO" and "ACTIVE" in r.message]
|
|
assert len(reset_records) == 1
|
|
assert "web_search" in reset_records[0].message
|
|
|
|
|
|
def test_log_hint_injection_emits_debug(caplog):
|
|
mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5)
|
|
rt = _make_runtime()
|
|
req = _make_tool_request(runtime=rt)
|
|
error_msg = _make_error_message()
|
|
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
mw.wrap_tool_call(req, lambda _r: error_msg)
|
|
|
|
model_req = _make_model_request([], rt)
|
|
with caplog.at_level(logging.DEBUG, logger=_MW_LOGGER):
|
|
mw.wrap_model_call(model_req, lambda _r: MagicMock())
|
|
|
|
debug_records = [r for r in caplog.records if r.levelname == "DEBUG" and "injecting" in r.message]
|
|
assert len(debug_records) == 1
|
|
assert "injecting 1 hint" in debug_records[0].message
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coexistence: ToolProgressMiddleware + LoopDetectionMiddleware
|
|
|
|
|
|
def test_tool_progress_and_loop_detection_coexist_without_interfering():
|
|
"""ToolProgressMiddleware and LoopDetectionMiddleware operate on separate signals
|
|
and must not interfere when both are active simultaneously.
|
|
|
|
ToolProgressMiddleware (position 8): result-quality guard, fires after tool execution,
|
|
tracks per-(thread, tool) stagnation, BLOCKs specific tools.
|
|
LoopDetectionMiddleware (position 19): call-pattern guard, fires after model response,
|
|
tracks repeated tool_call signatures, hard-stops the whole turn.
|
|
|
|
Both can inject HumanMessage hints in the same model call; neither reads or writes
|
|
the other's internal state.
|
|
"""
|
|
from langchain_core.messages import AIMessage
|
|
|
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
|
|
|
tp_mw = _make_mw(stagnation_threshold=2, warn_escalation_count=5, inject_assessment=True)
|
|
ld_mw = LoopDetectionMiddleware(warn_threshold=3, hard_limit=10)
|
|
|
|
tp_rt = _make_runtime(thread_id="t1", run_id="r1")
|
|
# LoopDetection uses its own runtime/thread context
|
|
ld_rt = _make_runtime(thread_id="ld-thread", run_id="ld-run")
|
|
req = _make_tool_request(runtime=tp_rt)
|
|
|
|
# --- Drive ToolProgress to WARNED via repeated error results (result-quality signal) ---
|
|
error_msg = _make_error_message() # recoverable error, stagnation_threshold=2
|
|
tp_mw.wrap_tool_call(req, lambda _: error_msg)
|
|
tp_mw.wrap_tool_call(req, lambda _: error_msg)
|
|
|
|
assert tp_mw._phase_states["t1"]["web_search"].phase == "warned"
|
|
tp_hints = list(tp_mw._pending.get(("t1", "r1"), []))
|
|
assert len(tp_hints) == 1, "ToolProgress must queue exactly one hint at stagnation"
|
|
|
|
# --- Drive LoopDetection to WARNED via repeated AIMessage tool_calls (call-pattern signal) ---
|
|
repeated_call = [{"name": "web_search", "args": {"query": "q"}, "id": "tc-1"}]
|
|
ld_state = {"messages": [AIMessage(content="", tool_calls=repeated_call)]}
|
|
for _ in range(3): # warn_threshold=3
|
|
ld_mw._apply(ld_state, ld_rt)
|
|
|
|
ld_warnings_live = ld_mw._pending_warnings.get(("ld-thread", "ld-run"), [])
|
|
assert len(ld_warnings_live) >= 1, "LoopDetection must queue at least one warning"
|
|
# Snapshot a copy so the final cross-contamination check compares a frozen
|
|
# baseline to the live state — a same-object comparison would always be True.
|
|
ld_warnings_snapshot = list(ld_warnings_live)
|
|
|
|
# --- Verify no cross-contamination between the two middlewares ---
|
|
# ToolProgress internal state is not visible to LoopDetection
|
|
assert not hasattr(ld_mw, "_phase_states"), "LoopDetection must not have _phase_states"
|
|
# LoopDetection internal state is not visible to ToolProgress
|
|
assert not hasattr(tp_mw, "_history"), "ToolProgress must not have _history"
|
|
# LoopDetection does not track ToolProgress's thread id
|
|
assert "t1" not in ld_mw._history, "LoopDetection must not have entries for ToolProgress's thread"
|
|
# ToolProgress does not have loop detection warnings
|
|
assert not any("LOOP" in h for h in tp_hints), "ToolProgress hints must not contain loop-detection text"
|
|
|
|
# --- ToolProgress hint injection is independent of LoopDetection ---
|
|
model_req = _make_model_request([], tp_rt)
|
|
captured: list = []
|
|
|
|
def capture_handler(r):
|
|
captured.extend(r.messages)
|
|
return MagicMock()
|
|
|
|
tp_mw.wrap_model_call(model_req, capture_handler)
|
|
injected = [m for m in captured if isinstance(m, HumanMessage)]
|
|
assert len(injected) == 1, "ToolProgress must inject exactly one hint message"
|
|
assert "PROGRESS HINT" in injected[0].content
|
|
|
|
# After ToolProgress drains, its queue is empty; LoopDetection warnings unchanged.
|
|
# Compare live state against the snapshot taken before the model call — a same-object
|
|
# comparison would be trivially True and would not detect accidental modifications.
|
|
assert tp_mw._pending.get(("t1", "r1"), []) == []
|
|
assert ld_mw._pending_warnings.get(("ld-thread", "ld-run"), []) == ld_warnings_snapshot
|