agentfork-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/PKG-INFO +34 -2
  2. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/README.md +27 -1
  3. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/pyproject.toml +6 -2
  4. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/src/agentfork/cli.py +17 -0
  5. agentfork_cli-0.2.0/src/agentfork/server.py +141 -0
  6. agentfork_cli-0.2.0/src/agentfork/ui.html +738 -0
  7. agentfork_cli-0.2.0/tests/test_ui.py +38 -0
  8. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/.gitignore +0 -0
  9. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/LICENSE +0 -0
  10. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/examples/demo_agent.py +0 -0
  11. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/examples/demo_agent_claude.py +0 -0
  12. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/examples/refund_agent.py +0 -0
  13. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/examples/refund_agent_cents.py +0 -0
  14. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/src/agentfork/__init__.py +0 -0
  15. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/src/agentfork/recorder.py +0 -0
  16. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/src/agentfork/replay.py +0 -0
  17. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/src/agentfork/store.py +0 -0
  18. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_anthropic.py +0 -0
  19. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_cli.py +0 -0
  20. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_fork.py +0 -0
  21. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_recorder.py +0 -0
  22. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_save_test.py +0 -0
  23. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_smoke.py +0 -0
  24. {agentfork_cli-0.1.0 → agentfork_cli-0.2.0}/tests/test_sweep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentfork-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Fork your LLM agent at any step and sweep prompts, models or tool results to find what fixes it.
5
5
  Project-URL: Homepage, https://github.com/bimal1023/agentfork
6
6
  Project-URL: Issues, https://github.com/bimal1023/agentfork/issues
@@ -20,10 +20,16 @@ Provides-Extra: anthropic
20
20
  Requires-Dist: anthropic; extra == 'anthropic'
21
21
  Provides-Extra: dev
22
22
  Requires-Dist: anthropic; extra == 'dev'
23
+ Requires-Dist: fastapi; extra == 'dev'
24
+ Requires-Dist: httpx; extra == 'dev'
23
25
  Requires-Dist: openai; extra == 'dev'
24
26
  Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: uvicorn; extra == 'dev'
25
28
  Provides-Extra: openai
26
29
  Requires-Dist: openai; extra == 'openai'
30
+ Provides-Extra: ui
31
+ Requires-Dist: fastapi; extra == 'ui'
32
+ Requires-Dist: uvicorn; extra == 'ui'
27
33
  Description-Content-Type: text/markdown
28
34
 
29
35
  # agentfork
@@ -69,7 +75,7 @@ Steps 1 and 2 replayed from cache in every run, so only the step that mattered w
69
75
  ## Quickstart
70
76
 
71
77
  ```bash
72
- pip install "agentfork-cli[openai]" # or agentfork[anthropic]
78
+ pip install "agentfork-cli[openai]" # or agentfork-cli[anthropic]
73
79
  ```
74
80
 
75
81
  Add three things to your agent:
@@ -99,6 +105,31 @@ Run your agent as usual. Each run is saved to `.agentfork/traces.db`.
99
105
  | `agentfork sweep <id> --step N --model a,b --system ... --repeat K --expect TEXT` | Try many variants of step N, K times each, and compare pass rate, tokens and time |
100
106
  | `agentfork diff <a> <b>` | Compare two runs step by step |
101
107
  | `agentfork save-test <id>` | Save a run as a pytest test that replays from cache in CI with no API key |
108
+ | `agentfork ui [--port N]` | Open the local web UI to browse, fork, sweep and compare runs |
109
+
110
+ ## Web UI
111
+
112
+ Prefer clicking to typing? The same fork, sweep and diff are available in a local web UI.
113
+
114
+ ```bash
115
+ pip install "agentfork-cli[openai,ui]"
116
+ agentfork ui # opens http://127.0.0.1:8000
117
+ ```
118
+
119
+ Start it from the folder you ran your agent in, since runs are saved to `.agentfork/` there. The server only listens on your own machine.
120
+
121
+ 1. **Find the step that went wrong.** Pick a run on the left. Its timeline shows each LLM and tool call in plain words, such as `Decided to call issue_refund(amount_usd=4999)`. Click a step to see its full input and output.
122
+ 2. **Try a change.** On that step, use another model, edit the system prompt, or fake a tool's result. Earlier steps replay from cache, and the new run appears under the original.
123
+ 3. **Test several.** Enter a few changes, how many times to run each, and text the final output must contain. You get a pass rate per change, with the original run as the baseline:
124
+
125
+ ```text
126
+ Change Passed Example output
127
+ Original ○ 0/1 I have processed a refund of $4,999.00...
128
+ Change 1 ●●● 3/3 I have processed a refund of $49.99...
129
+ Change 2 ○○○ 0/3 I have processed a refund of $4,999.00...
130
+ ```
131
+
132
+ 4. **Compare.** Tick two runs to see them side by side, with the first step where they differ highlighted.
102
133
 
103
134
  ## How it works
104
135
 
