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.
@@ -0,0 +1,24 @@
1
+ """quickstarted: test whether an AI agent can complete your quickstart.
2
+
3
+ Journeys (YAML) state a goal, a docs entrypoint, and a machine-checkable
4
+ success assertion. The harness runs an agent in a sandbox whose only docs
5
+ access is a recorded, allowlisted fetch tool, then scores the run with the
6
+ assertion script. See README.md.
7
+ """
8
+
9
+ from ._version import __version__
10
+ from .journey import Budgets, Journey, JourneyError, load_journey
11
+ from .run import RunResult, ScoreResult, run_journey
12
+ from .trace import Trace
13
+
14
+ __all__ = [
15
+ "Budgets",
16
+ "Journey",
17
+ "JourneyError",
18
+ "RunResult",
19
+ "ScoreResult",
20
+ "Trace",
21
+ "__version__",
22
+ "load_journey",
23
+ "run_journey",
24
+ ]
@@ -0,0 +1,7 @@
1
+ """Single source of the version, importable without pulling in the package.
2
+
3
+ Kept separate so low-level modules can stamp the version (User-Agent, results
4
+ schema) without a circular import through `quickstarted/__init__.py`.
5
+ """
6
+
7
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from .base import Agent, AgentOutcome, Toolbelt
2
+ from .replay import ReplayAgent
3
+
4
+ __all__ = ["Agent", "AgentOutcome", "ReplayAgent", "Toolbelt"]
@@ -0,0 +1,145 @@
1
+ """Agent adapter interface and the harness-owned toolbelt.
2
+
3
+ Every agent, whatever the model or vendor, acts on the sandbox only through
4
+ the Toolbelt. That is a deliberate design choice: because docs access flows
5
+ through `fetch`, the harness records every page the agent reads (failure
6
+ attribution) and enforces the journey's host allowlist. Adapters never talk
7
+ to the filesystem or network directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Callable, Protocol
14
+
15
+ from ..docs import DocsClient
16
+ from ..exec.base import Executor, truncate
17
+ from ..journey import Journey
18
+ from ..trace import Trace
19
+ from ..transport import html_to_text
20
+
21
+ _FETCH_LIMIT = 60_000
22
+
23
+
24
+ class Toolbelt:
25
+ def __init__(
26
+ self,
27
+ journey: Journey,
28
+ executor: Executor,
29
+ trace: Trace,
30
+ docs: DocsClient | None = None,
31
+ http_get: Callable[[str], tuple[str, str]] | None = None,
32
+ ):
33
+ self.journey = journey
34
+ self.executor = executor
35
+ self.trace = trace
36
+ self.docs = docs or DocsClient()
37
+ self._http_get = http_get
38
+
39
+ def bash(self, command: str) -> str:
40
+ budgets = self.journey.budgets
41
+ self.trace.add("tool_call", tool="bash", command=command)
42
+ result = self.executor.run(
43
+ command,
44
+ timeout=budgets.max_command_seconds,
45
+ max_output_chars=budgets.max_output_chars,
46
+ )
47
+ self.trace.add(
48
+ "tool_result",
49
+ tool="bash",
50
+ exit_code=result.exit_code,
51
+ duration=round(result.duration, 3),
52
+ timed_out=result.timed_out,
53
+ output=result.output,
54
+ )
55
+ return f"exit code: {result.exit_code}\n{result.output}"
56
+
57
+ def fetch(self, url: str) -> str:
58
+ self.trace.add("tool_call", tool="fetch", url=url)
59
+ if not self.journey.host_allowed(url):
60
+ allowed = ", ".join(self.journey.docs_allow)
61
+ message = (
62
+ f"BLOCKED: {url} is outside this journey's documentation allowlist "
63
+ f"({allowed}). Only the target project's docs may be read."
64
+ )
65
+ self.trace.add("fetch_blocked", url=url)
66
+ return message
67
+
68
+ if self._http_get is not None: # legacy injection point, used by tests
69
+ try:
70
+ content_type, body = self._http_get(url)
71
+ except Exception as exc:
72
+ self.trace.add("fetch_error", url=url, error=str(exc))
73
+ return f"FETCH ERROR for {url}: {exc}"
74
+ if "html" in content_type.lower():
75
+ body = html_to_text(body)
76
+ body = truncate(body, _FETCH_LIMIT)
77
+ self.trace.add("docs_fetch", url=url, chars=len(body))
78
+ return body
79
+
80
+ try:
81
+ result = self.docs.get(url)
82
+ except Exception as exc:
83
+ self.trace.add("fetch_error", url=url, error=str(exc))
84
+ return f"FETCH ERROR for {url}: {exc}"
85
+
86
+ if result.blocked_reason == "affordance_withheld":
87
+ # Ablation condition: the file exists, the agent may not have it.
88
+ self.trace.add("affordance_withheld", url=url)
89
+ return (
90
+ f"NOT AVAILABLE: {url} could not be retrieved. Use the regular "
91
+ "documentation pages."
92
+ )
93
+ if result.blocked_reason:
94
+ self.trace.add("fetch_blocked", url=url, reason=result.blocked_reason)
95
+ return f"BLOCKED: {url} ({result.blocked_reason})"
96
+
97
+ body = result.text
98
+ original = len(body)
99
+ body = truncate(body, _FETCH_LIMIT)
100
+ # A page too large to read is itself an agent-experience defect, so
101
+ # record it rather than silently trimming.
102
+ self.trace.add(
103
+ "docs_fetch",
104
+ url=url,
105
+ chars=len(body),
106
+ original_chars=original,
107
+ truncated=original > _FETCH_LIMIT,
108
+ from_cache=result.from_cache,
109
+ content_hash=result.content_hash,
110
+ )
111
+ if result.changed:
112
+ self.trace.add("docs_changed", url=url, content_hash=result.content_hash)
113
+ return body
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class AgentOutcome:
118
+ stop_reason: str # completed | command_failed | max_turns | timeout | refusal | error
119
+ turns: int
120
+ detail: str = ""
121
+ #: Uncached prompt tokens. Vendors disagree here: Anthropic reports
122
+ #: `input_tokens` already excluding cache traffic, while OpenAI and Google
123
+ #: report a prompt total that *includes* it. Adapters normalise to the
124
+ #: Anthropic meaning, so that the four counters sum to the run's real cost
125
+ #: and no token is billed twice in a cross-vendor comparison.
126
+ input_tokens: int = 0
127
+ output_tokens: int = 0
128
+ cache_write_tokens: int = 0
129
+ cache_read_tokens: int = 0
130
+
131
+ @property
132
+ def total_tokens(self) -> int:
133
+ return (
134
+ self.input_tokens
135
+ + self.output_tokens
136
+ + self.cache_write_tokens
137
+ + self.cache_read_tokens
138
+ )
139
+
140
+
141
+ class Agent(Protocol):
142
+ name: str
143
+
144
+ def run(self, journey: Journey, toolbelt: Toolbelt, deadline: float) -> AgentOutcome:
145
+ ...
@@ -0,0 +1,242 @@
1
+ """Claude agent adapter: a manual tool-use loop on the plain anthropic SDK.
2
+
3
+ The loop is deliberately hand-rolled rather than delegated to a heavyweight
4
+ agent framework: the harness must own the tools (sandboxed bash + allowlisted
5
+ docs fetch) so that every action and every page read lands in the trace.
6
+
7
+ Requires the `anthropic` package (pip install "quickstarted[claude]") and an API
8
+ key. The key is read from QUICKSTARTED_ANTHROPIC_API_KEY first so it can live in
9
+ your environment without other Anthropic tooling (Claude Code, for one) picking
10
+ it up and billing against it; ANTHROPIC_API_KEY is honoured as a fallback for
11
+ CI, where that name is the convention.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import random
18
+ import time
19
+ from typing import Any
20
+
21
+ from ..journey import Journey
22
+ from .base import AgentOutcome, Toolbelt
23
+ from .prompt import READ_DOCS_DESCRIPTION, SYSTEM
24
+ from .prompt import kickoff as build_kickoff
25
+
26
+ DEFAULT_MODEL = "claude-opus-5"
27
+
28
+ #: Transient upstream conditions. A benchmark sweep that dies on one 529 is
29
+ #: not a benchmark, and a run that reports these as a documentation failure is
30
+ #: worse than one that dies.
31
+ RETRYABLE_STATUS = (408, 409, 429, 500, 502, 503, 529)
32
+ MAX_ATTEMPTS = 5
33
+ KEY_ENV = "QUICKSTARTED_ANTHROPIC_API_KEY"
34
+ FALLBACK_KEY_ENV = "ANTHROPIC_API_KEY"
35
+
36
+
37
+ class _Usage:
38
+ """Running token totals for one journey.
39
+
40
+ The API reports cached prompt tokens outside `input_tokens`, so a run with
41
+ prompt caching on looks nearly free if you only add up that field. All four
42
+ counters are kept so the reported cost is the real one.
43
+ """
44
+
45
+ def __init__(self):
46
+ self.input_tokens = 0
47
+ self.output_tokens = 0
48
+ self.cache_write_tokens = 0
49
+ self.cache_read_tokens = 0
50
+
51
+ @property
52
+ def total(self) -> int:
53
+ return (
54
+ self.input_tokens
55
+ + self.output_tokens
56
+ + self.cache_write_tokens
57
+ + self.cache_read_tokens
58
+ )
59
+
60
+ def add(self, usage) -> dict:
61
+ turn = {
62
+ "input_tokens": usage.input_tokens or 0,
63
+ "output_tokens": usage.output_tokens or 0,
64
+ "cache_write_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0,
65
+ "cache_read_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0,
66
+ }
67
+ for field, value in turn.items():
68
+ setattr(self, field, getattr(self, field) + value)
69
+ return turn
70
+
71
+
72
+ def resolve_api_key():
73
+ """The quickstarted-specific name wins, so the key can live in a shell
74
+ without other Anthropic tooling on the machine spending it."""
75
+ return os.environ.get(KEY_ENV) or os.environ.get(FALLBACK_KEY_ENV) or None
76
+
77
+ _READ_DOCS_TOOL = {
78
+ "name": "read_docs",
79
+ "description": READ_DOCS_DESCRIPTION,
80
+ "input_schema": {
81
+ "type": "object",
82
+ "properties": {
83
+ "url": {"type": "string", "description": "Absolute http(s) URL of the docs page"}
84
+ },
85
+ "required": ["url"],
86
+ },
87
+ }
88
+
89
+
90
+ class ClaudeAgent:
91
+ name = "claude"
92
+
93
+ def __init__(self, model: str = DEFAULT_MODEL, max_tokens: int = 16000):
94
+ self.model = model
95
+ self.max_tokens = max_tokens
96
+ self.name = f"claude:{model}"
97
+ #: The exact model the API served, which is what a benchmark must cite:
98
+ #: an alias like "claude-opus-5" can resolve to different builds over
99
+ #: time, and a pass-rate trend across a silent change is meaningless.
100
+ self.model_reported = ""
101
+
102
+ def run(self, journey: Journey, toolbelt: Toolbelt, deadline: float) -> AgentOutcome:
103
+ try:
104
+ import anthropic
105
+ except ImportError:
106
+ return AgentOutcome(
107
+ stop_reason="error",
108
+ turns=0,
109
+ detail='anthropic package not installed; pip install "quickstarted[claude]"',
110
+ )
111
+
112
+ api_key = resolve_api_key()
113
+ try:
114
+ # max_retries=0: the harness owns retry policy so that every wait
115
+ # and every transient failure lands in the trace instead of being
116
+ # silently absorbed by the SDK.
117
+ client = (
118
+ anthropic.Anthropic(api_key=api_key, max_retries=0)
119
+ if api_key
120
+ else anthropic.Anthropic(max_retries=0)
121
+ )
122
+ except TypeError as exc:
123
+ return AgentOutcome(
124
+ stop_reason="error",
125
+ turns=0,
126
+ detail=f"no Anthropic credentials: set {KEY_ENV} ({exc})",
127
+ )
128
+ tools = [
129
+ {"type": "bash_20250124", "name": "bash"},
130
+ _READ_DOCS_TOOL,
131
+ ]
132
+ kickoff = build_kickoff(journey)
133
+ messages: list[dict[str, Any]] = [{"role": "user", "content": kickoff}]
134
+ used = _Usage()
135
+
136
+ def outcome(stop_reason: str, turns: int, detail: str = "") -> AgentOutcome:
137
+ return AgentOutcome(
138
+ stop_reason,
139
+ turns,
140
+ detail,
141
+ used.input_tokens,
142
+ used.output_tokens,
143
+ used.cache_write_tokens,
144
+ used.cache_read_tokens,
145
+ )
146
+
147
+ def call_with_retries(turn: int):
148
+ """Returns (response, error_detail). Retries transient upstream faults."""
149
+ last = ""
150
+ for attempt in range(1, MAX_ATTEMPTS + 1):
151
+ try:
152
+ return (
153
+ # The SDK's overloads require literal-typed tool
154
+ # params; ours are built at runtime. Behaviour is
155
+ # covered by the live runs, not by these types.
156
+ client.messages.create( # type: ignore[call-overload]
157
+ model=self.model,
158
+ max_tokens=self.max_tokens,
159
+ thinking={"type": "adaptive"},
160
+ cache_control={"type": "ephemeral"},
161
+ system=SYSTEM,
162
+ tools=tools,
163
+ messages=messages,
164
+ ),
165
+ "",
166
+ )
167
+ except TypeError as exc:
168
+ # The SDK resolves credentials lazily, at request time.
169
+ return None, f"no Anthropic credentials: set {KEY_ENV} ({exc})"
170
+ except anthropic.APIConnectionError as exc:
171
+ last = f"connection error: {exc}"
172
+ except anthropic.APIStatusError as exc:
173
+ last = f"API error {exc.status_code}: {exc.message}"
174
+ if exc.status_code not in RETRYABLE_STATUS:
175
+ return None, last
176
+ if attempt == MAX_ATTEMPTS:
177
+ break
178
+ # Exponential backoff with jitter, never past the deadline.
179
+ delay = min(2.0 ** (attempt - 1), 30.0) * (0.5 + random.random())
180
+ if time.monotonic() + delay > deadline:
181
+ return None, last
182
+ toolbelt.trace.add(
183
+ "api_retry", turn=turn, attempt=attempt,
184
+ sleep=round(delay, 2), error=last,
185
+ )
186
+ time.sleep(delay)
187
+ return None, last
188
+
189
+ for turn in range(1, journey.budgets.max_turns + 1):
190
+ if time.monotonic() > deadline:
191
+ return outcome("timeout", turn - 1)
192
+ cap = journey.budgets.max_tokens
193
+ if cap and used.total >= cap:
194
+ return outcome("token_budget", turn - 1, f"token budget {cap} exhausted")
195
+
196
+ response, error = call_with_retries(turn)
197
+ if response is None:
198
+ return outcome("error", turn - 1, error)
199
+
200
+ self.model_reported = getattr(response, "model", "") or self.model
201
+ turn_usage = used.add(response.usage)
202
+ toolbelt.trace.add(
203
+ "agent_turn", turn=turn, stop_reason=response.stop_reason,
204
+ model=self.model_reported, **turn_usage
205
+ )
206
+
207
+ if response.stop_reason == "refusal":
208
+ return outcome("refusal", turn, "model refused")
209
+
210
+ messages.append({"role": "assistant", "content": response.content})
211
+
212
+ if response.stop_reason == "pause_turn":
213
+ continue
214
+
215
+ tool_uses = [b for b in response.content if b.type == "tool_use"]
216
+ if not tool_uses:
217
+ final_text = " ".join(
218
+ b.text for b in response.content if b.type == "text"
219
+ ).strip()
220
+ toolbelt.trace.add("agent_final", text=final_text[:2000])
221
+ return outcome("completed", turn, final_text[:500])
222
+
223
+ results = []
224
+ for tu in tool_uses:
225
+ if tu.name == "bash":
226
+ if tu.input.get("restart"):
227
+ out = (
228
+ "Shell session reset. (Each command already runs "
229
+ "in a fresh shell.)"
230
+ )
231
+ else:
232
+ out = toolbelt.bash(tu.input.get("command", ""))
233
+ elif tu.name == "read_docs":
234
+ out = toolbelt.fetch(tu.input.get("url", ""))
235
+ else:
236
+ out = f"Unknown tool: {tu.name}"
237
+ results.append(
238
+ {"type": "tool_result", "tool_use_id": tu.id, "content": out}
239
+ )
240
+ messages.append({"role": "user", "content": results})
241
+
242
+ return outcome("max_turns", journey.budgets.max_turns)
@@ -0,0 +1,138 @@
1
+ """Gemini adapter, via the google-genai SDK.
2
+
3
+ As with the OpenAI adapter there is no default model, and credentials are read
4
+ from QUICKSTARTED_GEMINI_API_KEY before GOOGLE_API_KEY.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+
12
+ from ..journey import Journey
13
+ from .base import AgentOutcome, Toolbelt
14
+ from .prompt import BASH_DESCRIPTION, READ_DOCS_DESCRIPTION, SYSTEM, kickoff
15
+
16
+ KEY_ENV = "QUICKSTARTED_GEMINI_API_KEY"
17
+ FALLBACK_KEY_ENV = "GOOGLE_API_KEY"
18
+
19
+ FUNCTIONS = [
20
+ {
21
+ "name": "bash",
22
+ "description": BASH_DESCRIPTION,
23
+ "parameters": {
24
+ "type": "OBJECT",
25
+ "properties": {"command": {"type": "STRING"}},
26
+ "required": ["command"],
27
+ },
28
+ },
29
+ {
30
+ "name": "read_docs",
31
+ "description": READ_DOCS_DESCRIPTION,
32
+ "parameters": {
33
+ "type": "OBJECT",
34
+ "properties": {"url": {"type": "STRING"}},
35
+ "required": ["url"],
36
+ },
37
+ },
38
+ ]
39
+
40
+
41
+ def resolve_api_key():
42
+ return os.environ.get(KEY_ENV) or os.environ.get(FALLBACK_KEY_ENV) or None
43
+
44
+
45
+ class GeminiAgent:
46
+ def __init__(self, model: str = ""):
47
+ self.model = model
48
+ self.name = f"gemini:{model}" if model else "gemini"
49
+ self.model_reported = ""
50
+
51
+ def run(self, journey: Journey, toolbelt: Toolbelt, deadline: float) -> AgentOutcome:
52
+ if not self.model:
53
+ return AgentOutcome(
54
+ "error", 0, "gemini adapter requires --model (no default is assumed)"
55
+ )
56
+ try:
57
+ from google import genai
58
+ from google.genai import types
59
+ except ImportError:
60
+ return AgentOutcome(
61
+ "error", 0,
62
+ 'google-genai not installed; pip install "quickstarted[gemini]"',
63
+ )
64
+ api_key = resolve_api_key()
65
+ if not api_key:
66
+ return AgentOutcome("error", 0, f"no Gemini credentials: set {KEY_ENV}")
67
+
68
+ client = genai.Client(api_key=api_key)
69
+ config = types.GenerateContentConfig(
70
+ system_instruction=SYSTEM,
71
+ tools=[
72
+ types.Tool(function_declarations=FUNCTIONS) # type: ignore[arg-type]
73
+ ],
74
+ )
75
+ contents = [
76
+ types.Content(role="user", parts=[types.Part(text=kickoff(journey))])
77
+ ]
78
+ input_tokens = output_tokens = cached = 0
79
+
80
+ def outcome(reason: str, turns: int, detail: str = "") -> AgentOutcome:
81
+ return AgentOutcome(
82
+ reason, turns, detail, input_tokens, output_tokens, 0, cached
83
+ )
84
+
85
+ for turn in range(1, journey.budgets.max_turns + 1):
86
+ if time.monotonic() > deadline:
87
+ return outcome("timeout", turn - 1)
88
+ try:
89
+ response = client.models.generate_content(
90
+ model=self.model, contents=contents, config=config
91
+ )
92
+ except Exception as exc:
93
+ return outcome("error", turn - 1, f"API error: {exc}")
94
+
95
+ usage = getattr(response, "usage_metadata", None)
96
+ if usage:
97
+ prompt = getattr(usage, "prompt_token_count", 0) or 0
98
+ hit = getattr(usage, "cached_content_token_count", 0) or 0
99
+ # As with OpenAI, the prompt count includes cached tokens.
100
+ input_tokens += max(prompt - hit, 0)
101
+ cached += hit
102
+ output_tokens += getattr(usage, "candidates_token_count", 0) or 0
103
+ self.model_reported = getattr(response, "model_version", "") or self.model
104
+ toolbelt.trace.add("agent_turn", turn=turn, model=self.model_reported)
105
+
106
+ candidates = response.candidates or []
107
+ candidate = candidates[0] if candidates else None
108
+ if candidate is None or not candidate.content:
109
+ return outcome("error", turn, "empty response from model")
110
+ contents.append(candidate.content)
111
+
112
+ calls = [
113
+ p.function_call
114
+ for p in (candidate.content.parts or [])
115
+ if p.function_call
116
+ ]
117
+ if not calls:
118
+ final = (getattr(response, "text", "") or "").strip()
119
+ toolbelt.trace.add("agent_final", text=final[:2000])
120
+ return outcome("completed", turn, final[:500])
121
+
122
+ replies = []
123
+ for call in calls:
124
+ arguments = dict(call.args or {})
125
+ if call.name == "bash":
126
+ result = toolbelt.bash(arguments.get("command", ""))
127
+ elif call.name == "read_docs":
128
+ result = toolbelt.fetch(arguments.get("url", ""))
129
+ else:
130
+ result = f"Unknown tool: {call.name}"
131
+ replies.append(
132
+ types.Part.from_function_response(
133
+ name=call.name or "unknown", response={"result": result}
134
+ )
135
+ )
136
+ contents.append(types.Content(role="user", parts=replies))
137
+
138
+ return outcome("max_turns", journey.budgets.max_turns)