mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +00:00
* feat(agents): elide blocked write payloads from model-bound requests A write_file / str_replace call rejected by the read-before-write gate never runs, yet its payload (up to 80 KB for a non-append write, unbounded for append) stayed verbatim in every later model request: nothing in the chain rewrites AIMessage tool-call arguments, and ToolOutputBudgetMiddleware only budgets ToolMessage output. The gate demands a re-read plus a fresh call, so the model re-emits the content anyway and the original is pure dead weight. - ReadBeforeWriteMiddleware stamps `deerflow_write_block` on the blocked ToolMessage and, in wrap_model_call, replaces the paired call's payload fields (content / old_str / new_str) with a short deterministic placeholder in the model-bound request only. state["messages"], receipts, loop detection, and the run journal keep the original arguments; nothing is externalized to disk, since a file reference to content the model must re-derive after reading the target would only invite bypassing the gate. - New `tool_call_args` helper rewrites every provider surface together (structured tool_calls, raw additional_kwargs.tool_calls, tool_use content blocks, tool_call_chunks) so strict providers never see them disagree; the gate only supplies the policy (which calls, what placeholder). - `read_before_write.elide_blocked_payloads` (default on) and `read_before_write.elide_min_chars` (default 2000) configure it; the runtime builder passes the config through and the middleware declares it via release_policy_parameters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(agents): condense middleware guide entry 11 to fit the guidance budget The agent-guidance CI check failed: the effective AGENTS.md chain for agents/middlewares was 99673 bytes against a 98304-byte hard limit. The chain already sat at 98459 on main, so the ReadBeforeWrite entry could not grow. Rewrite entry 11 so it states the same facts (gate, lock scope, fail-open, authorization scope, blocked-payload elision, shared tool_call_args helper) in 1229 bytes instead of 2640; the chain is now 98262 bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(config): bump config_version for the read_before_write elision keys Review follow-ups on #5329: - `read_before_write.elide_blocked_payloads` / `elide_min_chars` are new user-settable YAML keys, i.e. a config schema change, so bump `config_version` 40 -> 41 in config.example.yaml; without it an existing config.yaml gets no outdated-config warning and `make config-upgrade` has nothing to signal. - Say in the `elide_min_chars` description (and the example comment) that the threshold and the placeholder's size figure are Python character counts, not tokens: the same value spans roughly 3-4x in real context cost between ASCII and CJK text. - The builder wiring test now asserts only the wired `elide_min_chars` value instead of the whole `ReadBeforeWriteConfig` dump, so future knobs do not have to edit an unrelated test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(helm): bump chart config_version to 41 validate-chart's config_version drift check failed after config.example.yaml moved to 41 in ef9ee267. Bare bump of the chart's embedded `config:` block and the README example; the chart does not mirror the read_before_write section, so no field changes are needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(agents): rewrite Responses and v1 content-block arguments too Review finding on #5329 (P2): the content rewriter only handled Anthropic `tool_use` blocks. With `use_responses_api=true` and `output_version='responses/v1'`, AIMessage.content carries `function_call` blocks whose `arguments` still hold the full write payload, and langchain_openai's Responses input builder emits that block instead of the rewritten structured call whose `call_id` it already carries. Standard `v1` `tool_call` blocks likewise keep `extras.arguments`, which the v1->Responses translator prefers over the structured args. So the blocked payload was still sent on every later Responses API request. `tool_call_args` now rewrites every content dialect that carries its own copy of the arguments: Anthropic `tool_use` (input, drop partial_json), Responses `function_call` (arguments, matched by call_id, `fc_...` item id and status preserved), and v1 `tool_call` / `tool_call_chunk` (args plus `extras.arguments`). Tests assert against the real adapter serializers: `_construct_responses_api_input` for responses/v1, v1, and v0 messages, `_convert_message_to_dict` for chat completions, and Anthropic `_format_messages` for native and v1 content, plus an end-to-end probe through the gate's wrap_model_call. * fix(agents): pair blocked writes per call occurrence and defeat Responses chaining Two review findings on #5329: - Tool-call ids may repeat across assistant turns (DanglingToolCallMiddleware pairs them with per-id queues). The gate matched blocked results against a history-wide id set, so a successful write sharing an id with a later (or earlier) blocked one also lost its payload and was labelled as blocked. `_blocked_call_occurrences` now pairs ToolMessages with call occurrences the same FIFO-per-id way and the selector keys on (message, call id). - With `use_previous_response_id`, the OpenAI adapter sends only the messages after the last AIMessage carrying a `resp_` response id and lets the server rebuild the rest from its stored copy, which still holds the original arguments and cannot be edited; every later response chains back to it. `rewrite_messages_tool_call_args` now drops every `resp_` id from the model-bound copy whenever it rewrote anything, so the adapter replays the full rewritten history (the `use_previous_response_id=False` request shape). OpenAI bills chained input tokens as input either way, so replay costs no more; the state keeps its ids. Tests cover success-before-block and block-before-success histories through the Chat Completions serializer, and chaining through `ChatOpenAI._get_request_payload` with `use_previous_response_id=True`: unrewritten history chains and omits the call, rewritten history is replayed with the placeholder and no `previous_response_id`. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
388 lines
19 KiB
Python
388 lines
19 KiB
Python
"""Tests for the shared model-bound tool-call argument rewriter (``tool_call_args``)."""
|
|
|
|
import json
|
|
|
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
|
|
|
from deerflow.agents.middlewares.tool_call_args import rewrite_messages_tool_call_args, rewrite_tool_call_args
|
|
|
|
ARGS = {"path": "/mnt/user-data/outputs/report.md", "content": "x" * 50}
|
|
NEW_ARGS = {"path": "/mnt/user-data/outputs/report.md", "content": "[elided]"}
|
|
|
|
|
|
def _full_surface_message(call_id="call-1"):
|
|
"""An AIMessage carrying the same call on every surface a provider adapter may read."""
|
|
return AIMessage(
|
|
content=[
|
|
{"type": "text", "text": "writing"},
|
|
{"type": "tool_use", "id": call_id, "name": "write_file", "input": dict(ARGS), "partial_json": json.dumps(ARGS)},
|
|
],
|
|
tool_calls=[{"name": "write_file", "id": call_id, "args": dict(ARGS)}],
|
|
additional_kwargs={"tool_calls": [{"id": call_id, "type": "function", "function": {"name": "write_file", "arguments": json.dumps(ARGS)}}]},
|
|
)
|
|
|
|
|
|
class TestRewriteToolCallArgs:
|
|
def test_no_matching_id_returns_same_object(self):
|
|
message = _full_surface_message()
|
|
assert rewrite_tool_call_args(message, {"other": NEW_ARGS}) is message
|
|
assert rewrite_tool_call_args(message, {}) is message
|
|
|
|
def test_rewrites_every_surface_together(self):
|
|
message = _full_surface_message()
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
assert rewritten is not message
|
|
assert rewritten.tool_calls[0]["args"] == NEW_ARGS
|
|
assert rewritten.tool_calls[0]["name"] == "write_file"
|
|
raw = rewritten.additional_kwargs["tool_calls"][0]
|
|
assert json.loads(raw["function"]["arguments"]) == NEW_ARGS
|
|
assert raw["function"]["name"] == "write_file"
|
|
assert rewritten.content[0] == {"type": "text", "text": "writing"}
|
|
assert rewritten.content[1] == {"type": "tool_use", "id": "call-1", "name": "write_file", "input": NEW_ARGS}
|
|
assert "x" * 50 not in json.dumps(rewritten.model_dump(), ensure_ascii=False)
|
|
|
|
def test_original_message_is_never_mutated(self):
|
|
message = _full_surface_message()
|
|
|
|
rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
assert message.tool_calls[0]["args"] == ARGS
|
|
assert message.content[1]["input"] == ARGS
|
|
assert "partial_json" in message.content[1]
|
|
assert json.loads(message.additional_kwargs["tool_calls"][0]["function"]["arguments"]) == ARGS
|
|
|
|
def test_untouched_sibling_calls_keep_identity(self):
|
|
other = {"name": "bash", "id": "call-2", "args": {"command": "ls"}}
|
|
message = AIMessage(content="", tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}, other])
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
# AIMessage validation copies tool-call dicts at construction, so identity is against the message's own list.
|
|
assert rewritten.tool_calls[1] is message.tool_calls[1]
|
|
assert rewritten.tool_calls[1]["args"] == other["args"]
|
|
assert rewritten.tool_calls[0]["args"] == NEW_ARGS
|
|
|
|
def test_rewrites_chunk_surfaces(self):
|
|
chunk = AIMessageChunk(content="", tool_call_chunks=[{"name": "write_file", "args": json.dumps(ARGS), "id": "call-1", "index": 0}])
|
|
assert chunk.tool_calls[0]["args"] == ARGS
|
|
|
|
rewritten = rewrite_tool_call_args(chunk, {"call-1": NEW_ARGS})
|
|
|
|
assert rewritten.tool_calls[0]["args"] == NEW_ARGS
|
|
assert json.loads(rewritten.tool_call_chunks[0]["args"]) == NEW_ARGS
|
|
assert rewritten.tool_call_chunks[0]["index"] == 0
|
|
assert chunk.tool_call_chunks[0]["args"] == json.dumps(ARGS)
|
|
|
|
def test_flattened_raw_provider_variants(self):
|
|
message = AIMessage(
|
|
content="",
|
|
tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}, {"name": "write_file", "id": "call-2", "args": dict(ARGS)}],
|
|
additional_kwargs={
|
|
"tool_calls": [
|
|
{"id": "call-1", "name": "write_file", "arguments": json.dumps(ARGS)},
|
|
{"id": "call-2", "name": "write_file", "args": dict(ARGS)},
|
|
{"id": "call-3", "name": "write_file"},
|
|
"not-a-dict",
|
|
]
|
|
},
|
|
)
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS, "call-2": NEW_ARGS, "call-3": NEW_ARGS})
|
|
|
|
raw = rewritten.additional_kwargs["tool_calls"]
|
|
assert json.loads(raw[0]["arguments"]) == NEW_ARGS
|
|
assert raw[1]["args"] == NEW_ARGS
|
|
assert raw[2] is message.additional_kwargs["tool_calls"][2]
|
|
assert raw[3] == "not-a-dict"
|
|
|
|
def test_non_string_ids_never_match(self):
|
|
message = AIMessage(
|
|
content=[{"type": "tool_use", "id": ["list", "id"], "name": "write_file", "input": dict(ARGS)}],
|
|
tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}],
|
|
additional_kwargs={"tool_calls": [{"id": {"dict": "id"}, "type": "function", "function": {"name": "write_file", "arguments": json.dumps(ARGS)}}]},
|
|
)
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
assert rewritten.tool_calls[0]["args"] == NEW_ARGS
|
|
assert rewritten.content[0]["input"] == ARGS
|
|
assert json.loads(rewritten.additional_kwargs["tool_calls"][0]["function"]["arguments"]) == ARGS
|
|
|
|
def test_result_is_deterministic(self):
|
|
message = _full_surface_message()
|
|
first = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
second = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
assert first.model_dump() == second.model_dump()
|
|
|
|
|
|
class TestRewriteMessagesToolCallArgs:
|
|
def test_returns_none_when_nothing_replaced(self):
|
|
messages = [HumanMessage(content="go"), _full_surface_message(), ToolMessage(content="ok", tool_call_id="call-1", name="write_file")]
|
|
assert rewrite_messages_tool_call_args(messages, lambda _message, _tool_call: None) is None
|
|
assert rewrite_messages_tool_call_args([], lambda _message, _tool_call: NEW_ARGS) is None
|
|
|
|
def test_selector_sees_message_and_call_and_untouched_messages_keep_identity(self):
|
|
human = HumanMessage(content="go")
|
|
target = _full_surface_message("call-1")
|
|
other = AIMessage(content="", tool_calls=[{"name": "bash", "id": "call-2", "args": {"command": "ls"}}])
|
|
tool = ToolMessage(content="ok", tool_call_id="call-1", name="write_file")
|
|
seen = []
|
|
|
|
def replacement_for(message, tool_call):
|
|
seen.append((message, tool_call["id"]))
|
|
return NEW_ARGS if tool_call["name"] == "write_file" else None
|
|
|
|
rewritten = rewrite_messages_tool_call_args([human, target, other, tool], replacement_for)
|
|
|
|
assert seen == [(target, "call-1"), (other, "call-2")]
|
|
assert rewritten[0] is human
|
|
assert rewritten[1] is not target
|
|
assert rewritten[1].tool_calls[0]["args"] == NEW_ARGS
|
|
assert rewritten[2] is other
|
|
assert rewritten[3] is tool
|
|
assert target.tool_calls[0]["args"] == ARGS
|
|
|
|
def test_calls_without_a_string_id_are_not_offered(self):
|
|
message = AIMessage(content="", tool_calls=[{"name": "write_file", "id": None, "args": dict(ARGS)}])
|
|
offered = []
|
|
|
|
assert rewrite_messages_tool_call_args([message], lambda _m, tc: offered.append(tc) or NEW_ARGS) is None
|
|
assert offered == []
|
|
|
|
|
|
RESPONSES_V1_BLOCK = {"type": "function_call", "id": "fc_1", "call_id": "call-1", "name": "write_file", "arguments": json.dumps(ARGS), "status": "completed"}
|
|
V1_BLOCK = {"type": "tool_call", "id": "call-1", "name": "write_file", "args": dict(ARGS), "extras": {"item_id": "fc_1", "arguments": json.dumps(ARGS), "status": "completed"}}
|
|
V1_CHUNK_BLOCK = {"type": "tool_call_chunk", "id": "call-1", "name": "write_file", "args": json.dumps(ARGS), "index": 0, "extras": {"item_id": "fc_1"}}
|
|
|
|
|
|
def _responses_v1_message():
|
|
return AIMessage(content=[{"type": "text", "text": "writing"}, dict(RESPONSES_V1_BLOCK)], tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}], response_metadata={"output_version": "responses/v1"})
|
|
|
|
|
|
def _v1_message():
|
|
return AIMessage(content=[{"type": "text", "text": "writing"}, {**V1_BLOCK, "extras": dict(V1_BLOCK["extras"])}], tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}], response_metadata={"output_version": "v1"})
|
|
|
|
|
|
class TestContentBlockVariants:
|
|
"""Every content-block dialect that carries its own copy of the arguments is rewritten, ids preserved."""
|
|
|
|
def test_responses_function_call_block_matched_by_call_id_keeps_item_id(self):
|
|
message = _responses_v1_message()
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
block = rewritten.content[1]
|
|
assert json.loads(block["arguments"]) == NEW_ARGS
|
|
assert block["id"] == "fc_1"
|
|
assert block["call_id"] == "call-1"
|
|
assert block["status"] == "completed"
|
|
assert rewritten.content[0] is message.content[0]
|
|
assert json.loads(message.content[1]["arguments"]) == ARGS
|
|
|
|
def test_responses_function_call_block_ignores_item_id_as_match_key(self):
|
|
message = _responses_v1_message()
|
|
assert rewrite_tool_call_args(message, {"fc_1": NEW_ARGS}) is message
|
|
|
|
def test_v1_tool_call_block_rewrites_args_and_extras_arguments(self):
|
|
message = _v1_message()
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
block = rewritten.content[1]
|
|
assert block["args"] == NEW_ARGS
|
|
assert json.loads(block["extras"]["arguments"]) == NEW_ARGS
|
|
assert block["extras"]["item_id"] == "fc_1"
|
|
assert block["extras"]["status"] == "completed"
|
|
assert message.content[1]["args"] == ARGS
|
|
assert json.loads(message.content[1]["extras"]["arguments"]) == ARGS
|
|
|
|
def test_v1_tool_call_block_without_extras_arguments_gets_no_extras_entry(self):
|
|
block = {"type": "tool_call", "id": "call-1", "name": "write_file", "args": dict(ARGS), "extras": {"item_id": "fc_1"}}
|
|
message = AIMessage(content=[block], tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}])
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
assert rewritten.content[0]["args"] == NEW_ARGS
|
|
assert rewritten.content[0]["extras"] == {"item_id": "fc_1"}
|
|
|
|
def test_v1_tool_call_chunk_block_rewrites_serialized_args(self):
|
|
chunk = AIMessageChunk(content=[dict(V1_CHUNK_BLOCK)], tool_call_chunks=[{"name": "write_file", "args": json.dumps(ARGS), "id": "call-1", "index": 0}])
|
|
|
|
rewritten = rewrite_tool_call_args(chunk, {"call-1": NEW_ARGS})
|
|
|
|
assert json.loads(rewritten.content[0]["args"]) == NEW_ARGS
|
|
assert rewritten.content[0]["extras"] == {"item_id": "fc_1"}
|
|
assert rewritten.content[0]["index"] == 0
|
|
assert json.loads(rewritten.tool_call_chunks[0]["args"]) == NEW_ARGS
|
|
assert json.loads(chunk.content[0]["args"]) == ARGS
|
|
|
|
def test_unrelated_block_types_pass_through_by_identity(self):
|
|
reasoning = {"type": "reasoning", "id": "rs_1", "summary": []}
|
|
message = AIMessage(content=[reasoning, dict(RESPONSES_V1_BLOCK)], tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}])
|
|
|
|
rewritten = rewrite_tool_call_args(message, {"call-1": NEW_ARGS})
|
|
|
|
assert rewritten.content[0] is message.content[0]
|
|
|
|
|
|
class TestProviderSerializers:
|
|
"""Lock the rewrite against the real adapter request builders: the payload must not reach the wire."""
|
|
|
|
PAYLOAD = ARGS["content"]
|
|
|
|
@staticmethod
|
|
def _responses_input(message):
|
|
from langchain_openai.chat_models.base import _construct_responses_api_input
|
|
|
|
return _construct_responses_api_input([message])
|
|
|
|
def _function_calls(self, message):
|
|
items = self._responses_input(message)
|
|
assert self.PAYLOAD not in json.dumps(items, ensure_ascii=False)
|
|
return [item for item in items if item.get("type") == "function_call"]
|
|
|
|
def test_responses_v1_content_sends_rewritten_arguments_once(self):
|
|
calls = self._function_calls(rewrite_tool_call_args(_responses_v1_message(), {"call-1": NEW_ARGS}))
|
|
|
|
assert len(calls) == 1
|
|
assert json.loads(calls[0]["arguments"]) == NEW_ARGS
|
|
assert calls[0]["call_id"] == "call-1"
|
|
assert calls[0]["id"] == "fc_1"
|
|
|
|
def test_v1_content_sends_rewritten_arguments_once(self):
|
|
calls = self._function_calls(rewrite_tool_call_args(_v1_message(), {"call-1": NEW_ARGS}))
|
|
|
|
assert len(calls) == 1
|
|
assert json.loads(calls[0]["arguments"]) == NEW_ARGS
|
|
assert calls[0]["call_id"] == "call-1"
|
|
assert calls[0]["id"] == "fc_1"
|
|
|
|
def test_v0_responses_message_sends_rewritten_arguments_with_item_id(self):
|
|
message = AIMessage(
|
|
content=[{"type": "text", "text": "writing"}],
|
|
tool_calls=[{"name": "write_file", "id": "call-1", "args": dict(ARGS)}],
|
|
additional_kwargs={"__openai_function_call_ids__": {"call-1": "fc_1"}},
|
|
)
|
|
|
|
calls = self._function_calls(rewrite_tool_call_args(message, {"call-1": NEW_ARGS}))
|
|
|
|
assert len(calls) == 1
|
|
assert json.loads(calls[0]["arguments"]) == NEW_ARGS
|
|
assert calls[0]["id"] == "fc_1"
|
|
|
|
def test_unrewritten_responses_message_still_carries_payload(self):
|
|
"""Sanity check that the probe can see the payload at all."""
|
|
items = self._responses_input(_responses_v1_message())
|
|
assert self.PAYLOAD in json.dumps(items, ensure_ascii=False)
|
|
|
|
def test_chat_completions_payload_uses_rewritten_arguments(self):
|
|
from langchain_openai.chat_models.base import _convert_message_to_dict
|
|
|
|
payload = _convert_message_to_dict(rewrite_tool_call_args(_full_surface_message(), {"call-1": NEW_ARGS}))
|
|
|
|
assert json.loads(payload["tool_calls"][0]["function"]["arguments"]) == NEW_ARGS
|
|
assert self.PAYLOAD not in json.dumps(payload, ensure_ascii=False)
|
|
|
|
def test_anthropic_native_tool_use_payload_uses_rewritten_input(self):
|
|
from langchain_anthropic.chat_models import _format_messages
|
|
|
|
_system, formatted = _format_messages([rewrite_tool_call_args(_full_surface_message(), {"call-1": NEW_ARGS})])
|
|
|
|
tool_use = [block for block in formatted[0]["content"] if block["type"] == "tool_use"]
|
|
assert len(tool_use) == 1
|
|
assert tool_use[0]["input"] == NEW_ARGS
|
|
assert self.PAYLOAD not in json.dumps(formatted, ensure_ascii=False)
|
|
|
|
def test_anthropic_v1_content_payload_uses_rewritten_input(self):
|
|
from langchain_anthropic._compat import _convert_from_v1_to_anthropic
|
|
from langchain_anthropic.chat_models import _format_messages
|
|
|
|
rewritten = rewrite_tool_call_args(_v1_message(), {"call-1": NEW_ARGS})
|
|
# Mirrors ChatAnthropic._get_request_payload's v1 translation step.
|
|
tcs = [{"type": "tool_call", "name": tc["name"], "args": tc["args"], "id": tc.get("id")} for tc in rewritten.tool_calls]
|
|
translated = rewritten.model_copy(update={"content": _convert_from_v1_to_anthropic(rewritten.content, tcs, "anthropic")})
|
|
|
|
_system, formatted = _format_messages([translated])
|
|
|
|
tool_use = [block for block in formatted[0]["content"] if block["type"] == "tool_use"]
|
|
assert len(tool_use) == 1
|
|
assert tool_use[0]["input"] == NEW_ARGS
|
|
assert self.PAYLOAD not in json.dumps(formatted, ensure_ascii=False)
|
|
|
|
|
|
class TestResponseChainInvalidation:
|
|
"""A rewritten history must be replayed, never chained to the original server-side copy."""
|
|
|
|
PAYLOAD = ARGS["content"]
|
|
|
|
@staticmethod
|
|
def _chained_history(rewritten_call=True):
|
|
first = AIMessage(content=[{"type": "text", "text": "earlier"}], response_metadata={"id": "resp_a", "model_name": "gpt-x"})
|
|
call = _responses_v1_message()
|
|
call = call.model_copy(update={"response_metadata": {**call.response_metadata, "id": "resp_b", "model_name": "gpt-x"}})
|
|
tool = ToolMessage(content="Error: blocked", tool_call_id="call-1", name="write_file", status="error")
|
|
later = AIMessage(content=[{"type": "text", "text": "later"}], response_metadata={"id": "resp_c"})
|
|
return [HumanMessage(content="go"), first, call, tool, later]
|
|
|
|
def test_rewrite_drops_resp_ids_from_every_ai_message(self):
|
|
messages = self._chained_history()
|
|
|
|
rewritten = rewrite_messages_tool_call_args(messages, lambda _m, tc: NEW_ARGS if tc["id"] == "call-1" else None)
|
|
|
|
assert [type(m) for m in rewritten] == [type(m) for m in messages]
|
|
for index in (1, 2, 4):
|
|
assert "id" not in rewritten[index].response_metadata
|
|
assert rewritten[index] is not messages[index]
|
|
assert rewritten[1].response_metadata == {"model_name": "gpt-x"}
|
|
assert rewritten[2].tool_calls[0]["args"] == NEW_ARGS
|
|
assert rewritten[0] is messages[0]
|
|
assert rewritten[3] is messages[3]
|
|
# Stored history keeps its chain ids and arguments.
|
|
assert messages[1].response_metadata["id"] == "resp_a"
|
|
assert messages[2].response_metadata["id"] == "resp_b"
|
|
assert messages[4].response_metadata["id"] == "resp_c"
|
|
assert messages[2].tool_calls[0]["args"] == ARGS
|
|
|
|
def test_non_resp_ids_are_left_alone(self):
|
|
anthropic_style = AIMessage(content="earlier", response_metadata={"id": "msg_01", "model": "claude"})
|
|
messages = [anthropic_style, _full_surface_message(), ToolMessage(content="ok", tool_call_id="call-1", name="write_file")]
|
|
|
|
rewritten = rewrite_messages_tool_call_args(messages, lambda _m, tc: NEW_ARGS)
|
|
|
|
assert rewritten[0] is anthropic_style
|
|
assert rewritten[0].response_metadata["id"] == "msg_01"
|
|
|
|
def test_no_rewrite_keeps_chain_ids(self):
|
|
messages = self._chained_history()
|
|
assert rewrite_messages_tool_call_args(messages, lambda _m, _tc: None) is None
|
|
assert messages[2].response_metadata["id"] == "resp_b"
|
|
|
|
@staticmethod
|
|
def _chained_model():
|
|
from langchain_openai import ChatOpenAI
|
|
|
|
return ChatOpenAI(model="gpt-4.1", api_key="test-key", use_responses_api=True, use_previous_response_id=True)
|
|
|
|
def test_unrewritten_history_chains_and_never_sends_the_call(self):
|
|
"""Documents the leak: with chaining on, the adapter sends only the tail after the last resp_ id."""
|
|
payload = self._chained_model()._get_request_payload(self._chained_history()[:4])
|
|
|
|
assert payload["previous_response_id"] == "resp_b"
|
|
assert [item["type"] for item in payload["input"]] == ["function_call_output"]
|
|
|
|
def test_rewritten_history_is_replayed_with_rewritten_arguments(self):
|
|
messages = self._chained_history()[:4]
|
|
rewritten = rewrite_messages_tool_call_args(messages, lambda _m, tc: NEW_ARGS if tc["id"] == "call-1" else None)
|
|
|
|
payload = self._chained_model()._get_request_payload(rewritten)
|
|
|
|
assert "previous_response_id" not in payload
|
|
calls = [item for item in payload["input"] if item.get("type") == "function_call"]
|
|
assert len(calls) == 1
|
|
assert json.loads(calls[0]["arguments"]) == NEW_ARGS
|
|
assert calls[0]["id"] == "fc_1"
|
|
assert any(item.get("type") == "function_call_output" for item in payload["input"])
|
|
assert self.PAYLOAD not in json.dumps(payload, ensure_ascii=False)
|