Repository navigation
fix(mcp): treat a Human Input Card reply as the current user request - #5426
Conversation
McpRoutingMiddleware picked the latest user message with is_real_user_message, which rejects every hide_from_ui message. A Human Input Card reply is hidden but is still the user's current request, so when the routing keyword lived only in the clarification answer the deferred MCP tool was never auto-promoted and the model had to call tool_search by hand. Switch _latest_user_message to is_genuine_user_message, the same predicate summarization_middleware already uses for this reason (bytedance#5416). Fixes bytedance#5425
willem-bd
left a comment
There was a problem hiding this comment.
Verified empirically: the regression test fails on main's source and passes at this head (19/19 in tests/test_mcp_routing_auto_promote.py), the predicate swap matches the rule summarization_middleware/tool_receipt_middleware/context_snapshot already use, the existing hidden-message skip test still holds (no human_input_response payload stays skipped per read_human_input_response validation), and ruff check/format are clean. One non-blocking consistency note below.
| """ | ||
| for message in reversed(messages): | ||
| if is_real_user_message(message): | ||
| if is_genuine_user_message(message): |
There was a problem hiding this comment.
Non-blocking follow-up: with this swap, a hidden Human Input Card reply now drives MCP routing but still not slash-skill activation — skill_activation_middleware._is_user_activation_target (line 89) still uses is_real_user_message, so a card reply whose answer text starts with /skill-name ... will promote the MCP tool yet fail to activate the skill. Same transport-dependent behavior class this PR fixes. Also, is_real_user_message's docstring in deerflow/utils/messages.py still lists MCP routing as a consumer. Worth a tracking issue or a sibling change rather than expanding this PR.
There was a problem hiding this comment.
Checked both points.
Docstring — agreed, is_real_user_message's docstring still lists MCP routing as a consumer, and this swap makes that line stale. Leaving it out of this PR per your note.
Slash activation — I ran it before changing anything, and the predicate swap alone is a no-op on a card reply. The text the activation path parses is the transport wrapper, not the answer:
_is_user_activation_target(reply) -> False
is_genuine_user_message(reply) -> True
activation input for the card reply -> 'For your clarification "Which skill should I run?", my answer is: /release-notes summarize v2'
parse_slash_skill_reference(that input) -> None
parse_slash_skill_reference('/release-notes summarize v2') -> SlashSkillReference(name='release-notes', remaining_text='summarize v2')
_SLASH_SKILL_RE is ^-anchored (skills/slash.py:4), and get_original_user_content_text does not unwrap a card reply: original_user_content is only set by input_sanitization_middleware / uploads_middleware, so for a card reply it returns the wrapped body verbatim. The raw answer survives only in additional_kwargs["human_input_response"]["value"].
So a card reply cannot reach slash activation by the same route MCP routing uses — even after swapping _is_user_activation_target to is_genuine_user_message, the parsed text still does not begin with /. A real fix is the predicate swap plus reading the answer out of the payload, and whether a card answer should activate a skill at all is a behavior decision rather than a consistency fix. Not expanding this PR.
Happy to file a tracking issue with the repro above; shout if you'd rather have the sibling change and I'll take it separately.
Fixes #5425
Why
When the agent asks a clarifying question through a Human Input Card, the user's answer arrives as a hidden
HumanMessage(hide_from_ui: true) carrying a validhuman_input_responsepayload. That reply is the user's current request, butMcpRoutingMiddleware._latest_user_messageselected the latest user message withis_real_user_message, which rejects anyhide_from_uimessage with no carve-out for a valid human-input reply. When the routing keyword appeared only in the answer, the middleware walked past the card reply and matched nothing, so the deferred MCP tool was never auto-promoted and the model had to calltool_searchby hand. The identical answer sent as a visible message promoted the tool, so the behaviour depended only on how the answer was transported.is_genuine_user_messagealready encodes the correct rule (skip hidden messages unless they carry a validhuman_input_response), andsummarization_middleware.pywas switched to it for exactly this reason in #5416.What changed
McpRoutingMiddleware._latest_user_messagenow usesis_genuine_user_messageinstead ofis_real_user_message, so a Human Input Card reply counts as the current user request for MCP routing. The import moves fromdeerflow.utils.messagestodeerflow.agents.middlewares.message_utils.No change to visible-message behaviour, tool filtering, or the routing index.
tests/test_mcp_routing_auto_promote.py::test_matching_uses_latest_real_human_message_onlystill passes — it covers a hidden message carrying nohuman_input_response, which stays skipped.Surface area
Bug fix verification
backend/tests/test_mcp_routing_auto_promote.py::test_matching_uses_a_hidden_human_input_card_reply_as_the_latest_messagemainand green on this branch? yes —1 failedon the unfixed tree (AssertionErrorat the assert),2 passedfor the new test plus the existing skip test after the fix.Validation
AI assistance
Tool(s) used: Claude Code
How you used it: used it to trace the two turn-detection predicates and draft the regression test; I reviewed the reasoning against
summarization_middleware.py's #5416 change and the existing skip test, and take responsibility for the diff.