Skip to content

Commit 0392e7e

Browse files
committed
feat(bench): real agentic trajectory A/B + dogfooding coding-agent A/B (dry-run validated)
Two opt-in "real test" harnesses where MemTrace's thesis is honestly measured — both structured as controlled A/B experiments: - agentic_trace_bench.py: ingests REAL SWE-agent (mini-swe-agent) trajectories (agents solving SWE-bench issues; failed commands = non-zero <returncode>) into the real runtime with failed steps rolled back, then A=plain-vector vs B=MemTrace retrieval measures dead-branch contamination. Dry-run (10 real trajectories, 340 steps, 29 failed): plain 10% contamination -> MemTrace 0%, recall 100% -> 90% (the honest tradeoff, now on real traces). scripts/fetch-swe-trajectories.sh fetches a bounded sample from HuggingFace (download -> use -> delete). - dogfood_agent.py: a minimal, SANDBOXED coding-agent loop (real LLM proposes shell commands, deny-listed executor runs them in a throwaway temp project, results recorded to MemTrace). A=no-memory vs B=MemTrace over N trials measures steps-to-success + repeated-mistake avoidance. Dry-run (gpt-5.4, 3 trials): B solved in 4 total steps vs A's 7 (memory recalls the working approach). Both skip cleanly without data / an LLM endpoint; neither touches the deterministic 16/16 suite. Signals are modest at dry-run scale — next: scale agentic to hundreds of trajectories (thousands of memories, bounded) and a harder dogfooding scenario.
1 parent e4d06ac commit 0392e7e

3 files changed

