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
|
@@ -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())
|