fix(tools): ignore unusable output budget limits (#6516)

* fix(tools): ignore non-finite output budget limits

* fix(tools): skip overflowing budget estimates
This commit is contained in:
yang rui 2026-10-09 07:10:54 +08:00 committed by GitHub
parent c58d04b6b6
commit 286bb5fe1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 18 deletions

View File

@ -315,6 +315,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
OpenRouter and similar OpenAI-compatible gateways should be configured with `langchain_openai:ChatOpenAI` plus `base_url`. If you prefer a provider-specific environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`). OpenRouter and similar OpenAI-compatible gateways should be configured with `langchain_openai:ChatOpenAI` plus `base_url`. If you prefer a provider-specific environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
The `write_file` tool's output-budget hint uses the active model's effective `max_tokens`. Missing or unusable limits, including YAML `.inf`, `-.inf`, `.nan`, and values too large for the character estimate, omit this hint without failing tool assembly; provider-specific model validation still applies.
To route OpenAI models through `/v1/responses`, keep using `langchain_openai:ChatOpenAI` and set `use_responses_api: true` with `output_version: responses/v1`. To route OpenAI models through `/v1/responses`, keep using `langchain_openai:ChatOpenAI` and set `use_responses_api: true` with `output_version: responses/v1`.
Models whose provider contract differs from DeerFlow's generic thinking/effort assumptions can declare a per-model mapping-valued `reasoning:` block (thinking `unsupported`/`optional`/`required`, the accepted effort values with aliases and a default, the payload dialect, and the reasoning-history requirement). The setup wizard's Z.AI GLM-5.3-Flash profile uses it: thinking stays on for every foreground and background call, and the effort selector offers the model's own `low`/`high`/`max` levels. Ollama's existing boolean `reasoning: true` remains a native provider setting and is forwarded to ChatOllama. When migrating a profile to a custom effort `path`, remove any old `reasoning_effort` setting from the profile and thinking templates; configuration validation rejects the leftover key. The chat UI drops a remembered provider-specific effort when switching to a legacy model that does not advertise it. Profiles without the block keep their existing provider behavior. See `config.example.yaml` for the shape and the equivalent manual configuration. Models whose provider contract differs from DeerFlow's generic thinking/effort assumptions can declare a per-model mapping-valued `reasoning:` block (thinking `unsupported`/`optional`/`required`, the accepted effort values with aliases and a default, the payload dialect, and the reasoning-history requirement). The setup wizard's Z.AI GLM-5.3-Flash profile uses it: thinking stays on for every foreground and background call, and the effort selector offers the model's own `low`/`high`/`max` levels. Ollama's existing boolean `reasoning: true` remains a native provider setting and is forwarded to ChatOllama. When migrating a profile to a custom effort `path`, remove any old `reasoning_effort` setting from the profile and thinking templates; configuration validation rejects the leftover key. The chat UI drops a remembered provider-specific effort when switching to a legacy model that does not advertise it. Profiles without the block keep their existing provider behavior. See `config.example.yaml` for the shape and the equivalent manual configuration.

View File

@ -23,7 +23,10 @@ the same host reader serves; keep reading guidance separate from permission enfo
Lead and bootstrap assembly pass the constructed `chat_model` to tool assembly. Lead and bootstrap assembly pass the constructed `chat_model` to tool assembly.
The cloned `write_file` budget hint uses that instance's effective `max_tokens`, The cloned `write_file` budget hint uses that instance's effective `max_tokens`,
including custom-agent and thinking-mode overrides; an absent cap omits the hint. including custom-agent and thinking-mode overrides; an absent or unusable cap
(including non-finite values or caps that overflow the character estimate)
omits the hint without aborting tool assembly. Guard estimate arithmetic as
well as integer conversion; a huge finite cap can pass the latter.
Only standalone tool discovery without a model falls back to the base profile. Only standalone tool discovery without a model falls back to the base profile.
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles: `get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles:

View File

@ -76,7 +76,7 @@ def _extract_max_tokens(model_config: object | None) -> int | None:
Handles ModelConfig (where max_tokens may be stored as an extra dynamic field), Handles ModelConfig (where max_tokens may be stored as an extra dynamic field),
dicts, SimpleNamespace, or test stubs. Rejects booleans, mocks, non-numeric dicts, SimpleNamespace, or test stubs. Rejects booleans, mocks, non-numeric
values, negative numbers, zero, and None. values, non-finite numbers, negative numbers, zero, and None.
""" """
if model_config is None: if model_config is None:
return None return None
@ -86,7 +86,7 @@ def _extract_max_tokens(model_config: object | None) -> int | None:
try: try:
val = int(raw) val = int(raw)
return val if val > 0 else None return val if val > 0 else None
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return None return None
@ -219,22 +219,27 @@ def get_available_tools(
# models configured without max_tokens. # models configured without max_tokens.
max_tokens = _extract_max_tokens(chat_model if chat_model is not None else model_config) max_tokens = _extract_max_tokens(chat_model if chat_model is not None else model_config)
if max_tokens is not None: if max_tokens is not None:
safe_chars = int(max_tokens * 3 * 0.7) try:
budget_note = ( safe_chars = int(max_tokens * 3 * 0.7)
f"\n\nPER-RESPONSE BUDGET: your output limit is {max_tokens} tokens " except OverflowError:
f"(≈{safe_chars} chars). Single non-append writes above this will be truncated. " # Even finite caps can be too large for this optional estimate.
"For larger documents, write the first section now, " pass
"then use append=True for subsequent sections." else:
) budget_note = (
loaded_tools = [ f"\n\nPER-RESPONSE BUDGET: your output limit is {max_tokens} tokens "
_clone_tool_with_description( f"(≈{safe_chars} chars). Single non-append writes above this will be truncated. "
tool, "For larger documents, write the first section now, "
f"{getattr(tool, 'description', '') or ''}{budget_note}", "then use append=True for subsequent sections."
) )
if tool.name == "write_file" and hasattr(tool, "description") and "PER-RESPONSE BUDGET:" not in (getattr(tool, "description", "") or "") loaded_tools = [
else tool _clone_tool_with_description(
for tool in loaded_tools tool,
] f"{getattr(tool, 'description', '') or ''}{budget_note}",
)
if tool.name == "write_file" and hasattr(tool, "description") and "PER-RESPONSE BUDGET:" not in (getattr(tool, "description", "") or "")
else tool
for tool in loaded_tools
]
# Get cached MCP tools if enabled # Get cached MCP tools if enabled
# NOTE: We use ExtensionsConfig.from_file() instead of config.extensions # NOTE: We use ExtensionsConfig.from_file() instead of config.extensions

View File

@ -10,6 +10,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
import yaml
from pydantic import BaseModel from pydantic import BaseModel
from deerflow.config.app_config import AppConfig, ModelConfig, SandboxConfig, ToolConfig from deerflow.config.app_config import AppConfig, ModelConfig, SandboxConfig, ToolConfig
@ -70,6 +71,14 @@ def test_extract_max_tokens_various_inputs():
assert _extract_max_tokens(mock_with) == 8000 assert _extract_max_tokens(mock_with) == 8000
@pytest.mark.parametrize("max_tokens", [float("inf"), float("-inf"), float("nan")], ids=["inf", "negative-inf", "nan"])
def test_extract_max_tokens_rejects_non_finite_values(max_tokens):
"""Non-finite caps are unusable for every supported config representation."""
assert _extract_max_tokens({"max_tokens": max_tokens}) is None
assert _extract_max_tokens(SimpleNamespace(max_tokens=max_tokens)) is None
assert _extract_max_tokens(ModelConfig(name="test", model="m", use="u", max_tokens=max_tokens)) is None
def test_clone_tool_with_description_preserves_singleton(): def test_clone_tool_with_description_preserves_singleton():
"""Verify _clone_tool_with_description returns an isolated copy and keeps original unchanged.""" """Verify _clone_tool_with_description returns an isolated copy and keeps original unchanged."""
original_desc = write_file_tool.description original_desc = write_file_tool.description
@ -107,6 +116,34 @@ def test_get_available_tools_with_model_config_lacking_max_tokens():
assert "PER-RESPONSE BUDGET:" not in write_tool.description assert "PER-RESPONSE BUDGET:" not in write_tool.description
@pytest.mark.parametrize(
"yaml_max_tokens",
[
".inf",
"-.inf",
".nan",
pytest.param("1.0e+308", id="huge-finite-float"),
pytest.param("1" + "0" * 400, id="huge-integer"),
pytest.param('"' + "1" + "0" * 400 + '"', id="huge-integer-string"),
],
)
@pytest.mark.parametrize("use_chat_model", [False, True], ids=["profile", "constructed-model"])
def test_get_available_tools_omits_unusable_budget(yaml_max_tokens, use_chat_model):
"""Non-finite or overflowing caps must not abort assembly or produce a hint."""
baseline_desc = write_file_tool.description
max_tokens = yaml.safe_load(f"max_tokens: {yaml_max_tokens}")["max_tokens"]
model = ModelConfig(name="budget-model", model="m", use="u", max_tokens=4096 if use_chat_model else max_tokens)
config = _build_minimal_app_config([model])
chat_model = SimpleNamespace(max_tokens=max_tokens) if use_chat_model else None
tools = get_available_tools(model_name="budget-model", app_config=config, include_mcp=False, chat_model=chat_model)
write_tool = next(t for t in tools if t.name == "write_file")
assert "PER-RESPONSE BUDGET:" not in write_tool.description
assert write_tool.description == baseline_desc
assert write_file_tool.description == baseline_desc
def test_write_file_singleton_remains_unmutated_across_assemblies(): def test_write_file_singleton_remains_unmutated_across_assemblies():
"""Verify write_file_tool process singleton is never mutated during tool assembly.""" """Verify write_file_tool process singleton is never mutated during tool assembly."""
baseline_desc = write_file_tool.description baseline_desc = write_file_tool.description