mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* fix(middlewares): end length-capped turns cleanly, prevent todo re-engagement, annotate write_file budget When a model hits its per-response output cap (finish_reason=length) while emitting a write_file tool call, ModelLengthFinishReasonMiddleware suppresses the truncated call and stamps model_length_termination. TodoMiddleware must not re-engage (jump_to=model) on such a capped turn -- doing so re-emits the same oversized call into the same cap, producing up to 3 futile responses with junk fragments instead of a clean truncation notice. Changes: - TodoMiddleware.after_model: skip completion reminder jump when additional_kwargs.model_length_termination is present (follows the existing deerflow_error_fallback precedent). - ModelLengthFinishReasonMiddleware: always append the length notice when tool calls were suppressed, even when partial text survived (collapses the visible-content ternary). Fixes a latent bug in append_visible_text that silently dropped string content. - tools.get_available_tools: annotate write_file's model-visible description with the model's configured max_tokens output budget. Guarded extraction safely handles missing or non-numeric tokens, and the tool is cloned via model_copy to keep module-level singletons immutable across assemblies and prevent guidance leakage to unbudgeted models. - release_policy_parameters() updated for both middlewares. - AGENTS.md chain entries (#20, #35) and module docstrings updated within AG002 guidance limits. - Tests: 8 new/focused unit tests + 1 updated pin + 1 real create_agent() integration test reproducing the incident (thread b1723286). * fix(tools): use effective model cap for write_file guidance --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
"""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}]
|
|
if isinstance(message.content, str) and message.content.strip():
|
|
return f"{message.content}\n\n{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
|