mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* fix(agents): keep todo reminders in Goal turns after an earlier provider stop TodoMiddleware skipped completion reminders whenever runtime.context held safety_capped or model_length_capped. The worker keeps that key across the run's hidden Goal continuation turns so the run record reports the first cap, and an unmet Goal continues after these provider stops (it stands down only on loop and token caps). After one capped response, every continuation turn therefore lost its todo reminders and could stop with unfinished items. Loop and token stops stay run-level and are still read from the context. Provider safety and length stops now apply to the response they ended, identified by the safety_termination / model_length_termination marker that those guards stamp on it. The check still runs before the parent hook, so a capped response cannot be resumed by the parallel-write retry. * refactor(agents): import provider-stop markers from the guards that stamp them TodoMiddleware matched the safety and length markers by string literals that duplicated the guards' own. Each guard now exports its marker and stop reason as module constants, used at its stamp and check sites and imported by TodoMiddleware, so a rename in a guard cannot silently disable suppression. Comment that completion_reminder_hard_stop_reasons is the inventory surface and only its run-level entries are read from runtime.context, and run the worker-level Goal continuation test for the safety guard too, which overwrites stop_reason instead of keeping the first one. --------- Co-authored-by: Totoro <msy626836554@gmail.com>
155 lines
7.2 KiB
Python
155 lines
7.2 KiB
Python
"""Todo completion reminders in Goal continuation turns after an earlier response cap.
|
|
|
|
The worker keeps the first cap reason in ``runtime.context["stop_reason"]`` across
|
|
hidden Goal turns so the run record reports it, and an unmet Goal keeps going after
|
|
a provider length or safety cap. Those turns are new model responses: a cap that
|
|
ended an earlier response must not switch off Todo reminders for them.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from langchain.agents import create_agent
|
|
from langchain_core.language_models.chat_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from pydantic import ConfigDict
|
|
|
|
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 TodoMiddleware
|
|
from deerflow.agents.thread_state import ThreadState
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.config.sandbox_config import SandboxConfig
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.goal import build_goal_state, read_thread_goal, write_thread_goal
|
|
from deerflow.runtime.runs import worker
|
|
from deerflow.runtime.runs.manager import RunManager
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
_UNFINISHED_TODOS = [
|
|
{"content": "Draft the report", "status": "completed"},
|
|
{"content": "Add the appendix", "status": "in_progress"},
|
|
]
|
|
|
|
|
|
class _ScriptedModel(BaseChatModel):
|
|
"""Return scripted responses in order and record every request."""
|
|
|
|
responses: list[AIMessage]
|
|
requests: list[list[BaseMessage]]
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "offline-todo-goal-continuation"
|
|
|
|
def bind_tools(self, tools: Any, **kwargs: Any) -> "_ScriptedModel":
|
|
return self
|
|
|
|
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
|
|
self.requests.append(list(messages))
|
|
if not self.responses:
|
|
raise AssertionError("The lead model was called more often than scripted")
|
|
return ChatResult(generations=[ChatGeneration(message=self.responses.pop(0))])
|
|
|
|
|
|
class _UnmetEvaluator:
|
|
async def ainvoke(self, messages: Any, config: Any = None) -> AIMessage:
|
|
return AIMessage(content=json.dumps({"satisfied": False, "blocker": "goal_not_met_yet", "reason": "The appendix is missing.", "evidence_summary": "Only the draft exists.", "relied_on_assumption": False}))
|
|
|
|
|
|
def _has_completion_reminder(request: list[BaseMessage]) -> bool:
|
|
return any(isinstance(message, HumanMessage) and message.name == "todo_completion_reminder" for message in request)
|
|
|
|
|
|
async def _run_goal_with_capped_user_turn(model: _ScriptedModel, guard: Any):
|
|
saver = InMemorySaver()
|
|
manager = RunManager(store=MemoryRunStore())
|
|
graph = create_agent(
|
|
model=model,
|
|
tools=[],
|
|
middleware=[TodoMiddleware(), guard],
|
|
state_schema=ThreadState,
|
|
context_schema=dict,
|
|
checkpointer=saver,
|
|
)
|
|
cfg = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
|
|
cfg.title.enabled = cfg.memory.enabled = cfg.memory.injection_enabled = False
|
|
bridge = SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
|
|
token = set_current_user(SimpleNamespace(id="todo-goal-continuation", role="admin"))
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="todo-goal-continuation-") as folder:
|
|
with (
|
|
patch("deerflow.config.paths._paths", Paths(base_dir=Path(folder))),
|
|
patch.dict(os.environ, {"DEER_FLOW_HOME": folder, "LANGSMITH_TRACING": "false", "LANGCHAIN_TRACING_V2": "false"}),
|
|
patch.object(worker, "create_goal_evaluator_model", lambda **kwargs: _UnmetEvaluator()),
|
|
):
|
|
record = await manager.create(f"todo-goal-{uuid4().hex}", user_id="todo-goal-continuation")
|
|
await write_thread_goal(saver, record.thread_id, build_goal_state("Write the report with its appendix.", max_continuations=1), create_if_missing=True)
|
|
await worker.run_agent(
|
|
bridge,
|
|
manager,
|
|
record,
|
|
ctx=worker.RunContext(checkpointer=saver, event_store=MemoryRunEventStore(), app_config=cfg),
|
|
agent_factory=lambda config: graph,
|
|
graph_input={"messages": [HumanMessage(content="Write the report with its appendix.")], "todos": _UNFINISHED_TODOS},
|
|
config={"configurable": {"thread_id": record.thread_id}, "recursion_limit": 100},
|
|
stream_modes=["values"],
|
|
)
|
|
goal = await read_thread_goal(saver, record.thread_id)
|
|
return record, goal
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("guard", "finish_reason", "stop_reason"),
|
|
[
|
|
# ModelLength keeps the first cap reason already in the context.
|
|
pytest.param(ModelLengthFinishReasonMiddleware, "length", "model_length_capped", id="length"),
|
|
# Safety overwrites the context's stop_reason unconditionally.
|
|
pytest.param(SafetyFinishReasonMiddleware, "content_filter", "safety_capped", id="safety"),
|
|
],
|
|
)
|
|
async def test_goal_continuation_after_a_provider_stop_still_gets_todo_reminders(monkeypatch, guard, finish_reason, stop_reason):
|
|
def deny_network(*args, **kwargs):
|
|
raise AssertionError("This controlled regression must not use the network")
|
|
|
|
monkeypatch.setattr(socket.socket, "connect", deny_network)
|
|
model = _ScriptedModel(
|
|
responses=[
|
|
# User turn: the provider stops the answer early.
|
|
AIMessage(content="Here is the first half of the report", response_metadata={"finish_reason": finish_reason}),
|
|
# Hidden Goal turn: a complete response that tries to stop with the appendix unfinished.
|
|
AIMessage(content="The report is done."),
|
|
AIMessage(content="Adding the appendix now."),
|
|
AIMessage(content="Appendix added."),
|
|
],
|
|
requests=[],
|
|
)
|
|
|
|
record, goal = await _run_goal_with_capped_user_turn(model, guard())
|
|
|
|
assert record.error is None
|
|
# The run still reports the stop from the user turn.
|
|
assert record.stop_reason == stop_reason
|
|
assert goal is not None and goal["continuation_count"] == 1
|
|
# The stopped user turn ends without a reminder ...
|
|
assert not _has_completion_reminder(model.requests[0])
|
|
# ... but the continuation's premature exit is reminded twice, up to the usual cap.
|
|
assert len(model.requests) == 4
|
|
assert [_has_completion_reminder(request) for request in model.requests[1:]] == [False, True, True]
|