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,167 @@
1
+ """OpenAI adapter: the same tool loop, the same harness-owned tools.
2
+
3
+ There is deliberately no default model. A benchmark that silently picks a
4
+ model for you produces numbers nobody can reproduce, and vendor model names
5
+ change faster than a pinned default would survive. Pass `--model`.
6
+
7
+ Credentials come from QUICKSTARTED_OPENAI_API_KEY first, falling back to
8
+ OPENAI_API_KEY, so a key can live in a shell without other tooling spending
9
+ it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import Any
18
+
19
+ from ..journey import Journey
20
+ from .base import AgentOutcome, Toolbelt
21
+ from .prompt import BASH_DESCRIPTION, READ_DOCS_DESCRIPTION, SYSTEM, kickoff
22
+
23
+ KEY_ENV = "QUICKSTARTED_OPENAI_API_KEY"
24
+ FALLBACK_KEY_ENV = "OPENAI_API_KEY"
25
+
26
+ TOOLS = [
27
+ {
28
+ "type": "function",
29
+ "function": {
30
+ "name": "bash",
31
+ "description": BASH_DESCRIPTION,
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {"command": {"type": "string"}},
35
+ "required": ["command"],
36
+ },
37
+ },
38
+ },
39
+ {
40
+ "type": "function",
41
+ "function": {
42
+ "name": "read_docs",
43
+ "description": READ_DOCS_DESCRIPTION,
44
+ "parameters": {
45
+ "type": "object",
46
+ "properties": {"url": {"type": "string"}},
47
+ "required": ["url"],
48
+ },
49
+ },
50
+ },
51
+ ]
52
+
53
+
54
+ def resolve_api_key():
55
+ return os.environ.get(KEY_ENV) or os.environ.get(FALLBACK_KEY_ENV) or None
56
+
57
+
58
+ class OpenAIAgent:
59
+ def __init__(self, model: str = "", max_tokens: int = 16000):
60
+ self.model = model
61
+ self.max_tokens = max_tokens
62
+ self.name = f"openai:{model}" if model else "openai"
63
+ self.model_reported = ""
64
+
65
+ def run(self, journey: Journey, toolbelt: Toolbelt, deadline: float) -> AgentOutcome:
66
+ if not self.model:
67
+ return AgentOutcome(
68
+ "error", 0, "openai adapter requires --model (no default is assumed)"
69
+ )
70
+ try:
71
+ import openai
72
+ except ImportError:
73
+ return AgentOutcome(
74
+ "error", 0,
75
+ 'openai package not installed; pip install "quickstarted[openai]"',
76
+ )
77
+ api_key = resolve_api_key()
78
+ if not api_key:
79
+ return AgentOutcome("error", 0, f"no OpenAI credentials: set {KEY_ENV}")
80
+ client = openai.OpenAI(api_key=api_key, max_retries=0)
81
+
82
+ messages: list[dict[str, Any]] = [
83
+ {"role": "system", "content": SYSTEM},
84
+ {"role": "user", "content": kickoff(journey)},
85
+ ]
86
+ input_tokens = output_tokens = cached = 0
87
+
88
+ def outcome(reason: str, turns: int, detail: str = "") -> AgentOutcome:
89
+ return AgentOutcome(
90
+ reason, turns, detail, input_tokens, output_tokens, 0, cached
91
+ )
92
+
93
+ for turn in range(1, journey.budgets.max_turns + 1):
94
+ if time.monotonic() > deadline:
95
+ return outcome("timeout", turn - 1)
96
+ try:
97
+ response = client.chat.completions.create(
98
+ model=self.model,
99
+ # The SDK types these as TypedDicts; ours are built at
100
+ # runtime and shared across adapters. Behaviour is covered
101
+ # by live runs, not by these annotations.
102
+ messages=messages, # type: ignore[arg-type]
103
+ tools=TOOLS, # type: ignore[arg-type]
104
+ max_completion_tokens=self.max_tokens,
105
+ )
106
+ except Exception as exc:
107
+ return outcome("error", turn - 1, f"API error: {exc}")
108
+
109
+ usage = getattr(response, "usage", None)
110
+ if usage:
111
+ prompt = getattr(usage, "prompt_tokens", 0) or 0
112
+ details = getattr(usage, "prompt_tokens_details", None)
113
+ hit = (getattr(details, "cached_tokens", 0) or 0) if details else 0
114
+ # prompt_tokens includes the cached ones here, unlike Anthropic.
115
+ # Subtract so the counters do not double count the same tokens.
116
+ input_tokens += max(prompt - hit, 0)
117
+ cached += hit
118
+ output_tokens += getattr(usage, "completion_tokens", 0) or 0
119
+ self.model_reported = getattr(response, "model", "") or self.model
120
+
121
+ choice = response.choices[0]
122
+ message = choice.message
123
+ toolbelt.trace.add(
124
+ "agent_turn", turn=turn, stop_reason=choice.finish_reason,
125
+ model=self.model_reported,
126
+ )
127
+ calls = list(getattr(message, "tool_calls", None) or [])
128
+ messages.append(
129
+ {
130
+ "role": "assistant",
131
+ "content": message.content or "",
132
+ "tool_calls": [
133
+ {
134
+ "id": call.id,
135
+ "type": "function",
136
+ "function": {
137
+ "name": call.function.name,
138
+ "arguments": call.function.arguments,
139
+ },
140
+ }
141
+ for call in calls
142
+ ],
143
+ }
144
+ if calls
145
+ else {"role": "assistant", "content": message.content or ""}
146
+ )
147
+ if not calls:
148
+ final = (message.content or "").strip()
149
+ toolbelt.trace.add("agent_final", text=final[:2000])
150
+ return outcome("completed", turn, final[:500])
151
+
152
+ for call in calls:
153
+ try:
154
+ arguments = json.loads(call.function.arguments or "{}")
155
+ except ValueError:
156
+ arguments = {}
157
+ if call.function.name == "bash":
158
+ result = toolbelt.bash(arguments.get("command", ""))
159
+ elif call.function.name == "read_docs":
160
+ result = toolbelt.fetch(arguments.get("url", ""))
161
+ else:
162
+ result = f"Unknown tool: {call.function.name}"
163
+ messages.append(
164
+ {"role": "tool", "tool_call_id": call.id, "content": result}
165
+ )
166
+
167
+ return outcome("max_turns", journey.budgets.max_turns)
@@ -0,0 +1,61 @@
1
+ """The prompt every adapter shares.
2
+
3
+ Kept in one place deliberately. If each vendor adapter phrased the task its
4
+ own way, a cross-model pass-rate comparison would be measuring the prompts as
5
+ much as the documentation, and the benchmark would mean nothing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..journey import Journey
11
+
12
+ SYSTEM = """You are a developer trying out an unfamiliar software project by \
13
+ following its documentation, inside a fresh throwaway workspace (your current \
14
+ directory). Your goal is stated in the first user message.
15
+
16
+ Rules of the exercise:
17
+ - Your ONLY source of information about the project is its documentation, \
18
+ read via the read_docs tool. Do not rely on prior knowledge of this specific \
19
+ project; the point is to test whether the docs alone get a newcomer to the \
20
+ goal. General programming knowledge is fine.
21
+ - Start by reading the documentation entrypoint, then follow links from it \
22
+ with read_docs when you need more.
23
+ - Use the bash tool to run commands in the workspace. Each command runs in a \
24
+ fresh shell in the workspace directory; state persists only on disk (use \
25
+ files, virtualenvs, etc.).
26
+ - The shell cannot reach documentation websites. That is deliberate: read \
27
+ documentation with read_docs, which records what you read.
28
+ - When you believe the goal is achieved, verify it yourself with a command, \
29
+ then stop and summarize what you did in one short paragraph. Do not keep \
30
+ polishing after the goal is met.
31
+ - If you are genuinely stuck because the documentation is missing or wrong, \
32
+ stop and say exactly where the docs failed you: which page, which step.
33
+ """
34
+
35
+ READ_DOCS_DESCRIPTION = (
36
+ "Read a documentation page. Call this whenever you need information about "
37
+ "the target project: before your first command, and again whenever the "
38
+ "docs you have already read do not answer the question at hand. Only "
39
+ "documentation hosts on this journey's allowlist are reachable; other "
40
+ "URLs return BLOCKED."
41
+ )
42
+
43
+ BASH_DESCRIPTION = (
44
+ "Run a shell command in the workspace. Each call runs in a fresh shell "
45
+ "whose working directory is the workspace; state persists only on disk."
46
+ )
47
+
48
+
49
+ def kickoff(journey: Journey) -> str:
50
+ text = (
51
+ f"Goal: {journey.goal}\n\n"
52
+ f"Documentation entrypoint: {journey.docs_entrypoint}\n"
53
+ f"Allowed documentation hosts: {', '.join(journey.docs_allow)}"
54
+ )
55
+ if journey.setup:
56
+ # Without this the agent cannot tell a prepared workspace from an empty
57
+ # one, and may rebuild state that setup already created.
58
+ text += "\n\nThe workspace was already prepared by running:\n" + "\n".join(
59
+ f" $ {cmd}" for cmd in journey.setup
60
+ )
61
+ return text
@@ -0,0 +1,31 @@
1
+ """Agent adapter registry.
2
+
3
+ Adapters exist for more than one vendor for a reason that is not marketing:
4
+ a benchmark run against a single model measures that model's habits as much
5
+ as the documentation. "Agents cannot follow your quickstart" is only a
6
+ defensible sentence when several of them could not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ AGENTS = ("replay", "claude", "openai", "gemini")
12
+
13
+
14
+ def build_agent(name: str, model: str = ""):
15
+ if name == "replay":
16
+ from .replay import ReplayAgent
17
+
18
+ return ReplayAgent()
19
+ if name == "claude":
20
+ from .claude import DEFAULT_MODEL, ClaudeAgent
21
+
22
+ return ClaudeAgent(model=model or DEFAULT_MODEL)
23
+ if name == "openai":
24
+ from .openai_agent import OpenAIAgent
25
+
26
+ return OpenAIAgent(model=model)
27
+ if name == "gemini":
28
+ from .gemini_agent import GeminiAgent
29
+
30
+ return GeminiAgent(model=model)
31
+ raise SystemExit(f"unknown agent {name!r} (choose from: {', '.join(AGENTS)})")
@@ -0,0 +1,39 @@
1
+ """Replay agent: runs the journey's documented commands literally, no LLM.
2
+
3
+ This is the free CI mode. If the commands your docs tell users to type do not
4
+ work verbatim, no agent (and no human) stands a chance; replay catches that on
5
+ every docs change without spending a token. It stops at the first failing
6
+ command, which is exactly the "docs break at step N" signal.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ from ..journey import Journey
14
+ from .base import AgentOutcome, Toolbelt
15
+
16
+
17
+ class ReplayAgent:
18
+ name = "replay"
19
+
20
+ def run(self, journey: Journey, toolbelt: Toolbelt, deadline: float) -> AgentOutcome:
21
+ if not journey.replay:
22
+ return AgentOutcome(
23
+ stop_reason="error",
24
+ turns=0,
25
+ detail="journey has no 'replay' commands; replay mode needs them",
26
+ )
27
+ toolbelt.fetch(journey.docs_entrypoint)
28
+ for i, command in enumerate(journey.replay, start=1):
29
+ if time.monotonic() > deadline:
30
+ return AgentOutcome(stop_reason="timeout", turns=i - 1)
31
+ result = toolbelt.bash(command)
32
+ first_line = result.splitlines()[0] if result else ""
33
+ if not first_line.startswith("exit code: 0"):
34
+ return AgentOutcome(
35
+ stop_reason="command_failed",
36
+ turns=i,
37
+ detail=f"replay step {i} failed: {command}",
38
+ )
39
+ return AgentOutcome(stop_reason="completed", turns=len(journey.replay))
quickstarted/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """quickstarted CLI.
2
+
3
+ quickstarted validate journeys/*.yaml
4
+ quickstarted run journeys/foo.yaml --agent replay
5
+ quickstarted run journeys/*.yaml --agent claude --repeat 5 --out results/
6
+ quickstarted doctor
7
+
8
+ Exit code 0 when every journey passed every evidential attempt, 1 otherwise,
9
+ so CI can gate on it. Runs that produced no evidence (rate limits, budget
10
+ exhaustion, harness bugs) do not silently turn into failures; use
11
+ `--strict-inconclusive` if you would rather they did.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .agents.registry import AGENTS, build_agent
21
+ from .docs import AFFORDANCE_POLICIES, DocsClient
22
+ from .exec import available_backends, resolve_backend
23
+ from .journey import JourneyError, load_journey
24
+ from .pricing import PriceBook
25
+ from .report import console_summary, markdown_report, markdown_suite_report, suite_summary
26
+ from .results import write_json, write_junit
27
+ from .run import EVIDENTIAL
28
+ from .suite import run_suite
29
+
30
+
31
+ def cmd_validate(args) -> int:
32
+ failures = 0
33
+ for path in args.journeys:
34
+ try:
35
+ journey = load_journey(path)
36
+ except JourneyError as exc:
37
+ print(f"INVALID {exc}")
38
+ failures += 1
39
+ else:
40
+ modes = "replay+agent" if journey.replay else "agent-only"
41
+ print(f"ok {path} ({journey.name}, {modes})")
42
+ for host in journey.network_conflicts:
43
+ print(
44
+ f"warning {host} is declared a docs host, so the shell "
45
+ f"cannot reach it; installs that need it will fail. "
46
+ f"Add it under network.allow if that is intended."
47
+ )
48
+ for host in journey.attribution_gaps:
49
+ print(
50
+ f"note {host} is both a docs host and network-allowed, "
51
+ f"so pages the shell reads there are not recorded."
52
+ )
53
+ return 1 if failures else 0
54
+
55
+
56
+ def cmd_doctor(args) -> int:
57
+ """What this machine can actually enforce, before you trust its numbers."""
58
+ backends = available_backends()
59
+ chosen = resolve_backend("auto")
60
+ print("quickstarted doctor")
61
+ print(f" backends available: {', '.join(backends)}")
62
+ print(f" auto would choose: {chosen}")
63
+ if chosen == "local":
64
+ print()
65
+ print(" WARNING: no enforced backend on this machine.")
66
+ print(" Commands would run as you, on your filesystem, with your network,")
67
+ print(" and an agent could read docs pages without the trace recording it.")
68
+ print(" Fine for journeys you wrote; do not benchmark other people's")
69
+ print(" projects this way. Install Docker, or run on macOS.")
70
+ prices = PriceBook.load(args.prices)
71
+ print(f" price book loaded: {'yes' if prices else 'no (token counts only)'}")
72
+ from .agents.claude import KEY_ENV, resolve_api_key
73
+
74
+ print(f" {KEY_ENV}: {'set' if resolve_api_key() else 'not set'}")
75
+ return 0
76
+
77
+
78
+ def cmd_run(args) -> int:
79
+ journeys = []
80
+ invalid = False
81
+ for path in args.journeys:
82
+ try:
83
+ journeys.append(load_journey(path))
84
+ except JourneyError as exc:
85
+ print(f"INVALID {exc}")
86
+ invalid = True
87
+ if not journeys:
88
+ return 1
89
+
90
+ backend = resolve_backend(args.backend)
91
+ if backend == "local" and not args.allow_unenforced:
92
+ print(
93
+ "REFUSING: no enforced execution backend is available, so the docs "
94
+ "allowlist and the page-read record cannot be guaranteed.\n"
95
+ "Run `quickstarted doctor` for details, or pass --allow-unenforced if "
96
+ "you wrote the journeys and the project under test yourself.",
97
+ file=sys.stderr,
98
+ )
99
+ return 1
100
+
101
+ prices = PriceBook.load(args.prices)
102
+ out_root = Path(args.out) if args.out else None
103
+
104
+ def emit(result) -> None:
105
+ print(console_summary(result))
106
+ if args.keep_sandbox:
107
+ print(f" sandbox kept at: {result.sandbox_path}")
108
+ if out_root:
109
+ out_dir = out_root / result.journey.name
110
+ if result.attempt > 1:
111
+ out_dir = out_dir / f"attempt-{result.attempt}"
112
+ out_dir.mkdir(parents=True, exist_ok=True)
113
+ result.trace.write_jsonl(out_dir / "trace.jsonl")
114
+ (out_dir / "report.md").write_text(
115
+ markdown_report(result), encoding="utf-8"
116
+ )
117
+
118
+ docs = DocsClient(
119
+ cache_dir=args.cache_dir or None,
120
+ rate_limit_seconds=args.rate_limit,
121
+ respect_robots=not args.ignore_robots,
122
+ affordances=args.affordances,
123
+ refresh=args.refresh,
124
+ offline=args.offline,
125
+ )
126
+
127
+ suite = run_suite(
128
+ journeys,
129
+ agent_factory=lambda: build_agent(args.agent, args.model),
130
+ repeat=args.repeat,
131
+ workers=args.workers,
132
+ backend=args.backend,
133
+ keep_sandbox=args.keep_sandbox,
134
+ image=args.image or None,
135
+ docs=docs,
136
+ probe_affordances=args.probe_affordances,
137
+ on_result=emit,
138
+ )
139
+
140
+ print(suite_summary(suite, prices))
141
+
142
+ if out_root:
143
+ out_root.mkdir(parents=True, exist_ok=True)
144
+ write_json(suite, out_root / "results.json", prices)
145
+ (out_root / "suite.md").write_text(
146
+ markdown_suite_report(suite, prices), encoding="utf-8"
147
+ )
148
+ if args.junit:
149
+ write_junit(suite, args.junit)
150
+ print(f" results written to {out_root}/")
151
+ elif args.junit:
152
+ write_junit(suite, args.junit)
153
+
154
+ if invalid:
155
+ return 1
156
+ inconclusive = [r for r in suite.runs if r.classification not in EVIDENTIAL]
157
+ if inconclusive and args.strict_inconclusive:
158
+ return 1
159
+ return 0 if suite.all_passed else 1
160
+
161
+
162
+ def main(argv=None) -> int:
163
+ parser = argparse.ArgumentParser(
164
+ prog="quickstarted",
165
+ description=(
166
+ "Test whether an AI agent can complete your quickstart from your docs alone."
167
+ ),
168
+ )
169
+ sub = parser.add_subparsers(dest="command", required=True)
170
+
171
+ p_validate = sub.add_parser("validate", help="validate journey files")
172
+ p_validate.add_argument("journeys", nargs="+")
173
+ p_validate.set_defaults(func=cmd_validate)
174
+
175
+ p_doctor = sub.add_parser("doctor", help="report what this machine can enforce")
176
+ p_doctor.add_argument("--prices", default="", help="path to a price book JSON file")
177
+ p_doctor.set_defaults(func=cmd_doctor)
178
+
179
+ p_run = sub.add_parser("run", help="run journeys")
180
+ p_run.add_argument("journeys", nargs="+")
181
+ p_run.add_argument(
182
+ "--agent", default="replay",
183
+ help=f"one of: {', '.join(sorted(AGENTS))} (default: replay)",
184
+ )
185
+ p_run.add_argument("--model", default="", help="model override for LLM agents")
186
+ p_run.add_argument("--out", default="", help="directory for traces and reports")
187
+ p_run.add_argument("--junit", default="", help="write JUnit XML to this path")
188
+ p_run.add_argument("--prices", default="", help="path to a price book JSON file")
189
+ p_run.add_argument(
190
+ "--repeat", type=int, default=1,
191
+ help="attempts per journey; >1 turns a verdict into a pass rate",
192
+ )
193
+ p_run.add_argument(
194
+ "--workers", type=int, default=1, help="run this many attempts in parallel"
195
+ )
196
+ p_run.add_argument(
197
+ "--backend", default="auto",
198
+ help="execution backend: auto (default), docker, seatbelt, local",
199
+ )
200
+ p_run.add_argument(
201
+ "--image", default="", help="container image for the docker backend"
202
+ )
203
+ p_run.add_argument(
204
+ "--allow-unenforced", action="store_true",
205
+ help="permit the local backend, whose network policy is advisory only",
206
+ )
207
+ p_run.add_argument(
208
+ "--strict-inconclusive", action="store_true",
209
+ help="exit non-zero when any run produced no evidence",
210
+ )
211
+ p_run.add_argument(
212
+ "--keep-sandbox", action="store_true",
213
+ help="keep the sandbox directory for inspection",
214
+ )
215
+ p_run.add_argument(
216
+ "--affordances", default="all", choices=list(AFFORDANCE_POLICIES),
217
+ help=(
218
+ "which machine-facing files the agent may read. 'none' withholds "
219
+ "llms.txt and .md variants: run both and compare pass rates to "
220
+ "measure whether the affordance helps"
221
+ ),
222
+ )
223
+ p_run.add_argument(
224
+ "--probe-affordances", action="store_true",
225
+ help="record which machine-facing files exist (context, never scored)",
226
+ )
227
+ p_run.add_argument("--cache-dir", default="", help="content-addressed docs cache")
228
+ p_run.add_argument(
229
+ "--refresh", action="store_true",
230
+ help="re-fetch cached pages and flag any whose content changed",
231
+ )
232
+ p_run.add_argument(
233
+ "--offline", action="store_true", help="use the cache only; never fetch"
234
+ )
235
+ p_run.add_argument(
236
+ "--rate-limit", type=float, default=1.0,
237
+ help="minimum seconds between requests to the same host (default: 1.0)",
238
+ )
239
+ p_run.add_argument(
240
+ "--ignore-robots", action="store_true",
241
+ help="fetch documentation even where robots.txt disallows it",
242
+ )
243
+ p_run.set_defaults(func=cmd_run)
244
+
245
+ args = parser.parse_args(argv)
246
+ return args.func(args)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())