alanhuangyoo 0f2813f6b9
fix(agents): keep queued guard warnings when a model call is retried (#5433)
* fix(agents): keep queued guard warnings when a model call is retried

LoopDetectionMiddleware, TokenBudgetMiddleware and ToolProgressMiddleware
pop their queued warning/hint before calling the model. When the call
raises, LLMErrorHandlingMiddleware (outside them) retries by running
their wrap_model_call again, and by then the queue is empty, so the
retried request goes out without the warning. Loop detection and the
token budget have already marked it as sent, so it is never queued
again, and a loop runs on to the hard stop unwarned.

Put the drained items back in front of the queue when the handler
raises, then re-raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agents): trim restored loop warnings from the tail and drop a dead helper

_restore_pending_warnings put the restored warnings at the front and then
trimmed the front, so if the cap ever fired it would drop exactly what it
restored. Trim the tail, as tool progress does. _augment_request had no
callers after the wrap_model_call change. Add the sync twin of the tool
progress retry test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(agents): drop tool progress's unused _augment_request

Its only remaining reference was a test name; the dedup that test checks lives
in _inject_hints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:52:17 +08:00

775 lines
33 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.audit_context import (
TOOL_PROGRESS_RECORDER_CONTEXT_KEY,
resolve_audit_recorder,
)
from deerflow.agents.middlewares.tool_result_meta import (
PROGRESS_GUARD_ERROR_TYPE,
TOOL_META_KEY,
TOOL_RESULT_ERROR_TYPES,
TOOL_RESULT_NEXT_ACTIONS,
TOOL_RESULT_STATUSES,
ToolResultMeta,
)
from deerflow.runtime.events.catalog import MIDDLEWARE_TOOL_PROGRESS_TAG
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
def _audit_error_type(value: object) -> str | None:
"""Project an untrusted tool stamp onto the bounded audit vocabulary."""
if value is None:
return None
return value if type(value) is str and value in TOOL_RESULT_ERROR_TYPES else "unknown"
def _audit_next_action(value: object) -> str:
"""Return only framework-defined recovery actions to persistence."""
return value if type(value) is str and value in TOOL_RESULT_NEXT_ACTIONS else "unknown"
def _audit_status(value: object) -> str:
"""Project the producer status onto the canonical result vocabulary."""
return value if type(value) is str and value in TOOL_RESULT_STATUSES else "unknown"
def _audit_recoverable(value: object) -> bool | None:
"""Reject truthy non-booleans from producer-supplied tool metadata."""
return value if type(value) is bool else None
# ---------------------------------------------------------------------------
# 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)
@dataclass(frozen=True, slots=True)
class ToolPhaseTransition:
"""The exact state-machine rule that produced a durable phase change."""
action: Literal["warn", "block", "recover", "reset"]
threshold: int | None
# ---------------------------------------------------------------------------
# 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 _result_tool_message(result: ToolMessage | Command, tool_call_id: str) -> ToolMessage | None:
"""Return the ToolMessage for this tool call, including Command-wrapped results."""
if isinstance(result, ToolMessage):
return result
update = result.update
if not isinstance(update, dict):
return None
messages = update.get("messages", [])
if isinstance(messages, ToolMessage):
messages = [messages]
if not isinstance(messages, (list, tuple)):
return None
for message in messages:
if isinstance(message, ToolMessage) and str(message.tool_call_id) == tool_call_id:
return message
return None
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) also protects embedded callers that use the
# synchronous wrapper from multiple threads. Recorder callbacks are deliberately
# invoked after this state lock is released; observability must not stall tool
# state updates. This matches LoopDetectionMiddleware's publication convention.
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)
def _record_phase_transition(
self,
*,
runtime: Runtime,
tool_name: str,
state: ToolPhaseState,
new_state: ToolPhaseState,
meta: ToolResultMeta | None,
hook: Literal["wrap_tool_call", "awrap_tool_call", "before_agent", "abefore_agent"],
transition: ToolPhaseTransition,
) -> None:
"""Persist one effective transition without copying tool content."""
recorder, is_subagent, agent_id = resolve_audit_recorder(
getattr(runtime, "context", None),
recorder_key=TOOL_PROGRESS_RECORDER_CONTEXT_KEY,
)
if recorder is None:
return
try:
recorder.record_middleware(
tag=MIDDLEWARE_TOOL_PROGRESS_TAG,
name=type(self).__name__,
hook=hook,
action=transition.action,
changes={
"is_subagent": is_subagent,
"agent_id": agent_id,
"tool_name": tool_name,
"from_phase": state.phase,
"to_phase": new_state.phase,
"consecutive_problems": new_state.consecutive_problems,
"status": _audit_status(meta.status) if meta is not None else None,
"error_type": _audit_error_type(meta.error_type) if meta is not None else None,
"recoverable_by_model": _audit_recoverable(meta.recoverable_by_model) if meta is not None else None,
"recommended_next_action": _audit_next_action(meta.recommended_next_action) if meta is not None else None,
"threshold": transition.threshold,
},
)
except Exception: # noqa: BLE001
# Observability must never alter the progress guard or agent run.
logger.warning(
"Failed to record middleware:tool_progress event",
exc_info=True,
)
# ------------------------------------------------------------------
# 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": PROGRESS_GUARD_ERROR_TYPE,
"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,
tool_call_id: str,
*,
hook: Literal["wrap_tool_call", "awrap_tool_call"],
) -> ToolMessage | Command:
"""Update the state machine from a tool result; queue hints if warranted."""
message = _result_tool_message(result, tool_call_id)
if message is None:
return result
meta = _parse_tool_meta((message.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(message)
thread_id = self._thread_id(runtime)
phase_change: tuple[ToolPhaseState, ToolPhaseState, ToolPhaseTransition] | None = None
with self._lock:
state = self._get_state(thread_id, tool_name)
new_state, hint, transition = self._assess_and_transition(state, meta, content)
self._set_state(thread_id, tool_name, new_state)
if transition is not None:
phase_change = (state, new_state, transition)
if phase_change is not None:
old_state, changed_state, phase_transition = phase_change
self._record_phase_transition(
runtime=runtime,
tool_name=tool_name,
state=old_state,
new_state=changed_state,
meta=meta,
hook=hook,
transition=phase_transition,
)
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, ToolPhaseTransition | None]:
"""Return the new state, optional hint, and rule that changed phase.
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, 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,
ToolPhaseTransition(action="block", threshold=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:]
transition = ToolPhaseTransition(action="recover", threshold=None) if state.phase == "warned" else None
return replace(state, consecutive_problems=0, phase="active", recent_word_sets=new_recent), None, transition
hint: str | None = None
transition: ToolPhaseTransition | 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")
if state.phase != "warned":
transition = ToolPhaseTransition(
action="warn",
threshold=self._stagnation_threshold + self._warn_escalation,
)
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)
transition = ToolPhaseTransition(
action="block",
threshold=self._stagnation_threshold + self._warn_escalation,
)
elif new_count >= self._stagnation_threshold:
hint = _format_hint(meta)
new_state = replace(state, consecutive_problems=new_count, phase="warned")
if state.phase != "warned":
transition = ToolPhaseTransition(action="warn", threshold=self._stagnation_threshold)
else:
new_state = replace(state, consecutive_problems=new_count)
return new_state, hint, transition
# ------------------------------------------------------------------
# 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 _restore_pending(self, runtime: Runtime, hints: list[str]) -> None:
"""Requeue hints taken for a model call that raised.
LLMErrorHandlingMiddleware sits outside this middleware and retries a
failed call by running this wrap again, so the retry must still find
the hints.
"""
if not hints:
return
key = self._pending_key(runtime)
with self._lock:
if key[0] not in self._phase_states:
return
queue = self._pending[key]
queue[:0] = hints
del queue[_MAX_PENDING_PER_RUN:]
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,
*,
hook: Literal["before_agent", "abefore_agent"],
) -> 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.
**Graph-entry scoping vs LoopDetectionMiddleware**: this reset at every
``before_agent`` is an intentional policy choice, not an oversight. Errors like
``rate_limited`` and ``transient`` are time-bound, so carrying a stale counter into a
later graph entry risks a false-positive BLOCKED on calls that would now succeed.
LoopDetectionMiddleware instead keys call-pattern state by ``(thread_id, run_id)``:
separate user runs are isolated even on a cached graph, while repeated graph entries
in one Gateway run (including hidden goal continuations) share a loop budget. The two
middlewares therefore guard different failure modes and use different lifetimes.
"""
thread_id = self._thread_id(runtime)
transitions: list[tuple[str, ToolPhaseState, ToolPhaseState]] = []
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()):
new_state = replace(
tool_state,
phase="active",
consecutive_problems=0,
block_reason=None,
recent_word_sets=(),
)
thread_tools[tool_name] = new_state
if tool_state.phase != new_state.phase:
transitions.append((tool_name, tool_state, new_state))
for tool_name, tool_state, new_state in transitions:
self._record_phase_transition(
runtime=runtime,
tool_name=tool_name,
state=tool_state,
new_state=new_state,
meta=None,
hook=hook,
transition=ToolPhaseTransition(action="reset", threshold=None),
)
# ------------------------------------------------------------------
# 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,
str(request.tool_call.get("id") or ""),
hook="wrap_tool_call",
)
@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,
str(request.tool_call.get("id") or ""),
hook="awrap_tool_call",
)
# ------------------------------------------------------------------
# wrap_model_call: drain pending hints and inject before model sees messages
def _inject_hints(self, request: ModelRequest, hints: list[str]) -> ModelRequest:
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:
hints = self._drain_pending(request.runtime)
try:
return handler(self._inject_hints(request, hints))
except Exception:
self._restore_pending(request.runtime, hints)
raise
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
hints = self._drain_pending(request.runtime)
try:
return await handler(self._inject_hints(request, hints))
except Exception:
self._restore_pending(request.runtime, hints)
raise
# ------------------------------------------------------------------
# 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, hook="before_agent")
return None
@override
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_stale_pending(runtime)
self._reset_run_states(runtime, hook="abefore_agent")
return None