|
|
@@ -1,6 +1,9 @@
|
|
|
import importlib
|
|
|
import importlib.util
|
|
|
import json
|
|
|
+import multiprocessing
|
|
|
+import threading
|
|
|
+from concurrent.futures import ThreadPoolExecutor
|
|
|
from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
|
|
|
|
@@ -10,6 +13,7 @@ from pydantic import ValidationError
|
|
|
from agent_lab.application.benchmark import (
|
|
|
BenchmarkCaseId,
|
|
|
BenchmarkConfig,
|
|
|
+ BenchmarkModelCallTiming,
|
|
|
BenchmarkMode,
|
|
|
BenchmarkRunResult,
|
|
|
)
|
|
|
@@ -54,8 +58,10 @@ def _run(
|
|
|
model_call_count: int | None = 1,
|
|
|
fallback_count: int | None = 0,
|
|
|
tool_count: int | None = 0,
|
|
|
+ tool_latencies_ms: list[int | None] | None = None,
|
|
|
semantic_failures: list[str] | None = None,
|
|
|
error: str | None = None,
|
|
|
+ model_calls: list[BenchmarkModelCallTiming] | None = None,
|
|
|
) -> BenchmarkRunResult:
|
|
|
return BenchmarkRunResult(
|
|
|
case_id=case_id,
|
|
|
@@ -77,13 +83,92 @@ def _run(
|
|
|
tool_event_names=[],
|
|
|
event_sources=[],
|
|
|
tool_statuses=[],
|
|
|
- tool_latencies_ms=[],
|
|
|
+ tool_latencies_ms=tool_latencies_ms or [],
|
|
|
semantic_failures=semantic_failures or [],
|
|
|
error=error,
|
|
|
- model_calls=[],
|
|
|
+ model_calls=model_calls or [],
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _model_call(
|
|
|
+ call_index: int,
|
|
|
+ elapsed_ms: int | None,
|
|
|
+) -> BenchmarkModelCallTiming:
|
|
|
+ return BenchmarkModelCallTiming(
|
|
|
+ call_index=call_index,
|
|
|
+ call_kind="chat_completion",
|
|
|
+ first_item_kind="raw_chunk",
|
|
|
+ provider_ttft_ms=1,
|
|
|
+ visible_ttft_ms=2,
|
|
|
+ elapsed_ms=elapsed_ms,
|
|
|
+ usage=None,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _marker_report(marker: str):
|
|
|
+ reporting = _reporting_module()
|
|
|
+ return reporting.build_benchmark_report(
|
|
|
+ _config(model=marker),
|
|
|
+ [_run()],
|
|
|
+ mock=True,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _process_report_writer(
|
|
|
+ output_dir: str,
|
|
|
+ marker: str,
|
|
|
+ timestamp: str,
|
|
|
+ first_temp_barrier,
|
|
|
+ result_queue,
|
|
|
+) -> None:
|
|
|
+ reporting = _reporting_module()
|
|
|
+ original_write_text = reporting._write_text
|
|
|
+ first_write = True
|
|
|
+
|
|
|
+ def synchronized_write(path: Path, content: str) -> None:
|
|
|
+ nonlocal first_write
|
|
|
+ original_write_text(path, content)
|
|
|
+ if first_write:
|
|
|
+ first_write = False
|
|
|
+ first_temp_barrier.wait()
|
|
|
+
|
|
|
+ reporting._write_text = synchronized_write
|
|
|
+ try:
|
|
|
+ json_path, markdown_path = reporting.write_benchmark_report(
|
|
|
+ _marker_report(marker),
|
|
|
+ output_dir,
|
|
|
+ timestamp=timestamp,
|
|
|
+ )
|
|
|
+ except BaseException as exc:
|
|
|
+ result_queue.put(("error", marker, repr(exc)))
|
|
|
+ return
|
|
|
+ result_queue.put(
|
|
|
+ ("ok", marker, json_path.name, markdown_path.name)
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _assert_concurrent_report_pairs(
|
|
|
+ output_dir: Path,
|
|
|
+ markers: list[str],
|
|
|
+) -> None:
|
|
|
+ json_paths = sorted(output_dir.glob("*.json"))
|
|
|
+ markdown_paths = sorted(output_dir.glob("*.md"))
|
|
|
+ assert len(json_paths) == len(markers)
|
|
|
+ assert len(markdown_paths) == len(markers)
|
|
|
+ assert {path.stem for path in json_paths} == {
|
|
|
+ path.stem for path in markdown_paths
|
|
|
+ }
|
|
|
+ actual_markers = set()
|
|
|
+ for json_path in json_paths:
|
|
|
+ marker = json.loads(json_path.read_text())["target"]["model"]
|
|
|
+ markdown = json_path.with_suffix(".md").read_text()
|
|
|
+ actual_markers.add(marker)
|
|
|
+ assert f"- Model: `{marker}`" in markdown
|
|
|
+ assert actual_markers == set(markers)
|
|
|
+ assert list(output_dir.glob("*.lock")) == []
|
|
|
+ assert list(output_dir.glob("*.tmp")) == []
|
|
|
+
|
|
|
+
|
|
|
@pytest.mark.parametrize(
|
|
|
("values", "quantile", "expected"),
|
|
|
[
|
|
|
@@ -131,6 +216,7 @@ def test_report_models_are_strict_and_include_required_summary_fields():
|
|
|
"passed": 1,
|
|
|
"failed": 0,
|
|
|
"warnings": report.overall.warnings,
|
|
|
+ "system_errors": [],
|
|
|
}
|
|
|
with pytest.raises(ValidationError):
|
|
|
reporting.BenchmarkReport.model_validate(
|
|
|
@@ -229,6 +315,83 @@ def test_aggregate_excludes_failed_and_null_samples_from_metrics():
|
|
|
}
|
|
|
|
|
|
|
|
|
+def test_aggregate_flattens_passed_model_and_tool_latency_samples():
|
|
|
+ reporting = _reporting_module()
|
|
|
+ runs = [
|
|
|
+ _run(
|
|
|
+ iteration=1,
|
|
|
+ model_calls=[
|
|
|
+ _model_call(1, 10),
|
|
|
+ _model_call(2, None),
|
|
|
+ _model_call(3, 30),
|
|
|
+ ],
|
|
|
+ tool_latencies_ms=[5, None, 15],
|
|
|
+ ),
|
|
|
+ _run(
|
|
|
+ iteration=2,
|
|
|
+ model_calls=[_model_call(1, 20)],
|
|
|
+ tool_latencies_ms=[25],
|
|
|
+ ),
|
|
|
+ _run(
|
|
|
+ iteration=3,
|
|
|
+ status="failed",
|
|
|
+ model_calls=[_model_call(1, 999)],
|
|
|
+ tool_latencies_ms=[999],
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+
|
|
|
+ report = reporting.build_benchmark_report(
|
|
|
+ _config(runs_per_case=3), runs, mock=True
|
|
|
+ )
|
|
|
+ aggregate = report.aggregates[0]
|
|
|
+ markdown = reporting.render_markdown(report)
|
|
|
+
|
|
|
+ assert aggregate.model_elapsed_ms.model_dump() == {
|
|
|
+ "count": 3,
|
|
|
+ "p50": 20.0,
|
|
|
+ "p95": 29.0,
|
|
|
+ }
|
|
|
+ assert aggregate.tool_latency_ms.model_dump() == {
|
|
|
+ "count": 3,
|
|
|
+ "p50": 15.0,
|
|
|
+ "p95": 24.0,
|
|
|
+ }
|
|
|
+ assert "Model elapsed p50/p95 ms" in markdown
|
|
|
+ assert "Tool latency p50/p95 ms" in markdown
|
|
|
+ assert "20.0/29.0" in markdown
|
|
|
+ assert "15.0/24.0" in markdown
|
|
|
+
|
|
|
+
|
|
|
+def test_zero_latency_samples_emit_low_confidence_warnings():
|
|
|
+ reporting = _reporting_module()
|
|
|
+
|
|
|
+ aggregate = reporting.build_benchmark_report(
|
|
|
+ _config(),
|
|
|
+ [
|
|
|
+ _run(
|
|
|
+ initial_provider_ttft_ms=None,
|
|
|
+ visible_ttft_ms=None,
|
|
|
+ turn_wall_time_ms=None,
|
|
|
+ model_calls=[],
|
|
|
+ tool_latencies_ms=[],
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ mock=True,
|
|
|
+ ).aggregates[0]
|
|
|
+
|
|
|
+ assert {
|
|
|
+ warning.split(" p95 low confidence:", 1)[0]
|
|
|
+ for warning in aggregate.warnings
|
|
|
+ } == {
|
|
|
+ "ordinary_chat/dual_agent initial_provider_ttft_ms",
|
|
|
+ "ordinary_chat/dual_agent visible_ttft_ms",
|
|
|
+ "ordinary_chat/dual_agent turn_wall_time_ms",
|
|
|
+ "ordinary_chat/dual_agent model_elapsed_ms",
|
|
|
+ "ordinary_chat/dual_agent tool_latency_ms",
|
|
|
+ }
|
|
|
+ assert all("0 samples" in warning for warning in aggregate.warnings)
|
|
|
+
|
|
|
+
|
|
|
def test_aggregates_are_stably_sorted_and_warn_for_low_confidence_p95():
|
|
|
reporting = _reporting_module()
|
|
|
runs = [
|
|
|
@@ -312,6 +475,21 @@ def test_errors_are_redacted_before_truncation_in_json_and_markdown():
|
|
|
assert "[REDACTED]" in markdown
|
|
|
|
|
|
|
|
|
+def test_error_truncation_is_utf8_safe_and_bounded_after_redaction():
|
|
|
+ reporting = _reporting_module()
|
|
|
+ real_key = "sk-byte-bound-secret"
|
|
|
+ error = f"Bearer {real_key} " + "密" * 1000
|
|
|
+
|
|
|
+ sanitized = reporting.sanitize_error(error, secrets=[real_key])
|
|
|
+ redacted = reporting.redact_text(error, secrets=[real_key])
|
|
|
+ expected = redacted.encode("utf-8")[:2048].decode("utf-8", errors="ignore")
|
|
|
+
|
|
|
+ assert sanitized == expected
|
|
|
+ assert real_key not in sanitized
|
|
|
+ assert len(sanitized.encode("utf-8")) <= 2048
|
|
|
+ assert "\ufffd" not in sanitized
|
|
|
+
|
|
|
+
|
|
|
def test_configured_secret_is_redacted_from_all_report_string_surfaces():
|
|
|
reporting = _reporting_module()
|
|
|
real_key = "sk-everywhere-secret"
|
|
|
@@ -338,6 +516,34 @@ def test_configured_secret_is_redacted_from_all_report_string_surfaces():
|
|
|
assert "[REDACTED]" in rendered
|
|
|
|
|
|
|
|
|
+def test_system_errors_are_typed_redacted_counted_and_rendered():
|
|
|
+ reporting = _reporting_module()
|
|
|
+ real_key = "sk-runner-system-secret"
|
|
|
+
|
|
|
+ report = reporting.build_benchmark_report(
|
|
|
+ _config(),
|
|
|
+ [],
|
|
|
+ mock=False,
|
|
|
+ secrets=[real_key],
|
|
|
+ system_errors=[f"Authorization: Bearer {real_key}"],
|
|
|
+ )
|
|
|
+ payload = json.loads(reporting.report_to_json(report))
|
|
|
+ markdown = reporting.render_markdown(report)
|
|
|
+
|
|
|
+ assert report.overall.model_dump() == {
|
|
|
+ "attempted": 1,
|
|
|
+ "passed": 0,
|
|
|
+ "failed": 1,
|
|
|
+ "warnings": [],
|
|
|
+ "system_errors": ["Authorization: [REDACTED] [REDACTED]"],
|
|
|
+ }
|
|
|
+ assert payload["overall"]["system_errors"] == report.overall.system_errors
|
|
|
+ assert "## System Errors" in markdown
|
|
|
+ assert "Authorization: [REDACTED] [REDACTED]" in markdown
|
|
|
+ assert real_key not in reporting.report_to_json(report)
|
|
|
+ assert real_key not in markdown
|
|
|
+
|
|
|
+
|
|
|
def test_markdown_is_rendered_from_the_same_json_facts():
|
|
|
reporting = _reporting_module()
|
|
|
report = reporting.build_benchmark_report(
|
|
|
@@ -412,3 +618,99 @@ def test_atomic_write_removes_temps_and_published_half_on_replace_failure(
|
|
|
)
|
|
|
|
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
+
|
|
|
+
|
|
|
+def test_reservation_failure_after_lock_creation_removes_own_lock(
|
|
|
+ tmp_path: Path,
|
|
|
+ monkeypatch: pytest.MonkeyPatch,
|
|
|
+):
|
|
|
+ reporting = _reporting_module()
|
|
|
+ original_exists = Path.exists
|
|
|
+
|
|
|
+ def failing_exists(path: Path) -> bool:
|
|
|
+ if path.parent == tmp_path and path.suffix == ".json":
|
|
|
+ raise OSError("simulated reservation path check failure")
|
|
|
+ return original_exists(path)
|
|
|
+
|
|
|
+ monkeypatch.setattr(Path, "exists", failing_exists)
|
|
|
+
|
|
|
+ with pytest.raises(
|
|
|
+ reporting.BenchmarkReportWriteError,
|
|
|
+ match="failed to write benchmark reports",
|
|
|
+ ):
|
|
|
+ reporting.write_benchmark_report(
|
|
|
+ _marker_report("reservation-failure"),
|
|
|
+ tmp_path,
|
|
|
+ timestamp="20260714T040506Z",
|
|
|
+ )
|
|
|
+
|
|
|
+ assert list(tmp_path.iterdir()) == []
|
|
|
+
|
|
|
+
|
|
|
+def test_concurrent_thread_writers_reserve_distinct_matching_pairs(
|
|
|
+ tmp_path: Path,
|
|
|
+ monkeypatch: pytest.MonkeyPatch,
|
|
|
+):
|
|
|
+ reporting = _reporting_module()
|
|
|
+ writer_count = 8
|
|
|
+ markers = [f"thread-writer-{index}" for index in range(writer_count)]
|
|
|
+ first_temp_barrier = threading.Barrier(writer_count, timeout=15)
|
|
|
+ original_write_text = reporting._write_text
|
|
|
+ thread_state = threading.local()
|
|
|
+
|
|
|
+ def synchronized_write(path: Path, content: str) -> None:
|
|
|
+ original_write_text(path, content)
|
|
|
+ if not getattr(thread_state, "first_write_done", False):
|
|
|
+ thread_state.first_write_done = True
|
|
|
+ first_temp_barrier.wait()
|
|
|
+
|
|
|
+ monkeypatch.setattr(reporting, "_write_text", synchronized_write)
|
|
|
+
|
|
|
+ with ThreadPoolExecutor(max_workers=writer_count) as executor:
|
|
|
+ results = list(
|
|
|
+ executor.map(
|
|
|
+ lambda marker: reporting.write_benchmark_report(
|
|
|
+ _marker_report(marker),
|
|
|
+ tmp_path,
|
|
|
+ timestamp="20260714T020304Z",
|
|
|
+ ),
|
|
|
+ markers,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ assert len({json_path.stem for json_path, _ in results}) == writer_count
|
|
|
+ _assert_concurrent_report_pairs(tmp_path, markers)
|
|
|
+
|
|
|
+
|
|
|
+def test_concurrent_process_writers_reserve_distinct_matching_pairs(
|
|
|
+ tmp_path: Path,
|
|
|
+):
|
|
|
+ context = multiprocessing.get_context("spawn")
|
|
|
+ writer_count = 4
|
|
|
+ markers = [f"process-writer-{index}" for index in range(writer_count)]
|
|
|
+ first_temp_barrier = context.Barrier(writer_count, timeout=20)
|
|
|
+ result_queue = context.Queue()
|
|
|
+ processes = [
|
|
|
+ context.Process(
|
|
|
+ target=_process_report_writer,
|
|
|
+ args=(
|
|
|
+ str(tmp_path),
|
|
|
+ marker,
|
|
|
+ "20260714T030405Z",
|
|
|
+ first_temp_barrier,
|
|
|
+ result_queue,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ for marker in markers
|
|
|
+ ]
|
|
|
+
|
|
|
+ for process in processes:
|
|
|
+ process.start()
|
|
|
+ results = [result_queue.get(timeout=30) for _ in processes]
|
|
|
+ for process in processes:
|
|
|
+ process.join(timeout=30)
|
|
|
+
|
|
|
+ assert all(process.exitcode == 0 for process in processes)
|
|
|
+ assert all(result[0] == "ok" for result in results), results
|
|
|
+ assert len({result[2][:-5] for result in results}) == writer_count
|
|
|
+ _assert_concurrent_report_pairs(tmp_path, markers)
|