fix(agents): preserve response_metadata in dynamic_context user copy (#5977)

* fix(agents): preserve response_metadata in dynamic_context user copy

The re-id'd user message was rebuilt field-by-field, dropping
response_metadata (and any other caller-attached field) from what the
run actually sees. model_copy keeps the documented {id}__user swap
while preserving the rest, matching the pattern already used by the
pii_redaction, thread_data and input_sanitization middlewares.

Fixes #5976

* fix(agents): preserve message metadata during upload injection

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
kuse 2026-09-28 15:21:01 +08:00 committed by GitHub
parent 00c35fcce6
commit c0c28a3f13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 58 additions and 11 deletions

View File

@ -67,7 +67,7 @@ strict providers reject.
4. **PiiRedactionMiddleware** - *(optional, `pii_redaction.enabled`, default off, #3190)* Rewrites PII in genuine user messages (`wrap_model_call`) and remote-content tool results (`wrap_tool_call`, the tool-result allowlist) to irreversible value-derived placeholders. Deterministic regex detectors only. Innermost Layer-1 wrapper; compaction input, summaries, title input and queued memory payloads are redacted via `redact_text`.
5. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves identity via `resolve_runtime_user_id(runtime)`, including Gateway runtime context and standalone LangGraph Server auth, then falls back to the request ContextVar / `"default"`
6. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation (lead agent only); upload existence checks use the same runtime-resolved user bucket as thread-data creation; a malformed client-supplied `files[*].size` is coerced to `0` (like a missing one), never raised
6. **UploadsMiddleware** - Injects current uploads (lead only). Check existence in the runtime user's bucket; coerce invalid `files[*].size` to `0`. Copy the user message when adding context to preserve caller metadata, including `response_metadata`.
7. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state. The
lead runtime normally owns the thread's physical Agent-skill projection;
delegated subagents and the prompt-only bootstrap agent are non-owners, so

View File

@ -542,12 +542,12 @@ class DynamicContextMiddleware(AgentMiddleware):
)
)
# model_copy preserves response_metadata and any other message fields
# that a hand-built HumanMessage call would silently drop, while the
# explicit id keeps the {id}__user swap described in the docstring.
messages.append(
HumanMessage(
content=original.content,
id=f"{stable_id}{INJECTED_USER_MESSAGE_ID_SUFFIX}",
name=original.name,
additional_kwargs=original.additional_kwargs,
original.model_copy(
update={"id": f"{stable_id}{INJECTED_USER_MESSAGE_ID_SUFFIX}"},
)
)
return messages

View File

@ -297,11 +297,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
else:
updated_content = original_content
updated_message = HumanMessage(
content=updated_content,
id=last_message.id,
name=last_message.name,
additional_kwargs=additional_kwargs,
updated_message = last_message.model_copy(
update={"content": updated_content, "additional_kwargs": additional_kwargs},
)
messages[last_message_index] = updated_message

View File

@ -630,6 +630,31 @@ def test_reminder_uses_original_id_user_message_uses_derived_id():
assert result["messages"][1].id == f"{original_id}__user"
def test_user_message_copy_preserves_response_metadata():
"""Re-id'd user message keeps fields a hand-built HumanMessage would drop.
API callers can attach response_metadata to the incoming turn; rebuilding
the message field-by-field silently discarded it, so the copy is made with
``model_copy`` (same pattern as the pii_redaction / uploads middlewares)
while still deriving the ``{id}__user`` id.
"""
mw = _make_middleware()
original = HumanMessage(
content="Hello",
id="original-id-abc",
response_metadata={"external": "value"},
)
state = {"messages": [original]}
with mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value=""), mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
result = mw.before_agent(state, _fake_runtime())
user_msg = result["messages"][1]
assert user_msg.id == "original-id-abc__user"
assert user_msg.response_metadata == {"external": "value"}
def test_message_without_id_gets_stable_uuid():
"""If the original HumanMessage has no ID, a UUID is generated and used consistently."""
mw = _make_middleware()

View File

@ -435,6 +435,31 @@ class TestBeforeAgent:
assert updated_kwargs.get("files") == files_meta
assert updated_kwargs.get("element") == "task"
def test_preserves_response_metadata_when_uploads_are_injected(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
(uploads_dir / "img.png").write_bytes(b"png")
files_meta = [{"filename": "img.png", "size": 3, "path": "/mnt/user-data/uploads/img.png"}]
original = HumanMessage(
content="check image",
id="user-turn",
name="caller",
additional_kwargs={"files": files_meta},
response_metadata={"external": "value"},
)
result = mw.before_agent(self._state(original), _runtime())
assert result is not None
updated = result["messages"][-1]
assert updated.id == "user-turn"
assert updated.name == "caller"
assert updated.response_metadata == {"external": "value"}
assert updated.additional_kwargs["files"] == files_meta
assert updated.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "check image"
assert original.content == "check image"
assert ORIGINAL_USER_CONTENT_KEY not in original.additional_kwargs
def test_preserves_original_user_content_before_upload_context(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)