Wenchao An 6d5d7bb1d5
feat(subagents): add opt-in parent context snapshots (#5367)
* 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
2026-09-12 16:20:31 +08:00

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")