Nan Gao 3f0b6ecc81
feat(agents): elide blocked write payloads from model-bound requests (#5329)
* 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>
2026-09-11 19:05:48 +08:00

200 lines
10 KiB
Python

"""Rewrite AIMessage tool-call arguments on every provider surface at once.
Middlewares that shrink or replace a historical tool call's arguments in the
*model-bound request* (never in graph state) share one hazard: a LangChain
``AIMessage`` carries the same arguments on up to four surfaces, and provider
adapters do not all read the same one —
- ``tool_calls``: the structured list most adapters prefer;
- ``additional_kwargs["tool_calls"]``: the raw provider payload (OpenAI
``function.arguments`` JSON string) some adapters fall back to;
- ``content`` blocks that carry their own copy of the arguments: Anthropic
``tool_use`` (``input`` + ``partial_json``), OpenAI Responses
``function_call`` (``arguments`` string, matched by ``call_id``; the
``fc_…`` item id is preserved), and LangChain standard-content
``tool_call`` / ``tool_call_chunk`` (``args`` plus ``extras.arguments``);
- ``tool_call_chunks`` on an ``AIMessageChunk``.
Rewriting only one surface leaves the original payload reachable through the
others and can hand a strict provider a request whose surfaces disagree. The
content surfaces matter most: ``langchain_openai``'s Responses input builder
emits a content ``function_call`` block *instead of* the structured call
whose ``call_id`` it already carries, and prefers ``extras.arguments`` over
the structured args when translating a v1 ``tool_call`` block, so a rewrite
that touched ``tool_calls`` alone would still send the original payload.
:func:`rewrite_tool_call_args` rewrites them together and returns a
``model_copy`` (or the same object when nothing matched), so callers never
mutate state and the result is identical across model calls. Policy — which
calls, and what replaces their arguments — stays with the caller; see
``read_before_write_middleware.elide_blocked_write_payloads`` for one.
A rewrite also invalidates server-side continuation. With
``use_previous_response_id`` the OpenAI adapter sends only the messages after
the last AIMessage carrying a ``resp_…`` ``response_metadata["id"]`` and lets
the server rebuild the rest from *its* stored copy of the conversation, which
still holds the original arguments; stored responses cannot be edited, and
every response produced after the rewritten call chains back to that history.
So whenever anything was rewritten, :func:`rewrite_messages_tool_call_args`
drops every ``resp_`` id from the model-bound copy and the adapter falls back
to replaying the full rewritten history (the same request shape as
``use_previous_response_id=False``; per OpenAI's docs chained input tokens are
billed either way, so replay costs no more).
"""
from __future__ import annotations
import json
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from langchain_core.messages import AIMessage
#: Replacement args keyed by tool-call id.
ArgsReplacements = Mapping[str, dict[str, Any]]
#: ``(message, tool_call) -> new_args`` or ``None`` to leave the call alone.
ReplacementSelector = Callable[[AIMessage, dict[str, Any]], dict[str, Any] | None]
def rewrite_messages_tool_call_args(messages: list[Any], replacement_for: ReplacementSelector) -> list[Any] | None:
"""Apply ``replacement_for(message, tool_call)`` to every AIMessage tool call in ``messages``.
Returns a new list with the rewritten AIMessages, or ``None`` when no call
was replaced. Untouched messages pass through by identity, except that once
anything was rewritten every AIMessage loses its ``resp_`` response id (see
the module docstring: the server-side history behind that id still holds
the original arguments). Only calls with a non-empty string id are offered
to the selector, since nothing else can be matched across surfaces.
"""
updated: list[Any] = []
changed = False
for message in messages:
patched = message
if isinstance(message, AIMessage) and message.tool_calls:
replacements: dict[str, dict[str, Any]] = {}
for tool_call in message.tool_calls:
if not isinstance(tool_call, dict):
continue
call_id = tool_call.get("id")
if not isinstance(call_id, str) or not call_id:
continue
new_args = replacement_for(message, tool_call)
if new_args is not None:
replacements[call_id] = new_args
if replacements:
patched = rewrite_tool_call_args(message, replacements)
if patched is not message:
changed = True
updated.append(patched)
if not changed:
return None
return [_without_response_chain_id(message) for message in updated]
def _without_response_chain_id(message: Any) -> Any:
"""Drop an OpenAI ``resp_`` response id so the adapter replays history instead of chaining to it."""
if not isinstance(message, AIMessage):
return message
response_metadata = message.response_metadata or {}
response_id = response_metadata.get("id")
if not (isinstance(response_id, str) and response_id.startswith("resp_")):
return message
return message.model_copy(update={"response_metadata": {key: value for key, value in response_metadata.items() if key != "id"}})
def rewrite_tool_call_args(message: AIMessage, replacements: ArgsReplacements) -> AIMessage:
"""Return ``message`` with the args of every tool call in ``replacements`` (by id) rewritten on all surfaces.
``message`` is never mutated; the same object comes back when no id matches.
"""
if not replacements:
return message
update: dict[str, Any] = {}
tool_calls = message.tool_calls or []
rewritten_calls = [dict(tool_call, args=new_args) if isinstance(tool_call, dict) and (new_args := _replacement_for_id(tool_call.get("id"), replacements)) is not None else tool_call for tool_call in tool_calls]
if _any_replaced(rewritten_calls, tool_calls):
update["tool_calls"] = rewritten_calls
tool_call_chunks = getattr(message, "tool_call_chunks", None)
if isinstance(tool_call_chunks, list):
rewritten_chunks = [dict(chunk, args=_serialize(new_args)) if isinstance(chunk, dict) and (new_args := _replacement_for_id(chunk.get("id"), replacements)) is not None else chunk for chunk in tool_call_chunks]
if _any_replaced(rewritten_chunks, tool_call_chunks):
update["tool_call_chunks"] = rewritten_chunks
additional_kwargs = message.additional_kwargs or {}
raw_tool_calls = additional_kwargs.get("tool_calls")
if isinstance(raw_tool_calls, list):
rewritten_raw = [_rewrite_raw_tool_call(entry, replacements) for entry in raw_tool_calls]
if _any_replaced(rewritten_raw, raw_tool_calls):
update["additional_kwargs"] = {**additional_kwargs, "tool_calls": rewritten_raw}
if isinstance(message.content, list):
rewritten_content = [_rewrite_content_block(block, replacements) for block in message.content]
if _any_replaced(rewritten_content, message.content):
update["content"] = rewritten_content
return message.model_copy(update=update) if update else message
def _replacement_for_id(identifier: Any, replacements: ArgsReplacements) -> dict[str, Any] | None:
"""Non-string ids (malformed provider payloads) never match, and never raise from a membership probe."""
return replacements.get(identifier) if isinstance(identifier, str) else None
def _any_replaced(rewritten: Sequence[Any], original: Sequence[Any]) -> bool:
return any(new is not old for new, old in zip(rewritten, original, strict=True))
def _serialize(args: dict[str, Any]) -> str:
return json.dumps(args, ensure_ascii=False)
def _rewrite_raw_tool_call(entry: Any, replacements: ArgsReplacements) -> Any:
"""Rewrite one raw provider tool-call payload (OpenAI ``function.arguments`` JSON string, or flattened variants)."""
if not isinstance(entry, dict):
return entry
new_args = _replacement_for_id(entry.get("id"), replacements)
if new_args is None:
return entry
function = entry.get("function")
if isinstance(function, dict):
return {**entry, "function": {**function, "arguments": _serialize(new_args)}}
if isinstance(entry.get("arguments"), str):
return {**entry, "arguments": _serialize(new_args)}
if isinstance(entry.get("args"), dict):
return {**entry, "args": new_args}
return entry
def _rewrite_content_block(block: Any, replacements: ArgsReplacements) -> Any:
"""Rewrite one content block that carries tool-call arguments; anything else passes through by identity."""
if not isinstance(block, dict):
return block
block_type = block.get("type")
if block_type == "tool_use":
# Anthropic: ``partial_json`` is dropped so it cannot leak the old payload.
new_args = _replacement_for_id(block.get("id"), replacements)
if new_args is None:
return block
rewritten = {key: value for key, value in block.items() if key != "partial_json"}
rewritten["input"] = new_args
return rewritten
if block_type == "function_call":
# OpenAI Responses (``responses/v1``): matched by ``call_id``; the ``fc_…`` item id and status are kept.
new_args = _replacement_for_id(block.get("call_id"), replacements)
if new_args is None:
return block
return {**block, "arguments": _serialize(new_args)}
if block_type in ("tool_call", "tool_call_chunk"):
# LangChain standard content (``v1``): ``args`` is a dict on tool_call and a JSON string on
# tool_call_chunk; ``extras.arguments`` (raw provider string) wins in the Responses translator.
new_args = _replacement_for_id(block.get("id"), replacements)
if new_args is None:
return block
rewritten = {**block, "args": new_args if block_type == "tool_call" else _serialize(new_args)}
extras = block.get("extras")
if isinstance(extras, dict) and "arguments" in extras:
rewritten["extras"] = {**extras, "arguments": _serialize(new_args)}
return rewritten
return block