mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 03:30:34 +00:00
fix(agents): pair null call IDs with empty result IDs (#7837)
* fix(agents): pair null call IDs with empty result IDs
* fix(agents): treat producer sentinel IDs as absent when pairing
Middleware producers spell a missing provider ID three ways: "" is canonical,
str(tool_call.get("id", "")) yields "None" once the key is present with a
null value, and the artifact/deferred/knowledge middlewares fall back to
"missing_tool_call_id". Only "" reached the pairing pass, so a result carrying
either sentinel was dropped as an orphan and the middleware's guidance was
replaced by the interrupted placeholder — the exact outcome this PR set out to
avoid, just through a different spelling.
Route every absent spelling through one canonicalisation used by the pairing
gate, the candidate comparison and the turn-completeness count.
---------
Co-authored-by: huyengiang101086-spec <270684046+huyengiang101086-spec@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
9e2cf685b0
commit
8331afa759
@ -224,6 +224,10 @@ For Google's official Gemini OpenAI-compatible endpoint, use the
|
|||||||
|
|
||||||
For MindIE XML tool calls, see the
|
For MindIE XML tool calls, see the
|
||||||
[argument parsing and newline compatibility guide](backend/docs/CONFIGURATION.md#mindie-xml-tool-arguments).
|
[argument parsing and newline compatibility guide](backend/docs/CONFIGURATION.md#mindie-xml-tool-arguments).
|
||||||
|
Tool-result recovery treats null and empty call IDs alike — including the
|
||||||
|
`"None"` and `"missing_tool_call_id"` spellings middleware producers use when a
|
||||||
|
provider call had no ID — preserving middleware guidance when a result can be
|
||||||
|
uniquely paired; ambiguous partial results remain rejected.
|
||||||
Both synchronous and asynchronous streams retain this compatibility: tool-enabled
|
Both synchronous and asynchronous streams retain this compatibility: tool-enabled
|
||||||
streams simulate chunks from a non-streaming response, while no-tool streams stay native.
|
streams simulate chunks from a non-streaming response, while no-tool streams stay native.
|
||||||
Newline compatibility preserves literal escapes inside backtick and tilde code
|
Newline compatibility preserves literal escapes inside backtick and tilde code
|
||||||
|
|||||||
@ -39,6 +39,28 @@ _UNKNOWN_TOOL_NAME = "unknown_tool"
|
|||||||
_EMPTY_TOOL_NAME_ERROR = "Tool call could not be executed because its name was missing or empty."
|
_EMPTY_TOOL_NAME_ERROR = "Tool call could not be executed because its name was missing or empty."
|
||||||
_SYNTHETIC_TOOL_CALL_ID_PREFIX = "deerflow_synthetic_tool_call_"
|
_SYNTHETIC_TOOL_CALL_ID_PREFIX = "deerflow_synthetic_tool_call_"
|
||||||
|
|
||||||
|
# Middleware producers spell an absent provider ID differently: "" is the canonical
|
||||||
|
# spelling, but ``str(tool_call.get("id", ""))`` yields "None" once the key is present
|
||||||
|
# with a null value, and the artifact/deferred/knowledge middlewares fall back to
|
||||||
|
# "missing_tool_call_id". They all answer the same call, so pairing treats them alike.
|
||||||
|
_ABSENT_TOOL_CALL_ID_SPELLINGS = frozenset({"None", "missing_tool_call_id"})
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_tool_call_id(tool_call_id: object) -> object:
|
||||||
|
"""Return one value for every spelling middleware uses for "this call had no ID"."""
|
||||||
|
return "" if _absent_tool_call_id(tool_call_id) else tool_call_id
|
||||||
|
|
||||||
|
|
||||||
|
def _absent_tool_call_id(tool_call_id: object) -> bool:
|
||||||
|
"""Whether an ID is missing in any spelling: null, blank, or a producer sentinel."""
|
||||||
|
if not _valid_tool_call_id(tool_call_id):
|
||||||
|
return True
|
||||||
|
return isinstance(tool_call_id, str) and tool_call_id in _ABSENT_TOOL_CALL_ID_SPELLINGS
|
||||||
|
|
||||||
|
|
||||||
|
def _same_tool_call_id(left: object, right: object) -> bool:
|
||||||
|
return _canonical_tool_call_id(left) == _canonical_tool_call_id(right)
|
||||||
|
|
||||||
|
|
||||||
def _valid_tool_name(name: object) -> bool:
|
def _valid_tool_name(name: object) -> bool:
|
||||||
return isinstance(name, str) and bool(name.strip())
|
return isinstance(name, str) and bool(name.strip())
|
||||||
@ -93,12 +115,12 @@ def _relabel_tool_call_ids(tool_calls: list, msg_index: int, source: str) -> tup
|
|||||||
|
|
||||||
|
|
||||||
def _turn_malformed_result_count(messages: list, start: int) -> int:
|
def _turn_malformed_result_count(messages: list, start: int) -> int:
|
||||||
"""Count the malformed results issued by the turn opened at ``start``."""
|
"""Count the unpaired results issued by the turn opened at ``start``."""
|
||||||
count = 0
|
count = 0
|
||||||
for msg in messages[start + 1 :]:
|
for msg in messages[start + 1 :]:
|
||||||
if getattr(msg, "type", None) == "ai":
|
if getattr(msg, "type", None) == "ai":
|
||||||
break
|
break
|
||||||
if isinstance(msg, ToolMessage) and not _valid_tool_call_id(msg.tool_call_id):
|
if isinstance(msg, ToolMessage) and _absent_tool_call_id(msg.tool_call_id):
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@ -123,7 +145,7 @@ def _claim_synthetic_id(open_calls: list[dict], result: ToolMessage, positional:
|
|||||||
Returning ``None`` leaves the result malformed for the orphan pass to drop, which is
|
Returning ``None`` leaves the result malformed for the orphan pass to drop, which is
|
||||||
what an unattributable result gets today — better than inventing a pairing.
|
what an unattributable result gets today — better than inventing a pairing.
|
||||||
"""
|
"""
|
||||||
candidates = [position for position, entry in enumerate(open_calls) if entry["original"] == result.tool_call_id and _names_can_pair(entry["name"], result.name)]
|
candidates = [position for position, entry in enumerate(open_calls) if _same_tool_call_id(entry["original"], result.tool_call_id) and _names_can_pair(entry["name"], result.name)]
|
||||||
if not candidates or (len(candidates) > 1 and not positional):
|
if not candidates or (len(candidates) > 1 and not positional):
|
||||||
return None
|
return None
|
||||||
return open_calls.pop(candidates[0])["synthetic"]
|
return open_calls.pop(candidates[0])["synthetic"]
|
||||||
@ -415,8 +437,9 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Re-point an already-paired result at its call's new id so the pairing
|
# Re-point an already-paired result at its call's new id so the pairing
|
||||||
# below keeps it instead of dropping it as an orphan.
|
# below keeps it instead of dropping it as an orphan. Results carrying a
|
||||||
if not isinstance(msg, ToolMessage) or _valid_tool_call_id(msg.tool_call_id):
|
# producer sentinel are still unpaired — the sentinel is not the call's id.
|
||||||
|
if not isinstance(msg, ToolMessage) or not _absent_tool_call_id(msg.tool_call_id):
|
||||||
continue
|
continue
|
||||||
synthetic = _claim_synthetic_id(open_calls, msg, positional)
|
synthetic = _claim_synthetic_id(open_calls, msg, positional)
|
||||||
if synthetic is not None:
|
if synthetic is not None:
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# Backend Tests
|
# Backend Tests
|
||||||
|
|
||||||
|
`test_null_tool_result_pairing.py` pins null/empty/sentinel ID equivalence, provider-wire
|
||||||
|
pairing, and ambiguity rejection for incomplete parallel results.
|
||||||
|
|
||||||
`test_lark_broker_image.py` stages Python files from the broker Dockerfile's
|
`test_lark_broker_image.py` stages Python files from the broker Dockerfile's
|
||||||
final-stage COPY instructions. Run shim installation and the health endpoint
|
final-stage COPY instructions. Run shim installation and the health endpoint
|
||||||
under `-I -S`, adding only the staged script directory to the import path;
|
under `-I -S`, adding only the staged script directory to the import path;
|
||||||
|
|||||||
94
backend/tests/test_null_tool_result_pairing.py
Normal file
94
backend/tests/test_null_tool_result_pairing.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Missing call IDs must not swallow middleware-generated tool guidance."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
|
from langchain_openai.chat_models.base import _convert_message_to_dict
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("call_id,result_id", [(None, ""), ("", None), (None, None), ("", "")])
|
||||||
|
def test_missing_ids_preserve_result_and_provider_pairing(call_id, result_id):
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": call_id, "args": {"path": "a.md"}}])
|
||||||
|
# Normal middleware replies use ""; model_construct also covers old None-ID history.
|
||||||
|
constructor = ToolMessage.model_construct if result_id is None else ToolMessage
|
||||||
|
result = constructor(content="Error: read the file before writing it", tool_call_id=result_id, name="write_file", status="error")
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert len(patched) == 2
|
||||||
|
assert patched[1].content == result.content
|
||||||
|
assert patched[1].status == "error"
|
||||||
|
wire_call, wire_result = map(_convert_message_to_dict, patched)
|
||||||
|
assert wire_call["tool_calls"][0]["id"] == wire_result["tool_call_id"]
|
||||||
|
assert wire_result["tool_call_id"]
|
||||||
|
assert call.tool_calls[0]["id"] == call_id
|
||||||
|
assert result.tool_call_id == result_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_missing_ids_keep_partial_same_name_results_ambiguous():
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
|
||||||
|
result = ToolMessage(content="UNATTRIBUTABLE", tool_call_id="", name="bash")
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert all(message.content != "UNATTRIBUTABLE" for message in patched[1:])
|
||||||
|
assert len(patched[1:]) == 2
|
||||||
|
assert all(message.status == "error" for message in patched[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_id_does_not_override_a_different_tool_name():
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": None, "args": {}}])
|
||||||
|
result = ToolMessage(content="UNRELATED", tool_call_id="", name="bash")
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert patched[1].content != "UNRELATED"
|
||||||
|
assert patched[1].status == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_missing_ids_complete_parallel_turn_keeps_input_order():
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
|
||||||
|
replies = [ToolMessage(content=text, tool_call_id="", name="bash") for text in ("FIRST", "SECOND")]
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, *replies])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert [message.content for message in patched[1:]] == ["FIRST", "SECOND"]
|
||||||
|
assert [message.tool_call_id for message in patched[1:]] == [tc["id"] for tc in patched[0].tool_calls]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sentinel", ["None", "missing_tool_call_id"])
|
||||||
|
def test_producer_sentinel_ids_pair_with_a_null_call_id(sentinel):
|
||||||
|
"""Producers spell a missing ID as "None" or "missing_tool_call_id", not ""."""
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": None, "args": {"path": "a.md"}}])
|
||||||
|
result = ToolMessage(content="Error: read the file before writing it", tool_call_id=sentinel, name="write_file", status="error")
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert len(patched) == 2
|
||||||
|
assert patched[1].content == result.content
|
||||||
|
assert patched[1].status == "error"
|
||||||
|
wire_call, wire_result = map(_convert_message_to_dict, patched)
|
||||||
|
assert wire_call["tool_calls"][0]["id"] == wire_result["tool_call_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_producer_sentinel_pairing_does_not_invent_one_for_a_valid_id():
|
||||||
|
"""A sentinel result must not steal the slot of a call that does have an ID."""
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "write_file", "id": "call_1", "args": {}}])
|
||||||
|
result = ToolMessage(content="UNRELATED", tool_call_id="missing_tool_call_id", name="write_file")
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, result])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert patched[1].content != "UNRELATED"
|
||||||
|
assert patched[1].status == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_producer_sentinels_complete_a_parallel_turn_in_input_order():
|
||||||
|
"""Mixed call spellings plus sentinel results: order still identifies the calls."""
|
||||||
|
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": call_id, "args": {}} for call_id in (None, "")])
|
||||||
|
replies = [ToolMessage(content=text, tool_call_id="missing_tool_call_id", name="bash") for text in ("FIRST", "SECOND")]
|
||||||
|
patched = DanglingToolCallMiddleware()._build_patched_messages([call, *replies])
|
||||||
|
|
||||||
|
assert patched is not None
|
||||||
|
assert [message.content for message in patched[1:]] == ["FIRST", "SECOND"]
|
||||||
|
assert [message.tool_call_id for message in patched[1:]] == [tc["id"] for tc in patched[0].tool_calls]
|
||||||
Loading…
x
Reference in New Issue
Block a user