deer-flow/backend/scripts/benchmark/checkpoint/bench_tool_result_probe.py
zeng-bohan a2808e8292
test(checkpoint): retention deletion contract + growth baseline (#4189 item 3) (#5255)
* test(checkpoint): retention deletion contract + growth baseline

Six contract scenarios x memory/sqlite/postgres pin what retention deletions
must never break (branch ancestors, explicit resume targets, pending writes,
duration-only chain links), prove the two safe shapes (leaf sibling branches,
trailing duration leaves), record the full-vs-delta growth baseline in the
normalized bench shape, and add an item 4 probe showing the default
ToolOutputBudgetMiddleware already externalizes oversized tool results.

Refs #4189

* test(checkpoint): make the retention contract load-bearing per review

Review findings from willem-bd and Ricky-7-Yan:

- scenario D pins its own row: before/after stats delta plus a serde
  round-trip of the stored write, instead of an always-true > 0 check
- _delete_checkpoint now performs the joint delete the doc mandates
  (checkpoint row + writes rows + blobs unreachable from surviving
  checkpoints), so E1/E2 exercise the shape they prescribe
- E1 builds the real runtime duration shape via persist_run_durations
  (parent dict clone, fresh id/ts, real metadata), which surfaces the
  shared-version case: the leaf's blobs are the surviving parent's rows
- contract doc: blob reachability must be computed from surviving
  checkpoints in a whole-thread pass; shared-version/duration-only
  hazard called out explicitly; memory data model includes saver.blobs
- _stats counts memory blob rows and returns the full normalized shape
  (logical byte totals included)
- probe: drops the unused middleware/outputs_dir graph parameters and
  discloses the manual-harness scope limit in the module docstring
- E1/E2 assert default head resolution (protected set item 5); unused
  graph_for helper and DURATION_ONLY_METADATA stand-in removed

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>

* fix(checkpoint): scope probe cleanup to owned dirs, key report by backend

Second-round review findings on #5255:

- [P1] bench_tool_result_probe.py removed the whole user-supplied
  --outputs-dir (and the shared .probe-tmp) in its finally block, so
  pre-existing files were deleted on success and failure alike. The run
  now writes into (and removes) a fresh owned probe-run-* child beneath
  the requested directory, and SQLite databases live in a unique
  mkdtemp'd temp directory that is removed with the run. Regression
  tests pin that unrelated pre-existing files survive both a successful
  and a simulated failing run.
- [P2] the optional retention report keyed every backend's measurements
  under one shared name, so a multi-backend invocation kept only the
  last backend's numbers. _report() now takes the parameterized backend
  explicitly (saver_env.kind); regression pins that memory and sqlite
  entries coexist in one report file.

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>

---------

Signed-off-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>
Co-authored-by: zengbohan1 <310902929+zengbohan1@users.noreply.github.com>
2026-09-08 19:21:57 +08:00

190 lines
7.5 KiB
Python

#!/usr/bin/env python
"""Probe: do oversized tool results reach checkpoint state as full text? (#4189 item 4)
`ToolOutputBudgetMiddleware` is registered by default and externalizes tool
results above `externalize_min_chars` (preview + file reference under
`.tool-results/`). This probe quantifies the storage effect of that
transformation: the same oversized tool result is driven through the
middleware's `awrap_tool_call` (or run raw), the resulting ToolMessage is
written into a checkpointed graph state, and the per-thread checkpoint
storage (rows + bytes, same normalized shape as bench_channels) is reported
for SQLite.
Scope limit (state this when citing the numbers): the middleware is invoked
manually and the resulting message is injected with ``aupdate_state`` — the
production agent-factory path (middleware stack wiring, ThreadDataMiddleware
runtime state, tool-node task writes) is NOT exercised. The probe therefore
bounds the middleware's own transformation and the checkpoint cost of its
output; by itself it cannot establish that "the default configuration
covers item 4". A residual gap claim must name the concrete factory path
and come with its own measurements.
Item 4 of #4189 can be closed as covered if the wrapped path's checkpoint
bytes stay flat as the result size grows and no factory-path measurement
shows full text landing in state.
Usage:
cd backend
python scripts/benchmark/checkpoint/bench_tool_result_probe.py \
[--result-bytes 50000] [--outputs-dir .tool-results-probe]
--outputs-dir may contain unrelated files: the probe creates and removes
only its own ``probe-run-*`` child inside it (externalized samples land
there), while SQLite databases go to a unique per-run temp directory.
Output: JSON on stdout.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Annotated, Any, TypedDict
from uuid import uuid4
from langchain_core.messages import AnyMessage, ToolMessage
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
PROBE_TOOL = "probe_tool"
class ProbeState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
async def _stats_sqlite(saver: AsyncSqliteSaver, thread_id: str) -> dict[str, int]:
async def one(sql: str) -> tuple[int, int]:
async with saver.conn.execute(sql, (thread_id,)) as cursor:
row = await cursor.fetchone()
return int(row[0]), int(row[1] or 0)
cp = await one("SELECT COUNT(*), COALESCE(SUM(LENGTH(checkpoint) + LENGTH(metadata)), 0) FROM checkpoints WHERE thread_id = ?")
wr = await one("SELECT COUNT(*), COALESCE(SUM(LENGTH(value)), 0) FROM writes WHERE thread_id = ?")
return {
"checkpoint_rows": cp[0],
"checkpoint_bytes": cp[1],
"write_rows": wr[0],
"write_bytes": wr[1],
}
def _make_graph(saver: Any) -> Any:
def call_probe_tool(state: dict[str, Any]) -> dict[str, Any]:
# the oversized result never flows through this node: the probe
# injects the (possibly externalized) ToolMessage via aupdate_state
# below, so the graph only provides a checkpointed state container
return {}
builder = StateGraph(ProbeState)
builder.add_node("tool", call_probe_tool)
builder.set_entry_point("tool")
builder.set_finish_point("tool")
return builder.compile(checkpointer=saver)
async def _run_path(
label: str,
result_bytes: int,
*,
wrapped: bool,
outputs_dir: Path | None,
tmp_dir: Path,
) -> dict[str, Any]:
tmp_dir.mkdir(parents=True, exist_ok=True)
async with AsyncSqliteSaver.from_conn_string(str(tmp_dir / f"probe-{label}.sqlite")) as saver:
await saver.setup()
middleware = ToolOutputBudgetMiddleware() if wrapped else None
graph = _make_graph(saver)
thread_id = f"probe-{label}"
config = {"configurable": {"thread_id": thread_id}}
# invoke the tool through the middleware's wrap (or raw), then persist
# the resulting ToolMessage into graph state and take a checkpoint
oversized = "A" * result_bytes
request = SimpleNamespace(
tool_call={"name": PROBE_TOOL, "id": "probe-call-1"},
runtime=SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}} if outputs_dir else {"thread_data": None}),
)
async def handler(_request: Any) -> ToolMessage:
return ToolMessage(content=oversized, tool_call_id="probe-call-1")
if wrapped and middleware is not None:
message = await middleware.awrap_tool_call(request, handler)
else:
message = await handler(request)
await graph.aupdate_state(config, {"messages": [message]})
stats = await _stats_sqlite(saver, thread_id)
content_chars = len(message.content) if isinstance(message.content, str) else -1
return {
"path": label,
"result_bytes": result_bytes,
"tool_message_content_chars": content_chars,
"externalized_file_bytes": (sum(f.stat().st_size for f in outputs_dir.rglob("*") if f.is_file()) if outputs_dir and outputs_dir.exists() else 0),
"checkpoint": stats,
}
async def _main(result_bytes: int, outputs_dir: Path, tmp_dir: Path) -> dict[str, Any]:
raw = await _run_path("raw-unwrapped", result_bytes, wrapped=False, outputs_dir=None, tmp_dir=tmp_dir)
externalized = await _run_path("budget-externalized", result_bytes, wrapped=True, outputs_dir=outputs_dir, tmp_dir=tmp_dir)
truncated = await _run_path("budget-truncated", result_bytes, wrapped=True, outputs_dir=None, tmp_dir=tmp_dir)
return {
"result_bytes": result_bytes,
"paths": [raw, externalized, truncated],
"verdict": {
"wrapped_content_stays_small": externalized["tool_message_content_chars"] < result_bytes,
"raw_content_is_full": raw["tool_message_content_chars"] == result_bytes,
},
}
def run_probe(result_bytes: int, outputs_dir: Path, tmp_dir: Path) -> dict[str, Any]:
"""Drive one probe run, cleaning up only directories this run owns.
``outputs_dir`` may be user-supplied and may pre-exist with unrelated
files: the run writes into (and removes) a fresh owned ``probe-run-*``
child of it, never the directory itself or anything beside it. ``tmp_dir``
hosts the per-run SQLite databases and is emptied by the cleanup.
"""
import shutil
owned_outputs = outputs_dir / f"probe-run-{uuid4().hex[:8]}"
owned_outputs.mkdir(parents=True, exist_ok=False)
tmp_dir.mkdir(parents=True, exist_ok=True)
try:
return asyncio.run(_main(result_bytes, owned_outputs, tmp_dir))
finally:
shutil.rmtree(owned_outputs, ignore_errors=True)
shutil.rmtree(tmp_dir, ignore_errors=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--result-bytes", type=int, default=50_000)
parser.add_argument("--outputs-dir", type=Path, default=Path(".tool-results-probe"))
args = parser.parse_args()
outputs_dir: Path = args.outputs_dir
outputs_dir.mkdir(parents=True, exist_ok=True)
tmp_dir = Path(tempfile.mkdtemp(prefix="deerflow-probe-"))
report = run_probe(args.result_bytes, outputs_dir, tmp_dir)
json.dump(report, __import__("sys").stdout, indent=2)
print()
if __name__ == "__main__":
main()