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/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