mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-13 06:31:47 +00:00
* fix(agents): count each delegation once when a provider reuses its call id Tool-call ids are unique per response, not per conversation. Some providers number every response's calls from call_0, so two task calls in one run can share an id. The delegation ledger used (run_id, tool_call_id) as a delegation's identity: - extract_delegations built one id -> entry map over the whole run, so a later call_0 replaced the earlier delegation's description and took its result while it was still running. - merge_delegations replaced the same (run_id, id), keeping one entry. - SubagentLimitMiddleware counted distinct ids, so the per-run cap (subagents.max_total_per_run) never advanced past the first call_0 and a run could launch any number of subagents one response at a time. Each entry now also records dispatch_message_id, the AIMessage that issued the call, and results pair through pair_tool_call_results, which answers a call only from its own response. The reducer, DurableContext's change filter and the cap use the extended identity. An entry without a dispatch id still updates its run's entry for that call id, and a dispatch-scoped entry adopts a ledger entry written before the field existed, so runs in flight across the upgrade are not counted twice. The steer path names the dispatching AIMessage from the tool request's state, so its delegation update and the graph's own capture stay one entry. * fix(agents): close an earlier run's dangling dispatch by its own response _close_delegations_left_by_earlier_runs decided "answered" by (run_id, tool_call_id). When a stopped run had dispatched call_0 twice, the first reply kept the second, dangling entry in_progress, so the lead kept hearing "already delegated" for work nothing would finish. Entries that carry dispatch_message_id are now decided by pairing that response's calls through pair_tool_call_results. Legacy entries and dispatches no longer in history keep the id-based inference. Also pin that two runs reusing call_0 stay two ledger entries, and cross-reference the three delegation identity helpers. * fix(agents): settle an approval-gated task delegation in the ledger With `task` in `hitl_approval.tools`, the paused run records the dispatch as in_progress and the resume run executes it. The resume run opens with no HumanMessage, so its capture window starts after the pre-existing messages and never contains the dispatching AIMessage: the result was dropped and the entry stayed in_progress with no result_brief, forever. DurableContext now pairs results the resumed window receives with the last pre-existing dispatch and settles the issuing run's entry, keeping its run_id instead of re-tagging it. HITL denials of `task` carry `cancelled` subagent status, so a rejected or denied delegation settles too. * fix(agents): address review on the delegation ledger - Steer path: hold each wait's dispatch AIMessage locally and pass it to the observer, so a later response reusing the call id while the first call is still observed cannot take over its args or dispatch id. - New-turn close: a dispatch whose reply carries subagent status settles from that reply instead of staying in_progress, covering a crash between the tools checkpoint and the next capture. - Reuse thread_state.delegation_key instead of a duplicate identity helper in DurableContext. - CHANGELOG: reference #8128. * docs(changelog): drop the #8128 entry per review
152 lines
7.4 KiB
Python
152 lines
7.4 KiB
Python
"""An approval-gated ``task`` call reaches a terminal entry in the delegation ledger.
|
|
|
|
With ``task`` in ``hitl_approval.tools`` the dispatch and its result belong to
|
|
two Runs: the paused Run issues the call and records it ``in_progress``, and the
|
|
resume Run executes it. The resume Run opens with no HumanMessage, so its
|
|
capture window starts after the pre-existing messages and does not contain the
|
|
dispatching AIMessage. Its result must still settle that entry, or the ledger
|
|
reports the delegation as running forever and compaction loses its result.
|
|
A rejected or denied call never executes and must settle as cancelled.
|
|
"""
|
|
|
|
from typing import Annotated
|
|
|
|
import pytest
|
|
from langchain.agents import create_agent
|
|
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langchain_core.tools import InjectedToolCallId, tool
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
|
|
from deerflow.agents.thread_state import ThreadState
|
|
from deerflow.approvals.middleware import ToolApprovalMiddleware
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.hitl_approval_config import HitlApprovalConfig
|
|
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
|
|
from deerflow.subagents.status_contract import make_subagent_additional_kwargs
|
|
|
|
|
|
class ScriptedModel(GenericFakeChatModel):
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self
|
|
|
|
|
|
def _dispatch(job: int, call_id: str = "call_0") -> AIMessage:
|
|
args = {"description": f"job {job}", "prompt": f"do job {job}", "subagent_type": "general-purpose"}
|
|
return AIMessage(content="", id=f"ai-{job}", tool_calls=[{"name": "task", "id": call_id, "args": args}])
|
|
|
|
|
|
def _build_graph(responses: list[AIMessage], executed: list[str]):
|
|
@tool("task")
|
|
def task(description: str, prompt: str, subagent_type: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
|
|
"""Delegate one job."""
|
|
executed.append(description)
|
|
return ToolMessage(
|
|
f"Task Succeeded. Result: result of {description}",
|
|
tool_call_id=tool_call_id,
|
|
name="task",
|
|
additional_kwargs=make_subagent_additional_kwargs("completed", result=f"result of {description}"),
|
|
)
|
|
|
|
# Production order: approval gates the tool call before execution, so the
|
|
# ledger has already captured the dispatch by the time the interrupt lands.
|
|
policy = HitlApprovalConfig(enabled=True, tools=["task"])
|
|
app_config = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy)
|
|
return create_agent(
|
|
model=ScriptedModel(messages=iter(responses)),
|
|
tools=[task],
|
|
middleware=[ToolApprovalMiddleware(policy, app_config=app_config), DurableContextMiddleware()],
|
|
state_schema=ThreadState,
|
|
checkpointer=InMemorySaver(),
|
|
)
|
|
|
|
|
|
def _ledger(state) -> list[tuple]:
|
|
return [(entry.get("run_id"), entry["id"], entry.get("dispatch_message_id"), entry["status"], entry.get("result_brief")) for entry in state["delegations"]]
|
|
|
|
|
|
def _pause(graph, config) -> dict:
|
|
opening = HumanMessage("delegate job 1", id="human-1", additional_kwargs={"run_id": "run-A"})
|
|
out = graph.invoke({"messages": [opening]}, config, context={"run_id": "run-A", "thread_id": "thread-1"})
|
|
assert "__interrupt__" in out
|
|
assert _ledger(out) == [("run-A", "call_0", "ai-1", "in_progress", None)]
|
|
return out
|
|
|
|
|
|
def _resume(graph, config, paused, option_id: str, **extra) -> dict:
|
|
action = paused["__interrupt__"][0].value["actions"][0]
|
|
response = {"option_id": option_id, "tool_call_id": action["tool_call_id"], "request_id": action["request_id"], **extra}
|
|
# The worker hands the resume Run the ids of the messages that predate it.
|
|
pre_existing = frozenset(message.id for message in graph.get_state(config).values["messages"])
|
|
context = {"run_id": "run-B", "thread_id": "thread-1", CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: pre_existing}
|
|
return graph.invoke(Command(resume={"responses": [response]}), config, context=context)
|
|
|
|
|
|
def test_approved_task_settles_the_paused_runs_delegation():
|
|
executed: list[str] = []
|
|
graph = _build_graph([_dispatch(1), AIMessage(content="final", id="ai-final")], executed)
|
|
config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
state = _resume(graph, config, _pause(graph, config), "approve")
|
|
|
|
assert executed == ["job 1"]
|
|
# The result settles the entry the paused Run recorded; it is not re-tagged
|
|
# as a delegation of the resume Run.
|
|
assert _ledger(state) == [("run-A", "call_0", "ai-1", "completed", "result of job 1")]
|
|
|
|
|
|
def test_rejected_task_settles_as_cancelled():
|
|
executed: list[str] = []
|
|
graph = _build_graph([_dispatch(1), AIMessage(content="final", id="ai-final")], executed)
|
|
config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
state = _resume(graph, config, _pause(graph, config), "reject", value="not now")
|
|
|
|
assert executed == []
|
|
[(run_id, call_id, dispatch_id, status, _)] = _ledger(state)
|
|
assert (run_id, call_id, dispatch_id, status) == ("run-A", "call_0", "ai-1", "cancelled")
|
|
[rejection] = [message for message in state["messages"] if isinstance(message, ToolMessage)]
|
|
assert "not now" in rejection.content
|
|
|
|
|
|
@pytest.mark.parametrize("context", [{"is_subagent": True}, {"non_interactive": True}], ids=["subagent", "non_interactive"])
|
|
def test_denied_task_settles_as_cancelled_in_the_same_run(context):
|
|
executed: list[str] = []
|
|
graph = _build_graph([_dispatch(1), AIMessage(content="final", id="ai-final")], executed)
|
|
config = {"configurable": {"thread_id": "thread-1"}}
|
|
opening = HumanMessage("delegate job 1", id="human-1", additional_kwargs={"run_id": "run-A"})
|
|
|
|
state = graph.invoke({"messages": [opening]}, config, context={"run_id": "run-A", "thread_id": "thread-1", **context})
|
|
|
|
assert "__interrupt__" not in state
|
|
assert executed == []
|
|
assert [(entry["id"], entry["status"]) for entry in state["delegations"]] == [("call_0", "cancelled")]
|
|
|
|
|
|
def test_resumed_result_leaves_other_dispatches_reusing_the_call_id_alone():
|
|
"""Only the response the resume Run answered is settled, even when an earlier one reused its id."""
|
|
executed: list[str] = []
|
|
graph = _build_graph([_dispatch(1), _dispatch(2), AIMessage(content="final", id="ai-final")], executed)
|
|
config = {"configurable": {"thread_id": "thread-1"}}
|
|
|
|
first = _resume(graph, config, _pause(graph, config), "approve")
|
|
assert "__interrupt__" in first
|
|
assert _ledger(first) == [
|
|
("run-A", "call_0", "ai-1", "completed", "result of job 1"),
|
|
("run-B", "call_0", "ai-2", "in_progress", None),
|
|
]
|
|
|
|
action = first["__interrupt__"][0].value["actions"][0]
|
|
response = {"option_id": "approve", "tool_call_id": action["tool_call_id"], "request_id": action["request_id"]}
|
|
pre_existing = frozenset(message.id for message in graph.get_state(config).values["messages"])
|
|
context = {"run_id": "run-C", "thread_id": "thread-1", CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: pre_existing}
|
|
state = graph.invoke(Command(resume={"responses": [response]}), config, context=context)
|
|
|
|
assert executed == ["job 1", "job 2"]
|
|
assert _ledger(state) == [
|
|
("run-A", "call_0", "ai-1", "completed", "result of job 1"),
|
|
("run-B", "call_0", "ai-2", "completed", "result of job 2"),
|
|
]
|