fix(utils): return empty text for content-less messages (#5563)

* fix(utils): return empty text for content-less messages

message_content_to_text fell through to str(content), so a message whose
content is None yielded the truthy literal "None". Two call sites already
work around it with a local `or ""` and name the helper in the comment; the
subagent executor's `text if text else "No response generated"` fallback and
the archive's `if not text: continue` skip cannot work around it, so a
content-less terminal turn was reported as an answer of "None" and a
content-less LLM error fallback surfaced "None" instead of its error_detail.

* test(utils): cover non-None content compatibility and document fallbacks

* test(utils): cover contentless task history and refresh guard comments

---------

Co-authored-by: Lengshuang <90967079+Lesereingrape@users.noreply.github.com>
Co-authored-by: JasonH <4430962+yang0228@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
JasonH 2026-09-19 10:56:21 +08:00 committed by GitHub
parent 23cc4f4d00
commit 82cf57a9c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 111 additions and 8 deletions

View File

@ -1457,6 +1457,8 @@ may still appear in progress; their outcome is not inferred from their text.
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions — when delegation has clear net benefit from real parallel latency, specialist capability, or context isolation. It keeps interdependent scopes and overlapping side effects out of parallel dispatch; a bounded sequential chain can still run in one sub-agent when specialist or context-isolation benefit clearly wins. The lead uses the fewest useful sub-agents and re-evaluates later batches instead of fanning out solely because a task is large or multi-step. Sub-agents report back structured results, and the lead agent verifies and synthesizes them into a coherent output. Deterministic tool receipts cover both direct tool messages and state-updating `Command` results such as delegated `task` responses; when the receipt ledger reaches its context budget, it retains the newest actions and their original receipt IDs. Operators can disable this provenance layer with `verification.receipts_enabled: false`. Their configured skills are resolved from the same user-scoped catalog as the lead agent, so user-owned custom skills remain available without exposing another user's version. Their internal AI and tool messages stay scoped to the delegated graph instead of entering the parent chat stream. Reloaded thread history enforces the same boundary: callback-captured sub-agent AI responses remain available in run-event diagnostics but are excluded from the parent transcript, while the parent `task` result remains attached to its subtask card. Long-running sub-agents compact older history when summarization is enabled and re-inject the summary as guarded, hidden durable context before continuing, so recent assistant/tool activity remains grounded in the task. Their system instructions, including the role and report contract, survive compaction; if only those instructions and the current request would be summarized, compaction is skipped. Provider/model request failures are reported as failed sub-agent tasks rather than successful results, so the lead agent and Web UI can react to them correctly. Concurrent parent runs also receive independent server-side sub-agent execution IDs, so a provider that reuses a tool-call ID cannot make one run poll, cancel, or clean up another run's background task. Collapsed sub-agent cards show the effective model and, when the provider returns usage metadata, a cumulative token total that updates after each completed sub-agent LLM call and persists after a reload. When token usage tracking is enabled, completed sub-agent usage is attributed back to the dispatching step from that run's terminal tool-message metadata rather than a process-global provider-ID cache.
Content-less sub-agent final messages report `No response generated` instead of the literal text `None`. A content-less provider-error fallback reports its structured error detail when available.
An ordinary `task` also receives a defensive snapshot of the dispatching run's current uploads. This lets eligible sub-agents use `list_uploaded_files` to find earlier-turn files without returning same-turn attachments as historical. Delayed or recovered `batch_task` workers leave this tool disabled because they have no valid turn-local upload boundary.
Ordinary `task` delegation and explicit durable `batch_task` execution share the startup-scoped `subagent_runtime` process capacity. Batch mode keeps large independent item sets in SQL with separate total, live, and running limits, restart recovery, bounded results, and a thread-scoped Web UI panel. The panel pages through bounded previews on demand; full stored result text is available only through the owner-scoped JSONL export, while internal execution and authorization context never enters owner-facing responses. If the batch worker is later stopped or disabled, threads with persisted batches retain read-only item inspection and JSONL export; execution controls remain disabled until the worker is running again. See `config.example.yaml` and [the implementation contract](docs/plans/2026-08-24-subagent-batch-capacity-implementation.md) for limits and recovery semantics.

View File

@ -374,12 +374,9 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
# thread until a new chat is started. Backfill an explanation so
# the persisted message is non-empty.
tool_calls = list(last.tool_calls or [])
# ``or ""`` normalizes every "no visible content" shape to blank:
# None, "", [] and whitespace all count. None is reachable via
# ``model_copy(update={"content": None})`` (a rewrite path that skips
# validation); without the guard message_content_to_text stringifies
# it to "None" and the backfill would be skipped, re-poisoning the
# thread this fix is meant to protect.
# Keep local falsey-content normalization as a defensive guard; the
# shared helper also handles None from validation-skipping rewrites.
# The trailing strip() makes whitespace-only content blank as well.
content_is_blank = not message_content_to_text(last.content or "").strip()
if not tool_calls and not content_is_blank:
return None

View File

@ -173,8 +173,8 @@ def build_subagent_step(
``truncated`` flag set accordingly.
"""
kind = "tool" if message.get("type") == "tool" else "ai"
# ``... or ""`` keeps a tool-call-only turn's content=None rendering as ""
# (message_content_to_text would otherwise str()-ify it to "None").
# Keep local falsey-content normalization for tool-call-only turns as a
# defensive guard; message_content_to_text also handles None directly.
text, truncated = truncate_step_text(message_content_to_text(message.get("content") or ""), max_chars)
step: dict[str, Any] = {

View File

@ -1,3 +1,13 @@
### Message Text Extraction
`message_content_to_text` takes raw message content and treats `None` as empty
text so downstream empty-result, error-detail, and archive-skip fallbacks work.
Keep its other conversions unchanged, including newline-separated list blocks
and literal strings such as `"None"`. Do not replace it with `message_to_text`,
which takes a whole message and uses different list/mapping semantics.
Regression coverage lives in `tests/test_utils_messages.py`,
`tests/test_subagent_executor.py`, and `tests/test_task_continuity.py`.
### Agent / Tool Assembly Off-Load
Tool and agent assembly re-enters `get_available_tools()` and may block on MCP discovery, so the four async assembly entry points — `run_agent`'s `agent_factory` call, `task_tool`, durable batch `_execute_item`, and `abuild_checkpoint_state_accessor` — dispatch through `deerflow.utils.assembly_io.run_assembly`, a dedicated ContextVar-preserving bounded executor (`DEER_FLOW_ASSEMBLY_WORKERS`, default 8) rather than the loop's default executor; a hung MCP server therefore parks an assembly worker instead of queueing unrelated default-executor work, and the pool logs a warning when pending assemblies exceed the worker count. `tests/blocking_io/test_tool_assembly_offloop.py` pins all four offloads plus the ContextVar propagation.

View File

@ -43,6 +43,11 @@ def strip_injected_user_message_id_suffix(message_id: str | None) -> str | None:
def message_content_to_text(content: Any) -> str:
"""Extract text from LangChain message content shapes."""
if content is None:
# ``str(None)`` is the truthy literal ``"None"``, so a content-less message
# would survive every downstream ``text if text else ...`` fallback and be
# reported as a real answer.
return ""
if isinstance(content, str):
return content
if isinstance(content, list):

View File

@ -1454,6 +1454,39 @@ class TestAsyncExecutionPath:
assert result.result is None
assert result.stop_reason is None
@pytest.mark.anyio
async def test_aexecute_llm_error_fallback_with_none_content_falls_back_to_detail(self, classes, base_config, mock_agent, msg):
"""A content-less error fallback still reports its structured detail.
``_extract_llm_error_fallback`` reads ``message_content_to_text(content)``
first; ``None`` stringified to the truthy ``"None"``, so that literal
reached the user instead of the ``error_detail`` the empty-content branch
was written for.
"""
AIMessage = classes["AIMessage"]
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
fallback_message = AIMessage(content="placeholder").model_copy(
update={
"content": None,
"additional_kwargs": {
"deerflow_error_fallback": True,
"error_type": "APIConnectionError",
"error_detail": "Connection error.",
},
}
)
final_state = {"messages": [msg.human("Do something"), fallback_message]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([final_state])
executor = SubagentExecutor(config=base_config, tools=[], thread_id="test-thread")
with patch.object(executor, "_create_agent", return_value=mock_agent):
result = await executor._aexecute("Do something")
assert result.status == SubagentStatus.FAILED
assert result.error == "Connection error."
@pytest.mark.anyio
async def test_aexecute_does_not_infer_llm_failure_from_message_text(self, classes, base_config, mock_agent, msg):
"""Error-looking prose without the middleware marker is valid output."""
@ -1778,6 +1811,34 @@ class TestAsyncExecutionPath:
assert "Part 1" in result.result
assert "Part 2" in result.result
@pytest.mark.anyio
async def test_aexecute_contentless_final_message_uses_no_response_sentinel(self, classes, base_config, mock_agent, msg):
"""A content-less terminal turn must not surface as the literal "None".
``AIMessage.content`` can be ``None`` after a rewrite path that skips
validation, and the shared text extractor then yielded the truthy string
``"None"``, so ``text if text else "No response generated"`` reported the
sentinel's literal name as the subagent's answer.
"""
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
contentless = classes["AIMessage"](content="").model_copy(update={"content": None})
final_state = {"messages": [msg.human("Task"), contentless]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([final_state])
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
)
with patch.object(executor, "_create_agent", return_value=mock_agent):
result = await executor._aexecute("Task")
assert result.status == SubagentStatus.COMPLETED
assert result.result == "No response generated"
@pytest.mark.anyio
async def test_aexecute_handles_agent_exception(self, classes, base_config, mock_agent):
"""Test that exceptions during execution are caught and returned as FAILED."""

View File

@ -145,6 +145,20 @@ def test_text_shapes_are_searchable_and_readable_before_and_after_capture(scoped
assert json.loads(history_read.func(scoped, source_id))["text"] == "Approved code ZX-731\nKeep backups"
@pytest.mark.parametrize("message_type", [HumanMessage, AIMessage, ToolMessage])
def test_capture_skips_contentless_messages_but_keeps_literal_none(scoped, message_type):
empty = message_type(content="", id="empty", **({"tool_call_id": "call"} if message_type is ToolMessage else {})).model_copy(update={"content": None})
literal = HumanMessage(content="None", id="literal")
history = archive.capture({}, scoped, [empty, literal], TaskContinuityConfig(enabled=True))
assert history["status"] == "available"
assert history["omitted_records"] == 0
result = archive.lookup({"task_history": history, "messages": []}, scoped, query="None")
assert result["status"] == "available"
assert [(row["message_id"], row["text"]) for row in result["results"]] == [("literal", "None")]
@pytest.mark.parametrize("query", ["Citrine", "保留备份", 'Citrine" OR "x', '" OR * NOT NEAR( x )'])
def test_keywords_and_fts_syntax_are_data(scoped, query):
state = {"task_history": archive.capture({}, scoped, conversation(), TaskContinuityConfig(enabled=True))}

View File

@ -10,6 +10,7 @@ from __future__ import annotations
from types import SimpleNamespace
import pytest
from langchain_core.messages import HumanMessage
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text, message_to_text, restore_original_human_message
@ -74,6 +75,19 @@ def test_message_content_to_text_still_joins_with_newline():
assert message_content_to_text(["a", {"text": "b"}]) == "a\nb"
def test_message_content_to_text_none_content_is_empty_not_literal_none():
# Content-less messages (tool-call-only turns, ``model_copy(update={"content": None})``
# rewrites) must yield empty text: ``str(None)`` is the truthy literal ``"None"``, which
# survives the ``text if text else ...`` fallbacks in the subagent executor and archive.
assert message_content_to_text(None) == ""
assert message_content_to_text(None) == message_to_text(SimpleNamespace(content=None))
@pytest.mark.parametrize("content, expected", [("None", "None"), (0, "0"), (False, "False")])
def test_message_content_to_text_preserves_non_none_values(content, expected):
assert message_content_to_text(content) == expected
# ---------- restore_original_human_message ----------