diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 0ed0ad57cf..333bb0c00c 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py index 80db3d6e4d..4649a80906 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @@ -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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index f9ac936354..52de786dc0 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -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 diff --git a/backend/tests/test_dynamic_context_middleware.py b/backend/tests/test_dynamic_context_middleware.py index 26ab99eef9..5a5427db92 100644 --- a/backend/tests/test_dynamic_context_middleware.py +++ b/backend/tests/test_dynamic_context_middleware.py @@ -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() diff --git a/backend/tests/test_uploads_middleware_core_logic.py b/backend/tests/test_uploads_middleware_core_logic.py index 6eb0a4c7e8..daf8f36420 100644 --- a/backend/tests/test_uploads_middleware_core_logic.py +++ b/backend/tests/test_uploads_middleware_core_logic.py @@ -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)