Lines changed: 528 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""Agentic real-trajectory benchmark — REAL SWE-agent traces, A/B contamination (opt-in).
2+
3+
This is the honest home-turf test: instead of synthetic markers, it ingests REAL
4+
[SWE-agent](https://github.com/SWE-agent/SWE-agent) execution trajectories (agents
5+
solving real SWE-bench GitHub issues) — sequences of (assistant action, tool
6+
observation) turns that include genuinely FAILED shell commands (non-zero
7+
``<returncode>``). Each failed command is finished as ``failed`` and its branch
8+
rolled back, exactly as a live agent would; the successful commands stay on the
9+
active path.
10+
11+
Then it runs a controlled **A/B** over the identical ingested memory:
12+
13+
- **A = plain vector** (``baseline_1``): vector/lexical retrieval, no gate.
14+
- **B = MemTrace** (``variant_2``): state-aware retrieval + admission gate.
15+
16+
and measures **dead-branch contamination** — how many of the retrieved
17+
positive-context blocks came from a *failed/rolled-back* command (a mistake the
18+
agent already abandoned). A plain vector store re-surfaces those failed commands
19+
because they're semantically similar; MemTrace's gate isolates them. Deterministic
20+
(no LLM, no network beyond fetching the traces): the win is structural, driven by
21+
``branch_status``, so the numbers are reproducible.
22+
23+
./scripts/fetch-swe-trajectories.sh # -> /tmp/swe_trajs/*.traj.json
24+
uv run python -m app.benchmark.agentic_trace_bench --dir /tmp/swe_trajs --output-dir reports
25+
"""
26+
from __future__ import annotations
27+
28+
import argparse
29+
import asyncio
30+
import json
31+
import os
32+
import re
33+
from pathlib import Path
34+
from typing import Any
35+
36+
from app.runtime.context_actions import positive_blocks
37+
from app.runtime.memory_runtime import MemoryRuntime
38+
from app.runtime.models import (
39+
EventRole,
40+
EventType,
41+
FinishStepRequest,
42+
RetrievalRequest,
43+
RetrievalStrategy,
44+
RollbackRequest,
45+
StartRunRequest,
46+
StartStepRequest,
47+
StepStatus,
48+
WriteEventRequest,
49+
)
50+
from app.runtime.repository import InMemoryRepository
51+
52+
_RETURNCODE = re.compile(r"<returncode>\s*(-?\d+)\s*</returncode>")
53+
_CONDITIONS = [("plain_vector", RetrievalStrategy.baseline_1), ("memtrace", RetrievalStrategy.variant_2)]
54+
55+
56+
# --------------------------------------------------------------------------- #
57+
# Parse mini-swe-agent trajectories
58+
# --------------------------------------------------------------------------- #
59+
def parse_trajectory(traj: dict[str, Any]) -> list[dict[str, Any]]:
60+
"""Pair each assistant action with the following tool observation. Failed steps
61+
are those whose observation reports a non-zero ``<returncode>``."""
62+
messages = traj.get("messages") or []
63+
steps: list[dict[str, Any]] = []
64+
pending_action: str | None = None
65+
for msg in messages:
66+
role, content = msg.get("role"), (msg.get("content") or "")
67+
if role == "assistant":
68+
pending_action = content.strip()
69+
elif role == "tool" and pending_action is not None:
70+
m = _RETURNCODE.search(content)
71+
rc = int(m.group(1)) if m else 0
72+
failed = rc != 0
73+
steps.append({
74+
"action": pending_action[:600],
75+
"observation": content.strip()[:1200],
76+
"returncode": rc,
77+
"failed": failed,
78+
})
79+
pending_action = None
80+
return steps
81+
82+
83+
def load_trajectories(directory: str) -> list[tuple[str, dict[str, Any]]]:
84+
out: list[tuple[str, dict[str, Any]]] = []
85+
for p in sorted(Path(directory).glob("*.traj.json")):
86+
try:
87+
out.append((p.stem.replace(".traj", ""), json.loads(p.read_text(encoding="utf-8"))))
88+
except Exception: # noqa: BLE001 - skip an unparseable file, don't abort
89+
continue
90+
return out
91+
92+
93+
# --------------------------------------------------------------------------- #
94+
# Ingest into MemTrace + A/B retrieval
95+
# --------------------------------------------------------------------------- #
96+
async def _ingest(rt: MemoryRuntime, ws: str, instance_id: str, steps: list[dict[str, Any]]):
97+
"""Drive the real runtime: each step is a tool call+result; failed steps are
98+
finished ``failed`` and rolled back. Returns (run_id, probe_step_id, failed_mem_ids,
99+
success_mem_ids)."""
100+
run = await rt.start_run(StartRunRequest(session_id=instance_id, task="resolve the issue", workspace_id=ws))
101+
failed_mem_ids: set[str] = set()
102+
success_mem_ids: set[str] = set()
103+
last_failed: str | None = None
104+
for i, step in enumerate(steps):
105+
s = await rt.start_step(StartStepRequest(
106+
run_id=run.run_id, intent=f"step {i}: {step['action'][:60]}",
107+
recovery_from_step_id=last_failed))
108+
await rt.write_event(WriteEventRequest(
109+
run_id=run.run_id, step_id=s.step_id, role=EventRole.assistant,
110+
event_type=EventType.tool_call, tool_name="bash", content=step["action"]))
111+
res = await rt.write_event(WriteEventRequest(
112+
run_id=run.run_id, step_id=s.step_id, role=EventRole.tool,
113+
event_type=EventType.tool_result, tool_name="bash", content=step["observation"],
114+
status="failed" if step["failed"] else "success"))
115+
mem_ids = list(res.created_memory_ids or [])
116+
if step["failed"]:
117+
await rt.finish_step(FinishStepRequest(
118+
run_id=run.run_id, step_id=s.step_id, status=StepStatus.failed,
119+
error_message=f"returncode={step['returncode']}"))
120+
await rt.rollback_branch(RollbackRequest(run_id=run.run_id, step_id=s.step_id, reason="command failed"))
121+
failed_mem_ids.update(mem_ids)
122+
last_failed = s.step_id
123+
else:
124+
await rt.finish_step(FinishStepRequest(run_id=run.run_id, step_id=s.step_id, status=StepStatus.completed))
125+
success_mem_ids.update(mem_ids)
126+
probe = await rt.start_step(StartStepRequest(run_id=run.run_id, intent="review what we tried"))
127+
return run.run_id, probe.step_id, failed_mem_ids, success_mem_ids
128+
129+
130+
async def run_agentic_trace_bench(directory: str, *, limit: int = 0, top_k: int = 12,
131+
token_budget: int = 1200, output_dir: str | None = "reports") -> dict[str, Any]:
132+
trajs = load_trajectories(directory)
133+
if not trajs:
134+
return {"skipped": True, "reason": f"no *.traj.json under {directory} (run scripts/fetch-swe-trajectories.sh)"}
135+
if limit > 0:
136+
trajs = trajs[:limit]
137+
138+
agg = {c: {"contam_blocks": 0, "total_blocks": 0, "contaminated_probes": 0, "recall_hit": 0,
139+
"tokens": 0, "probes": 0} for c, _ in _CONDITIONS}
140+
rows: list[dict[str, Any]] = []
141+
total_steps = total_failed = 0
142+
for instance_id, traj in trajs:
143+
steps = parse_trajectory(traj)
144+
n_failed = sum(1 for s in steps if s["failed"])
145+
total_steps += len(steps)
146+
total_failed += n_failed
147+
if not steps:
148+
continue
149+
repo = InMemoryRepository()
150+
ws = f"swe_{instance_id}"
151+
rt = MemoryRuntime(repo, default_workspace_id=ws)
152+
run_id, step_id, failed_ids, success_ids = await _ingest(rt, ws, instance_id, steps)
153+
# Probe: recall the commands that were run for this issue (surfaces tool evidence).
154+
query = "what shell commands were run to investigate and fix this issue and what were the results"
155+
row: dict[str, Any] = {"instance_id": instance_id, "steps": len(steps), "failed_steps": n_failed,
156+
"by_condition": {}}
157+
for cond, strategy in _CONDITIONS:
158+
ctx = await rt.retrieve_context(RetrievalRequest(
159+
run_id=run_id, step_id=step_id, query=query, strategy=strategy,
160+
token_budget=token_budget, top_k=top_k))
161+
blocks = positive_blocks(ctx)
162+
contam = sum(1 for b in blocks if b.memory_id in failed_ids)
163+
recall = any(b.memory_id in success_ids for b in blocks)
164+
tokens = sum(b.tokens or 0 for b in ctx.context_blocks)
165+
a = agg[cond]
166+
a["contam_blocks"] += contam
167+
a["total_blocks"] += len(blocks)
168+
a["contaminated_probes"] += int(contam > 0)
169+
a["recall_hit"] += int(recall)
170+
a["tokens"] += tokens
171+
a["probes"] += 1
172+
row["by_condition"][cond] = {"blocks": len(blocks), "failed_blocks": contam, "recall_hit": recall}
173+
rows.append(row)
174+
175+
payload = _aggregate(agg, rows, len(trajs), total_steps, total_failed, top_k)
176+
if output_dir:
177+
os.makedirs(output_dir, exist_ok=True)
178+
with open(os.path.join(output_dir, "agentic_trace_bench_results.json"), "w", encoding="utf-8") as fh:
179+
json.dump(payload, fh, indent=2, ensure_ascii=False)
180+
return payload
181+
182+
183+
def _aggregate(agg, rows, n_traj, total_steps, total_failed, top_k) -> dict[str, Any]:
184+
def rate(num, den):
185+
return round(num / den, 4) if den else 0.0
186+
187+
conds = {}
188+
for c, _ in _CONDITIONS:
189+
a = agg[c]
190+
conds[c] = {
191+
"contamination_rate": rate(a["contaminated_probes"], a["probes"]), # any failed cmd leaked
192+
"failed_block_share": rate(a["contam_blocks"], a["total_blocks"]), # fraction of context that's a mistake
193+
"recall_rate": rate(a["recall_hit"], a["probes"]), # a successful cmd still surfaced
194+
"avg_context_tokens": rate(a["tokens"], a["probes"]),
195+
}
196+
delta = {
197+
"contamination_reduction": round(conds["plain_vector"]["contamination_rate"]
198+
- conds["memtrace"]["contamination_rate"], 4),
199+
"failed_block_share_reduction": round(conds["plain_vector"]["failed_block_share"]
200+
- conds["memtrace"]["failed_block_share"], 4),
201+
"recall_delta": round(conds["memtrace"]["recall_rate"] - conds["plain_vector"]["recall_rate"], 4),
202+
}
203+
return {
204+
"skipped": False,
205+
"source": "SWE-agent (mini-swe-agent) real trajectories",
206+
"trajectories": n_traj,
207+
"total_steps": total_steps,
208+
"total_failed_steps": total_failed,
209+
"top_k": top_k,
210+
"by_condition": conds,
211+
"delta": delta,
212+
"rows": rows,
213+
}
214+
215+
216+
def main() -> int:
217+
p = argparse.ArgumentParser(description="Agentic real-trajectory (SWE-agent) A/B contamination benchmark")
218+
p.add_argument("--dir", default=os.environ.get("MEMTRACE_SWE_DIR", "/tmp/swe_trajs"))
219+
p.add_argument("--limit", type=int, default=0, help="max trajectories (0 = all)")
220+
p.add_argument("--top-k", type=int, default=12)
221+
p.add_argument("--token-budget", type=int, default=1200)
222+
p.add_argument("--output-dir", default="reports")
223+
a = p.parse_args()
224+
payload = asyncio.run(run_agentic_trace_bench(a.dir, limit=a.limit, top_k=a.top_k,
225+
token_budget=a.token_budget, output_dir=a.output_dir))
226+
if payload.get("skipped"):
227+
print(f"agentic_trace_bench skipped: {payload['reason']}")
228+
return 0
229+
print(f"source={payload['source']} trajectories={payload['trajectories']} "
230+
f"steps={payload['total_steps']} (failed={payload['total_failed_steps']})")
231+
for c, _ in _CONDITIONS:
232+
cc = payload["by_condition"][c]
233+
print(f" {c:>12}: contamination={cc['contamination_rate']:.1%} "
234+
f"failed_block_share={cc['failed_block_share']:.1%} recall={cc['recall_rate']:.1%} "
235+
f"ctx_tokens={cc['avg_context_tokens']:.0f}")
236+
d = payload["delta"]
237+
print(f" A/B delta: contamination -{d['contamination_reduction']:.1%} "
238+
f"failed_share -{d['failed_block_share_reduction']:.1%} recall {d['recall_delta']:+.1%}")
239+
return 0
240+
241+
242+
if __name__ == "__main__":
243+
raise SystemExit(main())

0 commit comments

Comments
 (0)