He Wang fd41fdb065
feat(middleware): add structured tool result meta and tool-progress state machine (#3601)
* 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>
2026-07-06 20:57:41 +08:00

579 lines
26 KiB
Python

"""Middleware for task-level tool call progress tracking with a state machine.
Implements RFC #3177: structured tool result signals drive a per-(thread, tool)
state machine that detects stagnation and repetition, injects hints early
(WARNED), and hard-blocks the tool when it has stopped producing value (BLOCKED).
Architecture:
ToolProgressMiddleware (outer)
└── handler → ToolErrorHandlingMiddleware (inner) → actual tool
ToolProgressMiddleware reads deerflow_tool_meta from the normalized result
State machine transitions per (thread_id, tool_name):
ACTIVE → WARNED (at stagnation_threshold problems)
Any problem-free call resets consecutive_problems=0 and reverts to ACTIVE.
Whether WARNED can escalate to BLOCKED depends on recoverable_by_model:
- recoverable_by_model=True (no_results, not_found, permission, Jaccard-duplicate success):
WARNED is terminal. The model received a hint and is expected to change strategy;
blocking would prevent a legitimate retry with different parameters.
- recoverable_by_model=False, action≠stop (transient, rate_limited):
WARNED → BLOCKED after warn_escalation_count more problems. The model cannot fix
these by retrying the same tool, so hard-blocking conserves API calls.
- recoverable_by_model=False, action=stop (auth, config, internal):
Immediately BLOCKED on the first occurrence — no retry can help.
Division of labor with LoopDetectionMiddleware (middleware position 23):
ToolProgressMiddleware (position 10) is a result-quality guard — it fires
after a tool executes, inspects what came back, and blocks *specific tools*
that have stopped producing new information.
LoopDetectionMiddleware is a call-pattern guard — it fires after the model
responds (before tools execute), inspects the tool_calls signature in the
AIMessage, and forces the *whole turn* to stop when the model keeps issuing
the same calls regardless of results.
They are complementary, not competing:
- ToolProgressMiddleware is fine-grained (per-tool BLOCK, other tools normal).
- LoopDetectionMiddleware is coarse-grained (strips all tool_calls, ends turn).
- Both can inject HumanMessage hints in the same model call without conflict;
the model sees both sets of hints and can reason about them.
- If LoopDetectionMiddleware hard-stops (strips tool_calls), no wrap_tool_call
is issued so ToolProgressMiddleware never fires — there is no double-stop.
- If ToolProgressMiddleware BLOCKs a tool (returns an error ToolMessage),
the model still makes a tool call that LoopDetectionMiddleware tracks; both
continue to operate on their own independent state.
"""
from __future__ import annotations
import logging
import re
import threading
from collections import OrderedDict, defaultdict
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Literal, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import HumanMessage, ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY, ToolResultMeta
if TYPE_CHECKING:
from deerflow.config.tool_progress_config import ToolProgressConfig
logger = logging.getLogger(__name__)
_MAX_PENDING_PER_RUN = 3
# Jaccard word-set computation is capped to avoid O(n) regex work on very large tool results.
_MAX_CONTENT_FOR_WORDSET = 8192
# ---------------------------------------------------------------------------
# State data structures
@dataclass(slots=True)
class ToolPhaseState:
"""Per (thread_id, tool_name) tracking state."""
phase: Literal["active", "warned", "blocked"] = "active"
consecutive_problems: int = 0
block_reason: str | None = None
# Immutable tuple so that dataclasses.replace() calls that omit recent_word_sets
# (problem paths) cannot accidentally share a mutable list between the old and new
# state objects and cause silent cross-state corruption via .append().
recent_word_sets: tuple[frozenset[str], ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# Content helpers
def word_set(content: str) -> frozenset[str]:
"""Extract lowercase words of length >= 3 for Jaccard similarity.
Content is capped at _MAX_CONTENT_FOR_WORDSET chars to bound memory and CPU cost on
large tool results (e.g. web pages). Tail content beyond the cap is omitted from the
set, which is acceptable because duplicate-detection is a heuristic, not a guarantee.
"""
return frozenset(re.findall(r"\b\w{3,}\b", content[:_MAX_CONTENT_FOR_WORDSET].lower()))
def is_near_duplicate(
current: frozenset[str],
recent: Sequence[frozenset[str]],
threshold: float,
min_words: int,
) -> bool:
"""Return True if current is similar to any of the last 3 recent word sets."""
if len(current) < min_words:
return False
for prev in recent[-3:]:
if len(prev) < min_words:
continue
union = len(current | prev)
if union == 0:
continue
if len(current & prev) / union >= threshold:
return True
return False
def _message_content_str(msg: ToolMessage) -> str:
return msg.content if isinstance(msg.content, str) else ""
def _parse_tool_meta(meta_dict: object) -> ToolResultMeta | None:
"""Safely deserialize a ToolResultMeta from a raw dict; returns None on schema mismatch."""
if not isinstance(meta_dict, dict):
return None
try:
return ToolResultMeta(**meta_dict)
except TypeError:
logger.warning("Unexpected tool meta schema, skipping progress tracking: %s", meta_dict)
return None
# ---------------------------------------------------------------------------
# Hint / block reason formatting
def _format_hint(meta: ToolResultMeta) -> str:
action_map = {
"rewrite_query": "Try rephrasing your search query with different keywords or approach.",
"try_alternative": "Consider using a different tool or strategy.",
"summarize": "Consider summarizing your current findings and moving forward.",
"stop": "Do not retry this operation — it is not recoverable.",
# Near-duplicate success results: recommended_next_action is "continue" by default,
# but the model should still change strategy to avoid re-fetching the same content.
"continue": "Try rephrasing your query or using a different search term.",
}
base = {
"no_results": "[PROGRESS HINT] Your search returned no results.",
"not_found": "[PROGRESS HINT] The resource was not found repeatedly.",
"rate_limited": "[PROGRESS HINT] The tool is being rate-limited.",
"transient": "[PROGRESS HINT] The tool encountered repeated transient failures.",
"partial_success": "[PROGRESS HINT] The tool has returned incomplete results multiple times.",
# Jaccard near-duplicate success: the tool is returning the same content repeatedly.
"success": "[PROGRESS HINT] The tool is returning duplicate results.",
}.get(
meta.error_type or meta.status,
"[PROGRESS HINT] The tool is not producing new information.",
)
suffix = action_map.get(meta.recommended_next_action, "")
return f"{base} {suffix}".strip()
def _block_reason(meta: ToolResultMeta) -> str:
return {
"no_results": "Repeated no-results — rewrite your query or try a different tool.",
"not_found": "Repeated not-found — rewrite your query or try a different resource.",
"rate_limited": "Repeated rate-limiting — summarize current findings and proceed.",
"transient": "Repeated transient failures — try a different approach.",
"auth": "Authentication failure — this tool cannot be used.",
"config": "Tool is not configured — this tool cannot be used.",
"internal": "Repeated internal errors — this tool is unavailable.",
}.get(
meta.error_type or "",
"Tool has not produced new information after multiple attempts — summarize and move on.",
)
# ---------------------------------------------------------------------------
# Middleware
class ToolProgressMiddleware(AgentMiddleware[AgentState]):
"""State-machine-based tool stagnation guard (RFC #3177)."""
def __init__(
self,
*,
stagnation_threshold: int = 3,
warn_escalation_count: int = 2,
inject_assessment: bool = True,
jaccard_threshold: float = 0.8,
min_words: int = 10,
exempt_tools: set[str] | None = None,
max_tracked_threads: int = 100,
) -> None:
self._stagnation_threshold = stagnation_threshold
self._warn_escalation = warn_escalation_count
self._inject_assessment = inject_assessment
self._jaccard_threshold = jaccard_threshold
self._min_words = min_words
self._exempt_tools: set[str] = exempt_tools if exempt_tools is not None else {"ask_clarification", "write_todos", "present_files", "task"}
self._max_tracked_threads = max_tracked_threads
# threading.Lock (not asyncio.Lock): critical sections are short in-memory dict
# ops with no I/O, so event-loop stall risk is negligible. asyncio.Lock would
# not protect the sync wrap_tool_call path used by subagent executor thread
# pools — two separate locks would be required instead. This matches the
# existing LoopDetectionMiddleware pattern; see module docstring for details.
self._lock = threading.Lock()
# LRU-evicting store: thread_id → {tool_name → ToolPhaseState}
self._phase_states: OrderedDict[str, dict[str, ToolPhaseState]] = OrderedDict()
# Pending hint queue: (thread_id, run_id) → [hint texts]
self._pending: dict[tuple[str, str], list[str]] = defaultdict(list)
@classmethod
def from_config(cls, config: ToolProgressConfig) -> ToolProgressMiddleware:
return cls(
stagnation_threshold=config.stagnation_threshold,
warn_escalation_count=config.warn_escalation_count,
inject_assessment=config.inject_assessment,
jaccard_threshold=config.jaccard_similarity_threshold,
min_words=config.min_word_count_for_similarity,
exempt_tools=set(config.exempt_tools),
max_tracked_threads=config.max_tracked_threads,
)
# ------------------------------------------------------------------
# Runtime helpers
@staticmethod
def _thread_id(runtime: Runtime) -> str:
tid = runtime.context.get("thread_id") if runtime.context else None
return str(tid) if tid else "default"
@staticmethod
def _run_id(runtime: Runtime) -> str:
rid = runtime.context.get("run_id") if runtime.context else None
return str(rid) if rid else "default"
def _pending_key(self, runtime: Runtime) -> tuple[str, str]:
return self._thread_id(runtime), self._run_id(runtime)
# ------------------------------------------------------------------
# State store (caller holds lock)
def _get_state(self, thread_id: str, tool_name: str) -> ToolPhaseState:
if thread_id not in self._phase_states:
self._phase_states[thread_id] = {}
while len(self._phase_states) > self._max_tracked_threads:
evicted_thread, _ = self._phase_states.popitem(last=False)
# Evict pending hints for the evicted thread to prevent unbounded growth.
for key in [k for k in self._pending if k[0] == evicted_thread]:
del self._pending[key]
self._phase_states.move_to_end(thread_id)
return self._phase_states[thread_id].get(tool_name, ToolPhaseState())
def _set_state(self, thread_id: str, tool_name: str, state: ToolPhaseState) -> None:
self._phase_states[thread_id][tool_name] = state
def _get_block_reason(self, runtime: Runtime, tool_name: str) -> str | None:
thread_id = self._thread_id(runtime)
with self._lock:
thread_tools = self._phase_states.get(thread_id)
if thread_tools is None:
return None
# Read-only check: do NOT call move_to_end here. Bumping recency on the read path
# would keep blocked threads permanently warm in the LRU, preventing healthy active
# threads from occupying those slots. Recency is updated only on _get_state writes.
tool_state = thread_tools.get(tool_name)
return tool_state.block_reason if tool_state is not None and tool_state.phase == "blocked" else None
def _make_blocked_message(self, request: ToolCallRequest, tool_name: str, block_reason: str) -> ToolMessage:
return ToolMessage(
content=f"[TOOL_BLOCKED] {block_reason}",
tool_call_id=str(request.tool_call.get("id", "")),
name=tool_name,
status="error",
additional_kwargs={
TOOL_META_KEY: {
"status": "error",
"error_type": "blocked_by_progress_guard",
"recoverable_by_model": True,
"recommended_next_action": "summarize",
"source": "progress_middleware",
}
},
)
def _update_state_from_result(
self,
result: ToolMessage | Command,
tool_name: str,
runtime: Runtime,
) -> ToolMessage | Command:
"""Update the state machine from a tool result; queue hints if warranted."""
if not isinstance(result, ToolMessage):
return result
meta = _parse_tool_meta((result.additional_kwargs or {}).get(TOOL_META_KEY))
if meta is None:
if tool_name not in self._exempt_tools:
logger.warning(
"tool_progress: deerflow_tool_meta missing for non-exempt tool %s — verify ToolProgressMiddleware is outer of ToolErrorHandlingMiddleware",
tool_name,
)
return result
content = _message_content_str(result)
thread_id = self._thread_id(runtime)
with self._lock:
state = self._get_state(thread_id, tool_name)
new_state, hint = self._assess_and_transition(state, meta, content)
self._set_state(thread_id, tool_name, new_state)
if new_state.phase != state.phase:
if new_state.phase == "blocked":
logger.warning(
"tool_progress: %s/%s -> BLOCKED: %s",
thread_id,
tool_name,
new_state.block_reason,
)
elif new_state.phase == "warned":
logger.info(
"tool_progress: %s/%s -> WARNED (consecutive_problems=%d)",
thread_id,
tool_name,
new_state.consecutive_problems,
)
elif new_state.phase == "active":
logger.info(
"tool_progress: %s/%s -> ACTIVE (reset after good result)",
thread_id,
tool_name,
)
if hint and self._inject_assessment:
self._queue_assessment(runtime, hint)
return result
# ------------------------------------------------------------------
# State machine
def _assess_and_transition(
self,
state: ToolPhaseState,
meta: ToolResultMeta,
content: str,
) -> tuple[ToolPhaseState, str | None]:
"""Return (new_state, hint_text_or_None).
The outer wrap_tool_call gate intercepts already-blocked states before
the handler is called, so this function is normally reached only for
active/warned states. If a blocked state arrives (e.g., concurrent
transition), the function returns it unchanged — no counter inflation,
no phase regression.
"""
# Guard: blocked is a terminal state; nothing should change it here.
# (In normal flow this branch is unreachable because wrap_tool_call
# intercepts blocked tools before calling the handler. The check exists
# to make concurrent-race semantics well-defined and prevent a
# recoverable-error result from silently demoting the phase back to warned.)
if state.phase == "blocked":
return state, None
# Count this call as a problem before branching so all exit paths leave
# consecutive_problems in a consistent state (never 0 when the tool has failed).
new_count = state.consecutive_problems + 1
# Immediately block on unrecoverable stop signals (auth, config, internal).
if not meta.recoverable_by_model and meta.recommended_next_action == "stop":
return replace(
state,
phase="blocked",
consecutive_problems=new_count,
block_reason=_block_reason(meta),
), None
# Compute word_set only for success results: error/partial_success are problems by
# definition and never reach the Jaccard check, so the O(n) regex is wasted on them.
ws = word_set(content) if meta.status == "success" else frozenset()
is_problem = meta.status in ("error", "partial_success") or (meta.status == "success" and is_near_duplicate(ws, state.recent_word_sets, self._jaccard_threshold, self._min_words))
if not is_problem:
# Good result: reset consecutive count, return to active.
new_recent = (*state.recent_word_sets, ws)[-3:]
return replace(state, consecutive_problems=0, phase="active", recent_word_sets=new_recent), None
hint: str | None = None
if new_count >= self._stagnation_threshold + self._warn_escalation:
if meta.recoverable_by_model:
# Model can fix this by changing strategy — keep warned, re-inject hint.
# BLOCKED would prevent a legitimate retry with different parameters.
hint = _format_hint(meta)
new_state = replace(state, consecutive_problems=new_count, phase="warned")
else:
# Model cannot fix this by retrying — block the tool.
reason = _block_reason(meta)
new_state = replace(state, consecutive_problems=new_count, phase="blocked", block_reason=reason)
elif new_count >= self._stagnation_threshold:
hint = _format_hint(meta)
new_state = replace(state, consecutive_problems=new_count, phase="warned")
else:
new_state = replace(state, consecutive_problems=new_count)
return new_state, hint
# ------------------------------------------------------------------
# Pending queue helpers
def _queue_assessment(self, runtime: Runtime, text: str) -> None:
key = self._pending_key(runtime)
thread_id = key[0]
with self._lock:
# Guard against creating a phantom _pending entry for a thread that was just
# evicted from _phase_states by the LRU. Such entries can never be cleaned up
# by the eviction loop (which only walks _phase_states) and accumulate silently.
if thread_id not in self._phase_states:
return
queue = self._pending[key]
if len(queue) < _MAX_PENDING_PER_RUN:
queue.append(text)
def _drain_pending(self, runtime: Runtime) -> list[str]:
key = self._pending_key(runtime)
with self._lock:
return self._pending.pop(key, [])
def _clear_stale_pending(self, runtime: Runtime) -> None:
thread_id, current_run = self._pending_key(runtime)
with self._lock:
for key in list(self._pending):
if key[0] == thread_id and key[1] != current_run:
del self._pending[key]
def _reset_run_states(self, runtime: Runtime) -> None:
"""Reset all per-run tool state for the thread at the start of a new agent run.
Every tool's consecutive_problems counter and recent_word_sets Jaccard window are
cleared unconditionally so state from a previous run never bleeds into the next:
- BLOCKED/WARNED tools are reset to ACTIVE (they re-block immediately if the root
cause persists, and the model has no memory of the prior-run hint).
- ACTIVE tools with non-zero consecutive_problems or non-empty recent_word_sets from
the previous run are also cleared so a single first-call problem in the new run
cannot falsely trip WARNED against stale context from a run the model no longer sees.
**Cross-run scoping vs LoopDetectionMiddleware**: this per-run reset is an intentional
policy choice, not an oversight. Errors like ``rate_limited`` and ``transient`` are
time-bound: their root cause may resolve between user turns, so carrying a stale
counter forward risks a false-positive BLOCKED on calls that would now succeed.
LoopDetectionMiddleware takes the opposite stance — it retains ``_history`` across
runs (only clearing other-run *pending* warnings at ``before_agent``), because
call-pattern loops are time-invariant: a model that keeps issuing the same tool_calls
regardless of results does so regardless of when the run started. The two middlewares
therefore guard different failure modes (result quality vs. call pattern) and their
cross-run scoping policies intentionally differ as a consequence.
"""
thread_id = self._thread_id(runtime)
with self._lock:
thread_tools = self._phase_states.get(thread_id)
if thread_tools is None:
return
for tool_name, tool_state in list(thread_tools.items()):
thread_tools[tool_name] = replace(
tool_state,
phase="active",
consecutive_problems=0,
block_reason=None,
recent_word_sets=(),
)
# ------------------------------------------------------------------
# wrap_tool_call
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
tool_name = str(request.tool_call.get("name", ""))
if not tool_name or tool_name in self._exempt_tools:
return handler(request)
runtime = getattr(request, "runtime", None)
if runtime is None:
return handler(request)
block_reason = self._get_block_reason(runtime, tool_name)
if block_reason:
logger.info(
"tool_progress: %s/%s call intercepted (blocked): %s",
self._thread_id(runtime),
tool_name,
block_reason,
)
return self._make_blocked_message(request, tool_name, block_reason)
return self._update_state_from_result(handler(request), tool_name, runtime)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
tool_name = str(request.tool_call.get("name", ""))
if not tool_name or tool_name in self._exempt_tools:
return await handler(request)
runtime = getattr(request, "runtime", None)
if runtime is None:
return await handler(request)
block_reason = self._get_block_reason(runtime, tool_name)
if block_reason:
logger.info(
"tool_progress: %s/%s call intercepted (blocked): %s",
self._thread_id(runtime),
tool_name,
block_reason,
)
return self._make_blocked_message(request, tool_name, block_reason)
return self._update_state_from_result(await handler(request), tool_name, runtime)
# ------------------------------------------------------------------
# wrap_model_call: drain pending hints and inject before model sees messages
def _augment_request(self, request: ModelRequest) -> ModelRequest:
hints = self._drain_pending(request.runtime)
if not hints:
return request
deduped = list(dict.fromkeys(hints))
logger.debug(
"tool_progress: injecting %d hint(s) for %s/%s",
len(deduped),
*self._pending_key(request.runtime),
)
new_messages = [
*request.messages,
HumanMessage(content="\n\n".join(deduped), name="progress_hint"),
]
return request.override(messages=new_messages)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(self._augment_request(request))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
return await handler(self._augment_request(request))
# ------------------------------------------------------------------
# before_agent: clean up stale pending hints from previous runs
@override
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_stale_pending(runtime)
self._reset_run_states(runtime)
return None
@override
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_stale_pending(runtime)
self._reset_run_states(runtime)
return None