deer-flow/backend/scripts/manual_task_continuity_check.py
Wenchao An 1b76ab9060
feat: add opt-in task notes and compacted history recall (#5382)
* feat: add opt-in task notes and compacted history recall

* fix: validate task continuity state and preserve user answers

Honor explicit opt-out, preserve clarification replies and capture failure statuses, validate notebook writes, and clear branch archive references. Update the config version and audit optional LLM credentials, with regression and integration evidence.

* fix: align Helm config version with task continuity schema

* fix: preserve mixed task history and declare continuity policies

* fix: recover malformed history and evict archives atomically
2026-09-12 21:01:46 +08:00

117 lines
7.0 KiB
Python

"""Manual live-model recovery check using production middleware and tools.
Run from backend with --endpoints /path/to/private.json --output /tmp/check.json.
The private file contains llm_base, llm_model and optional llm_key. No network
calls occur on import. Uses synthetic history only; no endpoint or response body
is retained in the public result. This is a controlled integration check, not a
production success-rate benchmark. The summary prompt intentionally omits codes
so successful recovery must exercise source recall.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import tempfile
from pathlib import Path
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
from deerflow.agents.task_continuity import archive
from deerflow.agents.task_continuity.tools import history_read, history_search, task_note
from deerflow.agents.thread_state import ThreadState
from deerflow.config.paths import Paths
from deerflow.config.task_continuity_config import TaskContinuityConfig
async def run(args):
private = json.loads(Path(args.endpoints).read_text())
model = ChatOpenAI(model=private["llm_model"], base_url=private["llm_base"], api_key=private.get("llm_key", "unused"), temperature=0, max_tokens=2048, timeout=180, max_retries=1, extra_body={"reasoning_effort": "none"})
results = []
with tempfile.TemporaryDirectory(prefix="deerflow-continuity-") as directory:
root = Path(directory)
original_paths = archive.get_paths
archive.get_paths = lambda: Paths(base_dir=root)
try:
for index in range(3):
code = f"CIT-{731 + index * 37}-B"
output = root / f"artifact-{index}.json"
@tool
def write_manifest(batch_code: str, keep_backups: bool) -> str:
"""Write the requested final manifest using recovered task facts."""
output.write_text(json.dumps({"batch_code": batch_code, "keep_backups": keep_backups}))
return "Manifest written."
tools = [task_note, history_search, history_read, write_manifest]
saver = InMemorySaver()
context = {"thread_id": f"live-{index}", "user_id": "continuity-check"}
config = {"configurable": {"thread_id": context["thread_id"]}, "recursion_limit": 30}
middleware = DeerFlowSummarizationMiddleware(
model=model,
trigger=("messages", 4),
keep=("messages", 2),
summary_prompt="Summarize the project purpose in one short sentence. Omit all batch identifiers and exact values. Historical data: {messages}",
task_continuity_config=TaskContinuityConfig(enabled=True),
)
# First invocation archives old source messages through the real graph.
graph = create_agent(model, tools=tools, middleware=[DurableContextMiddleware(task_continuity_enabled=True), middleware], state_schema=ThreadState, checkpointer=saver)
history = [
HumanMessage(content="Citrine project: inspect the latest approved batch.", id="u1"),
AIMessage(content="", tool_calls=[{"name": "inspect_batch", "id": "inspection", "args": {"project": "Citrine"}}], id="a1"),
ToolMessage(content=f"Citrine approved batch_code={code}; keep_backups=true. Older batch is retired.", tool_call_id="inspection", id="t1"),
AIMessage(content="Inspection completed.", id="a2"),
HumanMessage(content="Pause this task. Reply only 'paused'.", id="pause"),
]
record = {"case": index, "model": private["llm_model"]}
try:
paused = await graph.ainvoke({"messages": history}, config=config, context=context)
record["archived"] = bool(paused.get("task_history", {}).get("batches"))
record["code_absent_from_active_context"] = code not in paused.get("summary_text", "") and all(code not in str(m.content) for m in paused["messages"])
# Rebuild against the existing checkpoint and recover with native tool calls.
resumed = create_agent(model, tools=tools, middleware=[DurableContextMiddleware(task_continuity_enabled=True)], state_schema=ThreadState, checkpointer=saver)
state = await resumed.ainvoke(
{
"messages": [
HumanMessage(
content=(
"Resume Citrine. Search historical sources for the approved batch, read the exact source, "
"save a task note with its source ID, and write the final manifest. Preserve the backup decision. Do not guess missing values."
)
)
]
},
config=config,
context=context,
)
calls = [call["name"] for message in state["messages"] if isinstance(message, AIMessage) for call in message.tool_calls]
record["tools_used"] = sorted(set(calls))
record["notes_saved"] = bool(state.get("task_notes"))
actual = json.loads(output.read_text()) if output.exists() else None
record["artifact_verified"] = actual == {"batch_code": code, "keep_backups": True}
record["artifact_sha256"] = hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
record["passed"] = all(record.get(k) for k in ("archived", "code_absent_from_active_context", "notes_saved", "artifact_verified")) and {"history_search", "history_read", "task_note", "write_manifest"} <= set(calls)
except Exception as exc:
record.update({"passed": False, "error_type": type(exc).__name__})
results.append(record)
Path(args.output).write_text(json.dumps({"scope": "controlled production-middleware integration; synthetic input; summary intentionally drops exact codes", "cases": results}, indent=2))
print(json.dumps(record), flush=True)
finally:
archive.get_paths = original_paths
return all(row["passed"] for row in results)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--endpoints", required=True)
parser.add_argument("--output", required=True)
raise SystemExit(0 if asyncio.run(run(parser.parse_args())) else 1)