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/__init__.py +24 -0
- quickstarted/_version.py +7 -0
- quickstarted/agents/__init__.py +4 -0
- quickstarted/agents/base.py +145 -0
- quickstarted/agents/claude.py +242 -0
- quickstarted/agents/gemini_agent.py +138 -0
- quickstarted/agents/openai_agent.py +167 -0
- quickstarted/agents/prompt.py +61 -0
- quickstarted/agents/registry.py +31 -0
- quickstarted/agents/replay.py +39 -0
- quickstarted/cli.py +250 -0
- quickstarted/docs.py +263 -0
- quickstarted/exec/__init__.py +89 -0
- quickstarted/exec/base.py +131 -0
- quickstarted/exec/docker.py +216 -0
- quickstarted/exec/local.py +16 -0
- quickstarted/exec/seatbelt.py +83 -0
- quickstarted/journey.py +182 -0
- quickstarted/net/__init__.py +5 -0
- quickstarted/net/proxy.py +274 -0
- quickstarted/net/proxy_main.py +53 -0
- quickstarted/pricing.py +93 -0
- quickstarted/report.py +206 -0
- quickstarted/results.py +166 -0
- quickstarted/run.py +240 -0
- quickstarted/sandbox.py +15 -0
- quickstarted/suite.py +203 -0
- quickstarted/trace.py +53 -0
- quickstarted/transport.py +88 -0
- quickstarted-0.2.0.dist-info/METADATA +256 -0
- quickstarted-0.2.0.dist-info/RECORD +34 -0
- quickstarted-0.2.0.dist-info/WHEEL +4 -0
- quickstarted-0.2.0.dist-info/entry_points.txt +3 -0
- quickstarted-0.2.0.dist-info/licenses/LICENSE +21 -0
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()
|
quickstarted/sandbox.py
ADDED
|
@@ -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
|
+
)
|