mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* feat(subagents): add opt-in parent context snapshots * test(subagents): package synthetic snapshot evaluation * fix(subagents): preserve output text and defer snapshot capture * docs(subagents): keep snapshot guidance within chain budget * fix(subagents): omit unpaired tool calls from snapshots * fix(subagents): safely omit unserializable snapshot media
299 lines
16 KiB
Python
299 lines
16 KiB
Python
"""Explicit live execution; no runtime patching, configuration or I/O on import."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import importlib.metadata
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import time
|
|
from contextvars import ContextVar
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
from langchain_core.tools import tool
|
|
from langchain_openai import ChatOpenAI
|
|
|
|
from .cases import CASES
|
|
from .grading import passed, score
|
|
from .prompts import DISPATCH_POLICIES, LEAD_SYSTEM, WORKER_SYSTEM
|
|
from .report import clean_success, summarize, usage
|
|
|
|
CURRENT_MODEL = ContextVar("snapshot_benchmark_model")
|
|
ROOT = Path(__file__).resolve().parent
|
|
REPO = ROOT.parents[3]
|
|
|
|
|
|
def load_config(path):
|
|
config = json.loads(path.read_text(encoding="utf-8"))
|
|
expected = {"protocol", "temperature", "reasoning_effort", "max_tokens", "context_window", "http_timeout_seconds", "worker_timeout_seconds", "max_graph_steps", "max_retries", "seed", "order_seed", "concurrency"}
|
|
if not isinstance(config, dict) or set(config) != expected:
|
|
raise ValueError("Unsupported config fields; use the committed config schema, with provider settings only in environment variables")
|
|
for name in ("max_tokens", "context_window", "http_timeout_seconds", "worker_timeout_seconds", "max_graph_steps", "concurrency"):
|
|
if type(config[name]) is not int or config[name] < 1:
|
|
raise ValueError(f"{name} must be a positive integer")
|
|
for name in ("seed", "order_seed"):
|
|
if type(config[name]) is not int:
|
|
raise ValueError(f"{name} must be an integer")
|
|
if not isinstance(config["temperature"], (int, float)) or not math.isfinite(config["temperature"]) or not 0 <= config["temperature"] <= 2:
|
|
raise ValueError("temperature must be finite and between 0 and 2")
|
|
if config["reasoning_effort"] not in (None, "none", "minimal", "low", "medium", "high", "xhigh"):
|
|
raise ValueError("Unsupported reasoning_effort")
|
|
if config["max_retries"] != 0:
|
|
raise ValueError("This protocol does not retry provider calls")
|
|
return config
|
|
|
|
|
|
def parent_state(case):
|
|
messages = [SystemMessage(content="Parent-only role: coordinate the synthetic project.")]
|
|
for index, (role, content) in enumerate(case.history):
|
|
if role == "user":
|
|
messages.append(HumanMessage(content=content))
|
|
elif role == "assistant":
|
|
messages.append(AIMessage(content=content))
|
|
else:
|
|
call_id = f"historical-{case.name}-{index}"
|
|
name = "run_public_checks" if case.name == "fresh_evidence" else "read_reference"
|
|
args = {"document": "archived-note"} if name == "read_reference" else {}
|
|
messages.append(AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": call_id}]))
|
|
messages.append(ToolMessage(content=content, name=name, tool_call_id=call_id))
|
|
messages.append(AIMessage(content="", tool_calls=[{"name": "task", "args": {"prompt": case.brief}, "id": "current-dispatch"}]))
|
|
return {"messages": messages, "summary_text": case.summary}
|
|
|
|
|
|
class Transport:
|
|
"""Retain only usage/status/timing. Never retain headers or full payloads."""
|
|
|
|
def __init__(self, base_url, api_key, model, config, seed):
|
|
self.calls = []
|
|
self.stage = "lead_dispatch"
|
|
self.api_key = api_key
|
|
self.client = httpx.AsyncClient(timeout=config["http_timeout_seconds"], trust_env=False, event_hooks={"request": [self.on_request], "response": [self.on_response]})
|
|
self.model = ChatOpenAI(
|
|
model=model,
|
|
base_url=base_url,
|
|
api_key=api_key or "unused",
|
|
temperature=config["temperature"],
|
|
max_tokens=config["max_tokens"],
|
|
max_retries=config["max_retries"],
|
|
timeout=config["http_timeout_seconds"],
|
|
reasoning_effort=config["reasoning_effort"],
|
|
seed=seed,
|
|
streaming=False,
|
|
disable_streaming=True,
|
|
http_async_client=self.client,
|
|
)
|
|
|
|
async def on_request(self, request):
|
|
if not self.api_key:
|
|
request.headers.pop("authorization", None)
|
|
entry = {"stage": self.stage, "usage": None, "http_status": None}
|
|
self.calls.append(entry)
|
|
request.extensions["benchmark_call"] = (entry, time.perf_counter())
|
|
|
|
async def on_response(self, response):
|
|
await response.aread()
|
|
entry, start = response.request.extensions["benchmark_call"]
|
|
entry.update(http_status=response.status_code, seconds=time.perf_counter() - start)
|
|
try:
|
|
body = response.json()
|
|
raw = body.get("usage") or {}
|
|
entry["usage"] = {key: raw[key] for key in ("prompt_tokens", "completion_tokens", "total_tokens") if isinstance(raw.get(key), int)}
|
|
cached = (raw.get("prompt_tokens_details") or {}).get("cached_tokens")
|
|
if isinstance(cached, int):
|
|
entry["usage"]["prompt_tokens_details"] = {"cached_tokens": cached}
|
|
except (ValueError, AttributeError, TypeError):
|
|
pass # Missing/unparseable usage remains explicitly incomplete.
|
|
|
|
|
|
async def run_one(case, arm, repetition, output, config, provider):
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.extensions.registry import ExtensionRegistry
|
|
from deerflow.subagents.config import SubagentConfig
|
|
from deerflow.subagents.context_snapshot import ParentContextSnapshot
|
|
from deerflow.subagents.executor import SubagentExecutor
|
|
from deerflow.tools.builtins.task_tool import task_tool
|
|
|
|
job_id = f"{case.name}__{repetition}__{arm}"
|
|
directory = output / job_id
|
|
directory.mkdir()
|
|
artifact = directory / ("artifact.json" if case.kind == "json" else "artifact.py")
|
|
contents, events = [], []
|
|
checked_revision, public_ok = None, False
|
|
|
|
@tool
|
|
async def write_artifact(content: str) -> str:
|
|
"""Save the complete task artifact. Supply raw JSON or Python, without markdown fences."""
|
|
await asyncio.to_thread(artifact.write_text, content, encoding="utf-8")
|
|
contents.append(content)
|
|
await asyncio.to_thread(artifact.with_stem(f"revision-{len(contents)}").write_text, content, encoding="utf-8")
|
|
events.append({"tool": "write_artifact", "revision": len(contents)})
|
|
return f"Saved revision {len(contents)} to {artifact.name}"
|
|
|
|
@tool
|
|
async def read_reference(document: str) -> str:
|
|
"""Read a bundled reference by the exact document name given in the task."""
|
|
events.append({"tool": "read_reference"})
|
|
return case.references.get(document, "No such bundled document. Use only the requirements already supplied in the task and conversation.")
|
|
|
|
@tool
|
|
async def run_public_checks() -> str:
|
|
"""Check the saved artifact's public shape/basic example. Checks do not replace the full task requirements."""
|
|
nonlocal checked_revision, public_ok
|
|
grade = await asyncio.to_thread(score, case, contents[-1] if contents else None, public=True)
|
|
checked_revision, public_ok = len(contents), passed(grade)
|
|
events.append({"tool": "run_public_checks", "revision": checked_revision, "passed": public_ok})
|
|
return json.dumps({"public_checks_passed": public_ok, "revision": checked_revision, "details": grade})
|
|
|
|
transport = Transport(*provider, config, config["seed"] + repetition)
|
|
start, lead_seconds = time.perf_counter(), 0
|
|
result, dispatch, error_type = None, None, None
|
|
snapshot = ParentContextSnapshot.from_state(parent_state(case))
|
|
try:
|
|
response = await transport.model.bind_tools([task_tool], tool_choice="task").ainvoke(
|
|
[
|
|
SystemMessage(content=LEAD_SYSTEM + DISPATCH_POLICIES[arm]),
|
|
snapshot.to_message(),
|
|
HumanMessage(content="Current task:\n" + case.brief),
|
|
]
|
|
)
|
|
lead_seconds = time.perf_counter() - start
|
|
if len(response.tool_calls) != 1 or response.tool_calls[0]["name"] != "task":
|
|
raise ValueError("Expected one task call")
|
|
dispatch = response.tool_calls[0]["args"]
|
|
task_tool.tool_call_schema.model_validate(dispatch)
|
|
expected_mode = "snapshot" if arm == "snapshot" else "isolated"
|
|
if dispatch.get("context_mode", "isolated") != expected_mode or dispatch.get("subagent_type") != "general-purpose":
|
|
raise ValueError("Dispatch mode mismatch")
|
|
transport.stage = "worker"
|
|
app_config = AppConfig.model_validate(
|
|
{
|
|
"models": [{"name": "eval", "use": "langchain_openai:ChatOpenAI", "model": provider[2], "context_window": config["context_window"]}],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"authorization": {"enabled": False},
|
|
"skills": {"deferred_discovery": False},
|
|
"tool_search": {"enabled": False},
|
|
"summarization": {"enabled": False},
|
|
"memory": {"enabled": False},
|
|
}
|
|
)
|
|
executor = SubagentExecutor(
|
|
config=SubagentConfig(
|
|
name="live-eval",
|
|
description="Synthetic implementation worker",
|
|
system_prompt=WORKER_SYSTEM,
|
|
tools=["write_artifact", "read_reference", "run_public_checks"],
|
|
skills=[],
|
|
model="eval",
|
|
max_turns=config["max_graph_steps"],
|
|
timeout_seconds=config["worker_timeout_seconds"],
|
|
),
|
|
tools=[write_artifact, read_reference, run_public_checks],
|
|
app_config=app_config,
|
|
context_snapshot=snapshot if arm == "snapshot" else None,
|
|
acceptance_criteria=dispatch.get("acceptance_criteria"),
|
|
thread_id=job_id,
|
|
run_id=job_id,
|
|
user_id="synthetic-eval",
|
|
extensions=ExtensionRegistry().build(),
|
|
)
|
|
token = CURRENT_MODEL.set(transport.model)
|
|
try:
|
|
result = await asyncio.wait_for(executor._aexecute(dispatch["prompt"]), timeout=config["worker_timeout_seconds"])
|
|
finally:
|
|
CURRENT_MODEL.reset(token)
|
|
except Exception as exc:
|
|
error_type = type(exc).__name__ # Provider exceptions may contain endpoint/key values.
|
|
finally:
|
|
elapsed = time.perf_counter() - start
|
|
if transport.stage == "lead_dispatch":
|
|
lead_seconds = elapsed
|
|
await transport.client.aclose()
|
|
grade = await asyncio.to_thread(score, case, contents[-1] if contents else None)
|
|
first_grade = await asyncio.to_thread(score, case, contents[0] if contents else None)
|
|
row = {
|
|
"case": case.name,
|
|
"arm": arm,
|
|
"repetition": repetition,
|
|
"artifact_correct": passed(grade),
|
|
"first_artifact_correct": passed(first_grade),
|
|
"fresh_public_check": bool(contents) and checked_revision == len(contents) and public_ok,
|
|
"executor_status": result.status.value if result else None,
|
|
"stop_reason": result.stop_reason if result else None,
|
|
"error_type": error_type,
|
|
"seconds": elapsed,
|
|
"lead_seconds": lead_seconds,
|
|
"usage": usage(transport.calls),
|
|
"lead_usage": usage([call for call in transport.calls if call["stage"] == "lead_dispatch"]),
|
|
"worker_usage": usage([call for call in transport.calls if call["stage"] == "worker"]),
|
|
"brief_policy_adhered": dispatch["prompt"].strip() == case.brief.strip() if dispatch and arm == "snapshot" else None,
|
|
"acceptance_criteria_added": bool(dispatch and dispatch.get("acceptance_criteria")),
|
|
}
|
|
row["clean_success"] = clean_success(row)
|
|
# Full dispatch / grades stay in the ignored local output, outside public rows.
|
|
(directory / "details.json").write_text(json.dumps({"dispatch": dispatch, "grade": grade, "first_grade": first_grade, "events": events}, indent=2), encoding="utf-8")
|
|
(directory / "calls.json").write_text(json.dumps(transport.calls, indent=2), encoding="utf-8")
|
|
(directory / "result.json").write_text(json.dumps(row, indent=2), encoding="utf-8")
|
|
print(f"{job_id}: artifact={row['artifact_correct']} completed={row['clean_success']}", flush=True)
|
|
return row
|
|
|
|
|
|
async def run_live(args):
|
|
provider = tuple(os.environ.get(name, "").strip() for name in ("CONTEXT_SNAPSHOT_BASE_URL", "CONTEXT_SNAPSHOT_API_KEY", "CONTEXT_SNAPSHOT_MODEL"))
|
|
if not provider[0] or not provider[2]:
|
|
raise ValueError("Set CONTEXT_SNAPSHOT_BASE_URL and CONTEXT_SNAPSHOT_MODEL; API key is optional for keyless endpoints")
|
|
config = load_config(args.config)
|
|
output = args.output_dir.resolve()
|
|
output.mkdir(parents=True, exist_ok=False) # Never mix runs or overwrite failures.
|
|
(output / ".gitignore").write_text("*\n", encoding="utf-8")
|
|
selected = [case for case in CASES if not args.cases or case.name in args.cases]
|
|
jobs = [(case, arm, rep) for rep in range(args.repetitions) for case in selected for arm in DISPATCH_POLICIES]
|
|
random.Random(config["order_seed"]).shuffle(jobs)
|
|
source_paths = list(ROOT.glob("*.py")) + [
|
|
args.config.resolve(),
|
|
REPO / "backend/packages/harness/deerflow/subagents/context_snapshot.py",
|
|
REPO / "backend/packages/harness/deerflow/subagents/executor.py",
|
|
REPO / "backend/packages/harness/deerflow/tools/builtins/task_tool.py",
|
|
]
|
|
metadata = {
|
|
"config": config,
|
|
"model": provider[2],
|
|
"provider_env": ["CONTEXT_SNAPSHOT_BASE_URL", "CONTEXT_SNAPSHOT_API_KEY", "CONTEXT_SNAPSHOT_MODEL"],
|
|
"started_at": datetime.now(UTC).isoformat(),
|
|
"clock": "perf_counter; queue wait excluded",
|
|
"synthetic": True,
|
|
"git_revision": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPO, text=True).strip(),
|
|
"git_dirty": bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=REPO, text=True)),
|
|
"sha256": {str(path.relative_to(REPO)) if path.is_relative_to(REPO) else "external-config": hashlib.sha256(path.read_bytes()).hexdigest() for path in source_paths},
|
|
"versions": {name: importlib.metadata.version(name) for name in ("deerflow-harness", "langchain", "langgraph", "langchain-openai", "httpx")},
|
|
"jobs": [f"{case.name}__{rep}__{arm}" for case, arm, rep in jobs],
|
|
}
|
|
(output / "run.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
env = {"DEER_FLOW_HOME": str(output / "runtime"), "LANGCHAIN_TRACING_V2": "false", "LANGSMITH_TRACING": "false"}
|
|
with patch.dict(os.environ, env):
|
|
import deerflow.subagents.executor as executor_module
|
|
|
|
# Scope instrumentation to this standalone run and restore it afterward.
|
|
with patch.object(executor_module, "create_chat_model", side_effect=lambda *a, **kw: CURRENT_MODEL.get()), patch.object(executor_module, "build_tracing_callbacks", return_value=[]):
|
|
semaphore = asyncio.Semaphore(config["concurrency"])
|
|
|
|
async def run(job):
|
|
async with semaphore:
|
|
row = await run_one(*job, output, config, provider)
|
|
with (output / "rows.jsonl").open("a", encoding="utf-8") as file:
|
|
file.write(json.dumps(row, sort_keys=True) + "\n")
|
|
return row
|
|
|
|
rows = await asyncio.gather(*(run(job) for job in jobs))
|
|
(output / "summary.json").write_text(json.dumps(summarize(rows), indent=2), encoding="utf-8")
|