fix(agent): prevent todo reminders from resuming runs after hard stops (#7955)

* fix(agent): stop todo reminders after run hard limits

Respect loop_capped and token_capped before scheduling completion reminders, and include the behavior in the assembly policy fingerprint.

Cover sync and async agent graphs, blocked post-stop tools, preserved Todo state, and continuation from the same thread checkpoint. Validation: 316 targeted tests passed.

* docs(agent): explain todo behavior after hard stops

Document that loop and token hard stops suppress Todo completion reminders while retaining unfinished tasks for later runs.

* docs(changelog): record todo hard-stop fix

Add English and Chinese Unreleased entries referencing issue #7953.

* fix(agent): mark safety stops with partial responses

* fix(agent): honor provider stops before todo retries

* docs(agent): clarify todo behavior after provider stops

* test(agent): isolate todo safety-chain runtime dependencies

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
This commit is contained in:
golemon 2026-11-07 21:43:16 +08:00 committed by GitHub
parent 2bd60d1e6f
commit 372baf762f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 379 additions and 48 deletions

View File

@ -24,6 +24,11 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **agents:** Unfinished todos no longer trigger additional model calls or
tool execution after a loop-detection, run token-budget, provider-safety, or
model output-length stop. Todo state is preserved so users can continue in a
later run. ([#7953])
- **channels:** Long Feishu/Lark answers are no longer lost. Card text was
unbounded and ASCII-escaped, so an answer of about 4,400 CJK characters made
the card request exceed Feishu's 30 KB limit (`230025`); the final patch and
@ -16065,3 +16070,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7892]: https://github.com/bytedance/deer-flow/pull/7892
[#7907]: https://github.com/bytedance/deer-flow/pull/7907
[#7953]: https://github.com/bytedance/deer-flow/issues/7953

View File

@ -23,6 +23,10 @@
### 修复
- **智能体:** 循环检测、运行 token 预算、模型服务安全限制或输出长度限制触发停止后,
未完成的 Todo 不再触发额外的模型调用或工具执行。Todo 状态仍会保留,
用户可在后续运行中继续处理。([#7953])
- **渠道:** 飞书/Lark 的长回答不再丢失。此前卡片文本没有长度上限,并以 ASCII 转义
序列化,约 4,400 个中日韩字符就会让卡片请求超过飞书 30 KB 的限制(`230025`):
最终更新及其回复兜底都会被拒绝,运行中卡片停留在最后一次流式内容或只剩 `▉`。
@ -12780,3 +12784,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7953]: https://github.com/bytedance/deer-flow/issues/7953

View File

@ -2728,6 +2728,10 @@ call.
In planning mode, if the original `write_todos` call is no longer visible,
DeerFlow adds a reminder using the latest task statuses before the next model
call. Skipped or failed compaction leaves the existing messages unchanged.
When loop detection, the run token budget, or a provider safety/output-length
limit stops a run, unfinished todos are retained for a later user run; todo
completion reminders do not restart model calls or tool execution after that stop.
Safety-stopped partial text is preserved; it does not trigger a Todo retry.
Optional `pii_redaction.enabled` redacts detected identifiers in user messages,
remote tool results, compaction input, reinjected summaries, configured

View File

@ -150,7 +150,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event; a retried activation call replays the recorded reminder without re-auditing. Skill `allowed-tools` is compatibility metadata only: activation never filters, grants, or denies tools — Agent assembly/execution authorization owns the permission ceiling.
19. **DurableContextMiddleware** - Before compaction, captures task dispatch/result summaries in `ThreadState.delegations` and loaded skill name/path/description (never bodies) in `ThreadState.skill_context`; projects them per request. Static authority uses `SystemMessage`; untrusted `summary_text`, delegation results and skill descriptions use a separate hidden `HumanMessage` data block, never persisted as messages or promoted to system instructions. Subagents attach it immediately before summarization: summary precedes preserved AI/tool history for strict providers. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope.
20. **SummarizationMiddleware** - *(optional)* Compacts near token limits. Fraction trigger/keep profiles belong to the active run model; an explicit summary model only generates. Memory flush/manual compaction follow runtime/checkpoint agent policy. Preserve the latest user request and DynamicContext reminders; a backward cutoff may retain old AI/tool turns or make first-turn compaction a no-op.
21. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly. A raising model call requeues its reminder, uncounted, for the retry. Both hooks read stored todos through `_usable_todos`: thread-state updates and direct integrations can Overwrite that channel, and a non-mapping entry would otherwise raise on every model call.
21. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Before the parent hook, completion reminders respect loop/token/safety/model-length `stop_reason` values; provider termination markers remain fallbacks. Unfinished todos survive for later runs. A raising model call requeues its reminder, uncounted, for the retry. Both hooks read stored todos through `_usable_todos`: thread-state updates and direct integrations can Overwrite that channel, and a non-mapping entry would otherwise raise on every model call.
22. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
TODO attribution skips non-string statuses before membership; preserve args/TODO state for normal tool validation. Attribution normalizes the stored `todos` channel with `_normalize_todos` before reading entries, the same tolerance the todo middleware applies.
23. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.
@ -174,7 +174,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs, apply_to}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Optional `apply_to` selects both chains (default), lead, or subagent before import/construction. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review.
36. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
37. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp every cap; visible text-only caps get no notice. Add a notice only for suppressed calls or no visible content. Also registered on subagent chains (before Safety), where `consume_stop_reason` surfaces `model_length_capped` to the executor.
38. **SafetyFinishReasonMiddleware** - `safety_finish_reason.enabled`: suppresses valid/invalid safety-stopped calls. Responses: `status=incomplete` + `incomplete_details.reason=content_filter`. Clear invalid calls before shared cloning. Place after terminal/custom/configured hooks; `after_model` runs in reverse.
38. **SafetyFinishReasonMiddleware** - `safety_finish_reason.enabled`: marks all safety stops, preserving partial text and suppressing valid/invalid calls. Responses: `status=incomplete` + `incomplete_details.reason=content_filter`. Clear invalid calls before shared cloning. Place after terminal/custom/configured hooks; `after_model` runs in reverse.
39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []` — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
Inline `skill_references` accepts 1–16 names; empty lists retain slash/plain flow.

View File

@ -25,11 +25,10 @@ configured ``SafetyTerminationDetector`` fires it either
("message ... with role 'assistant' must not be empty"), stranding the
whole thread until a new chat is started.
A safety-terminated message that carries visible text but no tool calls is
left untouched so its partial answer still reaches the user. Either way we
append the explanation and stash observability fields in
``additional_kwargs.safety_termination`` so logs, traces, and SSE consumers
can see what happened.
A safety-terminated message that carries visible text but no tool calls keeps
its content without an explanation. Every detected termination stamps
``runtime.context.stop_reason`` and ``additional_kwargs.safety_termination``
so downstream hooks cannot mistake the partial answer for a premature exit.
Hook choice: ``after_model`` (not ``wrap_model_call``) because the response
is a *normal* return — not an exception — and we want to participate in the
@ -124,6 +123,7 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
detectors.append(descriptor)
return {
"action": "suppress_tool_calls",
"mark_text_only_termination": True,
"detectors": detectors,
}
@ -192,13 +192,15 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
) -> AIMessage:
tool_calls = [*(message.tool_calls or []), *(message.invalid_tool_calls or [])]
suppressed_names = [tc.get("name") or "unknown" for tc in tool_calls]
template = _USER_FACING_MESSAGE if tool_calls else _USER_FACING_EMPTY_MESSAGE
explanation = template.format(
reason_field=termination.reason_field,
reason_value=termination.reason_value,
detector=termination.detector,
)
new_content = self._append_user_message(message.content, explanation)
new_content = message.content
if tool_calls or not message_content_to_text(message.content or "").strip():
template = _USER_FACING_MESSAGE if tool_calls else _USER_FACING_EMPTY_MESSAGE
explanation = template.format(
reason_field=termination.reason_field,
reason_value=termination.reason_value,
detector=termination.detector,
)
new_content = self._append_user_message(message.content, explanation)
# clone_ai_message_with_tool_calls handles structured tool_calls,
# raw additional_kwargs.tool_calls, function_call, and provider
@ -365,9 +367,8 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
if not isinstance(last, AIMessage):
return None
# Two provider-safety failure modes are worth rewriting; a safety
# termination that produced visible text with no tool calls is left
# untouched so the partial answer still reaches the user naturally.
# All provider-safety terminations need a stop marker. Only two cases
# need a visible explanation; partial text otherwise stays unchanged.
# 1. tool_calls present: they may be truncated/unsafe (#3028), so
# suppress them.
# 2. blank content and no tool_calls: an empty assistant message
@ -381,9 +382,6 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
# 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
termination = self._detect(last)
if termination is None:
return None
@ -395,6 +393,10 @@ class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
ctx = getattr(runtime, "context", None)
if isinstance(ctx, dict):
ctx["stop_reason"] = "safety_capped"
# Re-observing a terminal message must not replace its original
# suppression record or emit another event. Still stamp this runtime.
if not tool_calls and not content_is_blank and (last.additional_kwargs or {}).get("safety_termination"):
return None
patched = self._build_suppressed_message(last, termination)
thread_id = None

View File

@ -15,6 +15,8 @@ of being persisted into graph state as a normal user-visible message.
The completion guard defers to ``model_length_termination``: when a length-capped
turn has already been terminalized by ``ModelLengthFinishReasonMiddleware``,
re-engaging would only re-emit the same oversized tool call into the same cap.
Run-level loop, token, provider-safety and model-length stops also suppress
completion reminders, leaving unfinished todos available for a later user run.
"""
from __future__ import annotations
@ -31,6 +33,9 @@ from langgraph.runtime import Runtime
from deerflow.agents.thread_state import ThreadState
TODO_REMINDER_MESSAGE_NAME = "todo_reminder"
# Runtime Goal continuation has a narrower policy; keep this local to Todo.
# Source-inventory and graph tests pin these values to the actual guard stamps.
_COMPLETION_REMINDER_HARD_STOP_REASONS = ("loop_capped", "token_capped", "safety_capped", "model_length_capped")
def _todos_in_messages(messages: list[Any]) -> bool:
@ -154,6 +159,8 @@ class TodoMiddleware(TodoListMiddleware):
"tool_description_hash": canonical_hash(self.tool_description),
"state_channel": "todos",
"skip_completion_reminder_on_length_cap": True,
"skip_completion_reminder_on_safety_cap": True,
"completion_reminder_hard_stop_reasons": list(_COMPLETION_REMINDER_HARD_STOP_REASONS),
}
@override
@ -347,6 +354,13 @@ class TodoMiddleware(TodoListMiddleware):
# no jump_to — the model cannot update todos through an unbound tool.
return None
# Guards run before this reversed after_model hook. They strip tool
# calls and stamp loop/token/safety/model-length stops. Check before
# the parent hook so its parallel-write retry cannot override a stop.
context = getattr(runtime, "context", None)
if isinstance(context, dict) and context.get("stop_reason") in _COMPLETION_REMINDER_HARD_STOP_REASONS:
return None
# 1. Preserve base class logic (parallel write_todos detection).
base_result = super().after_model(state, runtime)
if base_result is not None:
@ -363,11 +377,10 @@ class TodoMiddleware(TodoListMiddleware):
if (last_ai.additional_kwargs or {}).get("deerflow_error_fallback"):
return None
# A length-capped turn was already terminalized by
# ModelLengthFinishReasonMiddleware (tool calls suppressed, notice
# appended); re-engaging would only re-emit the same oversized tool
# call into the same cap.
if (last_ai.additional_kwargs or {}).get("model_length_termination"):
# Provider caps also mark the message: preserve this fallback when
# runtime context is unavailable or another reason already occupies it.
termination_metadata = last_ai.additional_kwargs or {}
if termination_metadata.get("model_length_termination") or termination_metadata.get("safety_termination"):
return None
# 3. Allow exit when all todos are completed or there are no todos.

View File

@ -26,6 +26,11 @@ ownership and capture cleanup. Retain the real XLSX conversion coverage.
Backend tests must preserve the runtime invariants they exercise without changing production execution topology.
Todo production-chain regressions bind their explicit config through the runtime
AppConfig context, isolate sandbox/path singletons, and clean up the test provider.
Keep the missing ambient config path so a developer's `config.yaml` cannot mask
an invocation-time dependency; retain the real sandbox and middleware chain.
`test_threads_router.py::test_goal_mutations_reject_run_owned_by_another_worker`
feeds real PUT/DELETE 409 errors into the IM classifier. Keep this contract check
so Gateway wording changes cannot silently remove localized busy feedback.

View File

@ -303,6 +303,28 @@ def _middleware_fingerprint(middleware):
).fingerprint
def test_todo_hard_stop_policy_changes_assembly_identity(monkeypatch):
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
current = TodoMiddleware()
previous = TodoMiddleware()
previous_params = previous.release_policy_parameters()
assert previous_params["completion_reminder_hard_stop_reasons"] == ["loop_capped", "token_capped", "safety_capped", "model_length_capped"]
previous_params["completion_reminder_hard_stop_reasons"] = ["loop_capped", "token_capped"]
assert previous_params.pop("skip_completion_reminder_on_safety_cap") is True
monkeypatch.setattr(previous, "release_policy_parameters", lambda: previous_params)
assert _middleware_fingerprint(current) != _middleware_fingerprint(previous)
def test_safety_text_only_stop_policy_changes_assembly_identity(monkeypatch):
current = _make_safety_finish_reason_middleware()
previous = _make_safety_finish_reason_middleware()
previous_params = previous.release_policy_parameters()
assert previous_params.pop("mark_text_only_termination") is True
monkeypatch.setattr(previous, "release_policy_parameters", lambda: previous_params)
assert _middleware_fingerprint(current) != _middleware_fingerprint(previous)
def _continuity_summarizer(config):
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware

View File

@ -177,9 +177,8 @@ async def test_safety_termination_event_reaches_astream_events():
assert _TOOL_INVOCATIONS == []
def test_content_filter_without_tool_calls_passes_through_unchanged():
"""No tool calls => issue scope says don't intervene; the partial
response should be delivered as-is so the user sees what they got."""
def test_content_filter_without_tool_calls_preserves_text_and_marks_termination():
"""Keep the partial response visible while telling later hooks to stop."""
_TOOL_INVOCATIONS.clear()
class _NoToolModel(BaseChatModel):
@ -210,8 +209,7 @@ def test_content_filter_without_tool_calls_passes_through_unchanged():
# Content untouched.
assert final_ai.content == "Partial answer truncated by safety filter"
# No safety_termination stamp because we didn't intervene.
assert "safety_termination" not in final_ai.additional_kwargs
assert final_ai.additional_kwargs["safety_termination"]["suppressed_tool_call_count"] == 0
# tool node never ran (there were no tool calls in the first place).
assert _TOOL_INVOCATIONS == []

View File

@ -1,5 +1,6 @@
"""Unit tests for SafetyFinishReasonMiddleware."""
import asyncio
from unittest.mock import MagicMock
import pytest
@ -100,19 +101,34 @@ class TestTriggerCriteria:
patched = result["messages"][0]
assert patched.tool_calls == []
def test_content_filter_without_tool_calls_passes_through(self):
"""issue scope: when there are no tool calls the partial text is a
legitimate final response and should not be rewritten."""
@pytest.mark.parametrize("invoke_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("content", ["partial response", [{"type": "text", "text": "partial response"}]])
def test_content_filter_without_tool_calls_preserves_text_and_stamps_stop(self, invoke_async, content):
"""Partial text stays visible, but downstream hooks must see the stop."""
mw = SafetyFinishReasonMiddleware()
state = {
"messages": [
_ai(
content="partial response",
response_metadata={"finish_reason": "content_filter"},
)
]
}
assert mw._apply(state, _runtime()) is None
message = _ai(content=content, response_metadata={"finish_reason": "content_filter"}, additional_kwargs={"reasoning": "original"})
message.id = "partial-safety-response"
runtime = _runtime()
state = {"messages": [message]}
result = asyncio.run(mw.aafter_model(state, runtime)) if invoke_async else mw.after_model(state, runtime)
assert result is not None
patched = result["messages"][0]
assert patched.content == content
assert patched.id == message.id
assert patched.response_metadata == message.response_metadata
assert patched.additional_kwargs["reasoning"] == "original"
assert patched.additional_kwargs["safety_termination"]["suppressed_tool_call_count"] == 0
assert runtime.context["stop_reason"] == "safety_capped"
assert "safety_termination" not in message.additional_kwargs
assert message.content == content
next_runtime = _runtime()
second_state = {"messages": [patched]}
second = asyncio.run(mw.aafter_model(second_state, next_runtime)) if invoke_async else mw.after_model(second_state, next_runtime)
assert second is None
assert next_runtime.context["stop_reason"] == "safety_capped"
def test_content_filter_blank_content_no_tool_calls_backfills(self):
"""#4393: an empty content_filter response with no tool calls would be
@ -445,7 +461,7 @@ class TestMessageRewrite:
def test_idempotent_on_already_cleared_message(self):
# Re-running the middleware on a message we already cleared must not
# re-trigger (tool_calls is now empty → fast passthrough).
# re-trigger or replace the original suppression record.
mw = SafetyFinishReasonMiddleware()
state = {
"messages": [

View File

@ -153,14 +153,19 @@ def test_responses_signal_not_combined_across_containers():
@pytest.mark.parametrize("content", ["", "Partial answer"])
def test_filtered_text_keeps_existing_empty_and_visible_behavior(content):
def test_filtered_text_marks_stop_and_preserves_visible_content(content):
message = AIMessage(content=content, response_metadata={"status": "incomplete", "incomplete_details": {"reason": "content_filter"}})
update = SafetyFinishReasonMiddleware().after_model({"messages": [message]}, SimpleNamespace(context={}))
runtime = SimpleNamespace(context={})
update = SafetyFinishReasonMiddleware().after_model({"messages": [message]}, runtime)
assert update is not None
patched = update["messages"][0]
assert runtime.context["stop_reason"] == "safety_capped"
assert patched.additional_kwargs["safety_termination"]["suppressed_tool_call_count"] == 0
assert patched.response_metadata == message.response_metadata
if content:
assert update is None
assert patched.content == content
else:
assert update is not None
assert "returned no content" in update["messages"][0].content
assert "returned no content" in patched.content
@pytest.mark.parametrize("native", [False, True])

View File

@ -1,20 +1,26 @@
"""Tests for TodoMiddleware context-loss detection."""
import ast
import asyncio
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware
from langchain.agents.middleware.types import ModelRequest
from langchain.tools import tool
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import PrivateAttr
from deerflow.agents.middlewares.model_length_finish_reason_middleware import (
ModelLengthFinishReasonMiddleware,
)
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.todo_middleware import (
TODO_REMINDER_MESSAGE_NAME,
TodoMiddleware,
@ -306,6 +312,89 @@ class TestToolCallIntentOrError:
class TestAfterModel:
def test_all_middleware_stop_reason_writes_have_an_explicit_todo_policy(self):
middleware_dir = Path(__file__).resolve().parents[1] / "packages/harness/deerflow/agents/middlewares"
stamped_reasons = set()
for source in sorted(middleware_dir.glob("*.py")):
tree = ast.parse(source.read_text(encoding="utf-8"))
constants = {target.id: node.value for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) and isinstance(node.value, ast.Constant)}
for node in ast.walk(tree):
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if not isinstance(target, ast.Subscript) or not isinstance(target.slice, ast.Constant) or target.slice.value != "stop_reason":
continue
# This copies a child result into a ledger entry; it does
# not stamp the current run's runtime.context.
if source.name == "delegation_ledger.py" and isinstance(target.value, ast.Name) and target.value.id == "entry":
continue
value = constants.get(node.value.id) if isinstance(node.value, ast.Name) else node.value
assert isinstance(value, ast.Constant) and isinstance(value.value, str), f"Classify stop_reason writer at {source.name}:{node.lineno}"
stamped_reasons.add(value.value)
# The subagent budget only removes task calls; the lead may still
# complete its work using other tools. Every other writer is terminal.
nonterminal_reasons = {"subagent_limit_capped"}
assert nonterminal_reasons <= stamped_reasons
policy = TodoMiddleware().release_policy_parameters()
assert set(policy["completion_reminder_hard_stop_reasons"]) == stamped_reasons - nonterminal_reasons
@pytest.mark.parametrize("marker", ["safety_termination", "model_length_termination"])
@pytest.mark.parametrize("context", [None, {}, {"stop_reason": "custom_stop"}])
def test_termination_marker_suppresses_reminders_without_recognized_context(self, marker, context):
mw = TodoMiddleware()
runtime = _make_runtime()
runtime.context = context
state = {"messages": [AIMessage(content="partial response", additional_kwargs={marker: {"suppressed_tool_call_count": 0}})], "todos": _incomplete_todos()}
assert mw.after_model(state, runtime) is None
assert mw._pending_completion_reminders == {}
@pytest.mark.parametrize("invoke_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("stop_reason", ["loop_capped", "token_capped", "safety_capped", "model_length_capped"])
def test_recognized_stop_reason_suppresses_completion_reminders(self, invoke_async, stop_reason):
mw = TodoMiddleware()
runtime = _make_runtime()
runtime.context["stop_reason"] = stop_reason
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
result = asyncio.run(mw.aafter_model(state, runtime)) if invoke_async else mw.after_model(state, runtime)
assert result is None
assert mw._pending_completion_reminders == {}
assert mw._completion_reminder_counts == {}
assert state["todos"] == _incomplete_todos()
@pytest.mark.parametrize("invoke_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("stop_reason", ["loop_capped", "token_capped", "safety_capped", "model_length_capped"])
def test_hard_stop_takes_precedence_over_base_jump(self, monkeypatch, invoke_async, stop_reason):
base_hook = MagicMock(return_value={"jump_to": "model"})
monkeypatch.setattr(TodoListMiddleware, "after_model", base_hook)
mw = TodoMiddleware()
runtime = _make_runtime()
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
# Preserve normal parent behavior, but never let it override a hard stop.
def after_model():
return asyncio.run(mw.aafter_model(state, runtime)) if invoke_async else mw.after_model(state, runtime)
assert after_model() == {"jump_to": "model"}
base_hook.assert_called_once()
base_hook.reset_mock()
runtime.context["stop_reason"] = stop_reason
assert after_model() is None
base_hook.assert_not_called()
@pytest.mark.parametrize("stop_reason", [None, "custom_stop", "subagent_limit_capped", [], {}])
def test_unrecognized_stop_reason_keeps_completion_reminders(self, stop_reason):
mw = TodoMiddleware()
runtime = _make_runtime()
runtime.context["stop_reason"] = stop_reason
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
assert mw.after_model(state, runtime) == {"jump_to": "model"}
def test_returns_none_when_agent_still_using_tools(self):
mw = TodoMiddleware()
state = {
@ -608,7 +697,173 @@ class TestWrapModelCall:
assert mw._completion_reminder_counts == {}
@pytest.fixture
def production_chain_config(tmp_path, monkeypatch):
from deerflow.config.app_config import AppConfig, pop_current_app_config, push_current_app_config
from deerflow.sandbox.sandbox_provider import shutdown_sandbox_provider
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
# A missing ambient config must not be masked by a developer's checkout.
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing-config.yaml"))
monkeypatch.setenv("LANGSMITH_TRACING", "false")
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false")
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setattr("deerflow.sandbox.sandbox_provider._default_sandbox_provider", None)
monkeypatch.setattr("deerflow.sandbox.local.local_sandbox_provider._singleton", None)
app_config = AppConfig.model_validate(
{
"models": [{"name": "offline", "use": "langchain_openai:ChatOpenAI", "model": "offline"}],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider", "allow_host_bash": False},
"skills": {"path": str(tmp_path / "skills")},
"memory": {"enabled": False},
"title": {"enabled": False},
"summarization": {"enabled": False},
}
)
# Sandbox lifecycle hooks resolve get_app_config() at invocation time,
# independently of the explicit config passed to build_middlewares().
# The runtime binding propagates through both graph and worker-thread calls.
push_current_app_config(app_config)
try:
yield app_config
finally:
try:
shutdown_sandbox_provider()
finally:
pop_current_app_config()
class TestTodoMiddlewareAgentGraphIntegration:
@pytest.mark.parametrize("invoke_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("response_kind", ["tool", "blank", "partial_text"])
def test_production_chain_stops_after_safety_response(self, production_chain_config, invoke_async, response_kind):
from deerflow.agents.lead_agent.agent import build_middlewares
executed = []
@tool
def record_action(marker: str) -> str:
"""Record a harmless action in memory."""
executed.append(marker)
return marker
filtered = AIMessage(
id="filtered-response",
content="Partial report." if response_kind == "partial_text" else "",
response_metadata={"finish_reason": "content_filter"},
tool_calls=[{"name": "record_action", "args": {"marker": "filtered"}, "id": "filtered-call"}] if response_kind == "tool" else [],
)
model = _CapturingFakeMessagesListChatModel(
responses=[
filtered,
AIMessage(content="", tool_calls=[{"name": "record_action", "args": {"marker": "after-stop"}, "id": "later-call"}]),
AIMessage(content="final"),
]
)
middlewares = build_middlewares({"configurable": {"is_plan_mode": True}}, model_name="offline", app_config=production_chain_config, memory_enabled=False, available_skills=set())
graph = create_agent(model=model, tools=[record_action], middleware=middlewares, state_schema=ThreadState)
todos = _incomplete_todos()
context = {"thread_id": "production-safety-thread", "run_id": "production-safety-run"}
state = {"messages": [("user", "Finish the report.")], "todos": todos}
result = asyncio.run(graph.ainvoke(state, context=context)) if invoke_async else graph.invoke(state, context=context)
assert len(model.seen_messages) == 1
assert executed == []
assert context["stop_reason"] == "safety_capped"
final = result["messages"][-1]
assert final.additional_kwargs["safety_termination"]
assert final.content
assert not final.tool_calls
assert result["todos"] == todos
if response_kind == "partial_text":
assert final.content == filtered.content
todo_mw = next(mw for mw in middlewares if isinstance(mw, TodoMiddleware))
assert todo_mw._pending_completion_reminders == {}
assert todo_mw._completion_reminder_counts == {}
@pytest.mark.parametrize("invoke_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("guard_kind", ["loop", "token", "safety", "model_length"])
@pytest.mark.parametrize("pending", [True, False], ids=["unfinished", "completed"])
def test_run_hard_stop_does_not_reengage_todos(self, invoke_async, guard_kind, pending):
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
from deerflow.config.token_budget_config import TokenBudgetConfig
executed = []
@tool
def record_action(marker: str) -> str:
"""Record a harmless action in memory."""
executed.append(marker)
return marker
def action(marker, call_id, tokens=10):
return AIMessage(
content="",
tool_calls=[{"name": "record_action", "args": {"marker": marker}, "id": call_id}],
usage_metadata={"input_tokens": tokens, "output_tokens": 0, "total_tokens": tokens},
)
todos = [{"content": "Record the requested actions", "status": "pending" if pending else "completed"}]
blocked_action = action("repeated", "blocked", tokens=1000 if guard_kind == "token" else 10)
if guard_kind in ("safety", "model_length"):
blocked_action.response_metadata = {"finish_reason": "content_filter" if guard_kind == "safety" else "length"}
model = _CapturingFakeMessagesListChatModel(
responses=[
AIMessage(content="", tool_calls=[{"name": "write_todos", "args": {"todos": todos}, "id": "todos-start"}]),
action("repeated", "first"),
blocked_action,
# A different action makes unintended post-stop execution observable.
action("after-stop", "after-stop"),
AIMessage(content="final answer"),
AIMessage(content="final answer"),
AIMessage(content="final answer"),
]
)
todo_mw = TodoMiddleware()
guards = {
"loop": LoopDetectionMiddleware(warn_threshold=2, hard_limit=2),
"token": TokenBudgetMiddleware(TokenBudgetConfig(enabled=True, max_tokens=1000)),
"safety": SafetyFinishReasonMiddleware(),
"model_length": ModelLengthFinishReasonMiddleware(),
}
guard = guards[guard_kind]
# Production registers Todo before the guards; after_model runs in reverse.
graph = create_agent(model=model, tools=[record_action], middleware=[todo_mw, guard], state_schema=ThreadState, checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "hard-stop-thread"}, "recursion_limit": 100}
context = {"thread_id": "hard-stop-thread", "run_id": "hard-stop-run"}
state = {"messages": [("user", "Record actions and track todos.")]}
def invoke(state, context):
return asyncio.run(graph.ainvoke(state, context=context, config=config)) if invoke_async else graph.invoke(state, context=context, config=config)
result = invoke(state, context)
assert context["stop_reason"] == f"{guard_kind}_capped"
assert executed == ["repeated"]
assert len(model.seen_messages) == 3
# Pin the real producers to Todo's declared policy, without importing
# runtime Goal policy (which currently covers only loop/token).
assert context["stop_reason"] in todo_mw.release_policy_parameters()["completion_reminder_hard_stop_reasons"]
assert result["todos"] == todos
assert not result["messages"][-1].tool_calls
assert not any(_todo_completion_reminders(messages) for messages in model.seen_messages)
assert todo_mw._pending_completion_reminders == {}
snapshot = graph.get_state(config)
assert snapshot.next == ()
assert snapshot.values["todos"] == todos
if pending:
# Restore the same thread from its checkpoint without resupplying Todo.
# The old cap must not disable the new run or recount historical usage.
next_context = {"thread_id": "hard-stop-thread", "run_id": "next-run"}
next_result = invoke({"messages": [("user", "Continue the unfinished task.")]}, next_context)
assert "stop_reason" not in next_context
assert executed == ["repeated", "after-stop"]
assert any(_todo_completion_reminders(messages) for messages in model.seen_messages[3:])
assert next_result["todos"] == todos
def test_reuses_thread_state_todos_schema_in_real_agent_graph(self):
mw = TodoMiddleware()
model = _CapturingFakeMessagesListChatModel(