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,138 @@
1
+ """word_count tool implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agentkit.errors import WorkspaceError
8
+ from agentkit.tools.base import FunctionTool, Tool
9
+ from agentkit.tools.types import ToolInvocation
10
+ from agentkit.workspace.fs import WorkspaceFS
11
+
12
+ from ._fs_common import (
13
+ count_text_metrics,
14
+ error_payload,
15
+ format_count,
16
+ format_path_workspace_error,
17
+ is_probably_binary,
18
+ path_details,
19
+ resolve_existing_file,
20
+ )
21
+
22
+
23
+ def build_word_count_tool(fs: WorkspaceFS) -> Tool:
24
+ """Build the workspace-bound ``word_count`` tool."""
25
+ return FunctionTool(
26
+ name="word_count",
27
+ description=(
28
+ "Count the number of words and lines in a workspace text file.\n"
29
+ "\n"
30
+ "USE THIS TOOL WHEN you need to:\n"
31
+ "- Verify whether a draft meets a minimum or maximum word count.\n"
32
+ "- Report text metrics after writing or revising.\n"
33
+ "\n"
34
+ "RETURNS:\n"
35
+ "- `word_count`: number of words.\n"
36
+ "- `line_count`: the total number of lines in the file.\n"
37
+ "\n"
38
+ "LIMITATIONS:\n"
39
+ "- Only works on text files in the workspace. Binary files are rejected.\n"
40
+ "- The file must be valid UTF-8."
41
+ ),
42
+ parameters={
43
+ "type": "object",
44
+ "properties": {
45
+ "description": {
46
+ "type": "string",
47
+ "description": (
48
+ "A short explanation of WHY you need the word count. Be specific. "
49
+ "Good: 'Verify the revised draft meets the 3,000-word target'. "
50
+ "Bad: 'count words'."
51
+ ),
52
+ },
53
+ "path": {
54
+ "type": "string",
55
+ "description": (
56
+ "Relative path (from workspace root) to the text file to count. "
57
+ "Must be an existing text file (not a directory, not a binary file). "
58
+ "Example: 'draft.md', 'report.md'."
59
+ ),
60
+ },
61
+ },
62
+ "required": ["description", "path"],
63
+ "additionalProperties": False,
64
+ },
65
+ handler=lambda args: _word_count(fs, args),
66
+ success_formatter=_format_word_count_output,
67
+ error_formatter=_format_word_count_error,
68
+ )
69
+
70
+
71
+ def _word_count(fs: WorkspaceFS, args: dict[str, Any]) -> dict[str, Any]:
72
+ """Count text metrics for an existing UTF-8 workspace file."""
73
+ path = args["path"]
74
+ target = resolve_existing_file(fs, path)
75
+ data = target.read_bytes()
76
+ if is_probably_binary(data):
77
+ raise WorkspaceError(f"Binary file is not supported for word_count: {path}")
78
+ try:
79
+ text = data.decode("utf-8")
80
+ except UnicodeDecodeError as exc:
81
+ raise WorkspaceError(f"File is not valid UTF-8 text: {path}") from exc
82
+ return {
83
+ "path": path,
84
+ **count_text_metrics(text),
85
+ }
86
+
87
+
88
+ def _format_word_count_output(output: Any, invocation: ToolInvocation) -> Any:
89
+ """Render the human-facing count summary shown back to the model."""
90
+ del invocation
91
+ if not isinstance(output, dict):
92
+ return output
93
+ path = output.get("path")
94
+ word_count = output.get("word_count")
95
+ line_count = output.get("line_count")
96
+ if (
97
+ isinstance(path, str)
98
+ and isinstance(word_count, int)
99
+ and isinstance(line_count, int)
100
+ ):
101
+ return (
102
+ f"The file {path} has {format_count(word_count, 'word')} "
103
+ f"across {format_count(line_count, 'line')}."
104
+ )
105
+ return {"output": output}
106
+
107
+
108
+ def _format_word_count_error(
109
+ error: Exception, invocation: ToolInvocation
110
+ ) -> dict[str, Any]:
111
+ """Translate ``word_count`` failures into stable model-facing errors."""
112
+ common = format_path_workspace_error(
113
+ error,
114
+ invocation,
115
+ missing_message="The file to count does not exist in the workspace.",
116
+ missing_hint="Choose an existing text file path inside the workspace.",
117
+ not_file_message="word_count only works on files.",
118
+ )
119
+ if common is not None:
120
+ return common
121
+
122
+ message = str(error)
123
+ details = path_details(invocation)
124
+ if message.startswith("Binary file is not supported for word_count: "):
125
+ return error_payload(
126
+ "binary_file",
127
+ "word_count only works on text files, not binary files.",
128
+ hint="Choose a text file such as .md, .txt, or another UTF-8 document.",
129
+ details=details,
130
+ )
131
+ if message.startswith("File is not valid UTF-8 text: "):
132
+ return error_payload(
133
+ "invalid_text_encoding",
134
+ "word_count requires valid UTF-8 text for exact counting.",
135
+ hint="Convert the file to UTF-8 and retry.",
136
+ details=details,
137
+ )
138
+ return error_payload("word_count_failed", message, details=details)
@@ -0,0 +1,81 @@
1
+ """Automatic tool loading from the tool library directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ import pkgutil
8
+ from typing import Any, Iterable
9
+
10
+ from agentkit.errors import ToolError
11
+ from agentkit.tools.base import Tool
12
+ from agentkit.workspace.fs import WorkspaceFS
13
+
14
+ TOOLS_LIBRARY_PACKAGE = "agentkit.tools.library"
15
+
16
+
17
+ def load_tools_from_library(fs: WorkspaceFS) -> list[Tool]:
18
+ """Load all tools exposed by modules under ``agentkit.tools.library``.
19
+
20
+ Tool modules can expose tools via:
21
+ - ``build_tools(fs)`` (or ``build_tools()``)
22
+ - ``TOOLS`` module variable
23
+
24
+ Args:
25
+ fs: Workspace filesystem injected into callable tool factories.
26
+
27
+ Returns:
28
+ list[Tool]: Flattened list of tools from all library modules.
29
+ """
30
+ package = importlib.import_module(TOOLS_LIBRARY_PACKAGE)
31
+ package_path = getattr(package, "__path__", None)
32
+ if package_path is None:
33
+ return []
34
+
35
+ loaded: list[Tool] = []
36
+ for module_info in sorted(pkgutil.iter_modules(package_path), key=lambda m: m.name):
37
+ if module_info.name.startswith("_"):
38
+ continue
39
+ module_name = f"{TOOLS_LIBRARY_PACKAGE}.{module_info.name}"
40
+ module = importlib.import_module(module_name)
41
+ loaded.extend(_load_from_module(module, fs, module_name=module_name))
42
+ return loaded
43
+
44
+
45
+ def _load_from_module(module: Any, fs: WorkspaceFS, *, module_name: str) -> list[Tool]:
46
+ """Extract tools from one library module."""
47
+ if hasattr(module, "build_tools"):
48
+ return _coerce_to_tools(getattr(module, "build_tools"), fs, module_name)
49
+ if hasattr(module, "TOOLS"):
50
+ return _coerce_to_tools(getattr(module, "TOOLS"), fs, module_name)
51
+ return []
52
+
53
+
54
+ def _coerce_to_tools(candidate: Any, fs: WorkspaceFS, module_name: str) -> list[Tool]:
55
+ """Normalize supported tool declarations into ``list[Tool]``."""
56
+ if isinstance(candidate, Tool):
57
+ return [candidate]
58
+
59
+ if callable(candidate):
60
+ built: Any
61
+ try:
62
+ signature = inspect.signature(candidate)
63
+ if len(signature.parameters) == 0:
64
+ built = candidate()
65
+ else:
66
+ built = candidate(fs)
67
+ except ValueError:
68
+ built = candidate(fs)
69
+ return _coerce_to_tools(built, fs, module_name)
70
+
71
+ if isinstance(candidate, Iterable) and not isinstance(
72
+ candidate, (str, bytes, dict)
73
+ ):
74
+ tools = list(candidate)
75
+ if all(isinstance(item, Tool) for item in tools):
76
+ return tools
77
+
78
+ raise ToolError(
79
+ f"Module '{module_name}' must expose Tool, Iterable[Tool], build_tools(...), or TOOLS."
80
+ )
81
+
@@ -0,0 +1,284 @@
1
+ """Tool registration, validation, and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Iterable
7
+
8
+ from agentkit.errors import ToolError
9
+ from agentkit.tools.base import Tool
10
+ from agentkit.tools.types import ToolCallOutcome, ToolInvocation, ToolModelError
11
+
12
+ _JSON_TYPE_MAP: dict[str, tuple[type, ...]] = {
13
+ "string": (str,),
14
+ "number": (int, float),
15
+ "integer": (int,),
16
+ "boolean": (bool,),
17
+ "object": (dict,),
18
+ "array": (list, tuple),
19
+ }
20
+
21
+
22
+ class ToolRegistry:
23
+ """Store, validate, and execute named tools."""
24
+
25
+ def __init__(self) -> None:
26
+ """Initialize an empty tool registry.
27
+
28
+ Returns:
29
+ None
30
+ """
31
+ self._tools: dict[str, Tool] = {}
32
+
33
+ def register(self, tool: Tool) -> None:
34
+ """Register a single tool by name.
35
+
36
+ Args:
37
+ tool: Tool instance to add.
38
+
39
+ Returns:
40
+ None
41
+
42
+ Raises:
43
+ agentkit.errors.ToolError: If tool name is invalid or duplicated.
44
+ """
45
+ if "." in tool.name:
46
+ raise ToolError(f"Tool name cannot contain '.': {tool.name}")
47
+ if tool.name in self._tools:
48
+ raise ToolError(f"Duplicate tool name: {tool.name}")
49
+ self._tools[tool.name] = tool
50
+
51
+ def register_many(self, tools: Iterable[Tool]) -> None:
52
+ """Register multiple tools.
53
+
54
+ Args:
55
+ tools: Iterable of tools to register.
56
+
57
+ Returns:
58
+ None
59
+
60
+ Raises:
61
+ agentkit.errors.ToolError: If any tool fails validation or duplicates an
62
+ existing name.
63
+ """
64
+ for tool in tools:
65
+ self.register(tool)
66
+
67
+ def get(self, name: str) -> Tool:
68
+ """Retrieve a registered tool by name.
69
+
70
+ Args:
71
+ name: Tool name.
72
+
73
+ Returns:
74
+ Tool: Registered tool instance.
75
+
76
+ Raises:
77
+ agentkit.errors.ToolError: If the tool is not registered.
78
+ """
79
+ tool = self._tools.get(name)
80
+ if tool is None:
81
+ raise ToolError(f"Tool not found: {name}")
82
+ return tool
83
+
84
+ def list_names(self) -> list[str]:
85
+ """Return all registered tool names in sorted order.
86
+
87
+ Returns:
88
+ list[str]: Sorted tool names.
89
+ """
90
+ return sorted(self._tools.keys())
91
+
92
+ def schemas(self, allowed: Iterable[str] | None = None) -> list[dict[str, Any]]:
93
+ """Build model-facing schemas for registered tools.
94
+
95
+ Args:
96
+ allowed: Optional iterable of allowed tool names.
97
+
98
+ Returns:
99
+ list[dict[str, Any]]: Tool schema dictionaries.
100
+ """
101
+ if allowed is None:
102
+ names = self.list_names()
103
+ else:
104
+ names = [name for name in allowed if name in self._tools]
105
+ return [self._tools[name].schema() for name in names]
106
+
107
+ def execute(self, invocation: ToolInvocation) -> ToolCallOutcome:
108
+ """Execute a tool and wrap success/failure in ``ToolCallOutcome``.
109
+
110
+ Args:
111
+ invocation: Tool invocation including name, arguments, and optional
112
+ correlation id.
113
+
114
+ Returns:
115
+ ToolCallOutcome: Outcome including arguments, latency, and errors.
116
+ """
117
+ start = time.perf_counter()
118
+ tool: Tool | None = None
119
+ try:
120
+ tool = self.get(invocation.name)
121
+ self._validate_arguments(tool.parameters, invocation.arguments)
122
+ output = tool.run(invocation.arguments)
123
+ duration_ms = (time.perf_counter() - start) * 1000
124
+ return ToolCallOutcome(
125
+ call_id=invocation.call_id,
126
+ name=invocation.name,
127
+ arguments=self._normalize_arguments(invocation.arguments),
128
+ output=output,
129
+ model_payload=self._normalize_model_payload(
130
+ tool.format_output_for_model(output, invocation),
131
+ fallback={"output": output},
132
+ ),
133
+ duration_ms=duration_ms,
134
+ )
135
+ except Exception as exc:
136
+ duration_ms = (time.perf_counter() - start) * 1000
137
+ return ToolCallOutcome(
138
+ call_id=invocation.call_id,
139
+ name=invocation.name,
140
+ arguments=self._normalize_arguments(invocation.arguments),
141
+ error=str(exc),
142
+ model_payload=self._format_error_payload(
143
+ exc,
144
+ invocation=invocation,
145
+ tool=tool,
146
+ ),
147
+ duration_ms=duration_ms,
148
+ )
149
+
150
+ def _normalize_arguments(self, arguments: Any) -> dict[str, Any]:
151
+ """Return a defensive copy of invocation arguments when they are object-like."""
152
+ if isinstance(arguments, dict):
153
+ return dict(arguments)
154
+ return {}
155
+
156
+ def _normalize_model_payload(self, payload: Any, *, fallback: Any) -> Any:
157
+ """Normalize formatter output into a stable payload shape."""
158
+ if payload is None:
159
+ return fallback
160
+ if isinstance(payload, dict):
161
+ return dict(payload)
162
+ return payload
163
+
164
+ def _format_error_payload(
165
+ self,
166
+ exc: Exception,
167
+ *,
168
+ invocation: ToolInvocation,
169
+ tool: Tool | None,
170
+ ) -> Any:
171
+ """Build the model-facing error payload for execution or registry failures."""
172
+ fallback = {
173
+ "error": {
174
+ "code": "tool_execution_failed",
175
+ "message": f"The tool '{invocation.name}' failed unexpectedly.",
176
+ }
177
+ }
178
+ if tool is not None:
179
+ payload = tool.format_error_for_model(exc, invocation)
180
+ return self._normalize_model_payload(payload, fallback=fallback)
181
+ return self._format_registry_error(exc, invocation)
182
+
183
+ def _format_registry_error(
184
+ self, exc: Exception, invocation: ToolInvocation
185
+ ) -> dict[str, Any]:
186
+ """Translate registry/validation failures into stable error codes."""
187
+ if isinstance(exc, ToolModelError):
188
+ return exc.to_model_payload()
189
+ if isinstance(exc, ToolError):
190
+ # Keep these codes stable so providers and downstream tooling can rely on
191
+ # them without parsing free-form exception strings.
192
+ message = str(exc)
193
+ if message.startswith("Tool not found: "):
194
+ return {
195
+ "error": {
196
+ "code": "tool_not_found",
197
+ "message": f"Tool '{invocation.name}' is not registered.",
198
+ }
199
+ }
200
+ if message == "Tool arguments must be an object.":
201
+ return {
202
+ "error": {
203
+ "code": "invalid_arguments",
204
+ "message": "Tool arguments must be a JSON object.",
205
+ }
206
+ }
207
+ if message.startswith("Missing required argument: "):
208
+ key = message.removeprefix("Missing required argument: ")
209
+ return {
210
+ "error": {
211
+ "code": "missing_argument",
212
+ "message": f"Missing required argument '{key}'.",
213
+ "hint": f"Call the tool again and include '{key}'.",
214
+ }
215
+ }
216
+ if message.startswith("Unexpected argument: "):
217
+ key = message.removeprefix("Unexpected argument: ")
218
+ return {
219
+ "error": {
220
+ "code": "unexpected_argument",
221
+ "message": f"Argument '{key}' is not accepted by this tool.",
222
+ }
223
+ }
224
+ if message.startswith("Invalid type for '"):
225
+ return {
226
+ "error": {
227
+ "code": "invalid_argument_type",
228
+ "message": message,
229
+ }
230
+ }
231
+ return {
232
+ "error": {
233
+ "code": "tool_invocation_invalid",
234
+ "message": message,
235
+ }
236
+ }
237
+ return {
238
+ "error": {
239
+ "code": "tool_execution_failed",
240
+ "message": f"The tool '{invocation.name}' failed unexpectedly.",
241
+ }
242
+ }
243
+
244
+ def _validate_arguments(
245
+ self, schema: dict[str, Any], arguments: dict[str, Any]
246
+ ) -> None:
247
+ """Validate runtime arguments against a JSON-schema subset.
248
+
249
+ Args:
250
+ schema: Tool parameter schema.
251
+ arguments: Runtime argument object.
252
+
253
+ Returns:
254
+ None
255
+
256
+ Raises:
257
+ agentkit.errors.ToolError: If required keys are missing, unexpected keys
258
+ are disallowed, or value types mismatch declared schema types.
259
+ """
260
+ if not isinstance(arguments, dict):
261
+ raise ToolError("Tool arguments must be an object.")
262
+
263
+ required = schema.get("required", [])
264
+ for key in required:
265
+ if key not in arguments:
266
+ raise ToolError(f"Missing required argument: {key}")
267
+
268
+ properties = schema.get("properties", {})
269
+ additional_allowed = schema.get("additionalProperties", True)
270
+
271
+ for key, value in arguments.items():
272
+ if key not in properties:
273
+ if additional_allowed is False:
274
+ raise ToolError(f"Unexpected argument: {key}")
275
+ continue
276
+
277
+ expected_type = properties[key].get("type")
278
+ if expected_type is None:
279
+ continue
280
+ py_types = _JSON_TYPE_MAP.get(expected_type)
281
+ if py_types and not isinstance(value, py_types):
282
+ raise ToolError(
283
+ f"Invalid type for '{key}': expected {expected_type}, got {type(value).__name__}"
284
+ )
@@ -0,0 +1,98 @@
1
+ """Tool-related invocation and outcome types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class ToolModelError(Exception):
11
+ """Tool-defined model-facing error payload.
12
+
13
+ Tool authors can raise this directly from custom tools when they want the model
14
+ to receive a stable, structured error description instead of a raw exception
15
+ string.
16
+ """
17
+
18
+ code: str
19
+ message: str
20
+ hint: str | None = None
21
+ details: dict[str, Any] = field(default_factory=dict)
22
+
23
+ def __post_init__(self) -> None:
24
+ """Initialize the underlying ``Exception`` with the human-readable message."""
25
+ Exception.__init__(self, self.message)
26
+
27
+ def __str__(self) -> str:
28
+ """Return the message so generic exception handling stays readable."""
29
+ return self.message
30
+
31
+ def to_model_payload(self) -> dict[str, Any]:
32
+ """Serialize the structured error into the canonical model payload shape."""
33
+ error: dict[str, Any] = {
34
+ "code": self.code,
35
+ "message": self.message,
36
+ }
37
+ if self.hint:
38
+ error["hint"] = self.hint
39
+ if self.details:
40
+ error["details"] = dict(self.details)
41
+ return {"error": error}
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ToolInvocation:
46
+ """Structured request to execute one tool.
47
+
48
+ Attributes:
49
+ name: Tool name.
50
+ arguments: Parsed argument mapping supplied by the caller.
51
+ call_id: Optional provider-assigned tool call identifier.
52
+ """
53
+
54
+ name: str
55
+ arguments: Any
56
+ call_id: str | None = None
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class ToolCallOutcome:
61
+ """Canonical outcome of a single tool call.
62
+
63
+ Attributes:
64
+ call_id: Optional provider-assigned tool call identifier.
65
+ name: Tool name.
66
+ arguments: Validated argument mapping when available.
67
+ output: Successful output payload.
68
+ error: Failure message when execution failed.
69
+ model_payload: Model-facing payload derived from tool-specific success/error
70
+ formatting hooks.
71
+ duration_ms: Execution latency in milliseconds.
72
+ """
73
+
74
+ call_id: str | None
75
+ name: str
76
+ arguments: dict[str, Any]
77
+ output: Any = None
78
+ error: str | None = None
79
+ model_payload: Any = None
80
+ duration_ms: float | None = None
81
+
82
+ @property
83
+ def is_error(self) -> bool:
84
+ """Return ``True`` when the tool finished with an error message."""
85
+ return self.error is not None
86
+
87
+ def to_event_payload(self) -> dict[str, Any]:
88
+ """Serialize the stable run-event payload for this tool outcome."""
89
+ return {
90
+ "call_id": self.call_id,
91
+ "name": self.name,
92
+ "is_error": self.is_error,
93
+ "arguments": dict(self.arguments),
94
+ "output": self.output,
95
+ "error": self.error,
96
+ "model_payload": self.model_payload,
97
+ "duration_ms": self.duration_ms,
98
+ }
@@ -0,0 +1,6 @@
1
+ """Public workspace filesystem exports."""
2
+
3
+ from .fs import WorkspaceFS
4
+ from .layout import init_workspace_layout
5
+
6
+ __all__ = ["WorkspaceFS", "init_workspace_layout"]