diff --git a/README.md b/README.md index 408adc968..2155e7875 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py index 767840c73..59ea54891 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py @@ -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 diff --git a/backend/packages/harness/deerflow/subagents/step_events.py b/backend/packages/harness/deerflow/subagents/step_events.py index fcce512e1..ebde57bb5 100644 --- a/backend/packages/harness/deerflow/subagents/step_events.py +++ b/backend/packages/harness/deerflow/subagents/step_events.py @@ -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] = { diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index 01d5b0133..f7f79f588 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/utils/messages.py b/backend/packages/harness/deerflow/utils/messages.py index cc7d51859..10befa133 100644 --- a/backend/packages/harness/deerflow/utils/messages.py +++ b/backend/packages/harness/deerflow/utils/messages.py @@ -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): diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index 9db812ba2..3b983dfcb 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -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.""" diff --git a/backend/tests/test_task_continuity.py b/backend/tests/test_task_continuity.py index 3e1b6ced4..d97ea6c03 100644 --- a/backend/tests/test_task_continuity.py +++ b/backend/tests/test_task_continuity.py @@ -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))} diff --git a/backend/tests/test_utils_messages.py b/backend/tests/test_utils_messages.py index 2856a5cdc..66c11ab3f 100644 --- a/backend/tests/test_utils_messages.py +++ b/backend/tests/test_utils_messages.py @@ -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 ----------