mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
fix(runtime): harden model response recovery at provider boundaries (#5080)
* fix(models): preserve DeepSeek thinking tool history * fix(runtime): harden model response recovery * fix(runtime): tighten model response recovery * fix(runtime): protect run-scoped retry state * fix(runtime): complete model recovery review fixes * fix(runtime): preserve empty-response diagnostics * fix(runtime): strip native tool calls on length caps * docs(middleware): fit recovery guidance within inherited size limit --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
a23dbdd837
commit
e89b128157
@ -716,15 +716,15 @@ def build_middlewares(
|
||||
if configured_middlewares:
|
||||
middlewares.extend(configured_middlewares)
|
||||
|
||||
# A provider may return an empty AIMessage after tool execution. Retry the
|
||||
# final response once, then persist a visible error fallback rather than
|
||||
# allowing LangChain's no-tool-call router to end a silent successful run.
|
||||
# LLMErrorHandlingMiddleware gives a run one model-boundary retry for a true
|
||||
# empty stop. Keep a terminal fallback for post-tool responses that still have
|
||||
# no user-visible text, without adding a graph-level recovery turn.
|
||||
middlewares.append(TerminalResponseMiddleware())
|
||||
|
||||
# A provider may also cap the final assistant response at the model output
|
||||
# limit. Preserve the assistant content unchanged, but stamp a run-level
|
||||
# stop_reason so Gateway consumers can tell a length-capped completion from
|
||||
# a clean one.
|
||||
# limit. Detector-matched caps stamp stop_reason=model_length_capped,
|
||||
# suppress that response's tool calls, and append a length notice when no
|
||||
# visible text was produced.
|
||||
middlewares.append(ModelLengthFinishReasonMiddleware())
|
||||
|
||||
# SafetyFinishReasonMiddleware — suppress tool execution when the provider
|
||||
|
||||
@ -70,7 +70,7 @@ strict providers reject.
|
||||
their narrower discovery allowlists never rebuild the shared thread view or
|
||||
force eager sandbox acquisition.
|
||||
7. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., user interruption), preserving raw provider tool-call payloads in `additional_kwargs["tool_calls"]`; malformed tool-call names and arguments are sanitized in the model-bound request so strict OpenAI-compatible providers do not reject the next request
|
||||
8. **LLMErrorHandlingMiddleware** - Converts provider/model failures to recoverable assistant errors. Async cancellation at admission, provider execution, retry events, or backoff releases only the call's own half-open probe (ownership assigned under the circuit lock), then propagates unchanged, without retry or failure accounting.
|
||||
8. **LLMErrorHandlingMiddleware** - Converts provider/model failures to recoverable assistant errors. Normal completions without visible text or tool-call intent (including whitespace/reasoning-only responses) get at most one retry per run, then a marked visible fallback; empties never count toward the circuit breaker. Cancellation during admission, execution, retry events or backoff releases only this call's half-open probe (assigned under the circuit lock), then propagates unchanged without retry or failure accounting.
|
||||
9. **Authorization / GuardrailMiddleware** - Up to two independent pre-tool-call gates run here. When `authorization.enabled`, the `AuthorizationProvider` instance already used for Layer 1 capability filtering is wrapped by `GuardrailAuthorizationAdapter` and reused for Layer 2 execution checks. A generated `tool_search` bypasses the adapter's second provider call only when the current build has a concrete deferred setup; its catalog was already filtered by Layer 1, and an ordinary same-named tool without that deferred setup receives no exemption. When `guardrails.enabled`, the explicitly configured `GuardrailProvider` is appended after authorization and still evaluates every call, including `tool_search`. Authorization therefore runs outermost and can deny before an external guardrail call; both use the existing middleware's fail-closed, audit, sync/async, and error-`ToolMessage` behavior. See the authorization RFC and [docs/GUARDRAILS.md](../../../../../docs/GUARDRAILS.md).
|
||||
|
||||
Every guardrail decision path publishes a neutral
|
||||
@ -120,7 +120,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
30. **TokenBudgetMiddleware** - `token_budget.enabled`: shares run-ID budgets across continuations; missing/invalid IDs clear invocation state.
|
||||
31. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
|
||||
32. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review.
|
||||
33. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
|
||||
34. **ModelLengthFinishReasonMiddleware** - Records `stop_reason=model_length_capped` when provider-specific length detectors match a terminal `AIMessage` without tool-call intent (`finish_reason=length` / `MAX_TOKENS`, or `stop_reason=max_tokens`), preserving the original assistant content and never reparsing textual tool-call-like envelopes
|
||||
33. **TerminalResponseMiddleware** - After tools following the latest real user message, an assistant response without visible text or tool intent gets a marked visible fallback in the same step. Preserves content blocks; no graph retry or separate length-reason vocabulary.
|
||||
34. **ModelLengthFinishReasonMiddleware** - A length-detector match stamps `stop_reason=model_length_capped` and ends the tool loop. Suppresses all structured/raw calls and native `tool_use` blocks, even fully parsed calls. Keeps text/thinking blocks; appends a length notice if no visible text exists. Audit metadata keeps detector, reason, call count/names, never suppressed arguments.
|
||||
35. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
36. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []`, which must not raise from the membership probe — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. Because this middleware can short-circuit before `on_tool_end`, `RunJournal` does a root-run reconciliation for `ToolMessage`s whose `tool_call_id` came from the current run, so cards survive checkpoint compaction. That reconciliation is **not** `ask_clarification`-only — any middleware that answers a tool call has the same gap, and a result the user saw must not vanish on reload (#4666 — `ReadBeforeWriteMiddleware` blocked-write errors reached the UI but not the event store). It is bounded by three conditions, not a name allowlist: the message is user-visible, the call belongs to this run's **lead agent** (`_remember_current_run_tool_calls` records lead-agent calls only; subagent results stay in `subagent.step`), and it is not already persisted. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
|
||||
|
||||
@ -22,12 +22,58 @@ from langchain.agents.middleware.types import (
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from deerflow.agents.middlewares.model_response import append_visible_text, finish_reason, has_tool_call_intent, has_visible_content, last_ai_message
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.models.request_admission import AdmissionError
|
||||
from deerflow.utils.custom_events import aemit_custom_event, emit_custom_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EMPTY_RESPONSE_RETRY_CONTEXT_KEY = "__empty_response_retry_consumed"
|
||||
_EMPTY_RESPONSE_RETRY_CONSUMED = object()
|
||||
_NON_CIRCUIT_FAILURE_REASONS = {"burst_rate", "empty_response"}
|
||||
|
||||
|
||||
class EmptyModelResponseError(RuntimeError):
|
||||
"""The model completed normally without producing persistent content."""
|
||||
|
||||
code = "EMPTY_RESPONSE"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Model returned a completed response with no content",
|
||||
*,
|
||||
response_message: AIMessage | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.response_message = response_message
|
||||
|
||||
|
||||
def _raise_for_empty_response(response: ModelCallResult) -> None:
|
||||
"""在响应写入图状态前把零内容 stop 转换为可重试错误。"""
|
||||
message = last_ai_message(response)
|
||||
if message is None:
|
||||
raise EmptyModelResponseError()
|
||||
if has_visible_content(message) or has_tool_call_intent(message):
|
||||
return
|
||||
reason = finish_reason(message)
|
||||
if reason in (None, "", "stop", "end_turn"):
|
||||
raise EmptyModelResponseError(response_message=message)
|
||||
|
||||
|
||||
def _consume_empty_response_retry(request: ModelRequest) -> bool:
|
||||
"""Consume the one empty-response retry budget stored in run context."""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
context = getattr(runtime, "context", None)
|
||||
if not isinstance(context, dict):
|
||||
# Direct middleware calls without runtime context retain one retry per call.
|
||||
return True
|
||||
if context.get(_EMPTY_RESPONSE_RETRY_CONTEXT_KEY) is _EMPTY_RESPONSE_RETRY_CONSUMED:
|
||||
return False
|
||||
context[_EMPTY_RESPONSE_RETRY_CONTEXT_KEY] = _EMPTY_RESPONSE_RETRY_CONSUMED
|
||||
return True
|
||||
|
||||
|
||||
_RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
_BUSY_PATTERNS = (
|
||||
"server busy",
|
||||
@ -95,7 +141,9 @@ _BURST_PATTERNS = (
|
||||
# value of 2 means "1 first attempt + 1 retry" (the CR-requested
|
||||
# "keep one retry" behavior).
|
||||
_RETRY_BUDGET_OVERRIDES: dict[str, int] = {
|
||||
"EmptyModelResponseError": 2,
|
||||
"StreamChunkTimeoutError": 2,
|
||||
"ReadTimeout": 2,
|
||||
}
|
||||
|
||||
# Per-reason retry budget overrides, applied in addition to the per-exception
|
||||
@ -413,6 +461,12 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
self._circuit_probe_in_flight = False
|
||||
self._circuit_probe_token: object | None = None
|
||||
|
||||
def release_policy_parameters(self) -> dict[str, object]:
|
||||
return {
|
||||
"empty_response_retry_limit": 1,
|
||||
"empty_response_retry_scope": "run",
|
||||
}
|
||||
|
||||
def _max_attempts_for(self, exc: BaseException, reason: str = "transient") -> int:
|
||||
"""Return the effective max attempt count for this exception.
|
||||
|
||||
@ -512,6 +566,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
error_code = _extract_error_code(exc)
|
||||
status_code = _extract_status_code(exc)
|
||||
|
||||
if isinstance(exc, EmptyModelResponseError):
|
||||
return True, "empty_response"
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
|
||||
return False, "quota"
|
||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||
@ -529,6 +585,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"InternalServerError",
|
||||
"ReadTimeout",
|
||||
"ConnectTimeout",
|
||||
"WriteTimeout",
|
||||
"PoolTimeout",
|
||||
"TimeoutException",
|
||||
"ReadError", # httpx.ReadError: connection dropped mid-stream
|
||||
"RemoteProtocolError", # httpx: server closed connection unexpectedly
|
||||
"StreamChunkTimeoutError", # langchain-openai: chunk gap exceeded stream_chunk_timeout
|
||||
@ -660,6 +721,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
reason_text = {
|
||||
"busy": "provider is busy",
|
||||
"burst_rate": "provider is throttling request burst rate",
|
||||
"empty_response": "provider returned an empty response",
|
||||
}.get(reason, "provider request failed temporarily")
|
||||
# ``max_attempts`` is the *effective* budget for this call (from
|
||||
# ``_max_attempts_for``), not the configured ceiling: a burst-rate call
|
||||
@ -678,16 +740,25 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
error_type: str,
|
||||
reason: str,
|
||||
detail: str,
|
||||
response_message: AIMessage | None = None,
|
||||
) -> AIMessage:
|
||||
return AIMessage(
|
||||
content=content,
|
||||
additional_kwargs={
|
||||
additional_kwargs = dict(response_message.additional_kwargs or {}) if response_message is not None else {}
|
||||
additional_kwargs.update(
|
||||
{
|
||||
"deerflow_error_fallback": True,
|
||||
"error_type": error_type,
|
||||
"error_reason": reason,
|
||||
"error_detail": detail,
|
||||
},
|
||||
}
|
||||
)
|
||||
if response_message is not None:
|
||||
return response_message.model_copy(
|
||||
update={
|
||||
"content": append_visible_text(response_message, content),
|
||||
"additional_kwargs": additional_kwargs,
|
||||
}
|
||||
)
|
||||
return AIMessage(content=content, additional_kwargs=additional_kwargs)
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
@ -697,6 +768,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
return "The configured LLM provider rejected the request because authentication or access is invalid. Please check the provider credentials and try again."
|
||||
if reason == "burst_rate":
|
||||
return "The configured LLM provider is temporarily throttling requests because the request rate increased too quickly (burst-rate limit). Please wait a moment and try again."
|
||||
if reason == "empty_response":
|
||||
return "The configured LLM provider returned an empty response after one automatic retry. Please continue the conversation or use a different model."
|
||||
if reason in {"busy", "transient"}:
|
||||
# Stream-drop failures (chunk-gap timeout, peer-closed connection,
|
||||
# raw read error) almost always point at a single oversized
|
||||
@ -721,6 +794,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
error_type=type(exc).__name__,
|
||||
reason=reason,
|
||||
detail=_extract_error_detail(exc),
|
||||
response_message=exc.response_message if isinstance(exc, EmptyModelResponseError) else None,
|
||||
)
|
||||
|
||||
def _build_retry_event(
|
||||
@ -804,6 +878,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
while True:
|
||||
try:
|
||||
response = self._bounded_model_call_sync(request, handler)
|
||||
_raise_for_empty_response(response)
|
||||
self._record_success()
|
||||
return response
|
||||
except GraphBubbleUp:
|
||||
@ -813,7 +888,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
except Exception as exc:
|
||||
retriable, reason = self._classify_error(exc)
|
||||
max_attempts = self._max_attempts_for(exc, reason)
|
||||
if retriable and attempt < max_attempts:
|
||||
should_retry = retriable and attempt < max_attempts
|
||||
if should_retry and reason == "empty_response":
|
||||
should_retry = _consume_empty_response_retry(request)
|
||||
if should_retry:
|
||||
wait_ms = self._build_retry_delay_ms(prev_delay_ms, exc, reason)
|
||||
prev_delay_ms = wait_ms
|
||||
logger.warning(
|
||||
@ -833,14 +911,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
_extract_error_detail(exc),
|
||||
exc_info=exc,
|
||||
)
|
||||
if retriable and reason != "burst_rate":
|
||||
if retriable and reason not in _NON_CIRCUIT_FAILURE_REASONS:
|
||||
self._record_failure()
|
||||
else:
|
||||
# Non-retriable, OR burst_rate (a transient provider
|
||||
# slope-throttle, not "provider down"): release the half-open
|
||||
# probe without recording a failure so the circuit doesn't
|
||||
# trip and fast-fail ALL calls for the recovery window - the
|
||||
# exact self-inflicted outage #4290 is trying to prevent.
|
||||
# These outcomes do not show that the provider is broadly unavailable.
|
||||
self._release_half_open_probe()
|
||||
return self._build_user_fallback_message(exc, reason)
|
||||
|
||||
@ -865,6 +939,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
while True:
|
||||
try:
|
||||
response = await self._bounded_model_call(request, handler)
|
||||
_raise_for_empty_response(response)
|
||||
self._record_success()
|
||||
return response
|
||||
except GraphBubbleUp:
|
||||
@ -874,7 +949,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
except Exception as exc:
|
||||
retriable, reason = self._classify_error(exc)
|
||||
max_attempts = self._max_attempts_for(exc, reason)
|
||||
if retriable and attempt < max_attempts:
|
||||
should_retry = retriable and attempt < max_attempts
|
||||
if should_retry and reason == "empty_response":
|
||||
should_retry = _consume_empty_response_retry(request)
|
||||
if should_retry:
|
||||
wait_ms = self._build_retry_delay_ms(prev_delay_ms, exc, reason)
|
||||
prev_delay_ms = wait_ms
|
||||
logger.warning(
|
||||
@ -894,14 +972,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
_extract_error_detail(exc),
|
||||
exc_info=exc,
|
||||
)
|
||||
if retriable and reason != "burst_rate":
|
||||
if retriable and reason not in _NON_CIRCUIT_FAILURE_REASONS:
|
||||
self._record_failure()
|
||||
else:
|
||||
# Non-retriable, OR burst_rate (a transient provider
|
||||
# slope-throttle, not "provider down"): release the half-open
|
||||
# probe without recording a failure so the circuit doesn't
|
||||
# trip and fast-fail ALL calls for the recovery window - the
|
||||
# exact self-inflicted outage #4290 is trying to prevent.
|
||||
# These outcomes do not show that the provider is broadly unavailable.
|
||||
self._release_half_open_probe()
|
||||
return self._build_user_fallback_message(exc, reason)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
"""Surface provider length-capped model responses as run stop reasons.
|
||||
"""Surface provider length-capped responses and block truncated tool calls.
|
||||
|
||||
Background — see issue bytedance/deer-flow#4271.
|
||||
|
||||
Some providers stop generation because the output budget is exhausted and
|
||||
surface that through ``finish_reason='length'`` while still returning assistant
|
||||
content. DeerFlow should preserve that content for audit, but it should not
|
||||
silently treat the run as an uncapped clean completion when the provider has
|
||||
explicitly signaled truncation.
|
||||
|
||||
This middleware keeps that boundary narrow:
|
||||
- it only marks a run-level stop reason when the final AIMessage is capped
|
||||
by a provider length signal and still has visible content;
|
||||
- it never rewrites the assistant content or reparses XML-like text into a
|
||||
tool call;
|
||||
- it ignores any response that still carries tool-call intent, malformed
|
||||
tool-call metadata, or no visible content, so only terminal assistant
|
||||
responses with visible content can be marked capped.
|
||||
|
||||
content. DeerFlow preserves visible content, adds a deterministic notice when
|
||||
no visible answer was produced, and drops tool calls that may have been
|
||||
truncated at the output boundary before they can execute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -34,44 +24,70 @@ from deerflow.agents.middlewares.model_length_termination_detectors import (
|
||||
ModelLengthTerminationDetector,
|
||||
default_detectors,
|
||||
)
|
||||
from deerflow.agents.middlewares.model_response import append_visible_text, has_tool_call_intent, has_visible_content
|
||||
|
||||
MODEL_LENGTH_CAPPED_STOP_REASON = "model_length_capped"
|
||||
_MODEL_LENGTH_CAPPED_CONTENT = "The model reached its output limit before producing a complete final response. Please continue the conversation to resume."
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
|
||||
if message.tool_calls or getattr(message, "invalid_tool_calls", None):
|
||||
return True
|
||||
def _tool_call_summary(message: AIMessage) -> tuple[int, list[str]]:
|
||||
"""Count suppressed tool calls and return their deduplicated names."""
|
||||
names: list[str] = []
|
||||
structured_calls: list[Any] = [*(message.tool_calls or []), *(getattr(message, "invalid_tool_calls", None) or [])]
|
||||
additional_kwargs = message.additional_kwargs or {}
|
||||
return bool(additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"))
|
||||
if structured_calls:
|
||||
# LangChain commonly keeps both parsed calls and their raw provider copy.
|
||||
calls = structured_calls
|
||||
else:
|
||||
calls = list(additional_kwargs.get("tool_calls") or [])
|
||||
function_call = additional_kwargs.get("function_call")
|
||||
if not calls and isinstance(function_call, dict):
|
||||
calls.append(function_call)
|
||||
if not calls:
|
||||
calls = _anthropic_tool_use_blocks(message)
|
||||
|
||||
for call in calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
name = call.get("name")
|
||||
function = call.get("function")
|
||||
if not isinstance(name, str) and isinstance(function, dict):
|
||||
name = function.get("name")
|
||||
if isinstance(name, str) and name and name not in names:
|
||||
names.append(name)
|
||||
return len(calls), names
|
||||
|
||||
|
||||
def _has_visible_content(message: AIMessage) -> bool:
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, str) and block.strip():
|
||||
return True
|
||||
if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return True
|
||||
return False
|
||||
def _anthropic_tool_use_blocks(message: AIMessage) -> list[dict[str, Any]]:
|
||||
"""Extract native Anthropic tool-use blocks retained in message content."""
|
||||
if not isinstance(message.content, list):
|
||||
return []
|
||||
return [block for block in message.content if isinstance(block, dict) and block.get("type") == "tool_use"]
|
||||
|
||||
|
||||
def _without_anthropic_tool_use_blocks(content: Any) -> Any:
|
||||
"""Remove native tool calls that cannot receive a matching tool result."""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
return [block for block in content if not (isinstance(block, dict) and block.get("type") == "tool_use")]
|
||||
|
||||
|
||||
class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Record provider length caps for terminal assistant responses with content.
|
||||
|
||||
If the last AIMessage still carries tool-call intent, this middleware
|
||||
leaves it alone and lets the normal tool-handling path decide what to do.
|
||||
"""
|
||||
"""Record provider length termination and block truncated tool calls."""
|
||||
|
||||
def __init__(self, detectors: list[ModelLengthTerminationDetector] | None = None) -> None:
|
||||
super().__init__()
|
||||
self._detectors: list[ModelLengthTerminationDetector] = list(detectors) if detectors else default_detectors()
|
||||
|
||||
def release_policy_parameters(self) -> dict[str, object]:
|
||||
from deerflow_extension_api import canonical_hash
|
||||
|
||||
return {
|
||||
"suppress_truncated_tool_calls": True,
|
||||
"empty_content_fallback_hash": canonical_hash(_MODEL_LENGTH_CAPPED_CONTENT),
|
||||
}
|
||||
|
||||
def _detect(self, message: AIMessage) -> ModelLengthTermination | None:
|
||||
for detector in self._detectors:
|
||||
try:
|
||||
@ -89,11 +105,6 @@ class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]):
|
||||
return None
|
||||
|
||||
last = messages[-1]
|
||||
if _has_tool_call_intent_or_error(last):
|
||||
return None
|
||||
if not _has_visible_content(last):
|
||||
return None
|
||||
|
||||
termination = self._detect(last)
|
||||
if termination is None:
|
||||
return None
|
||||
@ -119,7 +130,36 @@ class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]):
|
||||
"stamped_stop_reason": stamped_stop_reason,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
contains_tool_call = has_tool_call_intent(last) or bool(_anthropic_tool_use_blocks(last))
|
||||
cleaned_content = _without_anthropic_tool_use_blocks(last.content) if contains_tool_call else last.content
|
||||
content_source = last.model_copy(update={"content": cleaned_content})
|
||||
contains_visible_content = has_visible_content(content_source)
|
||||
if not contains_tool_call and contains_visible_content:
|
||||
return None
|
||||
|
||||
additional_kwargs = dict(last.additional_kwargs or {})
|
||||
suppressed_count, suppressed_names = _tool_call_summary(last) if contains_tool_call else (0, [])
|
||||
if contains_tool_call:
|
||||
# Tool arguments may be incomplete at a max-token boundary.
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
additional_kwargs["model_length_termination"] = {
|
||||
"detector": termination.detector,
|
||||
"reason_field": termination.reason_field,
|
||||
"reason_value": termination.reason_value,
|
||||
"suppressed_tool_call_count": suppressed_count,
|
||||
"suppressed_tool_call_names": suppressed_names,
|
||||
}
|
||||
replacement = last.model_copy(
|
||||
update={
|
||||
"content": (cleaned_content if contains_visible_content else append_visible_text(content_source, _MODEL_LENGTH_CAPPED_CONTENT)),
|
||||
"tool_calls": [],
|
||||
"invalid_tool_calls": [],
|
||||
"additional_kwargs": additional_kwargs,
|
||||
}
|
||||
)
|
||||
return {"messages": [replacement]}
|
||||
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
"""Shared model response content and termination classification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
def last_ai_message(response: Any) -> AIMessage | None:
|
||||
"""Return the last assistant message from a middleware model result."""
|
||||
if isinstance(response, AIMessage):
|
||||
return response
|
||||
result = getattr(response, "result", None)
|
||||
if isinstance(result, (list, tuple)):
|
||||
return next((message for message in reversed(result) if isinstance(message, AIMessage)), None)
|
||||
return None
|
||||
|
||||
|
||||
def has_tool_call_intent(message: AIMessage) -> bool:
|
||||
"""Return whether parsed or provider-raw tool-call intent is present."""
|
||||
if message.tool_calls or getattr(message, "invalid_tool_calls", None):
|
||||
return True
|
||||
additional_kwargs = message.additional_kwargs or {}
|
||||
return bool(additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"))
|
||||
|
||||
|
||||
def has_visible_content(message: AIMessage) -> bool:
|
||||
"""Return whether a message contains non-whitespace user-visible text."""
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
|
||||
for block in content:
|
||||
if isinstance(block, str) and block.strip():
|
||||
return True
|
||||
if not isinstance(block, dict) or block.get("type") not in {"text", "output_text"}:
|
||||
continue
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def append_visible_text(message: AIMessage, text: str) -> Any:
|
||||
"""Append a visible text block without dropping existing content blocks."""
|
||||
if isinstance(message.content, list):
|
||||
return [*message.content, {"type": "text", "text": text}]
|
||||
return text
|
||||
|
||||
|
||||
def finish_reason(message: AIMessage) -> str | None:
|
||||
"""Read and normalize common provider termination-reason fields."""
|
||||
for metadata in (message.response_metadata or {}, message.additional_kwargs or {}):
|
||||
for field in ("finish_reason", "stop_reason"):
|
||||
value = metadata.get(field)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
return None
|
||||
@ -1,57 +1,17 @@
|
||||
"""Ensure tool-using lead-agent turns end with a visible assistant response."""
|
||||
"""Prevent an empty post-tool terminal response from becoming silent success."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse, hook_config
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.middlewares._bounded_dict import BoundedDict
|
||||
from deerflow.agents.middlewares.model_response import append_visible_text, has_tool_call_intent, has_visible_content
|
||||
|
||||
_RECOVERY_PROMPT = (
|
||||
"<system_reminder>\n"
|
||||
"Your previous response after the tool execution was empty. Review the tool results "
|
||||
"already present in the conversation and provide a concise, user-visible final response. "
|
||||
"Do not call another tool unless it is strictly necessary.\n"
|
||||
"</system_reminder>"
|
||||
)
|
||||
|
||||
_FALLBACK_CONTENT = "The model completed the tool run but returned no final response, including after one automatic retry. Please try again or use a different model."
|
||||
|
||||
_TOOL_CALL_FINISH_REASONS = {"tool_calls", "function_call"}
|
||||
|
||||
|
||||
def _has_visible_content(message: AIMessage) -> bool:
|
||||
"""Return whether an AI message contains user-visible text."""
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, str) and block.strip():
|
||||
return True
|
||||
if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
|
||||
"""Keep tool routing and malformed tool-call handling out of this guard."""
|
||||
if message.tool_calls or getattr(message, "invalid_tool_calls", None):
|
||||
return True
|
||||
additional_kwargs = message.additional_kwargs or {}
|
||||
if additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"):
|
||||
return True
|
||||
response_metadata = message.response_metadata or {}
|
||||
return response_metadata.get("finish_reason") in _TOOL_CALL_FINISH_REASONS
|
||||
_FALLBACK_CONTENT = "The model completed the tool run but returned no final response. Please try again or use a different model."
|
||||
|
||||
|
||||
def _tool_result_in_current_turn(messages: list[Any]) -> bool:
|
||||
@ -63,161 +23,52 @@ def _tool_result_in_current_turn(messages: list[Any]) -> bool:
|
||||
if (message.additional_kwargs or {}).get("hide_from_ui"):
|
||||
continue
|
||||
latest_user_index = index
|
||||
# Scope: #4027 covers interactive post-tool turns. Scheduled/internal
|
||||
# invocations without a real HumanMessage need a separate terminal-success
|
||||
# invariant rather than being inferred from arbitrary historical tools.
|
||||
if latest_user_index == -1:
|
||||
return False
|
||||
return any(isinstance(message, ToolMessage) for message in messages[latest_user_index + 1 :])
|
||||
|
||||
|
||||
class TerminalResponseMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Retry one empty post-tool response, then persist a visible error fallback."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self._retry_counts: BoundedDict[tuple[str, str], int] = BoundedDict(1000)
|
||||
self._pending_prompts: BoundedDict[tuple[str, str], bool] = BoundedDict(1000)
|
||||
"""Last-resort fallback after model-boundary empty-response recovery."""
|
||||
|
||||
def release_policy_parameters(self) -> dict[str, object]:
|
||||
from deerflow_extension_api import canonical_hash
|
||||
|
||||
return {
|
||||
"post_tool_empty_retry_limit": 1,
|
||||
"recovery_prompt_hash": canonical_hash(_RECOVERY_PROMPT),
|
||||
"post_tool_empty_retry_limit": 0,
|
||||
"fallback_content_hash": canonical_hash(_FALLBACK_CONTENT),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _key(runtime: Runtime) -> tuple[str, str]:
|
||||
context = getattr(runtime, "context", None)
|
||||
if isinstance(context, dict):
|
||||
thread_id = str(context.get("thread_id") or "unknown-thread")
|
||||
run_id = str(context.get("run_id") or context.get("run_attempt_id") or id(runtime))
|
||||
return thread_id, run_id
|
||||
# Defensive fallback for tests/custom embeddings. Production Gateway
|
||||
# runs always provide thread_id and run_id in Runtime.context.
|
||||
return "unknown-thread", str(id(runtime))
|
||||
|
||||
def _clear(self, runtime: Runtime) -> None:
|
||||
key = self._key(runtime)
|
||||
with self._lock:
|
||||
self._retry_counts.pop(key, None)
|
||||
self._pending_prompts.pop(key, None)
|
||||
|
||||
def _clear_other_runs(self, runtime: Runtime) -> None:
|
||||
thread_id, run_id = self._key(runtime)
|
||||
with self._lock:
|
||||
stale = [key for key in self._retry_counts if key[0] == thread_id and key[1] != run_id]
|
||||
for key in stale:
|
||||
self._retry_counts.pop(key, None)
|
||||
self._pending_prompts.pop(key, None)
|
||||
|
||||
def _apply(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
messages = list(state.get("messages") or [])
|
||||
if not messages or not isinstance(messages[-1], AIMessage):
|
||||
return None
|
||||
|
||||
last = messages[-1]
|
||||
if _has_visible_content(last) or _has_tool_call_intent_or_error(last):
|
||||
if has_visible_content(last) or has_tool_call_intent(last):
|
||||
return None
|
||||
if not _tool_result_in_current_turn(messages):
|
||||
return None
|
||||
|
||||
key = self._key(runtime)
|
||||
with self._lock:
|
||||
# The recovery budget is once per run, not once per empty message.
|
||||
# A retry that calls another tool must not refresh the budget and
|
||||
# create an unbounded empty -> retry -> tool loop.
|
||||
retry_count = self._retry_counts.get(key, 0)
|
||||
if retry_count == 0:
|
||||
self._retry_counts[key] = 1
|
||||
self._pending_prompts[key] = True
|
||||
|
||||
if retry_count == 0:
|
||||
# The next model call gets a new message id. Remove this empty
|
||||
# terminal message now so a successful recovery does not leave it
|
||||
# in checkpoint history or future model context.
|
||||
message_updates = [RemoveMessage(id=last.id)] if last.id else []
|
||||
return {"messages": message_updates, "jump_to": "model"}
|
||||
|
||||
additional_kwargs = dict(last.additional_kwargs or {})
|
||||
additional_kwargs.update(
|
||||
{
|
||||
"deerflow_error_fallback": True,
|
||||
"error_reason": "Model returned an empty terminal response after one retry",
|
||||
"error_reason": "Model returned an empty terminal response",
|
||||
}
|
||||
)
|
||||
fallback = last.model_copy(
|
||||
update={
|
||||
"content": _FALLBACK_CONTENT,
|
||||
"content": append_visible_text(last, _FALLBACK_CONTENT),
|
||||
"additional_kwargs": additional_kwargs,
|
||||
}
|
||||
)
|
||||
return {"messages": [fallback]}
|
||||
|
||||
def _augment_request(self, request: ModelRequest) -> ModelRequest:
|
||||
key = self._key(request.runtime)
|
||||
with self._lock:
|
||||
pending = key in self._pending_prompts
|
||||
self._pending_prompts.pop(key, None)
|
||||
if not pending:
|
||||
return request
|
||||
reminder = HumanMessage(
|
||||
content=_RECOVERY_PROMPT,
|
||||
name="terminal_response_recovery",
|
||||
additional_kwargs={"hide_from_ui": True},
|
||||
)
|
||||
return request.override(messages=[*request.messages, reminder])
|
||||
|
||||
@override
|
||||
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_other_runs(runtime)
|
||||
# A prior invocation can bypass after_agent via Command(goto=END).
|
||||
# Reset the same run id here so resume starts with a fresh one-retry
|
||||
# budget; internal jump_to=model loops do not re-run before_agent.
|
||||
self._clear(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_other_runs(runtime)
|
||||
self._clear(runtime)
|
||||
return None
|
||||
|
||||
@hook_config(can_jump_to=["model"])
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
return self._apply(state, runtime)
|
||||
|
||||
@hook_config(can_jump_to=["model"])
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
return self._apply(state, runtime)
|
||||
|
||||
@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))
|
||||
|
||||
@override
|
||||
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def aafter_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear(runtime)
|
||||
return None
|
||||
|
||||
@ -297,6 +297,9 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
if not last_ai or _has_tool_call_intent_or_error(last_ai):
|
||||
return None
|
||||
|
||||
if (last_ai.additional_kwargs or {}).get("deerflow_error_fallback"):
|
||||
return None
|
||||
|
||||
# 3. Allow exit when all todos are completed or there are no todos.
|
||||
todos: list[Todo] = state.get("todos") or [] # type: ignore[assignment]
|
||||
if not todos or all(t.get("status") == "completed" for t in todos):
|
||||
|
||||
@ -23,12 +23,6 @@ def restore_assistant_payloads(
|
||||
restore: AssistantPayloadRestorer,
|
||||
) -> None:
|
||||
"""Restore provider-specific fields onto serialized assistant payloads."""
|
||||
if len(payload_messages) == len(original_messages):
|
||||
for payload_msg, orig_msg in zip(payload_messages, original_messages):
|
||||
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
|
||||
restore(payload_msg, orig_msg)
|
||||
return
|
||||
|
||||
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
|
||||
assistant_payloads = [m for m in payload_messages if m.get("role") == "assistant"]
|
||||
used_ai_indexes: set[int] = set()
|
||||
|
||||
@ -340,7 +340,11 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
||||
|
||||
_warn_unknown_model_settings(model_class, name, model_settings_from_config)
|
||||
|
||||
model_instance = model_class(**kwargs, **model_settings_from_config)
|
||||
# 配置提供默认值,调用方显式传入的非空参数统一覆盖配置。
|
||||
# 先合并再展开,避免同名字段通过两个 **dict 传入时触发 TypeError。
|
||||
effective_model_settings = dict(model_settings_from_config)
|
||||
effective_model_settings.update({key: value for key, value in kwargs.items() if value is not None})
|
||||
model_instance = model_class(**effective_model_settings)
|
||||
|
||||
if translate_context_window:
|
||||
# Applied *after* construction and merged into the provider's inferred
|
||||
|
||||
@ -10,11 +10,45 @@ on all assistant messages when thinking mode is enabled.
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
from deerflow.models.assistant_payload_replay import restore_assistant_payloads, restore_reasoning_content
|
||||
|
||||
|
||||
def _thinking_enabled(*sources: Any) -> bool:
|
||||
"""Return whether the request explicitly enables DeepSeek thinking mode."""
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
thinking = source.get("thinking")
|
||||
if isinstance(thinking, dict) and thinking.get("type") == "enabled":
|
||||
return True
|
||||
extra_body = source.get("extra_body")
|
||||
if isinstance(extra_body, dict):
|
||||
nested = extra_body.get("thinking")
|
||||
if isinstance(nested, dict) and nested.get("type") == "enabled":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _restore_deepseek_assistant_payload(
|
||||
payload_msg: dict[str, Any],
|
||||
orig_msg: AIMessage,
|
||||
*,
|
||||
thinking_enabled: bool,
|
||||
) -> None:
|
||||
"""Restore assistant history and required thinking-mode placeholders."""
|
||||
restore_reasoning_content(payload_msg, orig_msg)
|
||||
has_tool_calls = bool(payload_msg.get("tool_calls"))
|
||||
if has_tool_calls and payload_msg.get("content") is None:
|
||||
# DeepSeek requires an empty string, rather than null, for tool-call history.
|
||||
payload_msg["content"] = ""
|
||||
if thinking_enabled and has_tool_calls and "reasoning_content" not in payload_msg:
|
||||
# Thinking-mode tool turns require this field even when no reasoning was emitted.
|
||||
payload_msg["reasoning_content"] = ""
|
||||
|
||||
|
||||
class PatchedChatDeepSeek(ChatDeepSeek):
|
||||
"""ChatDeepSeek with proper reasoning_content preservation.
|
||||
|
||||
@ -44,16 +78,25 @@ class PatchedChatDeepSeek(ChatDeepSeek):
|
||||
Overrides the parent method to inject reasoning_content from
|
||||
additional_kwargs into assistant messages in the payload.
|
||||
"""
|
||||
# Get the original messages before conversion
|
||||
original_messages = self._convert_input(input_).to_messages()
|
||||
request_messages = [message for message in original_messages if not (isinstance(message, AIMessage) and (message.additional_kwargs or {}).get("deerflow_error_fallback"))]
|
||||
|
||||
# Call parent to get the base payload
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
payload = super()._get_request_payload(request_messages, stop=stop, **kwargs)
|
||||
|
||||
request_thinking_enabled = _thinking_enabled(
|
||||
payload,
|
||||
kwargs,
|
||||
{"extra_body": getattr(self, "extra_body", None)},
|
||||
)
|
||||
restore_assistant_payloads(
|
||||
payload.get("messages", []),
|
||||
original_messages,
|
||||
restore_reasoning_content,
|
||||
request_messages,
|
||||
lambda payload_msg, orig_msg: _restore_deepseek_assistant_payload(
|
||||
payload_msg,
|
||||
orig_msg,
|
||||
thinking_enabled=request_thinking_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
@ -9,10 +9,16 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware.types import ModelResponse
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from deerflow.agents.middlewares.llm_error_handling_middleware import (
|
||||
EmptyModelResponseError,
|
||||
LLMErrorHandlingMiddleware,
|
||||
)
|
||||
from deerflow.config.app_config import AppConfig, LlmCallConfig
|
||||
@ -235,6 +241,405 @@ def test_sync_model_call_uses_retry_after_header(monkeypatch: pytest.MonkeyPatch
|
||||
assert [event["type"] for event in events] == ["llm_retry"]
|
||||
|
||||
|
||||
def test_sync_empty_stop_retries_before_response_is_returned(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An empty stop is retried before the failed attempt reaches graph state."""
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
return AIMessage(content="recovered", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "recovered"
|
||||
assert attempts == 2
|
||||
|
||||
|
||||
def test_persistent_empty_stop_returns_explicit_error_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert isinstance(result, AIMessage)
|
||||
assert attempts == 2
|
||||
assert result.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
assert result.additional_kwargs["error_type"] == "EmptyModelResponseError"
|
||||
assert "empty response" in str(result.content).lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_empty_stop_retries_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
async def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
return AIMessage(content="recovered asynchronously", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
result = await middleware.awrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert result.content == "recovered asynchronously"
|
||||
assert attempts == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-1", "name": "bash", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "length"}),
|
||||
],
|
||||
)
|
||||
def test_nonempty_or_non_stop_response_is_not_classified_as_empty(message: AIMessage) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3)
|
||||
attempts = 0
|
||||
|
||||
def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return message
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert result is message
|
||||
assert attempts == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
AIMessage(content=" ", response_metadata={"finish_reason": "stop"}),
|
||||
AIMessage(content="", additional_kwargs={"reasoning_content": "thinking"}, response_metadata={"finish_reason": "stop"}),
|
||||
AIMessage(content=[{"type": "thinking", "thinking": "thinking"}], response_metadata={"finish_reason": "stop"}),
|
||||
AIMessage(content=[{"type": "reasoning", "reasoning": "thinking"}], response_metadata={"finish_reason": "stop"}),
|
||||
],
|
||||
)
|
||||
def test_nonvisible_stop_response_retries_then_returns_marked_fallback(
|
||||
message: AIMessage,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return message
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert attempts == 2
|
||||
assert result.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
def test_empty_response_fallback_preserves_reasoning_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
thinking_block = {"type": "thinking", "thinking": "internal reasoning"}
|
||||
message = AIMessage(
|
||||
content=[thinking_block],
|
||||
additional_kwargs={"reasoning_content": "provider reasoning"},
|
||||
response_metadata={"finish_reason": "stop"},
|
||||
)
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), lambda _request: message)
|
||||
|
||||
assert result.content[0] == thinking_block
|
||||
assert result.content[-1]["type"] == "text"
|
||||
assert "returned an empty response" in result.content[-1]["text"]
|
||||
assert result.additional_kwargs["reasoning_content"] == "provider reasoning"
|
||||
assert result.response_metadata == message.response_metadata
|
||||
|
||||
|
||||
def test_empty_model_response_container_retries_before_graph_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""生产环境的 ModelResponse.result 结构也必须在模型边界完成判空。"""
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> ModelResponse:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return ModelResponse(result=[AIMessage(content="", response_metadata={"finish_reason": "stop"})])
|
||||
return ModelResponse(result=[AIMessage(content="recovered", response_metadata={"finish_reason": "stop"})])
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert isinstance(result, ModelResponse)
|
||||
assert result.result[-1].content == "recovered"
|
||||
assert attempts == 2
|
||||
|
||||
|
||||
def test_empty_response_error_uses_one_retry_budget() -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3)
|
||||
|
||||
assert middleware._max_attempts_for(EmptyModelResponseError()) == 2
|
||||
assert middleware.release_policy_parameters() == {
|
||||
"empty_response_retry_limit": 1,
|
||||
"empty_response_retry_scope": "run",
|
||||
}
|
||||
|
||||
|
||||
def test_empty_response_retry_budget_is_shared_across_model_calls_in_one_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
request = SimpleNamespace(runtime=SimpleNamespace(context={"thread_id": "thread-1", "run_id": "run-1"}))
|
||||
first_attempts = 0
|
||||
second_attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def first_handler(_request) -> AIMessage:
|
||||
nonlocal first_attempts
|
||||
first_attempts += 1
|
||||
if first_attempts == 1:
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-1", "name": "bash", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
|
||||
def second_handler(_request) -> AIMessage:
|
||||
nonlocal second_attempts
|
||||
second_attempts += 1
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
first_result = middleware.wrap_model_call(request, first_handler)
|
||||
second_result = middleware.wrap_model_call(request, second_handler)
|
||||
|
||||
assert first_result.tool_calls
|
||||
assert first_attempts == 2
|
||||
assert second_attempts == 1
|
||||
assert second_result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
def test_caller_cannot_preconsume_empty_response_retry_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
request = SimpleNamespace(runtime=SimpleNamespace(context={"__empty_response_retry_consumed": True}))
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def empty_handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
result = middleware.wrap_model_call(request, empty_handler)
|
||||
|
||||
assert attempts == 2
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_empty_response_retry_budget_is_shared_across_model_calls_in_one_run(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
request = SimpleNamespace(runtime=SimpleNamespace(context={"thread_id": "thread-async", "run_id": "run-async"}))
|
||||
attempts = 0
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
async def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
first_result = await middleware.awrap_model_call(request, handler)
|
||||
second_result = await middleware.awrap_model_call(request, handler)
|
||||
|
||||
assert attempts == 3
|
||||
assert first_result.additional_kwargs["error_reason"] == "empty_response"
|
||||
assert second_result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
def test_empty_response_exhaustion_does_not_trip_circuit_breaker(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(
|
||||
circuit_failure_threshold=2,
|
||||
retry_max_attempts=2,
|
||||
retry_base_delay_ms=1,
|
||||
retry_cap_delay_ms=1,
|
||||
)
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def empty_handler(_request) -> AIMessage:
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
for index in range(3):
|
||||
request = SimpleNamespace(runtime=SimpleNamespace(context={"run_id": f"run-{index}"}))
|
||||
result = middleware.wrap_model_call(request, empty_handler)
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
healthy_calls = 0
|
||||
|
||||
def healthy_handler(_request) -> AIMessage:
|
||||
nonlocal healthy_calls
|
||||
healthy_calls += 1
|
||||
return AIMessage(content="healthy")
|
||||
|
||||
result = middleware.wrap_model_call(
|
||||
SimpleNamespace(runtime=SimpleNamespace(context={"run_id": "run-healthy"})),
|
||||
healthy_handler,
|
||||
)
|
||||
|
||||
assert middleware._circuit_failure_count == 0
|
||||
assert middleware._circuit_state == "closed"
|
||||
assert healthy_calls == 1
|
||||
assert result.content == "healthy"
|
||||
|
||||
|
||||
def test_empty_response_exhaustion_releases_half_open_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=2, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
middleware._circuit_state = "half_open"
|
||||
assert middleware._check_circuit() is False
|
||||
assert middleware._circuit_probe_in_flight is True
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
monkeypatch.setattr(middleware, "_check_circuit", lambda: False)
|
||||
|
||||
def empty_handler(_request) -> AIMessage:
|
||||
return AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
|
||||
result = middleware.wrap_model_call(
|
||||
SimpleNamespace(runtime=SimpleNamespace(context={"run_id": "half-open-run"})),
|
||||
empty_handler,
|
||||
)
|
||||
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
assert middleware._circuit_state == "half_open"
|
||||
assert middleware._circuit_probe_in_flight is False
|
||||
|
||||
|
||||
class _EmptyThenRecoveredGraphModel(BaseChatModel):
|
||||
call_count: int = 0
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "empty-then-recovered"
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
self.call_count += 1
|
||||
content = "" if self.call_count == 1 else "recovered through graph stream"
|
||||
message = AIMessage(content=content, response_metadata={"finish_reason": "stop"})
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_response_recovery_runs_through_create_agent_astream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Exercise the same create_agent().astream() path used by Gateway."""
|
||||
model = _EmptyThenRecoveredGraphModel()
|
||||
middleware = _build_middleware(retry_max_attempts=2, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
agent = create_agent(model=model, tools=[], middleware=[middleware], context_schema=dict)
|
||||
states = [
|
||||
state
|
||||
async for state in agent.astream(
|
||||
{"messages": [HumanMessage(content="hello")]},
|
||||
stream_mode="values",
|
||||
context={"thread_id": "stream-thread", "run_id": "stream-run"},
|
||||
)
|
||||
]
|
||||
|
||||
assert model.call_count == 2
|
||||
assert states[-1]["messages"][-1].content == "recovered through graph stream"
|
||||
|
||||
|
||||
class _EmptyRetryToolThenEmptyGraphModel(BaseChatModel):
|
||||
call_count: int = 0
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "empty-retry-tool-then-empty"
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
self.call_count += 1
|
||||
if self.call_count == 2:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-probe", "name": "probe", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
else:
|
||||
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_response_retry_budget_survives_real_agent_tool_loop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""真实工具循环中,整个 run 只能消费一次空响应重试。"""
|
||||
model = _EmptyRetryToolThenEmptyGraphModel()
|
||||
middleware = _build_middleware(retry_max_attempts=2, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
tool_invocations: list[str] = []
|
||||
|
||||
def probe() -> str:
|
||||
tool_invocations.append("probe")
|
||||
return "probe-result"
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
tool = StructuredTool.from_function(probe, name="probe", description="Record one deterministic invocation")
|
||||
agent = create_agent(model=model, tools=[tool], middleware=[middleware], context_schema=dict)
|
||||
states = [
|
||||
state
|
||||
async for state in agent.astream(
|
||||
{"messages": [HumanMessage(content="use the probe")]},
|
||||
stream_mode="values",
|
||||
context={"thread_id": "tool-loop-thread", "run_id": "tool-loop-run"},
|
||||
)
|
||||
]
|
||||
|
||||
final_message = states[-1]["messages"][-1]
|
||||
assert model.call_count == 3
|
||||
assert tool_invocations == ["probe"]
|
||||
assert final_message.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert final_message.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
def test_sync_retry_event_preserves_langgraph_control_flow(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware()
|
||||
|
||||
@ -782,6 +1187,48 @@ class _StreamChunkTimeoutError(Exception):
|
||||
_StreamChunkTimeoutError.__name__ = "StreamChunkTimeoutError"
|
||||
|
||||
|
||||
class _ReadTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_ReadTimeoutError.__name__ = "ReadTimeout"
|
||||
|
||||
|
||||
def test_read_timeout_is_retried_and_exhaustion_returns_marked_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> AIMessage:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise _ReadTimeoutError("no bytes received before read deadline")
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert attempts == 2
|
||||
assert result.additional_kwargs["error_reason"] == "transient"
|
||||
assert result.additional_kwargs["error_type"] == "ReadTimeout"
|
||||
|
||||
|
||||
def test_empty_model_result_is_retried_before_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
middleware = _build_middleware(retry_max_attempts=2, retry_base_delay_ms=1, retry_cap_delay_ms=1)
|
||||
attempts = 0
|
||||
monkeypatch.setattr("time.sleep", lambda _delay: None)
|
||||
|
||||
def handler(_request) -> ModelResponse:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return ModelResponse(result=[])
|
||||
|
||||
result = middleware.wrap_model_call(SimpleNamespace(), handler)
|
||||
|
||||
assert attempts == 2
|
||||
assert result.additional_kwargs["error_reason"] == "empty_response"
|
||||
|
||||
|
||||
def test_classify_error_stream_chunk_timeout_is_retriable() -> None:
|
||||
"""StreamChunkTimeoutError must be classified as transient/retriable."""
|
||||
middleware = _build_middleware()
|
||||
|
||||
@ -136,6 +136,20 @@ def _make_terminal_response_middleware():
|
||||
return TerminalResponseMiddleware()
|
||||
|
||||
|
||||
def _make_llm_error_handling_middleware():
|
||||
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
|
||||
return LLMErrorHandlingMiddleware(app_config=AppConfig(sandbox=SandboxConfig(use="test")))
|
||||
|
||||
|
||||
def _make_model_length_finish_reason_middleware():
|
||||
from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware
|
||||
|
||||
return ModelLengthFinishReasonMiddleware()
|
||||
|
||||
|
||||
def _make_todo_middleware():
|
||||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||
|
||||
@ -219,6 +233,12 @@ _MIDDLEWARE_DECLARATIONS = [
|
||||
("deerflow.agents.middlewares.loop_detection_middleware", "LoopDetectionMiddleware", _make_loop_detection_middleware),
|
||||
("deerflow.agents.middlewares.subagent_limit_middleware", "SubagentLimitMiddleware", _make_subagent_limit_middleware),
|
||||
("deerflow.agents.middlewares.terminal_response_middleware", "TerminalResponseMiddleware", _make_terminal_response_middleware),
|
||||
("deerflow.agents.middlewares.llm_error_handling_middleware", "LLMErrorHandlingMiddleware", _make_llm_error_handling_middleware),
|
||||
(
|
||||
"deerflow.agents.middlewares.model_length_finish_reason_middleware",
|
||||
"ModelLengthFinishReasonMiddleware",
|
||||
_make_model_length_finish_reason_middleware,
|
||||
),
|
||||
# DeerFlow's own subclass, not the LangChain base class re-exported into
|
||||
# this module under the same import path (TodoListMiddleware).
|
||||
("deerflow.agents.middlewares.todo_middleware", "TodoMiddleware", _make_todo_middleware),
|
||||
|
||||
@ -1264,6 +1264,48 @@ def test_no_duplicate_kwarg_when_reasoning_effort_in_config_and_thinking_disable
|
||||
assert captured.get("reasoning_effort") == "minimal"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("runtime_effort", "expected_effort"),
|
||||
[
|
||||
(None, "high"),
|
||||
("low", "low"),
|
||||
],
|
||||
)
|
||||
def test_runtime_reasoning_effort_merges_with_profile_without_duplicate_kwarg(
|
||||
monkeypatch,
|
||||
runtime_effort,
|
||||
expected_effort,
|
||||
):
|
||||
model = ModelConfig(
|
||||
name="deepseek-reasoner",
|
||||
display_name="DeepSeek Reasoner",
|
||||
description=None,
|
||||
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
|
||||
model="deepseek-reasoner",
|
||||
reasoning_effort="high",
|
||||
supports_thinking=True,
|
||||
supports_reasoning_effort=True,
|
||||
supports_vision=False,
|
||||
)
|
||||
cfg = _make_app_config([model])
|
||||
captured: dict = {}
|
||||
|
||||
class CapturingModel(FakeChatModel):
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
BaseChatModel.__init__(self, **kwargs)
|
||||
|
||||
_patch_factory(monkeypatch, cfg, model_class=CapturingModel)
|
||||
|
||||
factory_module.create_chat_model(
|
||||
name="deepseek-reasoner",
|
||||
thinking_enabled=True,
|
||||
reasoning_effort=runtime_effort,
|
||||
)
|
||||
|
||||
assert captured["reasoning_effort"] == expected_effort
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("profile", "thinking_enabled", "requested_effort", "expected_effort"),
|
||||
[
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||
from deerflow.agents.middlewares.model_length_finish_reason_middleware import (
|
||||
MODEL_LENGTH_CAPPED_STOP_REASON,
|
||||
ModelLengthFinishReasonMiddleware,
|
||||
@ -110,11 +112,28 @@ def test_length_cap_detection_logs_observability_fields(caplog):
|
||||
assert record.stamped_stop_reason is True
|
||||
|
||||
|
||||
def test_finish_reason_length_with_tool_calls_passes_through():
|
||||
def test_finish_reason_length_drops_potentially_truncated_tool_calls():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
msg = AIMessage(
|
||||
content="",
|
||||
content=[
|
||||
{"type": "text", "text": "partial answer"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_write_1",
|
||||
"name": "write_file",
|
||||
"input": {"path": "/mnt/user-data/outputs/report.md"},
|
||||
},
|
||||
],
|
||||
additional_kwargs={
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_write_1",
|
||||
"type": "function",
|
||||
"function": {"name": "write_file", "arguments": '{"path":"/tmp/report.md"'},
|
||||
}
|
||||
]
|
||||
},
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_write_1",
|
||||
@ -125,17 +144,151 @@ def test_finish_reason_length_with_tool_calls_passes_through():
|
||||
response_metadata={"finish_reason": "length"},
|
||||
)
|
||||
|
||||
assert mw._apply({"messages": [msg]}, runtime) is None
|
||||
assert "stop_reason" not in runtime.context
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert replacement.tool_calls == []
|
||||
assert replacement.invalid_tool_calls == []
|
||||
assert replacement.content == [{"type": "text", "text": "partial answer"}]
|
||||
assert "tool_calls" not in replacement.additional_kwargs
|
||||
assert replacement.additional_kwargs["model_length_termination"]["suppressed_tool_call_count"] == 1
|
||||
assert replacement.additional_kwargs["model_length_termination"]["suppressed_tool_call_names"] == ["write_file"]
|
||||
assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
|
||||
|
||||
|
||||
def test_empty_finish_reason_length_passes_through_for_terminal_response_recovery():
|
||||
def test_anthropic_content_only_tool_use_is_removed_before_next_request():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
msg = AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_write_1",
|
||||
"name": "write_file",
|
||||
"input": {"path": "/mnt/user-data/outputs/report.md"},
|
||||
}
|
||||
],
|
||||
response_metadata={"stop_reason": "max_tokens"},
|
||||
)
|
||||
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert all(block.get("type") != "tool_use" for block in replacement.content)
|
||||
metadata = replacement.additional_kwargs["model_length_termination"]
|
||||
assert metadata["suppressed_tool_call_count"] == 1
|
||||
assert metadata["suppressed_tool_call_names"] == ["write_file"]
|
||||
assert msg.content[0]["type"] == "tool_use"
|
||||
|
||||
messages = [HumanMessage("write a report"), replacement, HumanMessage("continue")]
|
||||
repaired = DanglingToolCallMiddleware()._build_patched_messages(messages) or messages
|
||||
payload = ChatAnthropic(model="claude-sonnet-4-5", api_key="test")._get_request_payload(repaired)
|
||||
assistant_message = next(item for item in payload["messages"] if item["role"] == "assistant")
|
||||
assert all(block.get("type") != "tool_use" for block in assistant_message["content"])
|
||||
|
||||
|
||||
def test_anthropic_thinking_is_preserved_when_native_tool_use_is_removed():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
thinking_block = {
|
||||
"type": "thinking",
|
||||
"thinking": "Need to write the file.",
|
||||
"signature": "signed",
|
||||
}
|
||||
msg = AIMessage(
|
||||
content=[
|
||||
thinking_block,
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_write_1",
|
||||
"name": "write_file",
|
||||
"input": {"path": "/mnt/user-data/outputs/report.md"},
|
||||
},
|
||||
],
|
||||
response_metadata={"stop_reason": "max_tokens"},
|
||||
)
|
||||
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
content = result["messages"][0].content
|
||||
assert thinking_block in content
|
||||
assert all(block.get("type") != "tool_use" for block in content)
|
||||
assert content[-1]["type"] == "text"
|
||||
assert "output limit" in content[-1]["text"]
|
||||
|
||||
|
||||
def test_finish_reason_length_suppresses_complete_tool_call_as_safety_policy():
|
||||
"""Even parsed arguments cannot be proven complete after a length cap."""
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_write_complete",
|
||||
"name": "write_file",
|
||||
"args": {"path": "/mnt/user-data/outputs/report.md", "content": "complete"},
|
||||
}
|
||||
],
|
||||
response_metadata={"finish_reason": "length"},
|
||||
)
|
||||
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert replacement.tool_calls == []
|
||||
assert replacement.additional_kwargs["model_length_termination"]["suppressed_tool_call_names"] == ["write_file"]
|
||||
assert "output limit" in str(replacement.content)
|
||||
|
||||
|
||||
def test_empty_finish_reason_length_gets_visible_capped_message():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
msg = AIMessage(content="", response_metadata={"finish_reason": "length"})
|
||||
|
||||
assert mw._apply({"messages": [msg]}, runtime) is None
|
||||
assert "stop_reason" not in runtime.context
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert "output limit" in replacement.content
|
||||
assert replacement.response_metadata["finish_reason"] == "length"
|
||||
assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
|
||||
|
||||
|
||||
def test_reasoning_only_length_preserves_reasoning_when_adding_visible_message():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
msg = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "internal reasoning"},
|
||||
response_metadata={"finish_reason": "length"},
|
||||
)
|
||||
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert replacement.additional_kwargs["reasoning_content"] == "internal reasoning"
|
||||
assert "output limit" in replacement.content
|
||||
|
||||
|
||||
def test_thinking_blocks_are_preserved_when_length_notice_is_appended():
|
||||
mw = ModelLengthFinishReasonMiddleware()
|
||||
runtime = _runtime()
|
||||
thinking_block = {"type": "thinking", "thinking": "internal reasoning"}
|
||||
msg = AIMessage(content=[thinking_block], response_metadata={"finish_reason": "length"})
|
||||
|
||||
result = mw._apply({"messages": [msg]}, runtime)
|
||||
|
||||
assert result is not None
|
||||
content = result["messages"][0].content
|
||||
assert content[0] == thinking_block
|
||||
assert content[-1]["type"] == "text"
|
||||
assert "output limit" in content[-1]["text"]
|
||||
|
||||
|
||||
def test_existing_stop_reason_is_not_overwritten(caplog):
|
||||
|
||||
@ -83,21 +83,36 @@ def _make_payload_message(role: str, content: str | None = None, tool_calls: lis
|
||||
return msg
|
||||
|
||||
|
||||
_TOOL_SPEC = [{"type": "function", "function": {"name": "bash", "parameters": {}}}]
|
||||
|
||||
|
||||
def test_reasoning_content_injected_into_assistant_message():
|
||||
"""reasoning_content from additional_kwargs is restored in the payload."""
|
||||
model = _make_model()
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
|
||||
human = HumanMessage(content="What is 2+2?")
|
||||
ai = AIMessage(
|
||||
content="4",
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "Let me think: 2+2=4"},
|
||||
tool_calls=[{"id": "call-1", "name": "calculator", "args": {"expression": "2+2"}}],
|
||||
)
|
||||
|
||||
base_payload = {
|
||||
"tools": _TOOL_SPEC,
|
||||
"messages": [
|
||||
_make_payload_message("user", "What is 2+2?"),
|
||||
_make_payload_message("assistant", "4"),
|
||||
]
|
||||
_make_payload_message(
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "calculator", "arguments": '{"expression":"2+2"}'},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
@ -107,6 +122,7 @@ def test_reasoning_content_injected_into_assistant_message():
|
||||
|
||||
assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant")
|
||||
assert assistant_msg["reasoning_content"] == "Let me think: 2+2=4"
|
||||
assert assistant_msg["content"] == ""
|
||||
|
||||
|
||||
def test_no_reasoning_content_is_noop():
|
||||
@ -132,22 +148,104 @@ def test_no_reasoning_content_is_noop():
|
||||
assert "reasoning_content" not in assistant_msg
|
||||
|
||||
|
||||
def test_thinking_tool_call_without_reasoning_gets_empty_placeholder():
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
human = HumanMessage(content="Check the repository")
|
||||
ai = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-1", "name": "bash", "args": {"command": "git status"}}],
|
||||
)
|
||||
base_payload = {
|
||||
"messages": [
|
||||
_make_payload_message("user", "Check the repository"),
|
||||
_make_payload_message(
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"git status"}'},
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai])
|
||||
payload = model._get_request_payload([human, ai])
|
||||
|
||||
assistant_msg = payload["messages"][1]
|
||||
assert assistant_msg["content"] == ""
|
||||
assert assistant_msg["reasoning_content"] == ""
|
||||
|
||||
|
||||
def test_disabled_thinking_tool_call_does_not_invent_reasoning_placeholder():
|
||||
"""关闭思考时不为原本不存在的 reasoning_content 扩展协议形状。"""
|
||||
model = _make_model(extra_body={"thinking": {"type": "disabled"}})
|
||||
ai = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-1", "name": "bash", "args": {"command": "pwd"}}],
|
||||
)
|
||||
base_payload = {
|
||||
"messages": [
|
||||
_make_payload_message(
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command":"pwd"}'},
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [ai])
|
||||
payload = model._get_request_payload([ai])
|
||||
|
||||
assistant_msg = payload["messages"][0]
|
||||
assert assistant_msg["content"] == ""
|
||||
assert "reasoning_content" not in assistant_msg
|
||||
|
||||
|
||||
def test_reasoning_content_multi_turn():
|
||||
"""All assistant turns each get their own reasoning_content."""
|
||||
model = _make_model()
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
|
||||
human1 = HumanMessage(content="Step 1?")
|
||||
ai1 = AIMessage(content="A1", additional_kwargs={"reasoning_content": "Thought1"})
|
||||
ai1 = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "Thought1"},
|
||||
tool_calls=[{"id": "call-1", "name": "lookup", "args": {}}],
|
||||
)
|
||||
human2 = HumanMessage(content="Step 2?")
|
||||
ai2 = AIMessage(content="A2", additional_kwargs={"reasoning_content": "Thought2"})
|
||||
|
||||
base_payload = {
|
||||
"tools": _TOOL_SPEC,
|
||||
"messages": [
|
||||
_make_payload_message("user", "Step 1?"),
|
||||
_make_payload_message("assistant", "A1"),
|
||||
_make_payload_message(
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
),
|
||||
_make_payload_message("user", "Step 2?"),
|
||||
_make_payload_message("assistant", "A2"),
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
@ -162,19 +260,34 @@ def test_reasoning_content_multi_turn():
|
||||
|
||||
def test_positional_fallback_when_count_differs():
|
||||
"""Falls back to positional matching when payload/original message counts differ."""
|
||||
model = _make_model()
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
|
||||
human = HumanMessage(content="hi")
|
||||
ai = AIMessage(content="hello", additional_kwargs={"reasoning_content": "My reasoning"})
|
||||
ai = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "My reasoning"},
|
||||
tool_calls=[{"id": "call-1", "name": "lookup", "args": {}}],
|
||||
)
|
||||
|
||||
# Simulate count mismatch: payload has 3 messages, original has 2
|
||||
extra_system = _make_payload_message("system", "You are helpful.")
|
||||
base_payload = {
|
||||
"tools": _TOOL_SPEC,
|
||||
"messages": [
|
||||
extra_system,
|
||||
_make_payload_message("user", "hi"),
|
||||
_make_payload_message("assistant", "hello"),
|
||||
]
|
||||
_make_payload_message(
|
||||
"assistant",
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
@ -184,3 +297,45 @@ def test_positional_fallback_when_count_differs():
|
||||
|
||||
assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant")
|
||||
assert assistant_msg["reasoning_content"] == "My reasoning"
|
||||
|
||||
|
||||
def test_thinking_does_not_replay_reasoning_for_assistant_without_tool_calls():
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
human = HumanMessage(content="continue")
|
||||
ai = AIMessage(content="local response")
|
||||
base_payload = {
|
||||
"tools": _TOOL_SPEC,
|
||||
"messages": [
|
||||
_make_payload_message("assistant", "local response"),
|
||||
_make_payload_message("user", "continue"),
|
||||
],
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [ai, human])
|
||||
payload = model._get_request_payload([ai, human])
|
||||
|
||||
assistant_msg = next(m for m in payload["messages"] if m["role"] == "assistant")
|
||||
assert "reasoning_content" not in assistant_msg
|
||||
|
||||
|
||||
def test_legacy_fallback_assistant_is_removed_before_deepseek_replay():
|
||||
model = _make_model(extra_body={"thinking": {"type": "enabled"}})
|
||||
fallback = AIMessage(
|
||||
content="temporary provider error",
|
||||
additional_kwargs={"deerflow_error_fallback": True},
|
||||
)
|
||||
human = HumanMessage(content="retry")
|
||||
base_payload = {
|
||||
"tools": _TOOL_SPEC,
|
||||
"messages": [_make_payload_message("user", "retry")],
|
||||
}
|
||||
|
||||
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
||||
with patch.object(model, "_convert_input") as mock_convert:
|
||||
mock_convert.return_value = MagicMock(to_messages=lambda: [fallback, human])
|
||||
payload = model._get_request_payload([fallback, human])
|
||||
|
||||
assert payload["messages"] == base_payload["messages"]
|
||||
assert not any(message.get("role") == "assistant" for message in payload["messages"])
|
||||
|
||||
@ -5,7 +5,7 @@ from typing import Any
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@ -20,9 +20,8 @@ def lookup_status() -> str:
|
||||
|
||||
|
||||
class _PostToolResponseModel(BaseChatModel):
|
||||
responses: list[str]
|
||||
response: AIMessage
|
||||
call_count: int = 0
|
||||
observed_messages: list[list[Any]] = []
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
@ -32,7 +31,6 @@ class _PostToolResponseModel(BaseChatModel):
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
self.observed_messages.append(list(messages))
|
||||
self.call_count += 1
|
||||
if self.call_count == 1:
|
||||
message = AIMessage(
|
||||
@ -41,46 +39,7 @@ class _PostToolResponseModel(BaseChatModel):
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
else:
|
||||
message = AIMessage(
|
||||
content=self.responses[self.call_count - 2],
|
||||
response_metadata={"finish_reason": "stop"},
|
||||
)
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||
|
||||
|
||||
class _PerRunRetryBudgetModel(BaseChatModel):
|
||||
call_count: int = 0
|
||||
observed_messages: list[list[Any]] = []
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "per-run-retry-budget"
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
self.observed_messages.append(list(messages))
|
||||
self.call_count += 1
|
||||
if self.call_count == 1:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-budget-1", "name": "lookup_status", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
elif self.call_count == 2:
|
||||
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
elif self.call_count == 3:
|
||||
message = AIMessage(
|
||||
content="I need one more status check.",
|
||||
tool_calls=[{"id": "call-budget-2", "name": "lookup_status", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
else:
|
||||
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
message = self.response
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
@ -95,184 +54,137 @@ def _agent(model: BaseChatModel):
|
||||
)
|
||||
|
||||
|
||||
def _empty_terminal_messages(messages: list[Any]) -> list[AIMessage]:
|
||||
return [message for message in messages if isinstance(message, AIMessage) and not message.tool_calls and not message.invalid_tool_calls and not str(message.content).strip()]
|
||||
def _runtime(run_id: str = "run-1"):
|
||||
return type("RuntimeStub", (), {"context": {"thread_id": "thread-1", "run_id": run_id}})()
|
||||
|
||||
|
||||
def test_retries_empty_post_tool_response_once_and_returns_model_answer():
|
||||
model = _PostToolResponseModel(responses=["", "The tool completed successfully."])
|
||||
def test_empty_post_tool_response_becomes_fallback_without_graph_retry():
|
||||
model = _PostToolResponseModel(response=AIMessage(content="", response_metadata={"finish_reason": "stop"}))
|
||||
|
||||
result = _agent(model).invoke(
|
||||
{"messages": [HumanMessage(content="Check the status")]},
|
||||
context={"thread_id": "thread-1", "run_id": "run-1"},
|
||||
)
|
||||
|
||||
assert model.call_count == 3
|
||||
final = result["messages"][-1]
|
||||
assert isinstance(final, AIMessage)
|
||||
assert final.content == "The tool completed successfully."
|
||||
assert _empty_terminal_messages(result["messages"]) == []
|
||||
assert any(isinstance(message, HumanMessage) and message.name == "terminal_response_recovery" and message.additional_kwargs.get("hide_from_ui") is True for message in model.observed_messages[-1])
|
||||
assert not any(isinstance(message, HumanMessage) and message.name == "terminal_response_recovery" for message in result["messages"])
|
||||
|
||||
|
||||
def test_second_empty_post_tool_response_becomes_visible_error_fallback():
|
||||
model = _PostToolResponseModel(responses=["", ""])
|
||||
|
||||
result = _agent(model).invoke(
|
||||
{"messages": [HumanMessage(content="Check the status")]},
|
||||
context={"thread_id": "thread-2", "run_id": "run-2"},
|
||||
)
|
||||
|
||||
assert model.call_count == 3
|
||||
assert model.call_count == 2
|
||||
final = result["messages"][-1]
|
||||
assert isinstance(final, AIMessage)
|
||||
assert "returned no final response" in str(final.content)
|
||||
assert final.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert _empty_terminal_messages(result["messages"]) == []
|
||||
assert _extract_llm_error_fallback_message(result) == ("Model returned an empty terminal response after one retry")
|
||||
assert _extract_llm_error_fallback_message(result) == "Model returned an empty terminal response"
|
||||
assert not any(isinstance(message, RemoveMessage) for message in result["messages"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_graph_retries_empty_post_tool_response_once():
|
||||
model = _PostToolResponseModel(responses=["", "Recovered asynchronously."])
|
||||
async def test_async_empty_post_tool_response_becomes_fallback_without_graph_retry():
|
||||
model = _PostToolResponseModel(response=AIMessage(content="", response_metadata={"finish_reason": "stop"}))
|
||||
|
||||
result = await _agent(model).ainvoke(
|
||||
{"messages": [HumanMessage(content="Check the status")]},
|
||||
context={"thread_id": "thread-async", "run_id": "run-async"},
|
||||
)
|
||||
|
||||
assert model.call_count == 3
|
||||
assert result["messages"][-1].content == "Recovered asynchronously."
|
||||
assert _empty_terminal_messages(result["messages"]) == []
|
||||
assert model.call_count == 2
|
||||
assert result["messages"][-1].additional_kwargs["deerflow_error_fallback"] is True
|
||||
|
||||
|
||||
def test_graph_with_thread_id_only_keeps_recovery_state_across_model_loop():
|
||||
model = _PostToolResponseModel(responses=["", "Recovered without a run id."])
|
||||
def test_direct_fallback_replaces_same_message_without_remove_or_jump():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
empty = AIMessage(id="empty-1", content="", response_metadata={"finish_reason": "stop"})
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="Check the status"),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-1"),
|
||||
empty,
|
||||
]
|
||||
}
|
||||
|
||||
result = _agent(model).invoke(
|
||||
{"messages": [HumanMessage(content="Check the status")]},
|
||||
context={"thread_id": "thread-only"},
|
||||
)
|
||||
result = middleware.after_model(state, _runtime())
|
||||
|
||||
assert model.call_count == 3
|
||||
assert result["messages"][-1].content == "Recovered without a run id."
|
||||
assert _empty_terminal_messages(result["messages"]) == []
|
||||
assert result is not None
|
||||
assert "jump_to" not in result
|
||||
assert len(result["messages"]) == 1
|
||||
replacement = result["messages"][0]
|
||||
assert isinstance(replacement, AIMessage)
|
||||
assert replacement.id == "empty-1"
|
||||
assert replacement.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert not isinstance(replacement, RemoveMessage)
|
||||
|
||||
|
||||
def test_recovery_budget_is_once_per_run_even_when_retry_calls_another_tool():
|
||||
model = _PerRunRetryBudgetModel()
|
||||
|
||||
result = _agent(model).invoke(
|
||||
{"messages": [HumanMessage(content="Check the status twice")]},
|
||||
context={"thread_id": "thread-budget", "run_id": "run-budget"},
|
||||
)
|
||||
|
||||
assert model.call_count == 4
|
||||
final = result["messages"][-1]
|
||||
assert final.additional_kwargs["deerflow_error_fallback"] is True
|
||||
assert _empty_terminal_messages(result["messages"]) == []
|
||||
recovery_prompt_count = sum(1 for request_messages in model.observed_messages for message in request_messages if isinstance(message, HumanMessage) and message.name == "terminal_response_recovery")
|
||||
assert recovery_prompt_count == 1
|
||||
|
||||
|
||||
def test_empty_response_without_tool_result_is_not_retried():
|
||||
def test_empty_response_without_tool_result_is_not_handled_by_terminal_guard():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
|
||||
state = {"messages": [HumanMessage(content="Hello"), message]}
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-3", "run_id": "run-3"}})()
|
||||
|
||||
assert middleware.after_model(state, runtime) is None
|
||||
assert middleware.after_model(state, _runtime()) is None
|
||||
|
||||
|
||||
def test_tool_call_intent_is_not_treated_as_empty_terminal_response():
|
||||
def test_tool_call_is_not_treated_as_empty_terminal():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"id": "call-2", "name": "lookup_status", "args": {}}],
|
||||
response_metadata={"finish_reason": "tool_calls"},
|
||||
)
|
||||
state = {"messages": [HumanMessage(content="Hello"), message]}
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-4", "run_id": "run-4"}})()
|
||||
state: dict[str, list[Any]] = {
|
||||
"messages": [
|
||||
HumanMessage(content="Check the status"),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-2"),
|
||||
message,
|
||||
]
|
||||
}
|
||||
|
||||
assert middleware.after_model(state, runtime) is None
|
||||
assert middleware.after_model(state, _runtime()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
AIMessage(content="", invalid_tool_calls=[{"id": "bad-1", "name": "lookup_status", "args": "{"}]),
|
||||
AIMessage(content="", additional_kwargs={"function_call": {"name": "lookup_status", "arguments": "{}"}}),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "function_call"}),
|
||||
AIMessage(content=" ", response_metadata={"finish_reason": "stop"}),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "thinking"},
|
||||
response_metadata={"finish_reason": "stop"},
|
||||
),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "length"}),
|
||||
],
|
||||
)
|
||||
def test_invalid_or_legacy_tool_call_intent_is_not_treated_as_empty_terminal_response(message):
|
||||
def test_nonvisible_post_tool_response_becomes_terminal_fallback(message: AIMessage):
|
||||
middleware = TerminalResponseMiddleware()
|
||||
state = {"messages": [HumanMessage(content="Hello"), message]}
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-5", "run_id": "run-5"}})()
|
||||
|
||||
assert middleware.after_model(state, runtime) is None
|
||||
|
||||
|
||||
def test_after_agent_clears_retry_state_for_the_run():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-6", "run_id": "run-6"}})()
|
||||
empty_after_tool = {
|
||||
state: dict[str, list[Any]] = {
|
||||
"messages": [
|
||||
HumanMessage(content="Check the status"),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-6"),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-2"),
|
||||
message,
|
||||
]
|
||||
}
|
||||
|
||||
first = middleware.after_model(empty_after_tool, runtime)
|
||||
assert first is not None and first["jump_to"] == "model"
|
||||
middleware.after_agent(empty_after_tool, runtime)
|
||||
second = middleware.after_model(empty_after_tool, runtime)
|
||||
assert second is not None and second["jump_to"] == "model"
|
||||
result = middleware.after_model(state, _runtime())
|
||||
|
||||
assert result is not None
|
||||
replacement = result["messages"][0]
|
||||
assert "returned no final response" in str(replacement.content)
|
||||
assert replacement.additional_kwargs["deerflow_error_fallback"] is True
|
||||
if "reasoning_content" in message.additional_kwargs:
|
||||
assert replacement.additional_kwargs["reasoning_content"] == "thinking"
|
||||
|
||||
|
||||
def test_before_agent_clears_same_run_state_for_resumed_invocation():
|
||||
def test_thinking_blocks_are_preserved_when_terminal_fallback_is_appended():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-7", "run_id": "run-7"}})()
|
||||
empty_after_tool = {
|
||||
"messages": [
|
||||
HumanMessage(content="Check the status"),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-7"),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
|
||||
]
|
||||
}
|
||||
|
||||
first = middleware.after_model(empty_after_tool, runtime)
|
||||
assert first is not None and first["jump_to"] == "model"
|
||||
middleware.before_agent(empty_after_tool, runtime)
|
||||
resumed = middleware.after_model(empty_after_tool, runtime)
|
||||
assert resumed is not None and resumed["jump_to"] == "model"
|
||||
|
||||
|
||||
def test_tool_history_without_real_user_message_does_not_trigger_recovery():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-8", "run_id": "run-8"}})()
|
||||
thinking_block = {"type": "thinking", "thinking": "internal reasoning"}
|
||||
message = AIMessage(content=[thinking_block], response_metadata={"finish_reason": "stop"})
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="internal", additional_kwargs={"hide_from_ui": True}),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-8"),
|
||||
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
|
||||
HumanMessage(content="Check the status"),
|
||||
ToolMessage(content="tool completed", tool_call_id="call-2"),
|
||||
message,
|
||||
]
|
||||
}
|
||||
|
||||
assert middleware.after_model(state, runtime) is None
|
||||
result = middleware.after_model(state, _runtime())
|
||||
|
||||
|
||||
def test_abandoned_run_state_is_bounded():
|
||||
middleware = TerminalResponseMiddleware()
|
||||
|
||||
for index in range(1001):
|
||||
key = (f"thread-{index}", f"run-{index}")
|
||||
middleware._retry_counts[key] = 1
|
||||
middleware._pending_prompts[key] = True
|
||||
|
||||
assert len(middleware._retry_counts) == 1000
|
||||
assert len(middleware._pending_prompts) == 1000
|
||||
assert ("thread-0", "run-0") not in middleware._retry_counts
|
||||
assert ("thread-0", "run-0") not in middleware._pending_prompts
|
||||
assert result is not None
|
||||
content = result["messages"][0].content
|
||||
assert content[0] == thinking_block
|
||||
assert content[-1]["type"] == "text"
|
||||
assert "returned no final response" in content[-1]["text"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user