From 286bb5fe1cb126be167e9309742fa8a5ea108694 Mon Sep 17 00:00:00 2001 From: yang rui <142162413+ruirui6946@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:10:54 +0800 Subject: [PATCH] fix(tools): ignore unusable output budget limits (#6516) * fix(tools): ignore non-finite output budget limits * fix(tools): skip overflowing budget estimates --- README.md | 2 + .../packages/harness/deerflow/tools/AGENTS.md | 5 ++- .../packages/harness/deerflow/tools/tools.py | 39 +++++++++++-------- .../test_write_file_tool_budget_annotation.py | 37 ++++++++++++++++++ 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9df9278fdd..98284e2e7f 100644 --- a/README.md +++ b/README.md @@ -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`). + 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`. 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. diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 35525d3e5d..20f96b8d59 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -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. 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. `get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles: diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 16c68be417..69c2ce3687 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -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), 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: return None @@ -86,7 +86,7 @@ def _extract_max_tokens(model_config: object | None) -> int | None: try: val = int(raw) return val if val > 0 else None - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return None @@ -219,22 +219,27 @@ def get_available_tools( # models configured without max_tokens. max_tokens = _extract_max_tokens(chat_model if chat_model is not None else model_config) if max_tokens is not None: - safe_chars = int(max_tokens * 3 * 0.7) - budget_note = ( - f"\n\nPER-RESPONSE BUDGET: your output limit is {max_tokens} tokens " - f"(≈{safe_chars} chars). Single non-append writes above this will be truncated. " - "For larger documents, write the first section now, " - "then use append=True for subsequent sections." - ) - loaded_tools = [ - _clone_tool_with_description( - tool, - f"{getattr(tool, 'description', '') or ''}{budget_note}", + try: + safe_chars = int(max_tokens * 3 * 0.7) + except OverflowError: + # Even finite caps can be too large for this optional estimate. + pass + else: + budget_note = ( + f"\n\nPER-RESPONSE BUDGET: your output limit is {max_tokens} tokens " + f"(≈{safe_chars} chars). Single non-append writes above this will be truncated. " + "For larger documents, write the first section now, " + "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 "") - else tool - for tool in loaded_tools - ] + loaded_tools = [ + _clone_tool_with_description( + 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 # NOTE: We use ExtensionsConfig.from_file() instead of config.extensions diff --git a/backend/tests/test_write_file_tool_budget_annotation.py b/backend/tests/test_write_file_tool_budget_annotation.py index d9e2d44318..b20d086ca4 100644 --- a/backend/tests/test_write_file_tool_budget_annotation.py +++ b/backend/tests/test_write_file_tool_budget_annotation.py @@ -10,6 +10,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +import yaml from pydantic import BaseModel 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 +@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(): """Verify _clone_tool_with_description returns an isolated copy and keeps original unchanged.""" 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 +@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(): """Verify write_file_tool process singleton is never mutated during tool assembly.""" baseline_desc = write_file_tool.description