diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb07fd6a2..71b63eadbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -433,6 +433,16 @@ This release closes that milestone with **301 merged pull requests**. ### Fixed +- **agents:** A retried model call in plan mode no longer loses the todo + completion reminder that `TodoMiddleware` had queued for it. The middleware + drained the reminder inside `wrap_model_call`; because + `LLMErrorHandlingMiddleware` wraps it and retries by calling its handler again, + the second attempt went out without the reminder while the run had already + spent one of its two reminders on it. `TodoMiddleware` now puts the drained + reminder back when the handler raises, without counting it again, so the retry + carries it and the cap still allows the second reminder. Successful calls are + unchanged, and a run whose reminder state was cleared meanwhile does not get + it back. ([#6132]) - **channels:** Stopping or restarting the Slack, Feishu, DingTalk or Discord channel no longer freezes the Gateway event loop. `SlackChannel.stop()` called `SocketModeClient.close()` inline, which joins the SDK's message-processor @@ -7502,5 +7512,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6091]: https://github.com/bytedance/deer-flow/pull/6091 [#6093]: https://github.com/bytedance/deer-flow/pull/6093 [#6112]: https://github.com/bytedance/deer-flow/pull/6112 +[#6132]: https://github.com/bytedance/deer-flow/pull/6132 [#6134]: https://github.com/bytedance/deer-flow/pull/6134 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index b6658ec7e2..d03dd099fe 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -412,6 +412,13 @@ ### 修复 +- **智能体:** 计划模式下被重试的模型调用不再丢失 `TodoMiddleware` + 已为其排队的待办完成提醒。该中间件在 `wrap_model_call` 中取出提醒;由于 + `LLMErrorHandlingMiddleware` 包裹着它并通过再次调用自己的 handler 来重试, + 第二次尝试在没有提醒的情况下发出,而该 run 已经为它消耗了两次提醒额度中的一次。 + 现在 `TodoMiddleware` 会在 handler 抛出时把取出的提醒放回队列,且不重复计数, + 从而让重试带上提醒,上限仍允许发送第二次提醒。成功调用的行为不变; + 若该 run 的提醒状态在此期间已被清除,提醒不会被恢复。([#6132]) - **渠道:** 停止或重启 Slack、飞书、钉钉、Discord 渠道不再冻结 Gateway 事件循环。 `SlackChannel.stop()` 直接调用 `SocketModeClient.close()`,它会 join SDK 的消息处理线程 (每次约 0.7 秒),并等待正在执行的事件监听器,而监听器中阻塞的 Slack Web API 调用 @@ -425,7 +432,6 @@ 而不再仅使用 `test -s` 检查文件是否非空。此前当远程沙箱写入中途被截断时(如磁盘满 或管道故障),残缺的文件也会通过校验并把错误路径交付给模型;现在会严格核对字节数与负载一致, 发生截断时返回 `None` 并回退到内联截断。([#6112]) - - **Gateway:** 含非 ASCII 字符的 CSRF token、GitHub webhook 签名、内部认证 token、 OIDC `state` 或 provisioner `X-API-Key` 现在按常规返回 403/401,而不是 500。 `hmac.compare_digest` 遇到含非 ASCII 字符的 `str` 参数会抛出 `TypeError`,而 @@ -6298,4 +6304,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6091]: https://github.com/bytedance/deer-flow/pull/6091 [#6093]: https://github.com/bytedance/deer-flow/pull/6093 [#6112]: https://github.com/bytedance/deer-flow/pull/6112 +[#6132]: https://github.com/bytedance/deer-flow/pull/6132 [#6134]: https://github.com/bytedance/deer-flow/pull/6134 diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 21b64994d1..cb35a73fd6 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -98,7 +98,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../ 19. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking. 20. **DurableContextMiddleware** - Before compaction, captures task dispatches/result summaries in `ThreadState.delegations` and loaded skill name/path/description references (never bodies) in `ThreadState.skill_context`; projects them into each model request. Static authority rules are injected as a `SystemMessage`; untrusted field values (`summary_text`, delegation results, skill descriptions) are injected separately as a hidden `HumanMessage` data block so compressed history, delegated work, and which skills are active stay visible without being stored as `messages` or promoted to system-role instructions. `build_subagent_runtime_middlewares` also attaches this middleware immediately before subagent summarization so a compacted `summary_text` is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope. 21. **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. -22. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly. +22. **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. 23. **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. 24. **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. 25. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket diff --git a/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py index 34cd35293b..94f6abe8ff 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py @@ -251,6 +251,25 @@ class TodoMiddleware(TodoListMiddleware): self._touch_completion_reminder_key_locked(key) return reminders + def _restore_completion_reminders(self, runtime: Runtime, reminders: list[str]) -> None: + """Requeue reminders taken for a model call that raised. + + LLMErrorHandlingMiddleware sits outside this middleware and retries a + failed call by running this wrap again, so the retry must still find + the reminder. The count is not bumped again: it was spent when the + reminder was queued. A run whose reminder state was dropped meanwhile + stays dropped. + """ + if not reminders: + return + key = self._pending_key(runtime) + with self._lock: + if key not in self._completion_reminder_counts: + return + queued = self._pending_completion_reminders.setdefault(key, []) + queued[:0] = [reminder for reminder in reminders if reminder not in queued] + self._touch_completion_reminder_key_locked(key) + def _clear_other_run_completion_reminders(self, runtime: Runtime) -> None: thread_id, current_run_id = self._pending_key(runtime) with self._lock: @@ -343,8 +362,7 @@ class TodoMiddleware(TodoListMiddleware): def _format_pending_completion_reminders(reminders: list[str]) -> str: return "\n\n".join(dict.fromkeys(reminders)) - def _augment_request(self, request: ModelRequest) -> ModelRequest: - reminders = self._drain_completion_reminders(request.runtime) + def _inject_completion_reminders(self, request: ModelRequest, reminders: list[str]) -> ModelRequest: if not reminders: return request new_messages = [ @@ -367,7 +385,12 @@ class TodoMiddleware(TodoListMiddleware): # without calling it the model is never told about the todo list feature. # Augment with pending completion reminders on the request that already # carries the injected system prompt. - return super().wrap_model_call(request, lambda req: handler(self._augment_request(req))) + reminders = self._drain_completion_reminders(request.runtime) + try: + return super().wrap_model_call(request, lambda req: handler(self._inject_completion_reminders(req, reminders))) + except Exception: + self._restore_completion_reminders(request.runtime, reminders) + raise @override async def awrap_model_call( @@ -376,10 +399,16 @@ class TodoMiddleware(TodoListMiddleware): handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelCallResult: # See wrap_model_call: preserve the base class system-prompt injection. - async def augmented_handler(req: ModelRequest) -> ModelResponse: - return await handler(self._augment_request(req)) + reminders = self._drain_completion_reminders(request.runtime) - return await super().awrap_model_call(request, augmented_handler) + async def augmented_handler(req: ModelRequest) -> ModelResponse: + return await handler(self._inject_completion_reminders(req, reminders)) + + try: + return await super().awrap_model_call(request, augmented_handler) + except Exception: + self._restore_completion_reminders(request.runtime, reminders) + raise @override def after_agent(self, state: ThreadState, runtime: Runtime) -> dict[str, Any] | None: diff --git a/backend/tests/test_todo_middleware.py b/backend/tests/test_todo_middleware.py index e69387365f..e2656cc3af 100644 --- a/backend/tests/test_todo_middleware.py +++ b/backend/tests/test_todo_middleware.py @@ -1,9 +1,11 @@ """Tests for TodoMiddleware context-loss detection.""" import asyncio +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.types import ModelRequest from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel @@ -561,6 +563,50 @@ class TestWrapModelCall: assert len(seen) == 1 assert seen[0].messages == state["messages"] + def test_pending_reminder_survives_a_failed_model_call(self): + """A call that raises is retried by LLMErrorHandlingMiddleware through this wrap; the reminder must still be sent.""" + mw = TodoMiddleware() + runtime = _make_runtime() + state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()} + mw.after_model(state, runtime) + + request = _make_model_request(state["messages"], runtime=runtime) + sent: list[list[Any]] = [] + + def flaky_handler(model_request: ModelRequest): + sent.append(model_request.messages) + if len(sent) == 1: + raise RuntimeError("503 Service Unavailable") + return "response" + + with pytest.raises(RuntimeError): + mw.wrap_model_call(request, flaky_handler) + assert mw.wrap_model_call(request, flaky_handler) == "response" + + assert [len(_todo_completion_reminders(messages)) for messages in sent] == [1, 1] + # Restoring is not a new reminder: the retry must not spend the cap. + assert mw._completion_reminder_count_for_runtime(runtime) == 1 + assert mw._drain_completion_reminders(runtime) == [] + + def test_failed_model_call_does_not_restore_a_reminder_for_a_cleared_run(self): + """If the run's reminder state was dropped while the call was in flight, the failure must not resurrect it.""" + mw = TodoMiddleware() + runtime = _make_runtime() + state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()} + mw.after_model(state, runtime) + + request = _make_model_request(state["messages"], runtime=runtime) + + def failing_handler(model_request: ModelRequest): + mw.after_agent({}, runtime) + raise RuntimeError("503 Service Unavailable") + + with pytest.raises(RuntimeError): + mw.wrap_model_call(request, failing_handler) + + assert mw._pending_completion_reminders == {} + assert mw._completion_reminder_counts == {} + class TestTodoMiddlewareAgentGraphIntegration: def test_reuses_thread_state_todos_schema_in_real_agent_graph(self): @@ -656,6 +702,66 @@ class TestTodoMiddlewareAgentGraphIntegration: assert mw._pending_completion_reminders == {} assert mw._completion_reminder_counts == {} + def test_completion_reminder_survives_a_retried_model_call_in_real_agent_graph(self): + """LLMErrorHandlingMiddleware retries a failed call by running the inner wraps again; the retry must still carry the reminder.""" + from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware + from deerflow.config.app_config import AppConfig, LlmCallConfig + from deerflow.config.sandbox_config import SandboxConfig + + class ProviderUnavailable(Exception): + def __init__(self) -> None: + super().__init__("503 Service Unavailable") + self.status_code = 503 + self.response = SimpleNamespace(status_code=503, headers={}) + + class FailsOnceOnReminder(_CapturingFakeMessagesListChatModel): + _failed: bool = PrivateAttr(default=False) + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + if not self._failed and _todo_completion_reminders(messages): + self._failed = True + self._seen_messages.append(list(messages)) + raise ProviderUnavailable() + return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) + + model = FailsOnceOnReminder( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "write_todos", + "id": "todos-1", + "args": {"todos": [{"content": "Step 1", "status": "pending"}]}, + } + ], + ), + AIMessage(content="premature final 1"), + AIMessage(content="premature final 2"), + AIMessage(content="premature final 3"), + ], + ) + app_config = AppConfig( + sandbox=SandboxConfig(use="test"), + llm_call=LlmCallConfig(retry_max_attempts=3, retry_base_delay_ms=0, retry_cap_delay_ms=0), + ) + graph = create_agent( + model=model, + tools=[], + middleware=[LLMErrorHandlingMiddleware(app_config=app_config), TodoMiddleware()], + state_schema=ThreadState, + ) + + result = graph.invoke( + {"messages": [("user", "finish all todos")]}, + context={"thread_id": "retry-thread", "run_id": "retry-run"}, + ) + + # write_todos, the premature exit, the failed attempt and its retry, then + # the second reminder: the retry did not spend the two-reminder cap. + assert [len(_todo_completion_reminders(messages)) for messages in model.seen_messages] == [0, 0, 1, 1, 1] + assert result["messages"][-1].content == "premature final 3" + def test_length_capped_write_file_does_not_reengage_todos(self): """Reproduces the incident (thread b1723286): model emits a write_file call with finish_reason=length, ModelLength suppresses it, and @@ -805,3 +911,25 @@ class TestAwrapModelCall: assert isinstance(injected, HumanMessage) assert injected.name == "todo_completion_reminder" assert injected.additional_kwargs["hide_from_ui"] is True + + def test_async_pending_reminder_survives_a_failed_model_call(self): + mw = TodoMiddleware() + runtime = _make_runtime() + state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()} + mw.after_model(state, runtime) + + request = _make_model_request(state["messages"], runtime=runtime) + sent: list[list[Any]] = [] + + async def flaky_handler(model_request: ModelRequest): + sent.append(model_request.messages) + if len(sent) == 1: + raise RuntimeError("503 Service Unavailable") + return "response" + + with pytest.raises(RuntimeError): + asyncio.run(mw.awrap_model_call(request, flaky_handler)) + assert asyncio.run(mw.awrap_model_call(request, flaky_handler)) == "response" + + assert [len(_todo_completion_reminders(messages)) for messages in sent] == [1, 1] + assert mw._completion_reminder_count_for_runtime(runtime) == 1