quickstarted 0.2.0__py3-none-any.whl

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.
quickstarted/run.py ADDED
@@ -0,0 +1,240 @@
1
+ """Run orchestration: setup, agent, deterministic scoring, classification.
2
+
3
+ Pass/fail is decided by the journey's success script exit code, run by the
4
+ harness after the agent stops. The agent's own opinion of whether it finished
5
+ is recorded but never trusted for scoring.
6
+
7
+ A verdict alone is not enough to publish, though. "FAIL" has to distinguish a
8
+ documentation gap from a network flake, an exhausted budget, or a bug in this
9
+ harness, because a benchmark that reports infrastructure noise as a docs
10
+ failure is worse than no benchmark. That is what `Classification` is for.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from dataclasses import dataclass
17
+
18
+ from .agents.base import Agent, AgentOutcome, Toolbelt
19
+ from .docs import DocsClient, affordance_summary
20
+ from .exec import ExecutorError, make_executor, needs_host_proxy, resolve_backend
21
+ from .journey import Journey
22
+ from .net.proxy import EgressProxy
23
+ from .trace import Trace
24
+
25
+ #: Only PASS and DOCS_GAP are statements about the documentation. Everything
26
+ #: else says the run did not produce evidence, and belongs outside the
27
+ #: numerator and the denominator of a pass rate.
28
+ PASSED = "passed"
29
+ DOCS_GAP = "docs_gap"
30
+ BUDGET_EXHAUSTED = "budget_exhausted"
31
+ INFRA_ERROR = "infra_error"
32
+ HARNESS_ERROR = "harness_error"
33
+ AGENT_REFUSAL = "agent_refusal"
34
+
35
+ EVIDENTIAL = (PASSED, DOCS_GAP)
36
+
37
+ _INFRA_MARKERS = (
38
+ "connection error",
39
+ "api error 429",
40
+ "api error 500",
41
+ "api error 502",
42
+ "api error 503",
43
+ "api error 529",
44
+ "overloaded",
45
+ "rate limit",
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ScoreResult:
51
+ passed: bool
52
+ exit_code: int
53
+ output: str
54
+
55
+
56
+ @dataclass
57
+ class RunResult:
58
+ journey: Journey
59
+ agent_name: str
60
+ outcome: AgentOutcome
61
+ score: ScoreResult | None
62
+ trace: Trace
63
+ duration: float
64
+ sandbox_path: str
65
+ backend: str = "local"
66
+ enforced: bool = False
67
+ classification: str = HARNESS_ERROR
68
+ attempt: int = 1
69
+ model_reported: str = ""
70
+ affordance_policy: str = "all"
71
+
72
+ @property
73
+ def passed(self) -> bool:
74
+ return bool(self.score and self.score.passed)
75
+
76
+ @property
77
+ def evidential(self) -> bool:
78
+ """True when this run says something about the documentation."""
79
+ return self.classification in EVIDENTIAL
80
+
81
+ @property
82
+ def suspect_page(self) -> str | None:
83
+ if self.passed:
84
+ return None
85
+ return self.trace.last_fetch_before_failure()
86
+
87
+ @property
88
+ def bypass_attempts(self) -> int:
89
+ return len(
90
+ [
91
+ e
92
+ for e in self.trace.of_type("egress_blocked")
93
+ if e.data.get("reason") == "docs_host_requires_read_docs"
94
+ ]
95
+ )
96
+
97
+
98
+ def classify(outcome: AgentOutcome, score: ScoreResult | None, trace: Trace) -> str:
99
+ if score and score.passed:
100
+ return PASSED
101
+ reason = outcome.stop_reason
102
+ detail = (outcome.detail or "").lower()
103
+ if reason == "refusal":
104
+ return AGENT_REFUSAL
105
+ if reason == "error":
106
+ if any(marker in detail for marker in _INFRA_MARKERS):
107
+ return INFRA_ERROR
108
+ return HARNESS_ERROR
109
+ if reason in ("max_turns", "timeout", "token_budget"):
110
+ return BUDGET_EXHAUSTED
111
+ # A run whose shell could not reach a package registry has not tested the
112
+ # documentation; it has tested the network.
113
+ if trace.of_type("egress_error"):
114
+ return INFRA_ERROR
115
+ blocked = trace.of_type("egress_blocked")
116
+ # Documented commands that died because our own policy refused their
117
+ # traffic say the journey's network allowlist is wrong, not the docs.
118
+ if blocked and reason == "command_failed":
119
+ return HARNESS_ERROR
120
+ if any(e.data.get("reason") == "not_allowlisted" for e in blocked):
121
+ return HARNESS_ERROR
122
+ return DOCS_GAP
123
+
124
+
125
+ def run_journey(
126
+ journey: Journey,
127
+ agent: Agent,
128
+ keep_sandbox: bool = False,
129
+ http_get=None,
130
+ backend: str = "auto",
131
+ image: str | None = None,
132
+ attempt: int = 1,
133
+ docs: DocsClient | None = None,
134
+ probe_affordances: bool = False,
135
+ ) -> RunResult:
136
+ backend = resolve_backend(backend)
137
+ docs = docs or DocsClient()
138
+ trace = Trace()
139
+ proxy: EgressProxy | None = None
140
+ start = time.monotonic()
141
+
142
+ if needs_host_proxy(backend):
143
+ proxy = EgressProxy(
144
+ network_allow=journey.network_allow,
145
+ docs_hosts=journey.docs_allow,
146
+ explicit_allow=journey.network_explicit,
147
+ trace=trace,
148
+ )
149
+ proxy.start()
150
+
151
+ try:
152
+ executor = make_executor(
153
+ backend,
154
+ keep=keep_sandbox,
155
+ proxy_url=proxy.url if proxy else None,
156
+ network_allow=journey.network_allow,
157
+ docs_hosts=journey.docs_allow,
158
+ image=image,
159
+ trace=trace,
160
+ )
161
+ except ExecutorError as exc:
162
+ if proxy:
163
+ proxy.stop()
164
+ trace.add("run_end", stop_reason="error", passed=False)
165
+ return RunResult(
166
+ journey, agent.name, AgentOutcome("error", 0, str(exc)), None, trace,
167
+ time.monotonic() - start, "", backend, False, HARNESS_ERROR, attempt,
168
+ )
169
+
170
+ trace.add(
171
+ "run_start",
172
+ journey=journey.name,
173
+ agent=agent.name,
174
+ backend=backend,
175
+ enforced=executor.enforced,
176
+ attempt=attempt,
177
+ affordance_policy=docs.affordances,
178
+ )
179
+
180
+ if probe_affordances:
181
+ # Recorded as context for whoever reads a failure, and as the variable
182
+ # for an ablation. Never part of the score.
183
+ trace.add(
184
+ "affordances", entrypoint=journey.docs_entrypoint,
185
+ found=affordance_summary(docs.probe(journey.docs_entrypoint)),
186
+ )
187
+
188
+ def finish(outcome: AgentOutcome, score: ScoreResult | None) -> RunResult:
189
+ classification = classify(outcome, score, trace)
190
+ trace.add(
191
+ "run_end",
192
+ stop_reason=outcome.stop_reason,
193
+ passed=bool(score and score.passed),
194
+ classification=classification,
195
+ )
196
+ return RunResult(
197
+ journey, agent.name, outcome, score, trace, time.monotonic() - start,
198
+ str(executor.root), backend, executor.enforced, classification, attempt,
199
+ getattr(agent, "model_reported", ""), docs.affordances,
200
+ )
201
+
202
+ try:
203
+ for command in journey.setup:
204
+ result = executor.run(
205
+ command,
206
+ timeout=journey.budgets.max_command_seconds,
207
+ max_output_chars=journey.budgets.max_output_chars,
208
+ )
209
+ trace.add(
210
+ "setup", command=command, exit_code=result.exit_code,
211
+ output=result.output,
212
+ )
213
+ if result.exit_code != 0:
214
+ return finish(
215
+ AgentOutcome("error", 0, f"setup command failed: {command}"), None
216
+ )
217
+
218
+ toolbelt = Toolbelt(journey, executor, trace, docs=docs, http_get=http_get)
219
+ deadline = time.monotonic() + journey.budgets.max_seconds
220
+ outcome = agent.run(journey, toolbelt, deadline)
221
+
222
+ check = executor.run(
223
+ journey.success_script,
224
+ timeout=journey.budgets.max_command_seconds,
225
+ max_output_chars=journey.budgets.max_output_chars,
226
+ )
227
+ score = ScoreResult(
228
+ passed=check.exit_code == 0,
229
+ exit_code=check.exit_code,
230
+ output=check.output,
231
+ )
232
+ trace.add(
233
+ "success_check", exit_code=check.exit_code, passed=score.passed,
234
+ output=check.output,
235
+ )
236
+ return finish(outcome, score)
237
+ finally:
238
+ executor.cleanup()
239
+ if proxy:
240
+ proxy.stop()
@@ -0,0 +1,15 @@
1
+ """Backwards-compatible aliases for the pre-executor API.
2
+
3
+ `Sandbox` was the only execution backend in v0. It is now `LocalExecutor`, one
4
+ of three, and the least safe: see `quickstarted.exec`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .exec.base import CommandResult, truncate
10
+ from .exec.local import LocalExecutor
11
+
12
+ #: Deprecated alias. Prefer `quickstarted.exec.make_executor`.
13
+ Sandbox = LocalExecutor
14
+
15
+ __all__ = ["CommandResult", "LocalExecutor", "Sandbox", "truncate"]
quickstarted/suite.py ADDED
@@ -0,0 +1,203 @@
1
+ """Running many journeys many times, and reporting rates rather than verdicts.
2
+
3
+ A single agent run is a sample, not a measurement. The same docs and the same
4
+ model can pass at 10:00 and fail at 10:05, so a benchmark that publishes one
5
+ run per project is publishing noise with a confident face on it.
6
+
7
+ Two rules follow, and both are enforced here rather than left to the reader:
8
+
9
+ * A pass *rate* is computed over evidential runs only. Runs that died on a
10
+ 429, exhausted their budget, or hit a harness bug are excluded from the
11
+ numerator and the denominator alike, and reported separately, because they
12
+ are not evidence about the documentation either way.
13
+ * Nothing is aggregated across models. Pass rates for different models are
14
+ different measurements that happen to share a journey.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from collections.abc import Sequence
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import dataclass, field
23
+ from typing import Callable
24
+
25
+ from .agents.base import Agent
26
+ from .exec import resolve_backend
27
+ from .journey import Journey
28
+ from .pricing import PriceBook
29
+ from .run import EVIDENTIAL, PASSED, RunResult, run_journey
30
+
31
+
32
+ @dataclass
33
+ class JourneyStats:
34
+ journey: str
35
+ agent: str
36
+ runs: list[RunResult] = field(default_factory=list)
37
+
38
+ @property
39
+ def attempts(self) -> int:
40
+ return len(self.runs)
41
+
42
+ @property
43
+ def evidential(self) -> list[RunResult]:
44
+ return [r for r in self.runs if r.classification in EVIDENTIAL]
45
+
46
+ @property
47
+ def passes(self) -> int:
48
+ return len([r for r in self.runs if r.classification == PASSED])
49
+
50
+ @property
51
+ def pass_rate(self) -> float | None:
52
+ """None when no run produced evidence: an honest 'we do not know'."""
53
+ usable = self.evidential
54
+ if not usable:
55
+ return None
56
+ return self.passes / len(usable)
57
+
58
+ @property
59
+ def discarded(self) -> dict[str, int]:
60
+ counts: dict[str, int] = {}
61
+ for run in self.runs:
62
+ if run.classification not in EVIDENTIAL:
63
+ counts[run.classification] = counts.get(run.classification, 0) + 1
64
+ return counts
65
+
66
+ @property
67
+ def models_seen(self) -> list[str]:
68
+ return sorted({r.model_reported for r in self.runs if r.model_reported})
69
+
70
+ def tokens(self) -> dict[str, int]:
71
+ totals = {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}
72
+ for run in self.runs:
73
+ totals["input"] += run.outcome.input_tokens
74
+ totals["output"] += run.outcome.output_tokens
75
+ totals["cache_write"] += run.outcome.cache_write_tokens
76
+ totals["cache_read"] += run.outcome.cache_read_tokens
77
+ return totals
78
+
79
+ def cost(self, prices: PriceBook) -> float | None:
80
+ if not prices:
81
+ return None
82
+ total = 0.0
83
+ seen = False
84
+ for run in self.runs:
85
+ model = run.model_reported or run.agent_name
86
+ estimate = prices.estimate(model, run.outcome)
87
+ if estimate is not None:
88
+ total += estimate
89
+ seen = True
90
+ return total if seen else None
91
+
92
+ @property
93
+ def suspect_pages(self) -> dict[str, int]:
94
+ """Which docs page failing runs were last on, most common first."""
95
+ counts: dict[str, int] = {}
96
+ for run in self.runs:
97
+ if run.classification == PASSED:
98
+ continue
99
+ page = run.suspect_page
100
+ if page:
101
+ counts[page] = counts.get(page, 0) + 1
102
+ return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
103
+
104
+
105
+ @dataclass
106
+ class SuiteResult:
107
+ stats: list[JourneyStats] = field(default_factory=list)
108
+ duration: float = 0.0
109
+ repeat: int = 1
110
+ backend: str = ""
111
+
112
+ @property
113
+ def runs(self) -> list[RunResult]:
114
+ return [r for s in self.stats for r in s.runs]
115
+
116
+ @property
117
+ def all_passed(self) -> bool:
118
+ """CI gate: every journey passed every attempt that produced evidence."""
119
+ for stat in self.stats:
120
+ if stat.pass_rate is None or stat.pass_rate < 1.0:
121
+ return False
122
+ return bool(self.stats)
123
+
124
+ def tokens(self) -> dict[str, int]:
125
+ totals = {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}
126
+ for stat in self.stats:
127
+ for key, value in stat.tokens().items():
128
+ totals[key] += value
129
+ return totals
130
+
131
+ def cost(self, prices: PriceBook) -> float | None:
132
+ if not prices:
133
+ return None
134
+ estimates = [s.cost(prices) for s in self.stats]
135
+ real = [e for e in estimates if e is not None]
136
+ return sum(real) if real else None
137
+
138
+
139
+ def run_suite(
140
+ journeys: Sequence[Journey],
141
+ agent_factory: Callable[[], Agent],
142
+ repeat: int = 1,
143
+ workers: int = 1,
144
+ backend: str = "auto",
145
+ keep_sandbox: bool = False,
146
+ http_get=None,
147
+ image: str | None = None,
148
+ docs=None,
149
+ probe_affordances: bool = False,
150
+ on_result: Callable[[RunResult], None] | None = None,
151
+ ) -> SuiteResult:
152
+ start = time.monotonic()
153
+ jobs = [
154
+ (journey, attempt)
155
+ for journey in journeys
156
+ for attempt in range(1, repeat + 1)
157
+ ]
158
+
159
+ def execute(job) -> RunResult:
160
+ journey, attempt = job
161
+ return run_journey(
162
+ journey,
163
+ agent_factory(),
164
+ keep_sandbox=keep_sandbox,
165
+ http_get=http_get,
166
+ backend=backend,
167
+ image=image,
168
+ attempt=attempt,
169
+ docs=docs,
170
+ probe_affordances=probe_affordances and attempt == 1,
171
+ )
172
+
173
+ results: list[RunResult] = []
174
+ if workers > 1 and len(jobs) > 1:
175
+ with ThreadPoolExecutor(max_workers=workers) as pool:
176
+ for result in pool.map(execute, jobs):
177
+ results.append(result)
178
+ if on_result:
179
+ on_result(result)
180
+ else:
181
+ for job in jobs:
182
+ result = execute(job)
183
+ results.append(result)
184
+ if on_result:
185
+ on_result(result)
186
+
187
+ by_journey: dict[str, JourneyStats] = {}
188
+ order: list[str] = []
189
+ for result in results:
190
+ key = f"{result.journey.name} {result.agent_name}"
191
+ if key not in by_journey:
192
+ by_journey[key] = JourneyStats(result.journey.name, result.agent_name)
193
+ order.append(key)
194
+ by_journey[key].runs.append(result)
195
+
196
+ return SuiteResult(
197
+ stats=[by_journey[k] for k in order],
198
+ duration=time.monotonic() - start,
199
+ repeat=repeat,
200
+ # The resolved backend, not the request: "auto" in a published result
201
+ # tells a reader nothing about what was enforced.
202
+ backend=resolve_backend(backend),
203
+ )
quickstarted/trace.py ADDED
@@ -0,0 +1,53 @@
1
+ """Run traces: an append-only event log, written as JSONL.
2
+
3
+ The trace is the product's raw material: it is what lets a failure be
4
+ attributed to the docs page the agent was reading when things went wrong.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import threading
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Event:
19
+ ts: float
20
+ type: str
21
+ data: dict[str, Any]
22
+
23
+
24
+ @dataclass
25
+ class Trace:
26
+ events: list[Event] = field(default_factory=list)
27
+ # The egress proxy appends from its own threads while the agent loop
28
+ # appends from the main one.
29
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
30
+
31
+ def add(self, type: str, **data: Any) -> Event:
32
+ event = Event(ts=time.time(), type=type, data=data)
33
+ with self._lock:
34
+ self.events.append(event)
35
+ return event
36
+
37
+ def of_type(self, *types: str) -> list[Event]:
38
+ with self._lock:
39
+ return [e for e in self.events if e.type in types]
40
+
41
+ def fetched_urls(self) -> list[str]:
42
+ return [e.data["url"] for e in self.events if e.type == "docs_fetch"]
43
+
44
+ def last_fetch_before_failure(self) -> str | None:
45
+ urls = self.fetched_urls()
46
+ return urls[-1] if urls else None
47
+
48
+ def write_jsonl(self, path: str | Path) -> None:
49
+ path = Path(path)
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ with path.open("w", encoding="utf-8") as fh:
52
+ for e in self.events:
53
+ fh.write(json.dumps({"ts": e.ts, "type": e.type, **e.data}) + "\n")
@@ -0,0 +1,88 @@
1
+ """Low-level HTTP for documentation reads, and HTML to text.
2
+
3
+ Separated from the docs client so that caching, rate limiting, robots, and
4
+ affordance policy can be tested without a network, and so tests have one
5
+ obvious place to intercept.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import gzip
12
+ import io
13
+ import re
14
+ import urllib.request
15
+ from dataclasses import dataclass
16
+ from html.parser import HTMLParser
17
+ from typing import ClassVar
18
+
19
+ from ._version import __version__ as _version
20
+
21
+ USER_AGENT = (
22
+ f"quickstarted/{_version} (+https://github.com/snehankekre/quickstarted; "
23
+ "documentation agent-readiness testing)"
24
+ )
25
+ MAX_BODY_BYTES = 4_000_000
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class HttpResponse:
30
+ status: int
31
+ content_type: str
32
+ text: str
33
+ url: str = ""
34
+
35
+
36
+ class _TextExtractor(HTMLParser):
37
+ _SKIP: ClassVar[frozenset] = frozenset({"script", "style", "noscript", "svg"})
38
+
39
+ def __init__(self):
40
+ super().__init__(convert_charrefs=True)
41
+ self._chunks: list[str] = []
42
+ self._skip_depth = 0
43
+
44
+ def handle_starttag(self, tag, attrs):
45
+ if tag in self._SKIP:
46
+ self._skip_depth += 1
47
+
48
+ def handle_endtag(self, tag):
49
+ if tag in self._SKIP and self._skip_depth:
50
+ self._skip_depth -= 1
51
+
52
+ def handle_data(self, data):
53
+ if not self._skip_depth:
54
+ self._chunks.append(data)
55
+
56
+ def text(self) -> str:
57
+ raw = "".join(self._chunks)
58
+ return re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]+", " ", raw)).strip()
59
+
60
+
61
+ def html_to_text(html: str) -> str:
62
+ parser = _TextExtractor()
63
+ try:
64
+ parser.feed(html)
65
+ return parser.text()
66
+ except Exception:
67
+ return html
68
+
69
+
70
+ def http_get(url: str, timeout: int = 30, method: str = "GET") -> HttpResponse:
71
+ """Fetch a URL. Raises the urllib.error family on transport failure."""
72
+ request = urllib.request.Request(
73
+ url,
74
+ headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"},
75
+ method=method,
76
+ )
77
+ with urllib.request.urlopen(request, timeout=timeout) as response:
78
+ content_type = response.headers.get("Content-Type", "")
79
+ body = response.read(MAX_BODY_BYTES) if method != "HEAD" else b""
80
+ if response.headers.get("Content-Encoding") == "gzip" and body:
81
+ with contextlib.suppress(OSError):
82
+ body = gzip.GzipFile(fileobj=io.BytesIO(body)).read()
83
+ return HttpResponse(
84
+ status=getattr(response, "status", 200),
85
+ content_type=content_type,
86
+ text=body.decode("utf-8", errors="replace"),
87
+ url=response.geturl(),
88
+ )