@@ -116,6 +147,7 @@ agentfork overlaps with record/replay tools like [agent-rewind](https://pypi.org
116
147
  - [x] Recorder, CLI viewer, fork, diff, snapshot tests
117
148
  - [x] OpenAI and Anthropic support
118
149
  - [x] Sweep with repeats and pass rates
150
+ - [x] Local web UI
119
151
  - [ ] Cost estimates per variant
120
152
  - [ ] Parallel sweeps
121
153
  - [ ] LangGraph integration
@@ -41,7 +41,7 @@ Steps 1 and 2 replayed from cache in every run, so only the step that mattered w
41
41
  ## Quickstart
42
42
 
43
43
  ```bash
44
- pip install "agentfork-cli[openai]" # or agentfork[anthropic]
44
+ pip install "agentfork-cli[openai]" # or agentfork-cli[anthropic]
45
45
  ```
46
46
 
47
47
  Add three things to your agent:
@@ -71,6 +71,31 @@ Run your agent as usual. Each run is saved to `.agentfork/traces.db`.
71
71
  | `agentfork sweep <id> --step N --model a,b --system ... --repeat K --expect TEXT` | Try many variants of step N, K times each, and compare pass rate, tokens and time |
72
72
  | `agentfork diff <a> <b>` | Compare two runs step by step |
73
73
  | `agentfork save-test <id>` | Save a run as a pytest test that replays from cache in CI with no API key |
74
+ | `agentfork ui [--port N]` | Open the local web UI to browse, fork, sweep and compare runs |
75
+
76
+ ## Web UI
77
+
78
+ Prefer clicking to typing? The same fork, sweep and diff are available in a local web UI.
79
+
80
+ ```bash
81
+ pip install "agentfork-cli[openai,ui]"
82
+ agentfork ui # opens http://127.0.0.1:8000
83
+ ```
84
+
85
+ Start it from the folder you ran your agent in, since runs are saved to `.agentfork/` there. The server only listens on your own machine.
86
+
87
+ 1. **Find the step that went wrong.** Pick a run on the left. Its timeline shows each LLM and tool call in plain words, such as `Decided to call issue_refund(amount_usd=4999)`. Click a step to see its full input and output.
88
+ 2. **Try a change.** On that step, use another model, edit the system prompt, or fake a tool's result. Earlier steps replay from cache, and the new run appears under the original.
89
+ 3. **Test several.** Enter a few changes, how many times to run each, and text the final output must contain. You get a pass rate per change, with the original run as the baseline:
90
+
91
+ ```text
92
+ Change Passed Example output
93
+ Original ○ 0/1 I have processed a refund of $4,999.00...
94
+ Change 1 ●●● 3/3 I have processed a refund of $49.99...
95
+ Change 2 ○○○ 0/3 I have processed a refund of $4,999.00...
96
+ ```
97
+
98
+ 4. **Compare.** Tick two runs to see them side by side, with the first step where they differ highlighted.
74
99
 
75
100
  ## How it works
76
101
 
@@ -88,6 +113,7 @@ agentfork overlaps with record/replay tools like [agent-rewind](https://pypi.org
88
113
  - [x] Recorder, CLI viewer, fork, diff, snapshot tests
89
114
  - [x] OpenAI and Anthropic support
90
115
  - [x] Sweep with repeats and pass rates
116
+ - [x] Local web UI
91
117
  - [ ] Cost estimates per variant
92
118
  - [ ] Parallel sweeps
93
119
  - [ ] LangGraph integration
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentfork-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Fork your LLM agent at any step and sweep prompts, models or tool results to find what fixes it."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -19,7 +19,8 @@ dependencies = ["typer", "rich"]
19
19
  [project.optional-dependencies]
20
20
  openai = ["openai"]
21
21
  anthropic = ["anthropic"]
22
- dev = ["pytest", "openai", "anthropic"]
22
+ ui = ["fastapi", "uvicorn"]
23
+ dev = ["pytest", "openai", "anthropic", "fastapi", "uvicorn", "httpx"]
23
24
 
24
25
  [project.urls]
25
26
  Homepage = "https://github.com/bimal1023/agentfork"
@@ -34,3 +35,6 @@ build-backend = "hatchling.build"
34
35
 
35
36
  [tool.hatch.build.targets.sdist]
36
37
  exclude = ["docs/", ".github/", "tests/agentfork/"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/agentfork"]
@@ -257,3 +257,20 @@ def sweep(run_id: str = typer.Argument(..., help="Run to sweep."),
257
257
  key, value = next(iter(v.items()))
258
258
  if key != "model":
259
259
  console.print(f"[bold]{labels[id(v)]}[/bold]: {value}")
260
+
261
+ @app.command()
262
+ def ui(port: int = typer.Option(8000, "--port", help="Port for the local web UI."),
263
+ no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser.")):
264
+ """Open the local web UI: runs, timelines, fork, sweep and compare."""
265
+ try:
266
+ import uvicorn
267
+ from .server import app as web_app
268
+ except ImportError:
269
+ console.print('[red]The UI needs extra packages: pip install "agentfork-cli[ui]"[/red]')
270
+ raise typer.Exit(1)
271
+ url = f"http://127.0.0.1:{port}"
272
+ console.print(f"[green]agentfork UI running at {url}[/green] (Ctrl+C to stop)")
273
+ if not no_browser:
274
+ import threading, webbrowser
275
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
276
+ uvicorn.run(web_app, host="127.0.0.1", port=port, log_level="warning")
@@ -0,0 +1,141 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.responses import HTMLResponse
8
+ from pydantic import BaseModel
9
+
10
+ from . import replay, store
11
+
12
+ PAGE = Path(__file__).with_name("ui.html")
13
+ app = FastAPI(title="agentfork")
14
+
15
+ def _load(text):
16
+ return json.loads(text) if text else None
17
+
18
+ def _resolve(prefix):
19
+ matches = store.find_runs(prefix)
20
+ if not matches:
21
+ raise HTTPException(404, f"No run starting with {prefix}")
22
+ if len(matches) > 1:
23
+ raise HTTPException(400, f"{prefix} matches several runs")
24
+ return matches[0]
25
+
26
+ def _preview(text, limit=160):
27
+ val = _load(text)
28
+ if val is None:
29
+ return ""
30
+ if isinstance(val, dict) and "args" in val:
31
+ parts = list(val.get("args") or []) + list((val.get("kwargs") or {}).values())
32
+ val = parts[0] if len(parts) == 1 else parts
33
+ return (val if isinstance(val, str) else json.dumps(val))[:limit]
34
+
35
+ def _brief(run, steps=None):
36
+ if steps is not None:
37
+ n_steps = len(steps)
38
+ tokens = sum((s["tokens_in"] or 0) + (s["tokens_out"] or 0) for s in steps)
39
+ else:
40
+ n_steps, tokens = run.get("n_steps", 0), run.get("tokens", 0)
41
+ duration = f"{run['ended_at'] - run['started_at']:.1f}s" if run.get("ended_at") else "-"
42
+ started = datetime.fromtimestamp(run["started_at"]).strftime("%m-%d %H:%M:%S")
43
+ return {"id": run["id"], "name": run["name"], "status": run["status"], "n_steps": n_steps,
44
+ "tokens": tokens, "duration": duration, "started": started,
45
+ "parent_run_id": run.get("parent_run_id"), "fork_step": run.get("fork_step"),
46
+ "input_preview": _preview(run.get("input_json")), "output_preview": _preview(run.get("output_json"))}
47
+
48
+ def summarize(step):
49
+ inp, out = _load(step["input_json"]), _load(step["output_json"])
50
+ if step["error"]:
51
+ return step["error"]
52
+ if step["type"] == "tool" and isinstance(inp, dict):
53
+ return f"{inp.get('kwargs') or inp.get('args')} -> {out}"
54
+ if step["type"] == "llm" and isinstance(out, dict):
55
+ if "choices" in out:
56
+ msg = (out.get("choices") or [{}])[0].get("message") or {}
57
+ calls = msg.get("tool_calls") or []
58
+ if calls:
59
+ return "calls " + ", ".join(c["function"]["name"] for c in calls)
60
+ return (msg.get("content") or "")[:300]
61
+ blocks = [b for b in (out.get("content") or []) if isinstance(b, dict)]
62
+ tools = [b.get("name") for b in blocks if b.get("type") == "tool_use"]
63
+ if tools:
64
+ return "calls " + ", ".join(tools)
65
+ return " ".join(b.get("text", "") for b in blocks if b.get("type") == "text")[:300]
66
+ return str(out)[:300]
67
+
68
+ @app.get("/", response_class=HTMLResponse)
69
+ def index():
70
+ return PAGE.read_text()
71
+
72
+ @app.get("/api/runs")
73
+ def runs(limit: int = 100):
74
+ return [_brief(r) for r in store.list_runs(limit)]
75
+
76
+ @app.get("/api/runs/{run_id}")
77
+ def run_detail(run_id: str):
78
+ run = _resolve(run_id)
79
+ steps = store.get_steps(run["id"])
80
+ return {"run": _brief(run, steps), "input": _load(run["input_json"]), "output": _load(run["output_json"]),
81
+ "steps": [{"seq": s["seq"], "type": s["type"], "name": s["name"], "cached": bool(s["cached"]),
82
+ "error": s["error"], "latency_ms": s["latency_ms"] or 0,
83
+ "tokens_in": s["tokens_in"], "tokens_out": s["tokens_out"],
84
+ "input": _load(s["input_json"]), "output": _load(s["output_json"]),
85
+ "summary": summarize(s)} for s in steps]}
86
+
87
+ class ForkIn(BaseModel):
88
+ run_id: str
89
+ step: int
90
+ output: Optional[str] = None
91
+ model: Optional[str] = None
92
+ system: Optional[str] = None
93
+
94
+ @app.post("/api/fork")
95
+ def fork(body: ForkIn):
96
+ run = _resolve(body.run_id)
97
+ try:
98
+ new_id, _ = replay.fork(run, body.step, output=body.output, model=body.model, system=body.system)
99
+ except ValueError as e:
100
+ raise HTTPException(400, str(e))
101
+ except Exception as e:
102
+ raise HTTPException(500, f"Fork failed: {e!r}")
103
+ return {"run_id": new_id}
104
+
105
+ class SweepIn(BaseModel):
106
+ run_id: str
107
+ step: int
108
+ kind: str
109
+ values: List[str]
110
+ repeat: int = 1
111
+ expect: Optional[str] = None
112
+
113
+ def _group(label, value, rows, expect):
114
+ n = len(rows)
115
+ return {"label": label, "value": value, "runs": n,
116
+ "ok": sum(r["status"] == "ok" for r in rows),
117
+ "passed": None if expect is None else sum(bool(r["passed"]) for r in rows),
118
+ "avg_tokens": round(sum(r["tokens"] for r in rows) / n),
119
+ "avg_time_s": round(sum(r["latency_ms"] for r in rows) / n / 1000, 1),
120
+ "sample": str(rows[0]["result"])[:200],
121
+ "run_ids": [r["run_id"] for r in rows if r["run_id"]]}
122
+
123
+ @app.post("/api/sweep")
124
+ def sweep(body: SweepIn):
125
+ if body.kind not in ("model", "system", "output"):
126
+ raise HTTPException(400, "kind must be model, system or output")
127
+ values = [v.strip() if body.kind == "model" else v for v in body.values if v.strip()]
128
+ if not values:
129
+ raise HTTPException(400, "Add at least one variant")
130
+ variants = [{body.kind: v} for v in values]
131
+ run = _resolve(body.run_id)
132
+ try:
133
+ results = replay.sweep(run, body.step, variants, repeat=max(1, min(body.repeat, 10)), expect=body.expect)
134
+ except ValueError as e:
135
+ raise HTTPException(400, str(e))
136
+ groups = [_group("original", None, [replay.baseline(run, body.step, body.expect)], body.expect)]
137
+ for i, v in enumerate(variants, 1):
138
+ rows = [r for r in results if r["variant"] is v]
139
+ label = f"model={v['model']}" if body.kind == "model" else f"{body.kind} #{i}"
140
+ groups.append(_group(label, None if body.kind == "model" else v[body.kind], rows, body.expect))
141
+ return {"groups": groups}
@@ -0,0 +1,738 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>agentfork</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --paper:#edf0f3;--sheet:#f8f9fb;--ink:#17202b;--ink-2:#4a5664;--ink-3:#7c8794;--rule:#d5dbe2;--rule-2:#c3cbd4;
13
+ --llm:#3346c4;--llm-soft:#e3e7fb;--tool:#a8670f;--tool-soft:#f6ead6;
14
+ --pass:#17804a;--pass-soft:#dcf1e4;--fail:#c42a3c;--fail-soft:#f9e0e3;--focus:#3346c4;
15
+ --sans:"Instrument Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
16
+ --mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
17
+ }
18
+ @media (prefers-color-scheme: dark){:root{
19
+ --paper:#151a21;--sheet:#1c222b;--ink:#e6eaef;--ink-2:#aab4c0;--ink-3:#77828f;--rule:#2b333e;--rule-2:#3a4450;
20
+ --llm:#8d9bff;--llm-soft:#262d52;--tool:#e5a54a;--tool-soft:#3a2d17;
21
+ --pass:#4fc983;--pass-soft:#16362a;--fail:#ff7a88;--fail-soft:#40202a;--focus:#8d9bff;
22
+ }}
23
+ *{box-sizing:border-box}
24
+ [hidden]{display:none!important}
25
+ html,body{height:100%}
26
+ body{margin:0;font:14px/1.5 var(--sans);background:var(--paper);color:var(--ink);-webkit-font-smoothing:antialiased}
27
+ button,input,textarea{font:inherit;color:inherit}
28
+ :focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
29
+ .mono{font-family:var(--mono);font-size:12.5px}
30
+ code{font:12.5px var(--mono);background:var(--sheet);border:1px solid var(--rule);border-radius:4px;padding:0 4px;word-break:break-word}
31
+ .quiet{color:var(--ink-3)}
32
+ a,.link{color:var(--llm);text-decoration:none;background:none;border:0;padding:0;cursor:pointer;font-weight:500}
33
+ a:hover,.link:hover{text-decoration:underline}
34
+
35
+ /* frame */
36
+ .app{display:grid;grid-template-columns:330px minmax(0,1fr);grid-template-rows:auto 1fr;height:100vh}
37
+ header{grid-column:1/-1;display:flex;align-items:center;gap:16px;padding:10px 20px;border-bottom:1px solid var(--rule);background:var(--sheet)}
38
+ .mark{display:flex;align-items:center;gap:8px;font-weight:700;font-size:16px;letter-spacing:-.01em}
39
+ .mark svg{color:var(--llm)}
40
+ .tagline{font-size:13px;color:var(--ink-3)}
41
+ header .spacer{flex:1}
42
+ #compare{display:flex;align-items:center;gap:10px;font-size:13px}
43
+
44
+ /* buttons */
45
+ .btn{border:1px solid var(--rule-2);background:var(--sheet);border-radius:6px;padding:5px 12px;cursor:pointer;font-weight:500;font-size:13px;white-space:nowrap}
46
+ .btn:hover:not(:disabled){border-color:var(--ink-3)}
47
+ .btn.primary{background:var(--ink);color:var(--sheet);border-color:var(--ink)}
48
+ .btn.primary:hover:not(:disabled){opacity:.88}
49
+ .btn:disabled{opacity:.45;cursor:default}
50
+ .btn.small{padding:2px 9px;font-size:12.5px}
51
+
52
+ /* run tree */
53
+ aside{border-right:1px solid var(--rule);background:var(--sheet);overflow:auto;padding:8px 0 24px}
54
+ .tree-head{padding:8px 16px 8px;font-size:12px;color:var(--ink-3)}
55
+ .tree-head b{display:block;font-size:13px;color:var(--ink-2);font-weight:600}
56
+ .node{position:relative}
57
+ .run{display:grid;grid-template-columns:16px 1fr;gap:8px;align-items:start;padding:8px 14px 8px 12px;cursor:pointer;border-left:3px solid transparent}
58
+ .run:hover{background:var(--paper)}
59
+ .run.active{background:var(--llm-soft);border-left-color:var(--llm)}
60
+ .run input{margin:3px 0 0;accent-color:var(--llm)}
61
+ .run-line{display:flex;align-items:center;gap:7px;min-width:0}
62
+ .run-title-text{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
63
+ .run-meta{display:flex;gap:10px;font-size:12px;color:var(--ink-3);flex-wrap:wrap}
64
+ .dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--pass)}
65
+ .dot.error{background:var(--fail)}.dot.running{background:var(--tool)}
66
+ .state{font-size:11.5px;font-weight:600;color:var(--fail)}.state.running{color:var(--tool)}
67
+ .children{margin-left:26px;border-left:1px solid var(--rule-2)}
68
+ .children .run{padding-left:10px}
69
+ .children .run::before{content:"";position:absolute;left:-1px;top:17px;width:9px;border-top:1px solid var(--rule-2)}
70
+ .fold{display:block;margin:0 0 6px 41px;font-size:12.5px}
71
+
72
+ /* run view */
73
+ main{overflow:auto;padding:28px 40px 80px}
74
+ .wrap{max-width:980px}
75
+
76
+ .guide{margin:0 0 28px;padding:16px 18px 18px;background:var(--sheet);border:1px solid var(--rule);border-radius:8px}
77
+ .guide-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:12px}
78
+ .guide h2{margin:0;font-size:15px}
79
+ .guide ol{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,1fr);gap:18px;counter-reset:g}
80
+ .guide li{counter-increment:g;display:grid;grid-template-columns:26px 1fr;gap:8px;font-size:13px;color:var(--ink-2)}
81
+ .guide li::before{content:counter(g);width:22px;height:22px;border-radius:50%;background:var(--ink);color:var(--sheet);font-weight:600;font-size:12px;display:grid;place-items:center}
82
+ .guide li b{display:block;color:var(--ink);font-size:13.5px;margin-bottom:2px}
83
+
84
+ .run-title{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin:0 0 6px}
85
+ .run-title h1{font-size:24px;line-height:1.2;letter-spacing:-.02em;margin:0;font-weight:700}
86
+ .run-title .mono{word-break:break-all}
87
+ .lineage{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:13.5px;color:var(--ink-2);margin:0 0 18px}
88
+ .flash{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 18px;padding:10px 14px;border-radius:6px;background:var(--pass-soft);font-size:13.5px}
89
+ .asked{margin:0 0 20px;max-width:78ch}
90
+ .asked h3,.final h3{margin:0 0 4px;font-size:12.5px;font-weight:600;color:var(--ink-3)}
91
+ .asked p{margin:0;font-size:16px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
92
+ .stats{display:flex;gap:28px;margin:0 0 26px;padding:0}
93
+ .stats div{display:flex;flex-direction:column}
94
+ .stats dt{font-size:12px;color:var(--ink-3);order:2}
95
+ .stats dd{margin:0;font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
96
+ .legend{display:flex;gap:18px;font-size:12px;color:var(--ink-3);margin:0 0 10px;flex-wrap:wrap}
97
+ .legend span{display:inline-flex;align-items:center;gap:6px}
98
+
99
+ /* timeline */
100
+ .timeline{list-style:none;margin:0;padding:0}
101
+ .step{position:relative;display:grid;grid-template-columns:36px 1fr;--c:var(--llm);--soft:var(--llm-soft)}
102
+ .step.tool{--c:var(--tool);--soft:var(--tool-soft)}
103
+ .rail{position:relative}
104
+ .rail::before{content:"";position:absolute;left:50%;top:0;bottom:0;border-left:2px solid var(--rule-2);transform:translateX(-1px)}
105
+ .step:first-child .rail::before{top:18px}
106
+ .step.cached .rail::before{border-left-style:dashed}
107
+ .pin{position:absolute;left:50%;top:12px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--c);border:2px solid var(--c);box-shadow:0 0 0 4px var(--paper)}
108
+ .step.cached .pin{background:var(--paper)}
109
+ .step.error .pin{background:var(--fail);border-color:var(--fail)}
110
+ .step.forkpoint .pin{width:18px;height:18px;margin-left:-9px;top:10px;box-shadow:0 0 0 3px var(--paper),0 0 0 5px var(--c)}
111
+ .body{padding:6px 0 20px;min-width:0}
112
+ .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
113
+ .toggle{display:flex;align-items:center;gap:10px;background:none;border:0;padding:2px 0;cursor:pointer;text-align:left;min-width:0}
114
+ .seq{font-variant-numeric:tabular-nums;color:var(--ink-3);font-size:13px;width:18px}
115
+ .kind{font-size:11.5px;font-weight:600;color:var(--c);background:var(--soft);border-radius:4px;padding:1px 7px}
116
+ .sname{font-weight:600;font-size:15px}
117
+ .chev{color:var(--ink-3);transition:transform .15s}
118
+ [aria-expanded=true] .chev{transform:rotate(90deg)}
119
+ .facts{display:flex;gap:12px;font-size:12.5px;color:var(--ink-3);font-variant-numeric:tabular-nums}
120
+ .tag{font-size:11.5px;font-weight:500;color:var(--ink-2);border:1px solid var(--rule-2);border-radius:4px;padding:0 6px}
121
+ .tag.err{color:var(--fail);border-color:currentColor}
122
+ .tag.forked{color:var(--c);border-color:currentColor}
123
+ .actions{margin-left:auto;display:flex;gap:6px}
124
+ .summary{margin:6px 0 0 28px;color:var(--ink-2);word-break:break-word;max-width:80ch;display:grid;gap:3px}
125
+ .summary.err{color:var(--fail)}
126
+ .summary .verb{color:var(--ink-3);font-size:12.5px;margin-right:6px}
127
+ .details{margin:10px 0 0 28px;display:grid;gap:10px}
128
+ .details h4{margin:0 0 4px;font-size:12px;font-weight:600;color:var(--ink-3)}
129
+ pre{margin:0;background:var(--sheet);border:1px solid var(--rule);border-radius:6px;padding:10px 12px;overflow:auto;max-height:340px;font:12px/1.55 var(--mono)}
130
+ .final .pin{border-radius:3px;background:var(--ink);border-color:var(--ink)}
131
+ .final .rail::before{bottom:auto;height:12px}
132
+ .final .out{margin:0;padding:12px 14px;background:var(--sheet);border:1px solid var(--rule);border-radius:6px;max-width:78ch;word-break:break-word;white-space:pre-wrap;font-size:15px}
133
+
134
+ /* fork / sweep panel */
135
+ .panel{margin:12px 0 4px 28px;padding:16px 18px 18px;background:var(--sheet);border:1px solid var(--rule);border-left:3px solid var(--c);border-radius:6px;display:grid;gap:14px}
136
+ .panel-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
137
+ .panel h3{margin:0;font-size:15px}
138
+ .panel p{margin:0;font-size:13px;color:var(--ink-2);max-width:72ch}
139
+ .field{display:grid;gap:4px}
140
+ .field label,.field .lbl{font-size:12.5px;font-weight:600;color:var(--ink-2)}
141
+ .field small{font-size:12px;color:var(--ink-3)}
142
+ .field input,.field textarea{width:100%;background:var(--paper);border:1px solid var(--rule-2);border-radius:5px;padding:7px 9px}
143
+ .field textarea{font:12.5px/1.5 var(--mono);resize:vertical}
144
+ .cols{display:grid;grid-template-columns:140px 1fr;gap:14px;align-items:start}
145
+ .seg{display:inline-flex;border:1px solid var(--rule-2);border-radius:6px;overflow:hidden;width:max-content}
146
+ .seg button{border:0;background:none;padding:4px 12px;font-size:13px;cursor:pointer}
147
+ .seg button[aria-pressed=true]{background:var(--ink);color:var(--sheet)}
148
+ .variants{display:grid;gap:8px}
149
+ .variant{display:grid;grid-template-columns:76px 1fr auto;gap:8px;align-items:start}
150
+ .variant .vn{font-size:12.5px;color:var(--ink-3);padding-top:8px}
151
+ .variant .link{padding-top:8px;font-size:12.5px;color:var(--ink-3)}
152
+ .go{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
153
+ .cost{font-size:12.5px;color:var(--ink-3)}
154
+ .msg{font-size:13px;color:var(--ink-2)}.msg.err{color:var(--fail)}
155
+
156
+ /* sweep results */
157
+ .results:empty{display:none}
158
+ .results{overflow-x:auto;border-top:1px solid var(--rule);padding-top:12px}
159
+ .results h4{margin:0 0 6px;font-size:14px}
160
+ .results table{border-collapse:collapse;width:100%;font-size:13px}
161
+ .results th{font-size:12px;font-weight:500;color:var(--ink-3);text-align:left;padding:6px 10px;border-bottom:1px solid var(--rule-2);white-space:nowrap}
162
+ .results td{padding:9px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
163
+ .results td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
164
+ .results tr.best td{background:var(--pass-soft)}
165
+ .results tr.best td:first-child{box-shadow:inset 3px 0 var(--pass)}
166
+ .vlabel{font-weight:600;white-space:nowrap}
167
+ .vvalue{font-size:12px;color:var(--ink-3);max-width:30ch;word-break:break-word;margin-top:2px}
168
+ .dots{display:flex;gap:3px;align-items:center}
169
+ .dots i{width:9px;height:9px;border-radius:50%;background:var(--fail-soft);border:1.5px solid var(--fail)}
170
+ .dots i.p{background:var(--pass);border-color:var(--pass)}
171
+ .dots b{margin-left:6px;font-weight:600;font-variant-numeric:tabular-nums}
172
+ .sample{color:var(--ink-2);max-width:40ch;word-break:break-word}
173
+ .verdict{font-size:13.5px;margin:12px 0 0}
174
+
175
+ /* compare */
176
+ .cmp{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed}
177
+ .cmp th{text-align:left;font-weight:500;color:var(--ink-3);font-size:12px;padding:6px 12px;border-bottom:1px solid var(--rule-2)}
178
+ .cmp th:first-child{width:64px}
179
+ .cmp td{padding:10px 12px;border-bottom:1px solid var(--rule);vertical-align:top;word-break:break-word}
180
+ .cmp td:first-child{color:var(--ink-3);font-variant-numeric:tabular-nums}
181
+ .cmp tr.same td:not(:first-child){color:var(--ink-3)}
182
+ .cmp tr.diff td{background:color-mix(in srgb,var(--tool) 9%,transparent)}
183
+ .cmp tr.first td:first-child{box-shadow:inset 3px 0 var(--tool)}
184
+ .cmp .k{display:flex;gap:8px;align-items:center;margin-bottom:3px}
185
+ .cmp .step{display:inline}
186
+ .diverge{font-size:14px;color:var(--ink-2);margin:0 0 16px}
187
+
188
+ .empty{max-width:60ch;margin:8vh auto 0;color:var(--ink-2)}
189
+ .empty h2{font-size:20px;margin:0 0 8px;color:var(--ink)}
190
+ .empty ol{padding-left:20px;display:grid;gap:14px}
191
+ .empty pre{margin-top:6px}
192
+ .tree-empty{padding:16px;color:var(--ink-3);font-size:13px}
193
+
194
+ @media (max-width:980px){.guide ol{grid-template-columns:1fr;gap:10px}}
195
+ @media (max-width:820px){
196
+ .app{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto 1fr;height:auto;min-height:100vh}
197
+ aside{max-height:38vh;border-right:0;border-bottom:1px solid var(--rule)}
198
+ main{padding:20px 16px 60px;overflow:visible}
199
+ header{padding:10px 16px;flex-wrap:wrap}
200
+ .tagline,#compare .quiet{display:none}
201
+ .actions{margin-left:28px;width:100%}
202
+ .summary,.details,.panel{margin-left:0}
203
+ .cols{grid-template-columns:1fr}
204
+ .variant{grid-template-columns:1fr auto}.variant .vn{display:none}
205
+ .stats{gap:20px;flex-wrap:wrap}
206
+ .cmp{font-size:12.5px}.cmp th:first-child{width:40px}.cmp td,.cmp th{padding:8px 6px}
207
+ }
208
+ @media (prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
209
+ </style>
210
+ </head>
211
+ <body>
212
+ <div class="app">
213
+ <header>
214
+ <div class="mark">
215
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="4" cy="3.5" r="2"/><circle cx="4" cy="14.5" r="2"/><circle cx="14" cy="8" r="2"/><path d="M4 5.5v7M4 8.5c0-1.5 1-2.5 3-2.5h5"/></svg>
216
+ agentfork
217
+ </div>
218
+ <span class="tagline">Find the step where your agent went wrong, and test fixes for it</span>
219
+ <span class="spacer"></span>
220
+ <div id="compare"></div>
221
+ <button class="btn small" id="helpBtn" aria-expanded="true">How it works</button>
222
+ </header>
223
+ <aside id="tree" aria-label="Runs"></aside>
224
+ <main id="main"></main>
225
+ </div>
226
+ <script>
227
+ const $ = s => document.querySelector(s);
228
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
229
+ const short = id => id ? id.slice(0, 8) : "";
230
+ const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : Math.round(ms) + " ms";
231
+ const fmtNum = n => Number(n || 0).toLocaleString();
232
+ const fmtOut = o => typeof o === "string" ? o : JSON.stringify(o, null, 2);
233
+ const compact = o => typeof o === "string" ? o : JSON.stringify(o);
234
+ const clip = (s, n = 400) => s.length > n ? s.slice(0, n) + "…" : s;
235
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
236
+ const store = {
237
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
238
+ set(k, v) { try { localStorage.setItem(k, v); } catch {} },
239
+ };
240
+
241
+ let runs = [], selected = new Set(), current = null, open = new Set(), detail = null, flash = null;
242
+ let showGuide = store.get("agentfork.guide") !== "hidden";
243
+
244
+ async function api(path, body) {
245
+ const r = await fetch(path, body ? {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)} : {});
246
+ const data = await r.json().catch(() => ({}));
247
+ if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : `${r.status} ${r.statusText}`);
248
+ return data;
249
+ }
250
+
251
+ /* ---------- guide ---------- */
252
+
253
+ const GUIDE = `<section class="guide" id="guide">
254
+ <div class="guide-head"><h2>How to fix a bad run</h2><button class="link" data-guide-close>Hide</button></div>
255
+ <ol>
256
+ <li><div><b>Find the step that went wrong</b>Read the timeline from top to bottom. Click a step's name to see exactly what went in and what came out.</div></li>
257
+ <li><div><b>Try a change at that step</b>Use another model, edit the system prompt, or pretend a tool returned something else. Earlier steps replay from cache, so they cost nothing.</div></li>
258
+ <li><div><b>Test several changes</b>Run each change a few times and see which one passes most often, so you're not trusting one lucky run.</div></li>
259
+ </ol></section>`;
260
+
261
+ function setGuide(on) {
262
+ showGuide = on;
263
+ store.set("agentfork.guide", on ? "shown" : "hidden");
264
+ $("#helpBtn").setAttribute("aria-expanded", String(on));
265
+ const g = $("#guide");
266
+ if (g) g.hidden = !on;
267
+ else if (on && $("#main .wrap")) $("#main .wrap").insertAdjacentHTML("afterbegin", GUIDE);
268
+ if (on && $("#guide")) $("#guide").scrollIntoView({block: "nearest"});
269
+ }
270
+ $("#helpBtn").onclick = () => setGuide(!showGuide || ($("#guide") && $("#guide").hidden));
271
+ $("#helpBtn").setAttribute("aria-expanded", String(showGuide));
272
+
273
+ /* ---------- run tree ---------- */
274
+
275
+ function buildTree() {
276
+ const byId = Object.fromEntries(runs.map(r => [r.id, {...r, kids: []}]));
277
+ const roots = [];
278
+ runs.forEach(r => (byId[r.parent_run_id] ? byId[r.parent_run_id].kids : roots).push(byId[r.id]));
279
+ return {byId, roots};
280
+ }
281
+
282
+ function runRow(r) {
283
+ const isFork = !!r.parent_run_id;
284
+ const title = isFork ? (r.output_preview || r.name) : (r.input_preview || r.name);
285
+ return `<div class="run ${current === r.id ? "active" : ""}" data-id="${r.id}">
286
+ <input type="checkbox" data-check="${r.id}" ${selected.has(r.id) ? "checked" : ""} aria-label="Select ${short(r.id)} to compare">
287
+ <div style="min-width:0">
288
+ <div class="run-line"><span class="dot ${r.status}" title="${esc(r.status)}"></span>
289
+ <span class="run-title-text" title="${esc(title)}">${esc(title)}</span>${r.status !== "ok" ? `<span class="state ${r.status}">${esc(r.status)}</span>` : ""}</div>
290
+ <div class="run-meta"><span class="mono">${short(r.id)}</span>${isFork ? `<span>changed step ${r.fork_step}</span>` : `<span>${esc(r.name)}</span>`}<span title="${r.started}">${r.started.slice(6)}</span></div>
291
+ </div></div>`;
292
+ }
293
+
294
+ function nodeHtml(n) {
295
+ const many = n.kids.length > 3 && !open.has(n.id);
296
+ return `<div class="node">${runRow(n)}
297
+ ${n.kids.length ? `<div class="children">${many ? "" : n.kids.map(nodeHtml).join("")}</div>
298
+ ${n.kids.length > 3 ? `<button class="link fold" data-fold="${n.id}">${many ? `Show ${plural(n.kids.length, "fork")}` : "Hide forks"}</button>` : ""}` : ""}
299
+ </div>`;
300
+ }
301
+
302
+ function renderTree() {
303
+ const {roots} = buildTree();
304
+ $("#tree").innerHTML = runs.length
305
+ ? `<div class="tree-head"><b>Your runs, newest first</b>Each fork is listed under the run it came from.</div>${roots.map(nodeHtml).join("")}`
306
+ : '<div class="tree-empty">No runs recorded yet.</div>';
307
+ renderCompareBar();
308
+ }
309
+
310
+ function revealInTree(id) {
311
+ const {byId} = buildTree();
312
+ for (let p = byId[id] && byId[byId[id].parent_run_id]; p; p = byId[p.parent_run_id]) open.add(p.id);
313
+ }
314
+
315
+ async function loadRuns() {
316
+ runs = await api("/api/runs?limit=200");
317
+ if (current) revealInTree(current);
318
+ renderTree();
319
+ }
320
+
321
+ function renderCompareBar() {
322
+ const n = selected.size;
323
+ $("#compare").innerHTML = n === 0 ? `<span class="quiet">Tick two runs to compare them</span>`
324
+ : `<span class="quiet">${n} of 2 selected</span>
325
+ <button class="btn small" data-clear>Clear</button>
326
+ <button class="btn small primary" data-compare ${n === 2 ? "" : "disabled"}>Compare</button>`;
327
+ }
328
+
329
+ $("#tree").addEventListener("click", e => {
330
+ const check = e.target.dataset.check;
331
+ if (check) {
332
+ if (e.target.checked) {
333
+ if (selected.size >= 2) selected.delete([...selected][0]);
334
+ selected.add(check);
335
+ } else selected.delete(check);
336
+ renderTree();
337
+ return;
338
+ }
339
+ const fold = e.target.dataset.fold;
340
+ if (fold) { open.has(fold) ? open.delete(fold) : open.add(fold); renderTree(); return; }
341
+ const row = e.target.closest(".run");
342
+ if (row) go(row.dataset.id);
343
+ });
344
+
345
+ $("#compare").addEventListener("click", e => {
346
+ if (e.target.dataset.clear !== undefined) { selected.clear(); renderTree(); }
347
+ if (e.target.dataset.compare !== undefined) { const [a, b] = [...selected]; location.hash = `compare=${a},${b}`; }
348
+ });
349
+
350
+ /* ---------- describing steps in plain words ---------- */
351
+
352
+ const kv = o => o && typeof o === "object" && !Array.isArray(o)
353
+ ? Object.entries(o).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")
354
+ : compact(o);
355
+
356
+ function argsText(input) {
357
+ const kw = (input && input.kwargs) || {}, args = (input && input.args) || [];
358
+ return Object.keys(kw).length ? kv(kw) : args.map(a => JSON.stringify(a)).join(", ");
359
+ }
360
+
361
+ function llmParts(out) {
362
+ const calls = [];
363
+ let text = "";
364
+ if (out && Array.isArray(out.choices)) {
365
+ const m = (out.choices[0] || {}).message || {};
366
+ (m.tool_calls || []).forEach(c => {
367
+ let a = c.function.arguments;
368
+ try { a = kv(JSON.parse(a)); } catch {}
369
+ calls.push(`${c.function.name}(${a})`);
370
+ });
371
+ text = m.content || "";
372
+ } else if (out && Array.isArray(out.content)) {
373
+ out.content.forEach(b => {
374
+ if (b.type === "tool_use") calls.push(`${b.name}(${kv(b.input)})`);
375
+ if (b.type === "text") text += b.text;
376
+ });
377
+ } else return null;
378
+ return {calls, text};
379
+ }
380
+
381
+ function describe(s) {
382
+ if (s.error) return `<span>${esc(s.error)}</span>`;
383
+ if (s.type === "tool") {
384
+ return `<div><span class="verb">Called with</span><code>${esc(clip(argsText(s.input), 300))}</code></div>
385
+ <div><span class="verb">Returned</span><code>${esc(clip(compact(s.output)))}</code></div>`;
386
+ }
387
+ const p = llmParts(s.output);
388
+ if (!p) return esc(s.summary);
389
+ const lines = [];
390
+ if (p.text) lines.push(`<div><span class="verb">Replied</span>${esc(clip(p.text))}</div>`);
391
+ p.calls.forEach(c => lines.push(`<div><span class="verb">Decided to call</span><code>${esc(clip(c, 300))}</code></div>`));
392
+ return lines.join("") || '<span class="quiet">No reply</span>';
393
+ }
394
+
395
+ function inputText(input) {
396
+ if (!input) return "";
397
+ const args = input.args || [], kw = input.kwargs || {};
398
+ if (args.length === 1 && !Object.keys(kw).length && typeof args[0] === "string") return args[0];
399
+ return argsText(input);
400
+ }
401
+
402
+ /* ---------- run view ---------- */
403
+
404
+ const go = id => { location.hash = "run=" + id; };
405
+
406
+ function stepHtml(s, run) {
407
+ const isFork = run.parent_run_id && s.seq === run.fork_step;
408
+ const cls = [s.type, s.cached && "cached", s.error && "error", isFork && "forkpoint"].filter(Boolean).join(" ");
409
+ const tokens = s.tokens_in != null ? `<span>${fmtNum(s.tokens_in)} in / ${fmtNum(s.tokens_out)} out tokens</span>` : "";
410
+ return `<li class="step ${cls}" id="step-${s.seq}">
411
+ <div class="rail"><span class="pin"></span></div>
412
+ <div class="body">
413
+ <div class="head">
414
+ <button class="toggle" aria-expanded="false" aria-controls="det-${s.seq}" data-toggle="${s.seq}" title="Show full input and output">
415
+ <span class="seq">${s.seq}</span><span class="kind">${s.type === "llm" ? "LLM" : "tool"}</span>
416
+ <span class="sname">${esc(s.name)}</span>
417
+ <svg class="chev" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 1.5 6.5 5 3 8.5" stroke="currentColor" stroke-width="1.6" fill="none"/></svg>
418
+ </button>
419
+ <span class="facts"><span>${fmtMs(s.latency_ms)}</span>${tokens}</span>
420
+ ${s.cached ? '<span class="tag" title="Replayed from the original run without calling the API">from cache</span>' : ""}
421
+ ${isFork ? '<span class="tag forked">changed in this fork</span>' : ""}
422
+ ${s.error ? '<span class="tag err">error</span>' : ""}
423
+ <span class="actions">
424
+ <button class="btn small" data-action="fork" data-seq="${s.seq}" title="Rerun from this step with one change">Try a change</button>
425
+ <button class="btn small" data-action="sweep" data-seq="${s.seq}" title="Run several changes at this step a few times each and compare">Test several</button>
426
+ </span>
427
+ </div>
428
+ <div class="summary ${s.error ? "err" : ""}">${describe(s)}</div>
429
+ <div class="details" id="det-${s.seq}" hidden>
430
+ <div><h4>Full input</h4><pre>${esc(JSON.stringify(s.input, null, 2))}</pre></div>
431
+ <div><h4>Full output</h4><pre>${esc(JSON.stringify(s.output, null, 2))}</pre></div>
432
+ </div>
433
+ <div class="slot"></div>
434
+ </div></li>`;
435
+ }
436
+
437
+ async function showRun(id) {
438
+ current = id;
439
+ revealInTree(id);
440
+ renderTree();
441
+ try { detail = await api("/api/runs/" + id); }
442
+ catch (err) { $("#main").innerHTML = `<div class="empty"><h2>Couldn't open run ${esc(short(id))}</h2><p>${esc(err.message)}</p></div>`; return; }
443
+ const r = detail.run;
444
+ current = r.id;
445
+ renderTree();
446
+ const cached = detail.steps.filter(s => s.cached).length;
447
+ const asked = inputText(detail.input);
448
+ const compareBtn = r.parent_run_id ? `<button class="btn small" data-cmp="${r.parent_run_id},${r.id}">Compare with original</button>` : "";
449
+ const banner = flash === r.id
450
+ ? `<div class="flash" role="status"><span>Your fork finished. Check the final output at the bottom, or compare it step by step with the original run.</span>${compareBtn}</div>` : "";
451
+ flash = null;
452
+ $("#main").innerHTML = `<div class="wrap">
453
+ ${showGuide ? GUIDE : ""}
454
+ ${banner}
455
+ <div class="run-title"><h1>${esc(r.name)}</h1><span class="mono quiet">${r.id}</span>
456
+ ${r.status !== "ok" ? `<span class="state ${r.status}">${esc(r.status)}</span>` : ""}</div>
457
+ <div class="lineage">${r.parent_run_id
458
+ ? `<span>Fork of <button class="link mono" data-goto="${r.parent_run_id}">${short(r.parent_run_id)}</button> with a change at step ${r.fork_step}. Steps before it were replayed from cache.</span>${banner ? "" : compareBtn}`
459
+ : `<span>Original run, started ${r.started}.</span>`}</div>
460
+ ${asked ? `<div class="asked"><h3>Input</h3><p>${esc(clip(asked, 1200))}</p></div>` : ""}
461
+ <dl class="stats">
462
+ <div><dt>steps</dt><dd>${r.n_steps}</dd></div>
463
+ <div><dt>tokens</dt><dd>${fmtNum(r.tokens)}</dd></div>
464
+ <div><dt>duration</dt><dd>${r.duration}</dd></div>
465
+ <div><dt>from cache</dt><dd>${cached}</dd></div>
466
+ </dl>
467
+ <div class="legend">
468
+ <span><svg width="12" height="12"><circle cx="6" cy="6" r="5" fill="var(--llm)"/></svg>LLM call</span>
469
+ <span><svg width="12" height="12"><circle cx="6" cy="6" r="5" fill="var(--tool)"/></svg>tool call</span>
470
+ <span><svg width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="var(--ink-3)" stroke-width="1.6"/></svg>replayed from cache</span>
471
+ </div>
472
+ <ol class="timeline">
473
+ ${detail.steps.map(s => stepHtml(s, r)).join("")}
474
+ <li class="step final"><div class="rail"><span class="pin"></span></div>
475
+ <div class="body"><h3>Final output</h3>
476
+ <div class="out">${esc(fmtOut(detail.output))}</div></div></li>
477
+ </ol></div>`;
478
+ $("#main").scrollTop = 0;
479
+ }
480
+
481
+ $("#main").addEventListener("click", e => {
482
+ if (e.target.closest("[data-guide-close]")) { setGuide(false); return; }
483
+ const t = e.target.closest("[data-goto],[data-action],[data-toggle],[data-cmp]");
484
+ if (!t) return;
485
+ if (t.dataset.goto) { go(t.dataset.goto); return; }
486
+ if (t.dataset.cmp) { location.hash = "compare=" + t.dataset.cmp; return; }
487
+ if (t.dataset.action) { openPanel(t.dataset.action, +t.dataset.seq); return; }
488
+ const det = document.getElementById("det-" + t.dataset.toggle);
489
+ det.hidden = !det.hidden;
490
+ t.setAttribute("aria-expanded", String(!det.hidden));
491
+ });
492
+
493
+ /* ---------- try a change (fork) & test several (sweep) ---------- */
494
+
495
+ function currentSystem(input) {
496
+ const kw = (input && input.kwargs) || {};
497
+ if (typeof kw.system === "string") return kw.system;
498
+ if (Array.isArray(kw.system)) return kw.system.map(b => b.text || "").join("\n");
499
+ const m = (kw.messages || []).find(m => m.role === "system");
500
+ return m && typeof m.content === "string" ? m.content : "";
501
+ }
502
+
503
+ const field = (name, label, hint, value, rows) => `<div class="field"><label for="f-${name}">${label}</label>
504
+ ${rows ? `<textarea id="f-${name}" name="${name}" rows="${rows}">${esc(value)}</textarea>` : `<input id="f-${name}" name="${name}" value="${esc(value)}">`}
505
+ ${hint ? `<small>${hint}</small>` : ""}</div>`;
506
+
507
+ function closePanels() {
508
+ document.querySelectorAll(".slot").forEach(s => { s.innerHTML = ""; delete s.dataset.kind; });
509
+ }
510
+
511
+ function openPanel(kind, seq) {
512
+ const s = detail.steps.find(x => x.seq === seq);
513
+ const slot = document.querySelector(`#step-${seq} .slot`);
514
+ const wasOpen = slot.dataset.kind === kind;
515
+ closePanels();
516
+ if (wasOpen) return;
517
+ slot.dataset.kind = kind;
518
+ const isTool = s.type === "tool";
519
+ const sys = isTool ? "" : currentSystem(s.input);
520
+ const model = (s.input && s.input.kwargs && s.input.kwargs.model) || s.name;
521
+ const replay = seq > 1 ? `Steps 1 to ${seq - 1} replay from cache for free. ` : "";
522
+ const close = `<button class="link" type="button" data-close>Cancel</button>`;
523
+
524
+ if (kind === "fork") {
525
+ slot.innerHTML = `<form class="panel" novalidate>
526
+ <div class="panel-head"><h3>Try a change at step ${seq}</h3>${close}</div>
527
+ <p>${replay}Step ${seq} runs with your change, and every step after it runs live, so you see how the rest of the run reacts.</p>
528
+ ${isTool
529
+ ? field("output", `What should <code>${esc(s.name)}</code> return instead?`, "This is what it returned in the original run. Edit it to pretend the tool gave a different answer.", fmtOut(s.output), 6)
530
+ : field("model", "Model", `The original run used <code>${esc(model)}</code>. A new model applies to this and every later LLM call.`, model) +
531
+ field("system", "System prompt", "This is the original prompt. Edit it to try new instructions.", sys, 5)}
532
+ <div class="go"><button class="btn primary" type="submit">Run with this change</button><span class="msg"></span></div></form>`;
533
+ const form = slot.querySelector("form");
534
+ form.onsubmit = ev => {
535
+ ev.preventDefault();
536
+ const body = {run_id: current, step: seq};
537
+ const v = n => form.elements[n].value;
538
+ if (isTool) { if (v("output") !== fmtOut(s.output)) body.output = v("output"); }
539
+ else {
540
+ if (v("model").trim() && v("model").trim() !== model) body.model = v("model").trim();
541
+ if (v("system") !== sys) body.system = v("system");
542
+ }
543
+ if (!body.output && !body.model && body.system === undefined)
544
+ return say(form, "Nothing has changed yet. Edit a field above, then run it.", true);
545
+ run(form, "Running your change", async () => {
546
+ const res = await api("/api/fork", body);
547
+ flash = res.run_id;
548
+ await loadRuns();
549
+ go(res.run_id);
550
+ });
551
+ };
552
+ } else {
553
+ const kinds = isTool ? [["output", "Tool result"]] : [["model", "Model"], ["system", "System prompt"]];
554
+ slot.innerHTML = `<form class="panel" novalidate>
555
+ <div class="panel-head"><h3>Test several changes at step ${seq}</h3>${close}</div>
556
+ <p>Each change runs a few times, and you get a table showing which one passes most often. The original run is always included as the first row, so you can see whether a change is actually better.</p>
557
+ ${kinds.length > 1 ? `<div class="field"><span class="lbl">What do you want to change?</span><div class="seg" role="group">${kinds.map(([v, t], i) =>
558
+ `<button type="button" data-kind="${v}" aria-pressed="${i === 0}">${t}</button>`).join("")}</div></div>` : ""}
559
+ <div class="field"><span class="lbl vtitle"></span><small class="vhint"></small><div class="variants"></div>
560
+ <div><button class="link" type="button" data-add>Add another</button></div></div>
561
+ <div class="cols">
562
+ ${field("repeat", "Runs per change", "", "3")}
563
+ ${field("expect", "A run passes if the final output contains", "For example the correct number or a word the answer must include. Leave blank to skip scoring.", "")}
564
+ </div>
565
+ <div class="go"><button class="btn primary" type="submit">Run the test</button><span class="cost"></span><span class="msg"></span></div>
566
+ <div class="results"></div></form>`;
567
+ const form = slot.querySelector("form");
568
+ form.elements.expect.placeholder = "e.g. 49.99";
569
+ form.elements.repeat.type = "number"; form.elements.repeat.min = 1; form.elements.repeat.max = 10;
570
+ const original = {model, system: sys, output: compact(s.output)};
571
+ const titles = {model: "Models to try", system: "System prompts to try", output: "Tool results to try"};
572
+ const firstValue = {model: "", system: sys, output: compact(s.output)};
573
+ const hints = {model: `The original model, ${model}, is always tested too. Add the models you want to compare it with.`,
574
+ system: "Change 1 starts as a copy of the original prompt. Edit it, or add more versions below. The original is always tested too.",
575
+ output: "Change 1 starts as a copy of what the tool returned. Edit it to pretend the tool gave a different answer."};
576
+ const box = form.querySelector(".variants");
577
+ let k = kinds[0][0];
578
+
579
+ const values = () => [...box.querySelectorAll("[data-v]")].map(el => el.value).filter(v => v.trim());
580
+ const updateCost = () => {
581
+ const n = values().length * Math.max(1, Math.min(10, +form.elements.repeat.value || 1));
582
+ form.querySelector(".cost").textContent = n ? `${plural(n, "run")} from step ${seq} onward, using your real API keys` : "";
583
+ };
584
+ const addVariant = (value = "") => {
585
+ const i = box.children.length + 1;
586
+ const input = k === "model"
587
+ ? `<input data-v value="${esc(value)}" placeholder="${i === 1 ? "e.g. gpt-4o" : "another model"}">`
588
+ : `<textarea data-v rows="${k === "system" ? 3 : 2}" placeholder="${k === "system" ? "Write a new system prompt" : "A different tool result"}">${esc(value)}</textarea>`;
589
+ box.insertAdjacentHTML("beforeend", `<div class="variant field"><span class="vn">Change ${i}</span>${input}<button class="link" type="button" data-remove>Remove</button></div>`);
590
+ updateCost();
591
+ };
592
+ const renumber = () => box.querySelectorAll(".vn").forEach((el, i) => el.textContent = `Change ${i + 1}`);
593
+ const setKind = nk => {
594
+ k = nk;
595
+ form.querySelectorAll(".seg button").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.kind === k)));
596
+ form.querySelector(".vtitle").textContent = titles[k];
597
+ form.querySelector(".vhint").textContent = hints[k];
598
+ box.innerHTML = "";
599
+ addVariant(firstValue[k]);
600
+ if (k === "model") addVariant();
601
+ };
602
+ setKind(k);
603
+ form.addEventListener("input", updateCost);
604
+ form.addEventListener("click", e => {
605
+ if (e.target.dataset.kind) setKind(e.target.dataset.kind);
606
+ if (e.target.dataset.add !== undefined) { addVariant(); box.lastElementChild.querySelector("[data-v]").focus(); }
607
+ if (e.target.dataset.remove !== undefined) {
608
+ if (box.children.length > 1) e.target.closest(".variant").remove(); else e.target.closest(".variant").querySelector("[data-v]").value = "";
609
+ renumber(); updateCost();
610
+ }
611
+ });
612
+ form.onsubmit = ev => {
613
+ ev.preventDefault();
614
+ const vals = values();
615
+ if (!vals.length) return say(form, "Add at least one change to test.", true);
616
+ const same = vals.findIndex(v => v.trim() === String(original[k]).trim());
617
+ if (same >= 0) return say(form, `Change ${same + 1} is the same as the original. Edit it or remove it.`, true);
618
+ const repeat = Math.max(1, Math.min(10, +form.elements.repeat.value || 1));
619
+ const expect = form.elements.expect.value.trim() || null;
620
+ run(form, `Running ${plural(vals.length * repeat, "run")}`, async () => {
621
+ const res = await api("/api/sweep", {run_id: current, step: seq, kind: k, values: vals, repeat, expect});
622
+ form.querySelector(".results").innerHTML = sweepHtml(res);
623
+ say(form, "");
624
+ loadRuns();
625
+ });
626
+ };
627
+ }
628
+ slot.querySelector("[data-close]").onclick = closePanels;
629
+ const first = slot.querySelector("textarea,input:not([type=number])");
630
+ if (first) first.focus();
631
+ }
632
+
633
+ function say(form, text, bad) {
634
+ const m = form.querySelector(".msg");
635
+ m.textContent = text;
636
+ m.className = "msg" + (bad ? " err" : "");
637
+ }
638
+
639
+ async function run(form, label, fn) {
640
+ const btn = form.querySelector("[type=submit]");
641
+ btn.disabled = true;
642
+ const t0 = Date.now();
643
+ const tick = () => say(form, `${label}… ${Math.round((Date.now() - t0) / 1000)}s`);
644
+ tick();
645
+ const timer = setInterval(tick, 1000);
646
+ try { await fn(); } catch (err) { say(form, err.message, true); }
647
+ finally { clearInterval(timer); btn.disabled = false; }
648
+ }
649
+
650
+ function sweepHtml(res) {
651
+ const groups = res.groups;
652
+ const hasExpect = groups.some(g => g.passed !== null);
653
+ const rate = g => g.passed / g.runs;
654
+ let best = null;
655
+ if (hasExpect) {
656
+ best = groups.slice(1).reduce((b, g) => !b || rate(g) > rate(b) || (rate(g) === rate(b) && g.avg_tokens < b.avg_tokens) ? g : b, null);
657
+ if (best && rate(best) <= rate(groups[0])) best = null;
658
+ }
659
+ const label = (g, i) => i === 0 ? "Original" : g.label.startsWith("model=") ? g.label.slice(6) : `Change ${i}`;
660
+ const dots = g => `<span class="dots" aria-label="${g.passed} of ${g.runs} passed">${
661
+ Array.from({length: g.runs}, (_, i) => `<i class="${i < g.passed ? "p" : ""}"></i>`).join("")}<b>${g.passed}/${g.runs}</b></span>`;
662
+ const rows = groups.map((g, i) => `<tr class="${g === best ? "best" : ""}">
663
+ <td><div class="vlabel">${esc(label(g, i))}</div>${g.value ? `<div class="vvalue">${esc(clip(g.value, 160))}</div>` : ""}</td>
664
+ ${hasExpect ? `<td>${dots(g)}</td>` : ""}
665
+ <td class="num">${g.ok}/${g.runs}</td>
666
+ <td class="num">${fmtNum(g.avg_tokens)}</td>
667
+ <td class="num">${g.avg_time_s}s</td>
668
+ <td class="sample">${esc(g.sample)}</td>
669
+ <td>${g.run_ids.map(id => `<button class="link mono" data-goto="${id}">${short(id)}</button>`).join(" ")}</td></tr>`).join("");
670
+ const bi = groups.indexOf(best);
671
+ const verdict = !hasExpect
672
+ ? `<p class="verdict quiet">Add pass text next time to get a pass rate for each change.</p>`
673
+ : best
674
+ ? `<p class="verdict"><b>${esc(label(best, bi))}</b> passed ${best.passed} of ${best.runs} runs, compared with ${groups[0].passed} of ${groups[0].runs} for the original.${best.run_ids.length ? ` <button class="link" data-goto="${best.run_ids[0]}">Open one of its runs</button>` : ""}</p>`
675
+ : `<p class="verdict">None of the changes passed more often than the original. Try different changes, or test a different step.</p>`;
676
+ return `<h4>Results</h4><table><thead><tr><th>Change</th>${hasExpect ? "<th>Passed</th>" : ""}<th>Finished without error</th><th>Avg tokens</th><th>Avg time</th><th>Example output</th><th>Runs</th></tr></thead>
677
+ <tbody>${rows}</tbody></table>${verdict}`;
678
+ }
679
+
680
+ /* ---------- compare ---------- */
681
+
682
+ async function compare(a, b) {
683
+ current = null;
684
+ selected = new Set([a, b]);
685
+ renderTree();
686
+ let A, B;
687
+ try { [A, B] = await Promise.all([api("/api/runs/" + a), api("/api/runs/" + b)]); }
688
+ catch (err) { $("#main").innerHTML = `<div class="empty"><h2>Couldn't compare these runs</h2><p>${esc(err.message)}</p></div>`; return; }
689
+ const by = d => Object.fromEntries(d.steps.map(s => [s.seq, s]));
690
+ const sa = by(A), sb = by(B);
691
+ const seqs = [...new Set([...A.steps, ...B.steps].map(s => s.seq))].sort((x, y) => x - y);
692
+ const same = q => sa[q] && sb[q] && sa[q].name === sb[q].name && sa[q].summary === sb[q].summary;
693
+ const first = seqs.find(q => !same(q));
694
+ const cell = s => s ? `<div class="k"><span class="step ${s.type}"><span class="kind">${s.type === "llm" ? "LLM" : "tool"}</span></span><b>${esc(s.name)}</b>${s.cached ? '<span class="tag">from cache</span>' : ""}</div><div class="summary" style="margin:0">${describe(s)}</div>` : '<span class="quiet">No step</span>';
695
+ const head = r => `<button class="link mono" data-goto="${r.run.id}">${short(r.run.id)}</button> <span class="quiet">${r.run.parent_run_id ? "fork" : "original"}</span>`;
696
+ const outSame = JSON.stringify(A.output) === JSON.stringify(B.output);
697
+ $("#main").innerHTML = `<div class="wrap">
698
+ <div class="run-title"><h1>Comparing two runs</h1></div>
699
+ <p class="diverge">${first === undefined
700
+ ? (outSame ? "Every step and the final output are the same." : "Every step is the same, but the final outputs differ.")
701
+ : first === 1 ? "The runs differ from the very first step. Highlighted rows show where they differ."
702
+ : `The runs are the same up to step ${first - 1} and first differ at step ${first}. Highlighted rows show where they differ.`}</p>
703
+ <table class="cmp"><thead><tr><th>Step</th><th>${head(A)}</th><th>${head(B)}</th></tr></thead><tbody>
704
+ ${seqs.map(q => `<tr class="${same(q) ? "same" : "diff"} ${q === first ? "first" : ""}"><td>${q}</td><td>${cell(sa[q])}</td><td>${cell(sb[q])}</td></tr>`).join("")}
705
+ <tr class="${outSame ? "same" : "diff"}"><td>Final</td><td>${esc(fmtOut(A.output))}</td><td>${esc(fmtOut(B.output))}</td></tr>
706
+ </tbody></table></div>`;
707
+ }
708
+
709
+ /* ---------- routing ---------- */
710
+
711
+ function emptyMain() {
712
+ $("#main").innerHTML = `<div class="wrap"><div class="empty"><h2>Record your first run</h2>
713
+ <p>agentfork shows runs of your own agent. Three changes to your code are enough:</p>
714
+ <ol>
715
+ <li>Wrap your LLM client so every call is recorded.<pre>from agentfork import trace, tool, wrap_openai
716
+ client = wrap_openai(OpenAI()) # or wrap_anthropic(Anthropic())</pre></li>
717
+ <li>Mark each tool function with <code>@tool</code> and your agent's main function with <code>@trace</code>.<pre>@tool
718
+ def lookup_order(order_id): ...
719
+
720
+ @trace
721
+ def run_agent(question): ...</pre></li>
722
+ <li>Run your agent as usual, then refresh this page. Runs are saved in <code>.agentfork/</code> in the folder you ran it from, so start <code>agentfork ui</code> from that same folder.</li>
723
+ </ol></div></div>`;
724
+ }
725
+
726
+ function route() {
727
+ const h = decodeURIComponent(location.hash.slice(1));
728
+ if (h.startsWith("run=")) return showRun(h.slice(4));
729
+ if (h.startsWith("compare=")) { const [a, b] = h.slice(8).split(","); return compare(a, b); }
730
+ if (runs.length) return go(runs[0].id);
731
+ emptyMain();
732
+ }
733
+
734
+ window.addEventListener("hashchange", route);
735
+ loadRuns().then(route).catch(err => { $("#main").innerHTML = `<div class="empty"><h2>Couldn't load runs</h2><p>${esc(err.message)}</p></div>`; });
736
+ </script>
737
+ </body>
738
+ </html>
@@ -0,0 +1,38 @@
1
+ from fastapi.testclient import TestClient
2
+
3
+ from agentfork import replay, store
4
+ from agentfork.server import app
5
+
6
+ AGENT = '''
7
+ import agentfork
8
+
9
+ @agentfork.tool
10
+ def lookup(city):
11
+ return "cloudy"
12
+
13
+ @agentfork.trace
14
+ def agent(city):
15
+ return f"{city} is {lookup(city)}"
16
+ '''
17
+
18
+ def test_ui_endpoints(tmp_path, monkeypatch):
19
+ monkeypatch.setenv("AGENTFORK_DIR", str(tmp_path / "db"))
20
+ path = tmp_path / "toy_agent.py"
21
+ path.write_text(AGENT)
22
+ replay.load_entry({"entry_file": str(path), "entry_name": "agent"})("paris")
23
+ run_id = store.list_runs(1)[0]["id"]
24
+ client = TestClient(app)
25
+
26
+ assert "agentfork" in client.get("/").text
27
+ assert client.get("/api/runs").json()[0]["id"] == run_id
28
+ detail = client.get(f"/api/runs/{run_id[:8]}").json()
29
+ assert detail["steps"][0]["summary"].endswith("-> cloudy")
30
+ assert detail["run"]["input_preview"] == "paris"
31
+ assert detail["run"]["output_preview"] == "paris is cloudy"
32
+
33
+ forked = client.post("/api/fork", json={"run_id": run_id, "step": 1, "output": "sunny"}).json()
34
+ assert client.get(f"/api/runs/{forked['run_id']}").json()["output"] == "paris is sunny"
35
+
36
+ sweep = client.post("/api/sweep", json={"run_id": run_id, "step": 1, "kind": "output",
37
+ "values": ["sunny", "rainy"], "repeat": 2, "expect": "sunny"}).json()
38
+ assert [g["passed"] for g in sweep["groups"]] == [0, 2, 0]
File without changes
File without changes