mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
fix(agents): remove provider tool-call blocks when guards strip calls (#5447)
* fix(agents): remove provider tool-call blocks when guards strip calls Token-budget and loop-detection hard stops, subagent-limit truncation, and safety-finish-reason suppression removed calls from tool_calls and the raw additional_kwargs payload, but left the provider's own tool-call blocks in AIMessage.content. Provider adapters re-serialize those blocks: langchain_anthropic sends a tool_use block whose id is not in tool_calls, and the OpenAI Responses input builder sends every function_call block. ChatAnthropic stores any tool-calling response as a block list, so a guard firing on a Claude tool call always left a tool_use without a tool_result. A truncated subagent call failed the next model request of the same run; a hard stop was checkpointed under the same message id and failed every later turn of the thread. clone_ai_message_with_tool_calls now trims content tool-call blocks to the calls that remain on the message: tool_use and LangChain v1 tool_call/tool_call_chunk by id, Responses function_call and custom_tool_call by call_id (their id is the fc_ item id), Google GenAI function_call by id, and id-less blocks by name in order. Blocks for calls still on invalid_tool_calls stay, because DanglingToolCallMiddleware answers those calls with placeholder results. The token-budget and loop-detection hard stops now build their messages through the helper instead of their own copies, and ClarificationMiddleware drops its private filter, which matched Responses blocks by item id. * docs(changelog): reference #5447 in the orphaned tool-call block entry * fix(agents): skip id-matched calls in the id-less block budget The name budget for id-less content tool-call blocks counted every retained call, including calls whose own id-bearing block had already matched. In mixed-shape content, a retained call "a" with a function_call block carrying id "a" also let a same-named id-less block survive, leaving the unpaired block this helper exists to remove. Collect the retained ids that id-bearing blocks matched first, and build the name budget only from retained calls outside that set. Content with no id-bearing blocks keeps the full budget, so the Gemini path is unchanged.
This commit is contained in:
parent
740dbc4b39
commit
f7f4a022e6
10
CHANGELOG.md
10
CHANGELOG.md
@ -582,6 +582,15 @@ This section accumulates work toward the **2.1.0** milestone
|
||||
|
||||
### Fixed
|
||||
|
||||
- **middleware:** Stop a guard that removes tool calls from breaking every later
|
||||
turn of a Claude or OpenAI Responses thread. Token-budget and loop-detection
|
||||
hard stops, subagent-limit truncation, and safety suppression cleared
|
||||
`tool_calls` but left the provider's own tool-call blocks in the message
|
||||
content. Anthropic and the Responses API resend those blocks, so the next
|
||||
request carried a tool call with no result and the provider rejected it, and
|
||||
a hard stop saved to the checkpoint kept failing on each new message. All
|
||||
guards now remove the matching content blocks through one shared helper,
|
||||
which also keeps a Responses call that clarification retains. ([#5447])
|
||||
- **sandbox:** Stop remote `glob` and `grep` from reporting "no matches" when
|
||||
their output was cut off. BoxLite, Tenki, E2B, and OpenSandbox cap the
|
||||
search's raw output and then filter it in Python (ignored directories such as
|
||||
@ -2858,4 +2867,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
[#5427]: https://github.com/bytedance/deer-flow/pull/5427
|
||||
[#5431]: https://github.com/bytedance/deer-flow/pull/5431
|
||||
[#5447]: https://github.com/bytedance/deer-flow/pull/5447
|
||||
|
||||
|
||||
@ -397,6 +397,12 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **中间件:** 移除工具调用的守卫不再导致 Claude 或 OpenAI Responses 线程之后的每一轮都失败。
|
||||
token 预算与循环检测的硬停止、subagent 数量限制的截断以及安全终止抑制只清空了 `tool_calls`,
|
||||
却把 provider 自身的工具调用块留在消息 content 中。Anthropic 与 Responses API 会重新发送这些块,
|
||||
导致下一次请求带着没有结果的工具调用而被 provider 拒绝;写入 checkpoint 的硬停止消息还会让之后
|
||||
每条新消息都失败。现在所有守卫都通过同一个共享 helper 删除对应的 content 块,该 helper 也会保留
|
||||
clarification 所保留的 Responses 调用。([#5447])
|
||||
- **沙箱:** 远程 `glob` 与 `grep` 的输出被截断时,不再报告"没有匹配"。BoxLite、Tenki、E2B 与
|
||||
OpenSandbox 会先限制搜索的原始输出行数,再在 Python 中过滤(`node_modules` 等忽略目录、匹配模式或 `glob`
|
||||
范围),但只有达到 `max_results` 时才报告 `truncated`。若被截取的行全部被过滤掉,截断位置之后仍有
|
||||
@ -2184,3 +2190,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
[#5427]: https://github.com/bytedance/deer-flow/pull/5427
|
||||
[#5431]: https://github.com/bytedance/deer-flow/pull/5431
|
||||
[#5447]: https://github.com/bytedance/deer-flow/pull/5447
|
||||
|
||||
@ -45,6 +45,10 @@ Continuity history readers share shape validation, including the capture failure
|
||||
path and DurableContext rendering, so malformed persisted metadata cannot abort
|
||||
ordinary compaction or a model call.
|
||||
|
||||
**Removing tool calls.** Use `clone_ai_message_with_tool_calls`, not a bare
|
||||
`tool_calls` update: adapters resend stale `content` tool-call blocks, which
|
||||
strict providers reject.
|
||||
|
||||
**Shared runtime base** (`build_lead_runtime_middlewares`; subagents reuse most of this via `build_subagent_runtime_middlewares`):
|
||||
|
||||
1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings. Caller markers are marked `untrusted_input`, never stripped; scope is every turn.
|
||||
@ -107,7 +111,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
26. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides deferred (MCP) tool schemas from the bound model until `tool_search` or `McpRoutingMiddleware` promotes them (reads per-thread promotions from `ThreadState.promoted`, hash-scoped)
|
||||
27. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
|
||||
28. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess ordinary `task` tool calls to enforce both the per-response concurrency limit (`max_concurrent_subagents`, resolved against startup `subagent_runtime.max_running` and the 1-64 safety range before construction) and the per-run total delegation cap (`max_total_subagents` runtime override or `subagents.max_total_per_run`, default 6, clamped to 1-50). The total cap counts current-run entries in the durable delegation ledger (entries are tagged with `run_id` when captured), so repeated planning checkpoints in one run cannot keep launching legal-sized batches indefinitely, while later user turns in the same thread get a fresh run budget. Explicit durable `batch_task` calls are a separate mode with persisted total/live/running limits and are not rewritten into ordinary ledger entries. If the ordinary cap is exhausted, the middleware strips remaining `task` calls, forces `finish_reason="stop"`, and appends a visible limit note so the run can synthesize existing results instead of ending with an empty tool-call response.
|
||||
29. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops as `middleware:loop_detection`, attributed with `is_subagent` and the optional `agent_id`, without tool arguments, message content, tool results, or argument-derived hashes. Ordinary task subagents get dedicated recorder keys through a parent-loop proxy; never pass `RunJournal` into their isolated loop. Durable batch subagents have no parent run journal and do not persist these transitions
|
||||
29. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears structured, raw, and content-block tool calls before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops as `middleware:loop_detection`, attributed with `is_subagent` and the optional `agent_id`, without tool arguments, message content, tool results, or argument-derived hashes. Ordinary task subagents get dedicated recorder keys through a parent-loop proxy; never pass `RunJournal` into their isolated loop. Durable batch subagents have no parent run journal and do not persist these transitions
|
||||
State is run-scoped: new user runs get fresh budgets; same-run goal
|
||||
continuations share history. Keep sibling warnings isolated and lifecycle
|
||||
hooks topology-stable. Before changing this guard, read
|
||||
|
||||
@ -71,30 +71,6 @@ class ClarificationMiddlewareState(AgentState):
|
||||
pass
|
||||
|
||||
|
||||
def _filter_content_tool_use(content: Any, kept_ids: set[str], kept_names: set[str]) -> Any:
|
||||
"""Drop provider tool-use blocks that were stripped from ``tool_calls``.
|
||||
|
||||
Anthropic ``tool_use`` blocks carry an ``id`` that matches ``tool_calls``.
|
||||
Gemini-style ``function_call`` blocks often have no ``id`` (langchain
|
||||
synthesizes ids onto ``tool_calls`` only), so those are matched by ``name``.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
filtered: list[Any] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") in {"tool_use", "function_call"}:
|
||||
block_id = block.get("id")
|
||||
if isinstance(block_id, str) and block_id:
|
||||
if block_id not in kept_ids:
|
||||
continue
|
||||
elif block.get("type") == "function_call":
|
||||
name = block.get("name")
|
||||
if not isinstance(name, str) or name not in kept_names:
|
||||
continue
|
||||
filtered.append(block)
|
||||
return filtered
|
||||
|
||||
|
||||
class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
"""Intercepts clarification tool calls and interrupts execution to present questions to the user.
|
||||
|
||||
@ -464,15 +440,9 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
dropped_names,
|
||||
)
|
||||
|
||||
kept_for_content = clarification_calls + invalid_clarification_calls
|
||||
kept_ids = {tc["id"] for tc in kept_for_content if isinstance(tc.get("id"), str) and tc["id"]}
|
||||
kept_names = {str(tc["name"]) for tc in kept_for_content if isinstance(tc.get("name"), str) and tc["name"]}
|
||||
new_content = _filter_content_tool_use(last.content, kept_ids, kept_names)
|
||||
patched = clone_ai_message_with_tool_calls(
|
||||
last,
|
||||
clarification_calls,
|
||||
content=new_content if new_content is not last.content else None,
|
||||
)
|
||||
# The clone also drops the siblings' provider content blocks, keeping
|
||||
# blocks for calls that remain on tool_calls or invalid_tool_calls.
|
||||
patched = clone_ai_message_with_tool_calls(last, clarification_calls)
|
||||
return {"messages": [patched]}
|
||||
|
||||
def _handle_disabled_clarification(self, request: ToolCallRequest) -> ToolMessage:
|
||||
|
||||
@ -68,7 +68,6 @@ import threading
|
||||
import uuid
|
||||
from collections import Counter, OrderedDict, defaultdict, deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal, override
|
||||
|
||||
@ -83,6 +82,7 @@ from deerflow.agents.middlewares.audit_context import (
|
||||
LOOP_DETECTION_RECORDER_CONTEXT_KEY,
|
||||
resolve_audit_recorder,
|
||||
)
|
||||
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||
from deerflow.runtime.events.catalog import MIDDLEWARE_LOOP_DETECTION_TAG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -708,26 +708,6 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
# Fallback: coerce unexpected types to str to avoid TypeError
|
||||
return str(content) + f"\n\n{text}"
|
||||
|
||||
@staticmethod
|
||||
def _build_hard_stop_update(last_msg, content: str | list) -> dict:
|
||||
"""Clear tool-call metadata so forced-stop messages serialize as plain assistant text."""
|
||||
update = {
|
||||
"tool_calls": [],
|
||||
"content": content,
|
||||
}
|
||||
|
||||
additional_kwargs = dict(getattr(last_msg, "additional_kwargs", {}) or {})
|
||||
for key in ("tool_calls", "function_call"):
|
||||
additional_kwargs.pop(key, None)
|
||||
update["additional_kwargs"] = additional_kwargs
|
||||
|
||||
response_metadata = deepcopy(getattr(last_msg, "response_metadata", {}) or {})
|
||||
if response_metadata.get("finish_reason") == "tool_calls":
|
||||
response_metadata["finish_reason"] = "stop"
|
||||
update["response_metadata"] = response_metadata
|
||||
|
||||
return update
|
||||
|
||||
def _record_audit_event(
|
||||
self,
|
||||
decision: _LoopDecision,
|
||||
@ -791,14 +771,15 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
ctx = getattr(runtime, "context", None)
|
||||
if isinstance(ctx, dict):
|
||||
ctx["stop_reason"] = "loop_capped"
|
||||
# Strip tool_calls from the last AIMessage to force text output.
|
||||
# Once tool_calls are stripped, the AIMessage no longer requires
|
||||
# matching ToolMessage responses, so mutating it in place here
|
||||
# is safe for OpenAI/Moonshot pairing validators.
|
||||
# Strip tool calls from every provider surface of the last
|
||||
# AIMessage (structured, raw, and content blocks) to force text
|
||||
# output. With no call left on any surface, the AIMessage no
|
||||
# longer requires matching ToolMessage responses, so replacing it
|
||||
# here is safe for strict provider pairing validators.
|
||||
messages = state.get("messages", [])
|
||||
last_msg = messages[-1]
|
||||
content = self._append_text(last_msg.content, warning or _HARD_STOP_MSG)
|
||||
stripped_msg = last_msg.model_copy(update=self._build_hard_stop_update(last_msg, content))
|
||||
stripped_msg = clone_ai_message_with_tool_calls(last_msg, [], content=content)
|
||||
return {"messages": [stripped_msg]}
|
||||
|
||||
if decision.action == "warn":
|
||||
|
||||
@ -201,7 +201,8 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
|
||||
new_content = self._append_user_message(message.content, explanation)
|
||||
|
||||
# clone_ai_message_with_tool_calls handles structured tool_calls,
|
||||
# raw additional_kwargs.tool_calls, and function_call in one shot.
|
||||
# raw additional_kwargs.tool_calls, function_call, and provider
|
||||
# tool-call content blocks in one shot.
|
||||
# It only rewrites finish_reason when the old value was "tool_calls",
|
||||
# which is not our case — content_filter / refusal / SAFETY stay put
|
||||
# so downstream SSE / converters keep seeing the real provider reason.
|
||||
|
||||
@ -9,7 +9,8 @@ Detection strategy:
|
||||
history.
|
||||
2. If the highest fraction (input, output, or total) >= warn_threshold,
|
||||
queue a warning.
|
||||
3. If the highest fraction >= hard_stop_threshold, strip tool_calls.
|
||||
3. If the highest fraction >= hard_stop_threshold, strip tool calls from
|
||||
every provider surface (structured, raw, and content blocks).
|
||||
Warning injection uses the deferred pattern:
|
||||
- after_model queues the warning (does NOT mutate state).
|
||||
- wrap_model_call injects it as a HumanMessage at the next model call.
|
||||
@ -55,6 +56,7 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.middlewares._bounded_dict import BoundedDict
|
||||
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||
from deerflow.config.token_budget_config import TokenBudgetConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -235,19 +237,7 @@ class TokenBudgetMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _build_hard_stop_update(self, msg: AIMessage, stop_msg: str) -> dict[str, Any]:
|
||||
"""Build the state update dictionary for a hard stop."""
|
||||
updated_content = self._append_text(msg.content, stop_msg)
|
||||
kwargs = dict(msg.additional_kwargs) if msg.additional_kwargs else {}
|
||||
if "tool_calls" in kwargs:
|
||||
del kwargs["tool_calls"]
|
||||
if "function_call" in kwargs:
|
||||
del kwargs["function_call"]
|
||||
|
||||
response_metadata = dict(getattr(msg, "response_metadata", {}) or {})
|
||||
|
||||
if response_metadata.get("finish_reason") == "tool_calls":
|
||||
response_metadata["finish_reason"] = "stop"
|
||||
|
||||
stopped_msg = msg.model_copy(update={"content": updated_content, "tool_calls": [], "additional_kwargs": kwargs, "response_metadata": response_metadata})
|
||||
stopped_msg = clone_ai_message_with_tool_calls(msg, [], content=self._append_text(msg.content, stop_msg))
|
||||
return {"messages": [stopped_msg]}
|
||||
|
||||
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
|
||||
@ -2,10 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
# Content block types that provider adapters re-serialize as tool calls,
|
||||
# mapped to the keys (in priority order) holding the id that pairs with
|
||||
# ``AIMessage.tool_calls[*]["id"]``. These mirror the content surfaces in
|
||||
# ``tool_call_args``. Anthropic re-emits a ``tool_use`` block whose id is
|
||||
# absent from ``tool_calls``; the OpenAI Responses input builder re-emits
|
||||
# every ``function_call``/``custom_tool_call`` block, whose ``id`` is the
|
||||
# ``fc_…`` item id and whose ``call_id`` is the tool-call id; Google GenAI
|
||||
# ``function_call`` blocks carry the tool-call id in ``id``, or none at all;
|
||||
# LangChain v1 ``tool_call``/``tool_call_chunk`` blocks convert back to any of
|
||||
# these shapes.
|
||||
_CONTENT_TOOL_CALL_ID_KEYS: dict[str, tuple[str, ...]] = {
|
||||
"tool_use": ("id",),
|
||||
"function_call": ("call_id", "id"),
|
||||
"custom_tool_call": ("call_id",),
|
||||
"tool_call": ("id",),
|
||||
"tool_call_chunk": ("id",),
|
||||
}
|
||||
|
||||
|
||||
def _raw_tool_call_id(raw_tool_call: Any) -> str | None:
|
||||
if not isinstance(raw_tool_call, dict):
|
||||
@ -15,18 +34,78 @@ def _raw_tool_call_id(raw_tool_call: Any) -> str | None:
|
||||
return raw_id if isinstance(raw_id, str) and raw_id else None
|
||||
|
||||
|
||||
def _content_block_call_id(block: dict[str, Any], id_keys: tuple[str, ...]) -> str | None:
|
||||
for key in id_keys:
|
||||
value = block.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _tool_call_block_id_keys(block: Any) -> tuple[str, ...] | None:
|
||||
block_type = block.get("type") if isinstance(block, dict) else None
|
||||
return _CONTENT_TOOL_CALL_ID_KEYS.get(block_type) if isinstance(block_type, str) else None
|
||||
|
||||
|
||||
def _sync_content_tool_call_blocks(content: Any, retained_calls: list[dict[str, Any]]) -> Any:
|
||||
"""Drop content tool-call blocks whose call is no longer on the message.
|
||||
|
||||
A block left behind is sent as a tool call with no matching tool result,
|
||||
which Anthropic and the OpenAI Responses API reject on every later request
|
||||
for the thread. Blocks without an id (Gemini-style ``function_call``) pair
|
||||
by name, in order, with the retained calls that no id-bearing block already
|
||||
matched. Returns ``content`` itself when no block is dropped.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
|
||||
retained_ids = {call["id"] for call in retained_calls if isinstance(call.get("id"), str) and call["id"]}
|
||||
# (block, is a tool-call block, the call id it carries or None when id-less)
|
||||
entries: list[tuple[Any, bool, str | None]] = []
|
||||
for block in content:
|
||||
id_keys = _tool_call_block_id_keys(block)
|
||||
entries.append((block, id_keys is not None, _content_block_call_id(block, id_keys) if id_keys is not None else None))
|
||||
matched_ids = {call_id for _, _, call_id in entries if call_id in retained_ids}
|
||||
idless_budget = Counter(call["name"] for call in retained_calls if isinstance(call.get("name"), str) and not (isinstance(call.get("id"), str) and call["id"] in matched_ids))
|
||||
|
||||
synced: list[Any] = []
|
||||
for block, is_tool_call_block, call_id in entries:
|
||||
if not is_tool_call_block:
|
||||
synced.append(block)
|
||||
continue
|
||||
if call_id is not None:
|
||||
if call_id in retained_ids:
|
||||
synced.append(block)
|
||||
continue
|
||||
name = block.get("name")
|
||||
if isinstance(name, str) and idless_budget[name] > 0:
|
||||
idless_budget[name] -= 1
|
||||
synced.append(block)
|
||||
return content if len(synced) == len(content) else synced
|
||||
|
||||
|
||||
def clone_ai_message_with_tool_calls(
|
||||
message: AIMessage,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
*,
|
||||
content: Any | None = None,
|
||||
) -> AIMessage:
|
||||
"""Clone an AIMessage while keeping raw provider tool-call metadata in sync."""
|
||||
"""Clone an AIMessage while keeping every provider tool-call surface in sync.
|
||||
|
||||
Besides ``tool_calls``, the raw ``additional_kwargs`` payload and the
|
||||
provider tool-call blocks in ``content`` (including a caller-supplied
|
||||
``content``) are trimmed to the retained calls. Blocks for calls still on
|
||||
``invalid_tool_calls`` are kept, because ``DanglingToolCallMiddleware``
|
||||
answers those calls with placeholder tool results.
|
||||
"""
|
||||
kept_ids = {tc["id"] for tc in tool_calls if isinstance(tc.get("id"), str) and tc["id"]}
|
||||
|
||||
update: dict[str, Any] = {"tool_calls": tool_calls}
|
||||
if content is not None:
|
||||
update["content"] = content
|
||||
invalid_tool_calls = [tc for tc in (getattr(message, "invalid_tool_calls", None) or []) if isinstance(tc, dict)]
|
||||
source_content = content if content is not None else message.content
|
||||
synced_content = _sync_content_tool_call_blocks(source_content, [*tool_calls, *invalid_tool_calls])
|
||||
if content is not None or synced_content is not message.content:
|
||||
update["content"] = synced_content
|
||||
|
||||
additional_kwargs = dict(getattr(message, "additional_kwargs", {}) or {})
|
||||
raw_tool_calls = additional_kwargs.get("tool_calls")
|
||||
|
||||
@ -1039,6 +1039,41 @@ class TestDropParallelSiblingTools:
|
||||
]
|
||||
assert [tc["name"] for tc in patched.tool_calls] == ["ask_clarification"]
|
||||
|
||||
def test_keeps_openai_responses_clarification_block_matched_by_call_id(self, middleware):
|
||||
# Responses blocks carry the fc_ item id in ``id`` and the tool-call id
|
||||
# in ``call_id``; matching on ``id`` would drop the kept call's block.
|
||||
clarify = {"type": "function_call", "id": "fc_1", "call_id": "c1", "name": "ask_clarification", "arguments": "{}"}
|
||||
sibling = {"type": "function_call", "id": "fc_2", "call_id": "b1", "name": "bash", "arguments": "{}"}
|
||||
msg = self._ai(
|
||||
[
|
||||
{"id": "c1", "name": "ask_clarification", "args": {"question": "q?"}},
|
||||
{"id": "b1", "name": "bash", "args": {"command": "ls"}},
|
||||
],
|
||||
content=[clarify, sibling],
|
||||
)
|
||||
patched = middleware.after_model({"messages": [msg]}, self._runtime())["messages"][0]
|
||||
assert patched.content == [clarify]
|
||||
|
||||
def test_keeps_content_block_for_invalid_sibling_left_on_message(self, middleware):
|
||||
# The invalid sibling stays on invalid_tool_calls and is answered by
|
||||
# DanglingToolCallMiddleware, so its block must stay to pair with it.
|
||||
content = [
|
||||
{"type": "tool_use", "id": "c1", "name": "ask_clarification", "input": {"question": "q?"}},
|
||||
{"type": "tool_use", "id": "b1", "name": "bash", "input": {"command": "ls"}},
|
||||
{"type": "tool_use", "id": "w1", "name": "write_file", "input": {}},
|
||||
]
|
||||
msg = self._ai(
|
||||
[
|
||||
{"id": "c1", "name": "ask_clarification", "args": {"question": "q?"}},
|
||||
{"id": "b1", "name": "bash", "args": {"command": "ls"}},
|
||||
],
|
||||
content=content,
|
||||
invalid_tool_calls=[{"id": "w1", "name": "write_file", "args": "{", "error": "parse", "type": "invalid_tool_call"}],
|
||||
)
|
||||
patched = middleware.after_model({"messages": [msg]}, self._runtime())["messages"][0]
|
||||
assert [block["id"] for block in patched.content] == ["c1", "w1"]
|
||||
assert [tc["id"] for tc in patched.invalid_tool_calls] == ["w1"]
|
||||
|
||||
def test_aafter_model_matches_sync(self, middleware):
|
||||
import asyncio
|
||||
|
||||
|
||||
@ -1381,6 +1381,24 @@ class TestHardStopWithListContent:
|
||||
assert msg.content[2]["type"] == "text"
|
||||
assert _HARD_STOP_MSG in msg.content[2]["text"]
|
||||
|
||||
def test_hard_stop_drops_provider_tool_use_blocks(self):
|
||||
"""A stripped call's Anthropic tool_use block must not outlive it in content."""
|
||||
mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4)
|
||||
runtime = _make_runtime()
|
||||
call = [_bash_call("ls")]
|
||||
list_content = [
|
||||
{"type": "text", "text": "I'll run ls"},
|
||||
{"type": "tool_use", "id": "call_ls", "name": "bash", "input": {"command": "ls"}},
|
||||
]
|
||||
|
||||
for _ in range(3):
|
||||
mw._apply(_make_state(tool_calls=call, content=list_content), runtime)
|
||||
result = mw._apply(_make_state(tool_calls=call, content=list_content), runtime)
|
||||
|
||||
msg = result["messages"][0]
|
||||
assert [block["type"] for block in msg.content] == ["text", "text"]
|
||||
assert _HARD_STOP_MSG in msg.content[-1]["text"]
|
||||
|
||||
def test_hard_stop_with_none_content(self):
|
||||
"""Hard stop on None content should produce a plain string."""
|
||||
mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4)
|
||||
|
||||
@ -422,6 +422,27 @@ class TestMessageRewrite:
|
||||
assert patched.content[-1]["type"] == "text"
|
||||
assert "safety-related signal" in patched.content[-1]["text"]
|
||||
|
||||
def test_drops_suppressed_calls_provider_tool_use_blocks(self):
|
||||
mw = SafetyFinishReasonMiddleware()
|
||||
call = _write_call()
|
||||
state = {
|
||||
"messages": [
|
||||
_ai(
|
||||
content=[
|
||||
{"type": "text", "text": "partial answer"},
|
||||
{"type": "tool_use", "id": call["id"], "name": call["name"], "input": call["args"]},
|
||||
],
|
||||
tool_calls=[call],
|
||||
response_metadata={"stop_reason": "refusal"},
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
patched = mw.after_model(state, _runtime())["messages"][0]
|
||||
|
||||
assert [block["type"] for block in patched.content] == ["text", "text"]
|
||||
assert "safety-related signal" in patched.content[-1]["text"]
|
||||
|
||||
def test_idempotent_on_already_cleared_message(self):
|
||||
# Re-running the middleware on a message we already cleared must not
|
||||
# re-trigger (tool_calls is now empty → fast passthrough).
|
||||
|
||||
@ -172,6 +172,35 @@ class TestTruncateTaskCalls:
|
||||
assert [tc["id"] for tc in updated_msg.additional_kwargs["tool_calls"]] == ["t1", "t2"]
|
||||
assert updated_msg.response_metadata["finish_reason"] == "tool_calls"
|
||||
|
||||
def test_truncation_syncs_provider_tool_use_content_blocks(self):
|
||||
# The tools node answers only the kept calls, so a dropped call's
|
||||
# Anthropic tool_use block would reach the next model request unpaired.
|
||||
mw = SubagentLimitMiddleware(max_concurrent=2)
|
||||
msg = AIMessage(
|
||||
content=[{"type": "tool_use", "id": call_id, "name": "task", "input": {"prompt": "p"}} for call_id in ("t1", "t2", "t3")],
|
||||
tool_calls=[_task_call("t1"), _task_call("t2"), _task_call("t3")],
|
||||
)
|
||||
|
||||
result = mw.after_model({"messages": [msg]}, _make_runtime())
|
||||
|
||||
updated_msg = result["messages"][0]
|
||||
assert [block["id"] for block in updated_msg.content] == ["t1", "t2"]
|
||||
|
||||
def test_total_limit_reached_drops_provider_tool_use_content_blocks(self):
|
||||
mw = SubagentLimitMiddleware(max_concurrent=3, max_total=1)
|
||||
msg = AIMessage(
|
||||
content=[{"type": "tool_use", "id": "t2", "name": "task", "input": {"prompt": "p"}}],
|
||||
tool_calls=[_task_call("t2")],
|
||||
)
|
||||
state = {"messages": [msg], "delegations": [_delegation("t1", run_id="run-1")]}
|
||||
|
||||
result = mw.after_model(state, _make_runtime())
|
||||
|
||||
updated_msg = result["messages"][0]
|
||||
assert updated_msg.tool_calls == []
|
||||
assert [block["type"] for block in updated_msg.content] == ["text"]
|
||||
assert "subagent delegation limit" in updated_msg.content[0]["text"]
|
||||
|
||||
def test_total_limit_counts_prior_delegations(self):
|
||||
mw = SubagentLimitMiddleware(max_concurrent=3, max_total=4)
|
||||
msg = AIMessage(
|
||||
|
||||
@ -10,6 +10,7 @@ from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
|
||||
from deerflow.config.token_budget_config import TokenBudgetConfig
|
||||
from deerflow.models.claude_provider import ClaudeChatModel
|
||||
|
||||
|
||||
def _make_runtime(thread_id="test-thread", run_id="test-run"):
|
||||
@ -219,6 +220,23 @@ class TestTokenBudgetHardStop:
|
||||
assert "Thinking" in msgs[0].content
|
||||
assert "TOKEN BUDGET EXCEEDED" in msgs[0].content
|
||||
|
||||
def test_hard_stop_drops_provider_tool_call_content_blocks(self):
|
||||
# Anthropic keeps tool_use blocks in content; one left behind without a
|
||||
# tool_result makes every later request on the thread fail with a 400.
|
||||
config = TokenBudgetConfig(max_tokens=100000, hard_stop_threshold=1.0, enabled=True)
|
||||
mw = TokenBudgetMiddleware.from_config(config)
|
||||
tool_calls = [{"name": "bash", "args": {"command": "ls"}, "id": "toolu_1"}]
|
||||
content = [
|
||||
{"type": "text", "text": "Listing"},
|
||||
{"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {"command": "ls"}},
|
||||
]
|
||||
|
||||
res = mw._apply(_make_state_with_usage(total=105000, tool_calls=tool_calls, content=content), _make_runtime())
|
||||
|
||||
stopped = res["messages"][0]
|
||||
assert [block["type"] for block in stopped.content] == ["text", "text"]
|
||||
assert "TOKEN BUDGET EXCEEDED" in stopped.content[-1]["text"]
|
||||
|
||||
def test_hard_stop_stamps_token_capped_stop_reason_consumed_once(self):
|
||||
"""#3875 Phase 2: a hard-stop stamps ``token_capped`` on a per-run
|
||||
accessor the executor reads post-run. It pops on read so a second read
|
||||
@ -350,6 +368,42 @@ class TestTokenBudgetAgentGraph:
|
||||
graph.invoke({"messages": [HumanMessage("next question")]}, config=config, context={"thread_id": "goal-thread", "run_id": "run-2"})
|
||||
assert executed == ["a", "b", "d"]
|
||||
|
||||
def test_checkpointed_hard_stop_leaves_next_anthropic_turn_well_formed(self):
|
||||
"""The stopped message is checkpointed and replayed; its tool_use must not reach the next request unpaired."""
|
||||
executed: list[str] = []
|
||||
|
||||
@as_tool
|
||||
def bash(command: str) -> str:
|
||||
"""Run a fake shell command."""
|
||||
executed.append(command)
|
||||
return "ok"
|
||||
|
||||
over_budget_call = AIMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Listing files."},
|
||||
{"type": "tool_use", "id": "toolu_ls", "name": "bash", "input": {"command": "ls"}},
|
||||
],
|
||||
id="ai-over-budget",
|
||||
tool_calls=[{"name": "bash", "id": "toolu_ls", "args": {"command": "ls"}}],
|
||||
usage_metadata={"input_tokens": 12_000, "output_tokens": 0, "total_tokens": 12_000},
|
||||
)
|
||||
answer = AIMessage(content="second answer", id="ai-answer", usage_metadata={"input_tokens": 100, "output_tokens": 0, "total_tokens": 100})
|
||||
model = _RecordingToolCallingFakeModel(responses=[over_budget_call, answer])
|
||||
mw = TokenBudgetMiddleware(TokenBudgetConfig(enabled=True, max_tokens=10_000))
|
||||
graph = create_agent(model=model, tools=[bash], middleware=[mw], checkpointer=InMemorySaver())
|
||||
config = {"configurable": {"thread_id": "stopped-thread"}}
|
||||
|
||||
graph.invoke({"messages": [HumanMessage("list files")]}, config=config, context={"thread_id": "stopped-thread", "run_id": "run-1"})
|
||||
graph.invoke({"messages": [HumanMessage("continue")]}, config=config, context={"thread_id": "stopped-thread", "run_id": "run-2"})
|
||||
|
||||
assert executed == []
|
||||
payload = ClaudeChatModel(model="claude-sonnet-4-5", anthropic_api_key="sk-ant-offline")._get_request_payload(model.requests[1])
|
||||
turns = [turn["content"] if isinstance(turn["content"], list) else [] for turn in payload["messages"]]
|
||||
for index, blocks in enumerate(turns):
|
||||
tool_use_ids = {block["id"] for block in blocks if block["type"] == "tool_use"}
|
||||
following = turns[index + 1] if index + 1 < len(turns) else []
|
||||
assert tool_use_ids <= {block["tool_use_id"] for block in following if block["type"] == "tool_result"}
|
||||
|
||||
@pytest.mark.parametrize("context", [{"thread_id": "no-run-id"}, {"thread_id": "no-run-id", "run_id": None}])
|
||||
def test_invocation_without_run_id_keeps_one_budget_across_graph_nodes(self, context):
|
||||
"""LangGraph hands each node its own Runtime, so an invocation without a run_id can't be keyed by id(runtime)."""
|
||||
|
||||
194
backend/tests/test_tool_call_metadata.py
Normal file
194
backend/tests/test_tool_call_metadata.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""Tests for keeping AIMessage tool-call surfaces consistent when calls are removed.
|
||||
|
||||
Provider adapters re-serialize tool calls from ``AIMessage.content`` blocks, not
|
||||
only from ``tool_calls``. A guard that strips a call from ``tool_calls`` but
|
||||
leaves its content block behind sends the provider a tool call with no matching
|
||||
tool result, which Anthropic and the OpenAI Responses API reject on every later
|
||||
request for that thread.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||
from deerflow.models.claude_provider import ClaudeChatModel
|
||||
|
||||
# DeerFlow deployments use ClaudeChatModel, which post-processes the payload
|
||||
# built by the upstream ChatAnthropic formatter; pin both.
|
||||
_ANTHROPIC_MODEL_CLASSES = pytest.mark.parametrize("model_class", [ChatAnthropic, ClaudeChatModel], ids=["ChatAnthropic", "ClaudeChatModel"])
|
||||
|
||||
|
||||
def _call(call_id: str, name: str = "bash") -> dict:
|
||||
return {"id": call_id, "name": name, "args": {}}
|
||||
|
||||
|
||||
def _tool_use(call_id: str, name: str = "bash") -> dict:
|
||||
return {"type": "tool_use", "id": call_id, "name": name, "input": {}}
|
||||
|
||||
|
||||
class TestContentToolCallBlockSync:
|
||||
def test_drops_anthropic_tool_use_blocks_for_removed_calls(self):
|
||||
message = AIMessage(
|
||||
content=[{"type": "text", "text": "running"}, _tool_use("a"), _tool_use("b")],
|
||||
tool_calls=[_call("a"), _call("b")],
|
||||
)
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
|
||||
|
||||
assert cloned.content == [{"type": "text", "text": "running"}, _tool_use("a")]
|
||||
assert [tc["id"] for tc in cloned.tool_calls] == ["a"]
|
||||
|
||||
def test_clearing_every_call_keeps_non_tool_call_blocks(self):
|
||||
thinking = {"type": "thinking", "thinking": "hmm", "signature": "sig"}
|
||||
server_tool = {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {}}
|
||||
message = AIMessage(content=[thinking, server_tool, _tool_use("a")], tool_calls=[_call("a")])
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [])
|
||||
|
||||
assert cloned.content == [thinking, server_tool]
|
||||
|
||||
def test_filters_caller_supplied_content(self):
|
||||
message = AIMessage(content=[_tool_use("a")], tool_calls=[_call("a")])
|
||||
stop_note = {"type": "text", "text": "stopped"}
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [], content=[*message.content, stop_note])
|
||||
|
||||
assert cloned.content == [stop_note]
|
||||
|
||||
def test_openai_responses_blocks_match_by_call_id_not_item_id(self):
|
||||
kept = {"type": "function_call", "id": "fc_1", "call_id": "a", "name": "bash", "arguments": "{}"}
|
||||
dropped = {"type": "function_call", "id": "fc_2", "call_id": "b", "name": "bash", "arguments": "{}"}
|
||||
dropped_custom = {"type": "custom_tool_call", "id": "ctc_3", "call_id": "c", "name": "patch", "input": "x"}
|
||||
message = AIMessage(
|
||||
content=[kept, dropped, dropped_custom],
|
||||
tool_calls=[_call("a"), _call("b"), _call("c", "patch")],
|
||||
)
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
|
||||
|
||||
assert cloned.content == [kept]
|
||||
|
||||
def test_langchain_standard_tool_call_blocks_match_by_id(self):
|
||||
kept = {"type": "tool_call", "id": "a", "name": "bash", "args": {}}
|
||||
dropped = {"type": "tool_call", "id": "b", "name": "bash", "args": {}}
|
||||
dropped_chunk = {"type": "tool_call_chunk", "id": "c", "name": "bash", "args": "{}", "index": 2}
|
||||
message = AIMessage(content=[kept, dropped, dropped_chunk], tool_calls=[_call("a"), _call("b"), _call("c")])
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
|
||||
|
||||
assert cloned.content == [kept]
|
||||
|
||||
def test_google_genai_function_call_blocks_match_by_id(self):
|
||||
# Keep the second of two same-named calls: an id match keeps its own
|
||||
# block, where falling back to name order would keep the first one.
|
||||
dropped = {"type": "function_call", "id": "a", "name": "bash", "args": {}}
|
||||
kept = {"type": "function_call", "id": "b", "name": "bash", "args": {}}
|
||||
message = AIMessage(content=[dropped, kept], tool_calls=[_call("a"), _call("b")])
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [_call("b")])
|
||||
|
||||
assert cloned.content == [kept]
|
||||
|
||||
def test_idless_blocks_keep_only_as_many_per_name_as_retained_calls(self):
|
||||
# Gemini-style blocks carry no id, so they pair with retained calls by
|
||||
# name, in order: truncating four same-named calls to three keeps three.
|
||||
blocks = [{"type": "function_call", "name": "task", "args": {"n": n}} for n in range(4)]
|
||||
calls = [_call(f"t{n}", "task") for n in range(4)]
|
||||
message = AIMessage(content=list(blocks), tool_calls=calls)
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, calls[:3])
|
||||
|
||||
assert cloned.content == blocks[:3]
|
||||
|
||||
def test_idless_budget_skips_calls_already_paired_by_id(self):
|
||||
# A retained call whose own id-bearing block matched must not also let
|
||||
# a same-named id-less block survive on the name budget.
|
||||
with_id = {"type": "function_call", "id": "a", "name": "bash", "args": {}}
|
||||
idless = {"type": "function_call", "name": "bash", "args": {}}
|
||||
message = AIMessage(content=[with_id, idless], tool_calls=[_call("a"), _call("b")])
|
||||
|
||||
assert clone_ai_message_with_tool_calls(message, [_call("a")]).content == [with_id]
|
||||
assert clone_ai_message_with_tool_calls(message, [_call("a"), _call("b")]).content is message.content
|
||||
|
||||
def test_keeps_blocks_for_calls_that_remain_invalid_tool_calls(self):
|
||||
# DanglingToolCallMiddleware answers invalid_tool_calls with a placeholder
|
||||
# ToolMessage, so their content block must stay to pair with it.
|
||||
message = AIMessage(
|
||||
content=[_tool_use("bad"), _tool_use("a")],
|
||||
tool_calls=[_call("a")],
|
||||
invalid_tool_calls=[{"type": "invalid_tool_call", "id": "bad", "name": "bash", "args": "{", "error": "parse"}],
|
||||
)
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [])
|
||||
|
||||
assert cloned.content == [_tool_use("bad")]
|
||||
assert [tc["id"] for tc in cloned.invalid_tool_calls] == ["bad"]
|
||||
|
||||
def test_leaves_content_untouched_when_every_block_is_still_paired(self):
|
||||
content = [{"type": "text", "text": "hi"}, _tool_use("a")]
|
||||
message = AIMessage(content=content, tool_calls=[_call("a")])
|
||||
|
||||
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
|
||||
|
||||
assert cloned.content is message.content
|
||||
|
||||
def test_string_content_is_unchanged(self):
|
||||
message = AIMessage(content="plain", tool_calls=[_call("a")])
|
||||
|
||||
assert clone_ai_message_with_tool_calls(message, []).content == "plain"
|
||||
|
||||
|
||||
def _anthropic_payload_turns(model_class: type[ChatAnthropic], messages: list) -> list[tuple[str, list[str]]]:
|
||||
payload = model_class(model="claude-sonnet-4-5", anthropic_api_key="sk-ant-offline")._get_request_payload(messages)
|
||||
turns = []
|
||||
for turn in payload["messages"]:
|
||||
blocks = turn["content"] if isinstance(turn["content"], list) else [{"type": "text"}]
|
||||
turns.append((turn["role"], [block.get("id") or block.get("tool_use_id") or block["type"] for block in blocks if block["type"] in ("tool_use", "tool_result")]))
|
||||
return turns
|
||||
|
||||
|
||||
class TestProviderRequestContract:
|
||||
"""Drive the real provider request builders offline; no network is used."""
|
||||
|
||||
@_ANTHROPIC_MODEL_CLASSES
|
||||
def test_anthropic_request_pairs_every_tool_use_after_calls_are_cleared(self, model_class):
|
||||
message = AIMessage(
|
||||
content=[{"type": "text", "text": "reading"}, _tool_use("toolu_a")],
|
||||
tool_calls=[_call("toolu_a")],
|
||||
response_metadata={"model_provider": "anthropic"},
|
||||
)
|
||||
stopped = clone_ai_message_with_tool_calls(message, [], content=[*message.content, {"type": "text", "text": "stopped"}])
|
||||
|
||||
turns = _anthropic_payload_turns(model_class, [HumanMessage("hi"), stopped, HumanMessage("continue")])
|
||||
|
||||
assert turns == [("user", []), ("assistant", []), ("user", [])]
|
||||
|
||||
@_ANTHROPIC_MODEL_CLASSES
|
||||
def test_anthropic_request_pairs_every_tool_use_after_calls_are_truncated(self, model_class):
|
||||
calls = [_call(f"toolu_{n}", "task") for n in range(3)]
|
||||
message = AIMessage(
|
||||
content=[_tool_use(call["id"], "task") for call in calls],
|
||||
tool_calls=calls,
|
||||
response_metadata={"model_provider": "anthropic"},
|
||||
)
|
||||
truncated = clone_ai_message_with_tool_calls(message, calls[:2])
|
||||
results = [ToolMessage(content="ok", tool_call_id=call["id"]) for call in truncated.tool_calls]
|
||||
|
||||
turns = _anthropic_payload_turns(model_class, [HumanMessage("go"), truncated, *results])
|
||||
|
||||
assert turns == [("user", []), ("assistant", ["toolu_0", "toolu_1"]), ("user", ["toolu_0", "toolu_1"])]
|
||||
|
||||
def test_openai_responses_request_drops_function_call_after_calls_are_cleared(self):
|
||||
message = AIMessage(
|
||||
content=[{"type": "function_call", "id": "fc_1", "call_id": "call_a", "name": "bash", "arguments": "{}"}],
|
||||
tool_calls=[_call("call_a")],
|
||||
response_metadata={"model_provider": "openai"},
|
||||
)
|
||||
stopped = clone_ai_message_with_tool_calls(message, [], content=[*message.content, {"type": "text", "text": "stopped"}])
|
||||
llm = ChatOpenAI(model="gpt-5", api_key="sk-offline", use_responses_api=True, output_version="responses/v1")
|
||||
|
||||
payload = llm._get_request_payload([HumanMessage("hi"), stopped, HumanMessage("continue")])
|
||||
|
||||
assert [item for item in payload["input"] if item.get("type") == "function_call"] == []
|
||||
Loading…
x
Reference in New Issue
Block a user