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,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)
|
agentkit/tools/loader.py
ADDED
|
@@ -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
|
+
)
|
agentkit/tools/types.py
ADDED
|
@@ -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
|
+
}
|