base-agentkit 0.1.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.
- agentkit/__init__.py +35 -0
- agentkit/agent/__init__.py +7 -0
- agentkit/agent/agent.py +368 -0
- agentkit/agent/budgets.py +48 -0
- agentkit/agent/report.py +166 -0
- agentkit/agent/tool_runtime.py +77 -0
- agentkit/cli/__init__.py +5 -0
- agentkit/cli/main.py +108 -0
- agentkit/config/__init__.py +23 -0
- agentkit/config/loader.py +108 -0
- agentkit/config/provider_defaults.py +96 -0
- agentkit/config/schema.py +148 -0
- agentkit/constants.py +21 -0
- agentkit/errors.py +58 -0
- agentkit/llm/__init__.py +53 -0
- agentkit/llm/base.py +36 -0
- agentkit/llm/factory.py +27 -0
- agentkit/llm/providers/__init__.py +15 -0
- agentkit/llm/providers/anthropic_provider.py +371 -0
- agentkit/llm/providers/gemini_provider.py +396 -0
- agentkit/llm/providers/openai_provider.py +881 -0
- agentkit/llm/providers/qwen_provider.py +34 -0
- agentkit/llm/providers/vllm_provider.py +47 -0
- agentkit/llm/types.py +215 -0
- agentkit/llm/usage.py +72 -0
- agentkit/py.typed +0 -0
- agentkit/runlog/__init__.py +15 -0
- agentkit/runlog/events.py +67 -0
- agentkit/runlog/jsonl.py +90 -0
- agentkit/runlog/recorder.py +94 -0
- agentkit/runlog/sinks.py +15 -0
- agentkit/tools/__init__.py +16 -0
- agentkit/tools/base.py +139 -0
- agentkit/tools/library/__init__.py +8 -0
- agentkit/tools/library/_fs_common.py +330 -0
- agentkit/tools/library/create_file.py +168 -0
- agentkit/tools/library/fs_tools.py +21 -0
- agentkit/tools/library/str_replace.py +241 -0
- agentkit/tools/library/view.py +372 -0
- agentkit/tools/library/word_count.py +138 -0
- agentkit/tools/loader.py +81 -0
- agentkit/tools/registry.py +284 -0
- agentkit/tools/types.py +98 -0
- agentkit/workspace/__init__.py +6 -0
- agentkit/workspace/fs.py +288 -0
- agentkit/workspace/layout.py +33 -0
- base_agentkit-0.1.0.dist-info/METADATA +142 -0
- base_agentkit-0.1.0.dist-info/RECORD +51 -0
- base_agentkit-0.1.0.dist-info/WHEEL +4 -0
- base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
- base_agentkit-0.1.0.dist-info/licenses/LICENSE +183 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Gemini GenerateContent API provider adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from agentkit.config.provider_defaults import DEFAULT_GEMINI_BASE_URL
|
|
10
|
+
from agentkit.config.schema import ProviderConfig
|
|
11
|
+
from agentkit.errors import ProviderError, ProviderIssue
|
|
12
|
+
from agentkit.llm.base import BaseLLMProvider
|
|
13
|
+
from agentkit.llm.types import (
|
|
14
|
+
CompletionReason,
|
|
15
|
+
ConversationItem,
|
|
16
|
+
MessageItem,
|
|
17
|
+
ReasoningItem,
|
|
18
|
+
StatePatch,
|
|
19
|
+
ToolCallItem,
|
|
20
|
+
ToolResultItem,
|
|
21
|
+
TurnStatus,
|
|
22
|
+
UnifiedLLMRequest,
|
|
23
|
+
UnifiedLLMResponse,
|
|
24
|
+
Usage,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class GeminiProvider(BaseLLMProvider):
|
|
28
|
+
"""Gemini GenerateContent adapter."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: ProviderConfig) -> None:
|
|
31
|
+
self.config = config
|
|
32
|
+
self.model = config.model
|
|
33
|
+
self._session = requests.Session()
|
|
34
|
+
|
|
35
|
+
def generate(self, req: UnifiedLLMRequest) -> UnifiedLLMResponse:
|
|
36
|
+
payload = self._build_payload(req)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
response = self._session.post(
|
|
40
|
+
self._endpoint(req.model),
|
|
41
|
+
headers=self._headers,
|
|
42
|
+
json=payload,
|
|
43
|
+
timeout=self.config.timeout_s,
|
|
44
|
+
)
|
|
45
|
+
except requests.Timeout as exc: # pragma: no cover - network specific
|
|
46
|
+
raise ProviderError(
|
|
47
|
+
f"Gemini request timed out: {exc}",
|
|
48
|
+
issue=ProviderIssue(category="timeout", retryable=True),
|
|
49
|
+
) from exc
|
|
50
|
+
except requests.RequestException as exc: # pragma: no cover - network specific
|
|
51
|
+
raise ProviderError(
|
|
52
|
+
f"Gemini request failed: {exc}",
|
|
53
|
+
issue=ProviderIssue(category="upstream", retryable=True),
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
if response.status_code >= 400:
|
|
57
|
+
self._raise_http_error(response)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
body = response.json()
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
raise ProviderError(
|
|
63
|
+
"Gemini response is not valid JSON.",
|
|
64
|
+
issue=ProviderIssue(category="parse", retryable=False),
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
return self._parse_response(body)
|
|
68
|
+
|
|
69
|
+
def render_output_text(
|
|
70
|
+
self,
|
|
71
|
+
output_items: list[ConversationItem],
|
|
72
|
+
raw_response: dict[str, object] | None,
|
|
73
|
+
) -> str:
|
|
74
|
+
del raw_response
|
|
75
|
+
texts = [
|
|
76
|
+
item.text
|
|
77
|
+
for item in output_items
|
|
78
|
+
if isinstance(item, MessageItem) and item.role == "assistant" and item.text
|
|
79
|
+
]
|
|
80
|
+
return "\n".join(texts).strip()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def _headers(self) -> dict[str, str]:
|
|
84
|
+
headers = {"content-type": "application/json"}
|
|
85
|
+
if self.config.api_key:
|
|
86
|
+
headers["x-goog-api-key"] = self.config.api_key
|
|
87
|
+
return headers
|
|
88
|
+
|
|
89
|
+
def _endpoint(self, model: str) -> str:
|
|
90
|
+
base = self.config.base_url or DEFAULT_GEMINI_BASE_URL
|
|
91
|
+
if ":generateContent" in base:
|
|
92
|
+
return base
|
|
93
|
+
return f"{base.rstrip('/')}/models/{model}:generateContent"
|
|
94
|
+
|
|
95
|
+
def _build_payload(self, req: UnifiedLLMRequest) -> dict[str, Any]:
|
|
96
|
+
contents = self._compile_contents(req.state.history + req.inputs, req)
|
|
97
|
+
|
|
98
|
+
payload: dict[str, Any] = {"contents": contents}
|
|
99
|
+
|
|
100
|
+
system_instruction = self._compile_system_instruction(req)
|
|
101
|
+
if system_instruction:
|
|
102
|
+
payload["systemInstruction"] = system_instruction
|
|
103
|
+
|
|
104
|
+
if req.tools:
|
|
105
|
+
payload["tools"] = [
|
|
106
|
+
{
|
|
107
|
+
"functionDeclarations": [
|
|
108
|
+
{
|
|
109
|
+
"name": tool.name,
|
|
110
|
+
"description": tool.description,
|
|
111
|
+
"parameters": tool.parameters,
|
|
112
|
+
}
|
|
113
|
+
for tool in req.tools
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
generation_config: dict[str, Any] = {}
|
|
119
|
+
temperature = (
|
|
120
|
+
req.options.temperature
|
|
121
|
+
if req.options.temperature is not None
|
|
122
|
+
else self.config.temperature
|
|
123
|
+
)
|
|
124
|
+
if temperature is not None:
|
|
125
|
+
generation_config["temperature"] = temperature
|
|
126
|
+
if req.options.max_output_tokens is not None:
|
|
127
|
+
generation_config["maxOutputTokens"] = req.options.max_output_tokens
|
|
128
|
+
if req.options.stop_sequences:
|
|
129
|
+
generation_config["stopSequences"] = list(req.options.stop_sequences)
|
|
130
|
+
if generation_config:
|
|
131
|
+
payload["generationConfig"] = generation_config
|
|
132
|
+
|
|
133
|
+
return payload
|
|
134
|
+
|
|
135
|
+
def _compile_system_instruction(self, req: UnifiedLLMRequest) -> dict[str, Any] | None:
|
|
136
|
+
text = req.instructions.strip()
|
|
137
|
+
if not text:
|
|
138
|
+
return None
|
|
139
|
+
return {"parts": [{"text": text}]}
|
|
140
|
+
|
|
141
|
+
def _compile_contents(
|
|
142
|
+
self,
|
|
143
|
+
items: list[ConversationItem],
|
|
144
|
+
req: UnifiedLLMRequest,
|
|
145
|
+
) -> list[dict[str, Any]]:
|
|
146
|
+
call_name_map = req.state.provider_meta.get("tool_name_by_call_id", {})
|
|
147
|
+
if not isinstance(call_name_map, dict):
|
|
148
|
+
call_name_map = {}
|
|
149
|
+
|
|
150
|
+
contents: list[dict[str, Any]] = []
|
|
151
|
+
for item in items:
|
|
152
|
+
content = self._item_to_content(item, call_name_map)
|
|
153
|
+
if content is None:
|
|
154
|
+
continue
|
|
155
|
+
if contents and contents[-1]["role"] == content["role"]:
|
|
156
|
+
contents[-1]["parts"].extend(content["parts"])
|
|
157
|
+
else:
|
|
158
|
+
contents.append(content)
|
|
159
|
+
return contents
|
|
160
|
+
|
|
161
|
+
def _item_to_content(
|
|
162
|
+
self,
|
|
163
|
+
item: ConversationItem,
|
|
164
|
+
call_name_map: dict[str, Any],
|
|
165
|
+
) -> dict[str, Any] | None:
|
|
166
|
+
if isinstance(item, MessageItem):
|
|
167
|
+
role = "user" if item.role == "user" else "model"
|
|
168
|
+
return {"role": role, "parts": [{"text": item.text}]}
|
|
169
|
+
|
|
170
|
+
if isinstance(item, ToolCallItem):
|
|
171
|
+
function_call: dict[str, Any] = {
|
|
172
|
+
"name": item.name,
|
|
173
|
+
"args": item.arguments,
|
|
174
|
+
}
|
|
175
|
+
if item.call_id:
|
|
176
|
+
function_call["id"] = item.call_id
|
|
177
|
+
return {"role": "model", "parts": [{"functionCall": function_call}]}
|
|
178
|
+
|
|
179
|
+
if isinstance(item, ToolResultItem):
|
|
180
|
+
tool_name = item.tool_name or call_name_map.get(item.call_id)
|
|
181
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
182
|
+
tool_name = "tool_result"
|
|
183
|
+
if isinstance(item.payload, dict):
|
|
184
|
+
response_payload = dict(item.payload)
|
|
185
|
+
response_payload.setdefault("call_id", item.call_id)
|
|
186
|
+
response_payload.setdefault("tool_name", tool_name)
|
|
187
|
+
else:
|
|
188
|
+
response_payload = {
|
|
189
|
+
"content": item.output_text,
|
|
190
|
+
"call_id": item.call_id,
|
|
191
|
+
"tool_name": tool_name,
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
"role": "user",
|
|
195
|
+
"parts": [
|
|
196
|
+
{
|
|
197
|
+
"functionResponse": {
|
|
198
|
+
"name": tool_name,
|
|
199
|
+
"response": response_payload,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if item.replay_hint and item.raw_item:
|
|
206
|
+
if "thoughtSignature" in item.raw_item or item.raw_item.get("thought") is True:
|
|
207
|
+
return {"role": "model", "parts": [item.raw_item]}
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def _parse_response(self, body: dict[str, Any]) -> UnifiedLLMResponse:
|
|
212
|
+
prompt_feedback = self._to_dict(body.get("promptFeedback") or {})
|
|
213
|
+
candidates = body.get("candidates") or []
|
|
214
|
+
|
|
215
|
+
if prompt_feedback.get("blockReason") and not candidates:
|
|
216
|
+
return UnifiedLLMResponse(
|
|
217
|
+
response_id=None,
|
|
218
|
+
status="blocked",
|
|
219
|
+
reason="content_filter",
|
|
220
|
+
output_items=[],
|
|
221
|
+
output_text="",
|
|
222
|
+
usage=self._parse_usage(body),
|
|
223
|
+
state_patch=StatePatch(),
|
|
224
|
+
provider_name="gemini",
|
|
225
|
+
raw_response=body,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if not candidates:
|
|
229
|
+
raise ProviderError(
|
|
230
|
+
"Gemini response has no candidates.",
|
|
231
|
+
issue=ProviderIssue(category="parse", retryable=False),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
candidate = self._to_dict(candidates[0])
|
|
235
|
+
content = self._to_dict(candidate.get("content") or {})
|
|
236
|
+
parts = content.get("parts") or []
|
|
237
|
+
|
|
238
|
+
output_items: list[ConversationItem] = []
|
|
239
|
+
tool_name_patch: dict[str, str] = {}
|
|
240
|
+
|
|
241
|
+
for idx, raw_part in enumerate(parts):
|
|
242
|
+
part = self._to_dict(raw_part)
|
|
243
|
+
|
|
244
|
+
function_call = self._to_dict(part.get("functionCall") or {})
|
|
245
|
+
if function_call:
|
|
246
|
+
call_id = str(function_call.get("id") or f"gemini-call-{idx + 1}")
|
|
247
|
+
name = str(function_call.get("name") or "")
|
|
248
|
+
arguments = function_call.get("args")
|
|
249
|
+
if not isinstance(arguments, dict):
|
|
250
|
+
arguments = {}
|
|
251
|
+
output_items.append(
|
|
252
|
+
ToolCallItem(
|
|
253
|
+
call_id=call_id,
|
|
254
|
+
name=name,
|
|
255
|
+
arguments=arguments,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
if name:
|
|
259
|
+
tool_name_patch[call_id] = name
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
has_thought = part.get("thought") is True or "thoughtSignature" in part
|
|
263
|
+
if has_thought:
|
|
264
|
+
reasoning_text = part.get("text") if isinstance(part.get("text"), str) else None
|
|
265
|
+
output_items.append(
|
|
266
|
+
ReasoningItem(
|
|
267
|
+
text=reasoning_text,
|
|
268
|
+
summary=None,
|
|
269
|
+
raw_item=part,
|
|
270
|
+
replay_hint=True,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
text = part.get("text")
|
|
276
|
+
if isinstance(text, str) and text:
|
|
277
|
+
output_items.append(MessageItem(role="assistant", text=text))
|
|
278
|
+
|
|
279
|
+
status, reason = self._map_status(candidate, output_items)
|
|
280
|
+
|
|
281
|
+
return UnifiedLLMResponse(
|
|
282
|
+
response_id=None,
|
|
283
|
+
status=status,
|
|
284
|
+
reason=reason,
|
|
285
|
+
output_items=output_items,
|
|
286
|
+
output_text=self.render_output_text(output_items, body),
|
|
287
|
+
usage=self._parse_usage(body),
|
|
288
|
+
state_patch=StatePatch(provider_meta_patch={"tool_name_by_call_id": tool_name_patch}),
|
|
289
|
+
provider_name="gemini",
|
|
290
|
+
raw_response=body,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _map_status(
|
|
294
|
+
self,
|
|
295
|
+
candidate: dict[str, Any],
|
|
296
|
+
output_items: list[ConversationItem],
|
|
297
|
+
) -> tuple[TurnStatus, CompletionReason]:
|
|
298
|
+
if any(isinstance(item, ToolCallItem) for item in output_items):
|
|
299
|
+
return "requires_tool", "tool_call"
|
|
300
|
+
|
|
301
|
+
finish_reason = str(candidate.get("finishReason") or "")
|
|
302
|
+
mapping: dict[str, tuple[TurnStatus, CompletionReason]] = {
|
|
303
|
+
"STOP": ("completed", "stop"),
|
|
304
|
+
"MAX_TOKENS": ("incomplete", "max_tokens"),
|
|
305
|
+
"SAFETY": ("blocked", "content_filter"),
|
|
306
|
+
"RECITATION": ("blocked", "content_filter"),
|
|
307
|
+
"BLOCKLIST": ("blocked", "content_filter"),
|
|
308
|
+
"PROHIBITED_CONTENT": ("blocked", "content_filter"),
|
|
309
|
+
}
|
|
310
|
+
if finish_reason in mapping:
|
|
311
|
+
return mapping[finish_reason]
|
|
312
|
+
|
|
313
|
+
if finish_reason in {"MODEL_ARMOR", "MALFORMED_FUNCTION_CALL"}:
|
|
314
|
+
return "failed", "error"
|
|
315
|
+
|
|
316
|
+
return "incomplete", "unknown"
|
|
317
|
+
|
|
318
|
+
def _parse_usage(self, body: dict[str, Any]) -> Usage:
|
|
319
|
+
usage = self._to_dict(body.get("usageMetadata") or {})
|
|
320
|
+
return Usage(
|
|
321
|
+
input_tokens=self._as_int(usage.get("promptTokenCount")),
|
|
322
|
+
output_tokens=self._as_int(usage.get("candidatesTokenCount")),
|
|
323
|
+
total_tokens=self._as_int(usage.get("totalTokenCount")),
|
|
324
|
+
reasoning_tokens=self._as_int(usage.get("thoughtsTokenCount")),
|
|
325
|
+
cache_read_tokens=self._as_int(usage.get("cachedContentTokenCount")),
|
|
326
|
+
raw=usage or None,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _raise_http_error(self, response: requests.Response) -> None:
|
|
330
|
+
body: dict[str, Any] | None = None
|
|
331
|
+
try:
|
|
332
|
+
parsed = response.json()
|
|
333
|
+
if isinstance(parsed, dict):
|
|
334
|
+
body = parsed
|
|
335
|
+
except ValueError:
|
|
336
|
+
body = None
|
|
337
|
+
|
|
338
|
+
status = response.status_code
|
|
339
|
+
category = "unknown"
|
|
340
|
+
retryable = False
|
|
341
|
+
|
|
342
|
+
if status in {401, 403}:
|
|
343
|
+
category = "auth"
|
|
344
|
+
elif status == 429:
|
|
345
|
+
category = "rate_limit"
|
|
346
|
+
retryable = True
|
|
347
|
+
elif 400 <= status < 500:
|
|
348
|
+
category = "invalid_request"
|
|
349
|
+
elif status >= 500:
|
|
350
|
+
category = "upstream"
|
|
351
|
+
retryable = True
|
|
352
|
+
|
|
353
|
+
provider_code: str | None = None
|
|
354
|
+
if body:
|
|
355
|
+
error = self._to_dict(body.get("error") or {})
|
|
356
|
+
provider_code = str(error.get("status") or error.get("code") or "") or None
|
|
357
|
+
err_message = str(error.get("message") or "").lower()
|
|
358
|
+
if "safety" in err_message or "policy" in err_message:
|
|
359
|
+
category = "safety"
|
|
360
|
+
|
|
361
|
+
raise ProviderError(
|
|
362
|
+
f"Gemini request failed with status {status}.",
|
|
363
|
+
issue=ProviderIssue(
|
|
364
|
+
category=category,
|
|
365
|
+
http_status=status,
|
|
366
|
+
provider_code=provider_code,
|
|
367
|
+
retryable=retryable,
|
|
368
|
+
raw=body,
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _to_dict(self, value: Any) -> dict[str, Any]:
|
|
373
|
+
if isinstance(value, dict):
|
|
374
|
+
return value
|
|
375
|
+
if hasattr(value, "model_dump"):
|
|
376
|
+
try:
|
|
377
|
+
dumped = value.model_dump(mode="python")
|
|
378
|
+
if isinstance(dumped, dict):
|
|
379
|
+
return dumped
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
return {}
|
|
383
|
+
|
|
384
|
+
def _as_int(self, value: Any) -> int | None:
|
|
385
|
+
if isinstance(value, bool):
|
|
386
|
+
return int(value)
|
|
387
|
+
if isinstance(value, int):
|
|
388
|
+
return value
|
|
389
|
+
if isinstance(value, float):
|
|
390
|
+
return int(value)
|
|
391
|
+
if isinstance(value, str):
|
|
392
|
+
try:
|
|
393
|
+
return int(value)
|
|
394
|
+
except ValueError:
|
|
395
|
+
return None
|
|
396
|
+
return None
|