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/__init__.py
ADDED
|
@@ -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
|
+
]
|
quickstarted/_version.py
ADDED
|
@@ -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)
|