toolproxy 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.
- toolproxy/__init__.py +90 -0
- toolproxy/agent.py +184 -0
- toolproxy/config.py +98 -0
- toolproxy/exceptions.py +58 -0
- toolproxy/executor.py +123 -0
- toolproxy/llm_client.py +409 -0
- toolproxy/loop.py +180 -0
- toolproxy/planner.py +241 -0
- toolproxy/py.typed +0 -0
- toolproxy/schemas.py +125 -0
- toolproxy/tools.py +221 -0
- toolproxy-0.1.0.dist-info/METADATA +243 -0
- toolproxy-0.1.0.dist-info/RECORD +15 -0
- toolproxy-0.1.0.dist-info/WHEEL +4 -0
- toolproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
toolproxy/planner.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Planner module for toolproxy.
|
|
3
|
+
|
|
4
|
+
The Planner is responsible for:
|
|
5
|
+
1. Building system prompts (native or emulated mode).
|
|
6
|
+
2. Calling the LLM client.
|
|
7
|
+
3. Parsing the response into Action objects (emulated) or ToolCall lists (native).
|
|
8
|
+
4. Retrying on malformed JSON up to parse_retries times.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from typing import List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from .exceptions import SchemaValidationError
|
|
17
|
+
from .llm_client import LLMClient, ModelResponse
|
|
18
|
+
from .schemas import Action, Message, ToolCall
|
|
19
|
+
from .tools import ToolRegistry
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Prompt templates
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
EMULATED_SYSTEM_TEMPLATE = """\
|
|
26
|
+
You are a helpful AI assistant with access to the following tools:
|
|
27
|
+
|
|
28
|
+
{tool_descriptions}
|
|
29
|
+
|
|
30
|
+
## CRITICAL INSTRUCTIONS
|
|
31
|
+
|
|
32
|
+
You MUST respond with ONLY a valid JSON object. No other text, no markdown, no explanation.
|
|
33
|
+
|
|
34
|
+
Your response must match EXACTLY one of these two formats:
|
|
35
|
+
|
|
36
|
+
FORMAT 1 - When you need to use a tool:
|
|
37
|
+
{{"type": "tool_call", "tool": {{"tool_name": "<name>", "arguments": {{<key>: <value>}}}}}}
|
|
38
|
+
|
|
39
|
+
FORMAT 2 - When you have a final answer:
|
|
40
|
+
{{"type": "final", "content": "<your answer here>"}}
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
- Respond ONLY with raw JSON, nothing else.
|
|
44
|
+
- Always choose the correct tool from the list above.
|
|
45
|
+
- Arguments must match the tool's parameter types exactly.
|
|
46
|
+
- When you have enough information to answer, use format 2.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
TOOL_RESULT_TEMPLATE = "Tool '{name}' returned: {result}"
|
|
50
|
+
|
|
51
|
+
RETRY_PROMPT_TEMPLATE = """\
|
|
52
|
+
Your previous response was not valid JSON or did not match the required schema.
|
|
53
|
+
Error: {error}
|
|
54
|
+
|
|
55
|
+
Please try again. You MUST respond with ONLY a valid JSON object matching one of these formats:
|
|
56
|
+
- {{"type": "tool_call", "tool": {{"tool_name": "<name>", "arguments": {{}}}}}}
|
|
57
|
+
- {{"type": "final", "content": "<answer>"}}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# JSON extraction helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _extract_json(text: str) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Try to extract a JSON object from *text*.
|
|
68
|
+
|
|
69
|
+
Handles cases where the model wraps JSON in markdown code fences or
|
|
70
|
+
adds leading/trailing commentary.
|
|
71
|
+
"""
|
|
72
|
+
# Remove markdown fences
|
|
73
|
+
text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip()
|
|
74
|
+
|
|
75
|
+
# Try to find the outermost JSON object
|
|
76
|
+
start = text.find("{")
|
|
77
|
+
if start == -1:
|
|
78
|
+
return text
|
|
79
|
+
|
|
80
|
+
depth = 0
|
|
81
|
+
in_string = False
|
|
82
|
+
escape_next = False
|
|
83
|
+
for i, ch in enumerate(text[start:], start=start):
|
|
84
|
+
if escape_next:
|
|
85
|
+
escape_next = False
|
|
86
|
+
continue
|
|
87
|
+
if ch == "\\" and in_string:
|
|
88
|
+
escape_next = True
|
|
89
|
+
continue
|
|
90
|
+
if ch == '"':
|
|
91
|
+
in_string = not in_string
|
|
92
|
+
if not in_string:
|
|
93
|
+
if ch == "{":
|
|
94
|
+
depth += 1
|
|
95
|
+
elif ch == "}":
|
|
96
|
+
depth -= 1
|
|
97
|
+
if depth == 0:
|
|
98
|
+
return text[start : i + 1]
|
|
99
|
+
return text[start:]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Planner
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
class Planner:
|
|
107
|
+
"""
|
|
108
|
+
Coordinates model calls, prompt construction, and response parsing.
|
|
109
|
+
|
|
110
|
+
In native mode:
|
|
111
|
+
- Passes tool schemas to the LLM client.
|
|
112
|
+
- Converts provider tool-call objects to internal ToolCall list.
|
|
113
|
+
|
|
114
|
+
In emulated mode:
|
|
115
|
+
- Injects tool descriptions into the system prompt.
|
|
116
|
+
- Instructs the model to output a JSON Action.
|
|
117
|
+
- Validates and retries on schema errors.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
client: LLMClient,
|
|
123
|
+
registry: ToolRegistry,
|
|
124
|
+
mode: str = "auto",
|
|
125
|
+
parse_retries: int = 3,
|
|
126
|
+
) -> None:
|
|
127
|
+
self._client = client
|
|
128
|
+
self._registry = registry
|
|
129
|
+
self._parse_retries = parse_retries
|
|
130
|
+
|
|
131
|
+
if mode == "auto":
|
|
132
|
+
self._use_native = client.supports_native_tools()
|
|
133
|
+
elif mode == "native_only":
|
|
134
|
+
self._use_native = True
|
|
135
|
+
else:
|
|
136
|
+
self._use_native = False
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def use_native(self) -> bool:
|
|
140
|
+
return self._use_native
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Main entry point
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def plan(
|
|
147
|
+
self, messages: List[Message]
|
|
148
|
+
) -> Union[List[ToolCall], Action]:
|
|
149
|
+
"""
|
|
150
|
+
Given *messages*, ask the model what to do next.
|
|
151
|
+
|
|
152
|
+
Returns either:
|
|
153
|
+
- A list of ToolCall objects (native mode, could be multiple).
|
|
154
|
+
- An Action object (emulated mode).
|
|
155
|
+
"""
|
|
156
|
+
if self._use_native:
|
|
157
|
+
return self._plan_native(messages)
|
|
158
|
+
return self._plan_emulated(messages)
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Native mode
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _plan_native(self, messages: List[Message]) -> Union[List[ToolCall], Action]:
|
|
165
|
+
tool_schemas = self._registry.to_openai_schema()
|
|
166
|
+
response: ModelResponse = self._client.generate(messages, tools=tool_schemas)
|
|
167
|
+
|
|
168
|
+
if response.has_tool_calls:
|
|
169
|
+
return response.tool_calls
|
|
170
|
+
|
|
171
|
+
# Model gave a text response with no tool calls → final answer
|
|
172
|
+
return Action(type="final", content=response.raw_text or "")
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Emulated mode
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _build_system_message(self) -> Message:
|
|
179
|
+
tool_descriptions = self._registry.to_prompt_description()
|
|
180
|
+
content = EMULATED_SYSTEM_TEMPLATE.format(tool_descriptions=tool_descriptions)
|
|
181
|
+
return Message(role="system", content=content)
|
|
182
|
+
|
|
183
|
+
def _inject_system(self, messages: List[Message]) -> List[Message]:
|
|
184
|
+
"""Prepend or replace the system message with the emulated tool prompt."""
|
|
185
|
+
if messages and messages[0].role == "system":
|
|
186
|
+
# Replace existing system message content
|
|
187
|
+
existing_content = messages[0].content
|
|
188
|
+
new_content = self._build_system_message().content
|
|
189
|
+
if existing_content not in new_content:
|
|
190
|
+
new_content = new_content + "\n\n# Additional instructions:\n" + existing_content
|
|
191
|
+
updated = Message(role="system", content=new_content)
|
|
192
|
+
return [updated] + messages[1:]
|
|
193
|
+
return [self._build_system_message()] + messages
|
|
194
|
+
|
|
195
|
+
def _plan_emulated(self, messages: List[Message]) -> Action:
|
|
196
|
+
full_messages = self._inject_system(messages)
|
|
197
|
+
last_error: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
for attempt in range(self._parse_retries):
|
|
200
|
+
if attempt > 0 and last_error:
|
|
201
|
+
# Append a retry nudge as assistant + user alternation
|
|
202
|
+
retry_content = RETRY_PROMPT_TEMPLATE.format(error=last_error)
|
|
203
|
+
full_messages = full_messages + [
|
|
204
|
+
Message(role="user", content=retry_content)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
response: ModelResponse = self._client.generate(full_messages)
|
|
208
|
+
raw = response.raw_text.strip()
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
action = self._parse_action(raw)
|
|
212
|
+
return action
|
|
213
|
+
except (SchemaValidationError, json.JSONDecodeError, ValueError) as exc:
|
|
214
|
+
last_error = str(exc)
|
|
215
|
+
|
|
216
|
+
raise SchemaValidationError(
|
|
217
|
+
f"Failed to parse valid Action after {self._parse_retries} attempts. "
|
|
218
|
+
f"Last error: {last_error}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _parse_action(self, raw_text: str) -> Action:
|
|
222
|
+
"""Parse *raw_text* into an Action, raising on failure."""
|
|
223
|
+
if not raw_text:
|
|
224
|
+
raise SchemaValidationError("Model returned empty response.")
|
|
225
|
+
|
|
226
|
+
extracted = _extract_json(raw_text)
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(extracted)
|
|
229
|
+
except json.JSONDecodeError as exc:
|
|
230
|
+
raise SchemaValidationError(f"Invalid JSON: {exc}. Raw text: {raw_text!r}") from exc
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
action = Action.model_validate(data)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
raise SchemaValidationError(f"Schema mismatch: {exc}") from exc
|
|
236
|
+
|
|
237
|
+
# Validate tool_call has a tool
|
|
238
|
+
if action.type == "tool_call" and action.tool is None:
|
|
239
|
+
raise SchemaValidationError("Action type='tool_call' but 'tool' field is missing.")
|
|
240
|
+
|
|
241
|
+
return action
|
toolproxy/py.typed
ADDED
|
File without changes
|
toolproxy/schemas.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic schemas shared across toolproxy components.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- Conversation messages
|
|
6
|
+
- Tool calls (native and emulated)
|
|
7
|
+
- Agent Action (emulated mode output schema)
|
|
8
|
+
- Tool results
|
|
9
|
+
- Trace and final response
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Conversation message types
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class Message(BaseModel):
|
|
23
|
+
"""A single conversation message."""
|
|
24
|
+
|
|
25
|
+
role: Literal["system", "user", "assistant", "tool"] = "user"
|
|
26
|
+
content: str
|
|
27
|
+
# For tool role messages
|
|
28
|
+
tool_name: Optional[str] = None
|
|
29
|
+
tool_call_id: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Tool-call representations
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class ToolCall(BaseModel):
|
|
37
|
+
"""
|
|
38
|
+
Represents a request to invoke a single tool.
|
|
39
|
+
|
|
40
|
+
Used both in emulated mode (parsed from model JSON output) and
|
|
41
|
+
native mode (converted from provider-specific format).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
tool_name: str
|
|
45
|
+
arguments: Dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
# Provider-supplied ID, present only in native mode
|
|
47
|
+
call_id: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Emulated-mode Action schema
|
|
52
|
+
# This is the JSON schema the LLM must emit in emulated mode.
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
class Action(BaseModel):
|
|
56
|
+
"""
|
|
57
|
+
The structured output the LLM must emit in emulated mode.
|
|
58
|
+
|
|
59
|
+
type="tool_call" → the model wants to call a tool.
|
|
60
|
+
type="final" → the model is ready to give a final answer.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
type: Literal["tool_call", "final"]
|
|
64
|
+
tool: Optional[ToolCall] = None
|
|
65
|
+
content: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="ignore")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Tool result
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class ToolResult(BaseModel):
|
|
75
|
+
"""The outcome of executing a single tool call."""
|
|
76
|
+
|
|
77
|
+
tool_name: str
|
|
78
|
+
call_id: Optional[str] = None
|
|
79
|
+
output: Optional[str] = None
|
|
80
|
+
error: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def success(self) -> bool:
|
|
84
|
+
return self.error is None
|
|
85
|
+
|
|
86
|
+
def as_message_content(self) -> str:
|
|
87
|
+
"""Serialize the result for injection back into the conversation."""
|
|
88
|
+
if self.success:
|
|
89
|
+
return f"[Tool '{self.tool_name}' result]: {self.output}"
|
|
90
|
+
return f"[Tool '{self.tool_name}' error]: {self.error}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Trace (debug info)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
class TraceEntry(BaseModel):
|
|
98
|
+
"""One step in the agent trace."""
|
|
99
|
+
|
|
100
|
+
step: int
|
|
101
|
+
tool_call: Optional[ToolCall] = None
|
|
102
|
+
tool_result: Optional[ToolResult] = None
|
|
103
|
+
model_output: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AgentTrace(BaseModel):
|
|
107
|
+
"""Full trace of an agent run."""
|
|
108
|
+
|
|
109
|
+
steps: List[TraceEntry] = Field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def tool_calls(self) -> List[ToolCall]:
|
|
113
|
+
return [e.tool_call for e in self.steps if e.tool_call is not None]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Final agent response
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
class AgentResponse(BaseModel):
|
|
121
|
+
"""The final response returned by UniversalAgent.run()."""
|
|
122
|
+
|
|
123
|
+
content: str
|
|
124
|
+
trace: Optional[AgentTrace] = None
|
|
125
|
+
steps_taken: int = 0
|
toolproxy/tools.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool registry and @tool decorator for toolproxy.
|
|
3
|
+
|
|
4
|
+
Allows users to register Python callables as tools with automatic
|
|
5
|
+
Pydantic schema generation from type hints or explicit args_schema.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import Any, Callable, Dict, Optional, Type, get_type_hints
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, create_model
|
|
16
|
+
|
|
17
|
+
from .exceptions import ToolNotFoundError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# ToolDefinition
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ToolDefinition:
|
|
26
|
+
"""Metadata + callable for a single registered tool."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
description: str
|
|
30
|
+
callable: Callable[..., Any]
|
|
31
|
+
args_schema: Type[BaseModel]
|
|
32
|
+
|
|
33
|
+
def to_openai_schema(self) -> Dict[str, Any]:
|
|
34
|
+
"""
|
|
35
|
+
Return an OpenAI-compatible function/tool schema dict.
|
|
36
|
+
|
|
37
|
+
Compatible with OpenAI, OpenRouter, and most OpenAI-style providers.
|
|
38
|
+
"""
|
|
39
|
+
schema = self.args_schema.model_json_schema()
|
|
40
|
+
# Remove title from root and from nested properties for cleaner prompts
|
|
41
|
+
schema.pop("title", None)
|
|
42
|
+
for prop in schema.get("properties", {}).values():
|
|
43
|
+
prop.pop("title", None)
|
|
44
|
+
return {
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": self.name,
|
|
48
|
+
"description": self.description,
|
|
49
|
+
"parameters": schema,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def to_prompt_description(self) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Return a human-readable description of the tool for emulated-mode prompts.
|
|
56
|
+
"""
|
|
57
|
+
schema = self.args_schema.model_json_schema()
|
|
58
|
+
props = schema.get("properties", {})
|
|
59
|
+
required = schema.get("required", [])
|
|
60
|
+
params_lines = []
|
|
61
|
+
for param_name, param_info in props.items():
|
|
62
|
+
req_marker = " (required)" if param_name in required else " (optional)"
|
|
63
|
+
param_type = param_info.get("type", "any")
|
|
64
|
+
desc = param_info.get("description", "")
|
|
65
|
+
line = f" - {param_name}: {param_type}{req_marker}"
|
|
66
|
+
if desc:
|
|
67
|
+
line += f" — {desc}"
|
|
68
|
+
params_lines.append(line)
|
|
69
|
+
params_str = "\n".join(params_lines) if params_lines else " (no parameters)"
|
|
70
|
+
return (
|
|
71
|
+
f"Tool: {self.name}\n"
|
|
72
|
+
f"Description: {self.description}\n"
|
|
73
|
+
f"Parameters:\n{params_str}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Schema auto-generation from function signature
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _build_args_schema(func: Callable[..., Any]) -> Type[BaseModel]:
|
|
82
|
+
"""
|
|
83
|
+
Introspect *func*'s type hints and build a Pydantic model for its args.
|
|
84
|
+
|
|
85
|
+
Only non-return, non-self/cls parameters are included. Parameters without
|
|
86
|
+
type annotations default to `Any`.
|
|
87
|
+
"""
|
|
88
|
+
hints = get_type_hints(func)
|
|
89
|
+
hints.pop("return", None)
|
|
90
|
+
sig = inspect.signature(func)
|
|
91
|
+
fields: Dict[str, Any] = {}
|
|
92
|
+
for param_name, param in sig.parameters.items():
|
|
93
|
+
if param_name in ("self", "cls"):
|
|
94
|
+
continue
|
|
95
|
+
annotation = hints.get(param_name, Any)
|
|
96
|
+
if param.default is inspect.Parameter.empty:
|
|
97
|
+
fields[param_name] = (annotation, ...)
|
|
98
|
+
else:
|
|
99
|
+
fields[param_name] = (annotation, param.default)
|
|
100
|
+
model_name = f"{func.__name__.capitalize()}Args"
|
|
101
|
+
return create_model(model_name, **fields) # type: ignore[call-overload]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# @tool decorator
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def tool(
|
|
109
|
+
func: Optional[Callable[..., Any]] = None,
|
|
110
|
+
*,
|
|
111
|
+
name: Optional[str] = None,
|
|
112
|
+
description: Optional[str] = None,
|
|
113
|
+
args_schema: Optional[Type[BaseModel]] = None,
|
|
114
|
+
) -> Any:
|
|
115
|
+
"""
|
|
116
|
+
Decorator that marks a Python callable as a toolproxy tool.
|
|
117
|
+
|
|
118
|
+
Usage (auto-detected schema)::
|
|
119
|
+
|
|
120
|
+
@tool
|
|
121
|
+
def search_web(query: str, top_k: int = 5) -> str:
|
|
122
|
+
\"\"\"Search the web for recent information.\"\"\"
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
Usage (explicit schema)::
|
|
126
|
+
|
|
127
|
+
class SearchArgs(BaseModel):
|
|
128
|
+
query: str
|
|
129
|
+
top_k: int = 5
|
|
130
|
+
|
|
131
|
+
@tool(name="search_web", args_schema=SearchArgs, description="Search the web")
|
|
132
|
+
def search_web_tool(args: SearchArgs) -> str:
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
Returns the original function (unchanged), but attaches a
|
|
136
|
+
`._tool_definition` attribute that ToolRegistry can read.
|
|
137
|
+
"""
|
|
138
|
+
def _decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
139
|
+
tool_name = name or fn.__name__
|
|
140
|
+
tool_desc = description or (inspect.getdoc(fn) or "")
|
|
141
|
+
schema = args_schema or _build_args_schema(fn)
|
|
142
|
+
|
|
143
|
+
definition = ToolDefinition(
|
|
144
|
+
name=tool_name,
|
|
145
|
+
description=tool_desc,
|
|
146
|
+
callable=fn,
|
|
147
|
+
args_schema=schema,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@wraps(fn)
|
|
151
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
return fn(*args, **kwargs)
|
|
153
|
+
|
|
154
|
+
wrapper._tool_definition = definition # type: ignore[attr-defined]
|
|
155
|
+
return wrapper
|
|
156
|
+
|
|
157
|
+
if func is not None:
|
|
158
|
+
# Called as @tool (no parentheses)
|
|
159
|
+
return _decorate(func)
|
|
160
|
+
# Called as @tool(...) with arguments
|
|
161
|
+
return _decorate
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# ToolRegistry
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
class ToolRegistry:
|
|
169
|
+
"""Stores and provides access to all registered tools."""
|
|
170
|
+
|
|
171
|
+
def __init__(self) -> None:
|
|
172
|
+
self._tools: Dict[str, ToolDefinition] = {}
|
|
173
|
+
|
|
174
|
+
def register(self, tool_or_definition: Any) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Register a tool.
|
|
177
|
+
|
|
178
|
+
Accepts either:
|
|
179
|
+
- A callable decorated with @tool (has ._tool_definition attribute).
|
|
180
|
+
- A ToolDefinition instance directly.
|
|
181
|
+
"""
|
|
182
|
+
if isinstance(tool_or_definition, ToolDefinition):
|
|
183
|
+
defn = tool_or_definition
|
|
184
|
+
elif hasattr(tool_or_definition, "_tool_definition"):
|
|
185
|
+
defn = tool_or_definition._tool_definition
|
|
186
|
+
else:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"Cannot register '{tool_or_definition}': "
|
|
189
|
+
"must be a @tool-decorated callable or a ToolDefinition."
|
|
190
|
+
)
|
|
191
|
+
if defn.name in self._tools:
|
|
192
|
+
raise ValueError(f"A tool named '{defn.name}' is already registered.")
|
|
193
|
+
self._tools[defn.name] = defn
|
|
194
|
+
|
|
195
|
+
def get(self, name: str) -> ToolDefinition:
|
|
196
|
+
"""Return the ToolDefinition for *name*, raising ToolNotFoundError if absent."""
|
|
197
|
+
try:
|
|
198
|
+
return self._tools[name]
|
|
199
|
+
except KeyError:
|
|
200
|
+
raise ToolNotFoundError(name)
|
|
201
|
+
|
|
202
|
+
def list_tools(self) -> list[ToolDefinition]:
|
|
203
|
+
"""Return all registered ToolDefinitions."""
|
|
204
|
+
return list(self._tools.values())
|
|
205
|
+
|
|
206
|
+
def to_openai_schema(self) -> list[Dict[str, Any]]:
|
|
207
|
+
"""Return all tools as a list of OpenAI-compatible tool schemas."""
|
|
208
|
+
return [t.to_openai_schema() for t in self._tools.values()]
|
|
209
|
+
|
|
210
|
+
def to_prompt_description(self) -> str:
|
|
211
|
+
"""Return a multi-tool prompt block for emulated mode."""
|
|
212
|
+
if not self._tools:
|
|
213
|
+
return "No tools available."
|
|
214
|
+
blocks = [t.to_prompt_description() for t in self._tools.values()]
|
|
215
|
+
return "\n\n".join(blocks)
|
|
216
|
+
|
|
217
|
+
def __len__(self) -> int:
|
|
218
|
+
return len(self._tools)
|
|
219
|
+
|
|
220
|
+
def __contains__(self, name: str) -> bool:
|
|
221
|
+
return name in self._tools
|