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.
Files changed (51) hide show
  1. agentkit/__init__.py +35 -0
  2. agentkit/agent/__init__.py +7 -0
  3. agentkit/agent/agent.py +368 -0
  4. agentkit/agent/budgets.py +48 -0
  5. agentkit/agent/report.py +166 -0
  6. agentkit/agent/tool_runtime.py +77 -0
  7. agentkit/cli/__init__.py +5 -0
  8. agentkit/cli/main.py +108 -0
  9. agentkit/config/__init__.py +23 -0
  10. agentkit/config/loader.py +108 -0
  11. agentkit/config/provider_defaults.py +96 -0
  12. agentkit/config/schema.py +148 -0
  13. agentkit/constants.py +21 -0
  14. agentkit/errors.py +58 -0
  15. agentkit/llm/__init__.py +53 -0
  16. agentkit/llm/base.py +36 -0
  17. agentkit/llm/factory.py +27 -0
  18. agentkit/llm/providers/__init__.py +15 -0
  19. agentkit/llm/providers/anthropic_provider.py +371 -0
  20. agentkit/llm/providers/gemini_provider.py +396 -0
  21. agentkit/llm/providers/openai_provider.py +881 -0
  22. agentkit/llm/providers/qwen_provider.py +34 -0
  23. agentkit/llm/providers/vllm_provider.py +47 -0
  24. agentkit/llm/types.py +215 -0
  25. agentkit/llm/usage.py +72 -0
  26. agentkit/py.typed +0 -0
  27. agentkit/runlog/__init__.py +15 -0
  28. agentkit/runlog/events.py +67 -0
  29. agentkit/runlog/jsonl.py +90 -0
  30. agentkit/runlog/recorder.py +94 -0
  31. agentkit/runlog/sinks.py +15 -0
  32. agentkit/tools/__init__.py +16 -0
  33. agentkit/tools/base.py +139 -0
  34. agentkit/tools/library/__init__.py +8 -0
  35. agentkit/tools/library/_fs_common.py +330 -0
  36. agentkit/tools/library/create_file.py +168 -0
  37. agentkit/tools/library/fs_tools.py +21 -0
  38. agentkit/tools/library/str_replace.py +241 -0
  39. agentkit/tools/library/view.py +372 -0
  40. agentkit/tools/library/word_count.py +138 -0
  41. agentkit/tools/loader.py +81 -0
  42. agentkit/tools/registry.py +284 -0
  43. agentkit/tools/types.py +98 -0
  44. agentkit/workspace/__init__.py +6 -0
  45. agentkit/workspace/fs.py +288 -0
  46. agentkit/workspace/layout.py +33 -0
  47. base_agentkit-0.1.0.dist-info/METADATA +142 -0
  48. base_agentkit-0.1.0.dist-info/RECORD +51 -0
  49. base_agentkit-0.1.0.dist-info/WHEEL +4 -0
  50. base_agentkit-0.1.0.dist-info/entry_points.txt +3 -0
  51. 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