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/llm_client.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM client abstraction layer for toolproxy.
|
|
3
|
+
|
|
4
|
+
Provides a common interface (LLMClient) over different backends:
|
|
5
|
+
- OpenRouterClient (OpenRouter via OpenAI-compatible API)
|
|
6
|
+
- OpenAIClient (Direct OpenAI or OpenAI-compatible endpoint)
|
|
7
|
+
- OllamaClient (Local Ollama server)
|
|
8
|
+
- MockClient (For testing without real API keys)
|
|
9
|
+
|
|
10
|
+
Factory function:
|
|
11
|
+
get_client(model, **kwargs) -> LLMClient
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .config import model_supports_native_tools
|
|
23
|
+
from .exceptions import ModelError
|
|
24
|
+
from .schemas import Message, ToolCall
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Provider-agnostic response
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
class ModelResponse:
|
|
32
|
+
"""
|
|
33
|
+
Provider-agnostic response from an LLM.
|
|
34
|
+
|
|
35
|
+
raw_text — the plain-text content of the response (may be empty if
|
|
36
|
+
the model produced only tool calls).
|
|
37
|
+
tool_calls — list of native ToolCall objects (empty if none).
|
|
38
|
+
raw — the full provider response dict, for debugging.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
raw_text: str = "",
|
|
44
|
+
tool_calls: Optional[List[ToolCall]] = None,
|
|
45
|
+
raw: Optional[Dict[str, Any]] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.raw_text = raw_text
|
|
48
|
+
self.tool_calls: List[ToolCall] = tool_calls or []
|
|
49
|
+
self.raw = raw or {}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def has_tool_calls(self) -> bool:
|
|
53
|
+
return bool(self.tool_calls)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Abstract base
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class LLMClient(ABC):
|
|
61
|
+
"""Abstract interface all LLM clients must implement."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def generate(
|
|
65
|
+
self,
|
|
66
|
+
messages: List[Message],
|
|
67
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
68
|
+
) -> ModelResponse:
|
|
69
|
+
"""
|
|
70
|
+
Send *messages* to the model and return a ModelResponse.
|
|
71
|
+
|
|
72
|
+
If *tools* is provided and the backend supports native tools, pass
|
|
73
|
+
them to the provider; otherwise ignore.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def supports_native_tools(self) -> bool:
|
|
78
|
+
"""Return True if this client/model supports native tool calling."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Helpers
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _messages_to_openai(messages: List[Message]) -> List[Dict[str, Any]]:
|
|
86
|
+
"""Convert internal Message objects to OpenAI chat format."""
|
|
87
|
+
result = []
|
|
88
|
+
for msg in messages:
|
|
89
|
+
d: Dict[str, Any] = {"role": msg.role, "content": msg.content}
|
|
90
|
+
if msg.role == "tool":
|
|
91
|
+
d["tool_call_id"] = msg.tool_call_id or "unknown"
|
|
92
|
+
d["name"] = msg.tool_name or "unknown"
|
|
93
|
+
result.append(d)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_native_tool_calls(raw_calls: List[Dict[str, Any]]) -> List[ToolCall]:
|
|
98
|
+
"""Parse OpenAI-format tool_calls into internal ToolCall objects."""
|
|
99
|
+
parsed = []
|
|
100
|
+
for c in raw_calls:
|
|
101
|
+
fn = c.get("function", {})
|
|
102
|
+
try:
|
|
103
|
+
args = json.loads(fn.get("arguments", "{}"))
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
args = {}
|
|
106
|
+
parsed.append(
|
|
107
|
+
ToolCall(
|
|
108
|
+
tool_name=fn.get("name", ""),
|
|
109
|
+
arguments=args,
|
|
110
|
+
call_id=c.get("id"),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return parsed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# OpenRouter client (uses OpenAI-compatible REST API)
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
class OpenRouterClient(LLMClient):
|
|
121
|
+
"""
|
|
122
|
+
Client for the OpenRouter API.
|
|
123
|
+
|
|
124
|
+
Reads OPENROUTER_API_KEY from the environment unless *api_key* is given.
|
|
125
|
+
Native tool support is determined by the MODEL_TOOL_SUPPORT map (see config).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
BASE_URL = "https://openrouter.ai/api/v1"
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
model: str,
|
|
133
|
+
api_key: Optional[str] = None,
|
|
134
|
+
native_tools: Optional[bool] = None,
|
|
135
|
+
timeout: float = 60.0,
|
|
136
|
+
) -> None:
|
|
137
|
+
self.model = model
|
|
138
|
+
self._api_key = api_key or os.environ.get("OPENROUTER_API_KEY", "")
|
|
139
|
+
self._native_tools = (
|
|
140
|
+
native_tools
|
|
141
|
+
if native_tools is not None
|
|
142
|
+
else model_supports_native_tools(model)
|
|
143
|
+
)
|
|
144
|
+
self._timeout = timeout
|
|
145
|
+
|
|
146
|
+
def supports_native_tools(self) -> bool:
|
|
147
|
+
return self._native_tools
|
|
148
|
+
|
|
149
|
+
def generate(
|
|
150
|
+
self,
|
|
151
|
+
messages: List[Message],
|
|
152
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
153
|
+
) -> ModelResponse:
|
|
154
|
+
headers = {
|
|
155
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"HTTP-Referer": "https://github.com/universal-agent",
|
|
158
|
+
"X-Title": "universal-agent",
|
|
159
|
+
}
|
|
160
|
+
body: Dict[str, Any] = {
|
|
161
|
+
"model": self.model,
|
|
162
|
+
"messages": _messages_to_openai(messages),
|
|
163
|
+
}
|
|
164
|
+
if tools and self._native_tools:
|
|
165
|
+
body["tools"] = tools
|
|
166
|
+
body["tool_choice"] = "auto"
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
resp = httpx.post(
|
|
170
|
+
f"{self.BASE_URL}/chat/completions",
|
|
171
|
+
headers=headers,
|
|
172
|
+
json=body,
|
|
173
|
+
timeout=self._timeout,
|
|
174
|
+
)
|
|
175
|
+
resp.raise_for_status()
|
|
176
|
+
except httpx.HTTPStatusError as exc:
|
|
177
|
+
raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
|
|
178
|
+
except httpx.RequestError as exc:
|
|
179
|
+
raise ModelError(f"Request failed: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
data = resp.json()
|
|
182
|
+
return self._parse_response(data)
|
|
183
|
+
|
|
184
|
+
def _parse_response(self, data: Dict[str, Any]) -> ModelResponse:
|
|
185
|
+
choices = data.get("choices", [])
|
|
186
|
+
if not choices:
|
|
187
|
+
raise ModelError("No choices in model response.")
|
|
188
|
+
msg = choices[0].get("message", {})
|
|
189
|
+
raw_text = msg.get("content") or ""
|
|
190
|
+
native_calls = msg.get("tool_calls") or []
|
|
191
|
+
tool_calls = _parse_native_tool_calls(native_calls) if native_calls else []
|
|
192
|
+
return ModelResponse(raw_text=raw_text, tool_calls=tool_calls, raw=data)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# OpenAI client (direct OpenAI or any OpenAI-compatible endpoint)
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
class OpenAIClient(LLMClient):
|
|
200
|
+
"""
|
|
201
|
+
Client for OpenAI or any OpenAI-compatible endpoint (e.g., Together, Anyscale).
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
model: str,
|
|
209
|
+
api_key: Optional[str] = None,
|
|
210
|
+
base_url: Optional[str] = None,
|
|
211
|
+
native_tools: Optional[bool] = None,
|
|
212
|
+
timeout: float = 60.0,
|
|
213
|
+
) -> None:
|
|
214
|
+
self.model = model
|
|
215
|
+
self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
216
|
+
self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
217
|
+
self._native_tools = (
|
|
218
|
+
native_tools
|
|
219
|
+
if native_tools is not None
|
|
220
|
+
else model_supports_native_tools(model)
|
|
221
|
+
)
|
|
222
|
+
self._timeout = timeout
|
|
223
|
+
|
|
224
|
+
def supports_native_tools(self) -> bool:
|
|
225
|
+
return self._native_tools
|
|
226
|
+
|
|
227
|
+
def generate(
|
|
228
|
+
self,
|
|
229
|
+
messages: List[Message],
|
|
230
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
231
|
+
) -> ModelResponse:
|
|
232
|
+
headers = {
|
|
233
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
}
|
|
236
|
+
body: Dict[str, Any] = {
|
|
237
|
+
"model": self.model,
|
|
238
|
+
"messages": _messages_to_openai(messages),
|
|
239
|
+
}
|
|
240
|
+
if tools and self._native_tools:
|
|
241
|
+
body["tools"] = tools
|
|
242
|
+
body["tool_choice"] = "auto"
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
resp = httpx.post(
|
|
246
|
+
f"{self._base_url}/chat/completions",
|
|
247
|
+
headers=headers,
|
|
248
|
+
json=body,
|
|
249
|
+
timeout=self._timeout,
|
|
250
|
+
)
|
|
251
|
+
resp.raise_for_status()
|
|
252
|
+
except httpx.HTTPStatusError as exc:
|
|
253
|
+
raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
|
|
254
|
+
except httpx.RequestError as exc:
|
|
255
|
+
raise ModelError(f"Request failed: {exc}") from exc
|
|
256
|
+
|
|
257
|
+
data = resp.json()
|
|
258
|
+
choices = data.get("choices", [])
|
|
259
|
+
if not choices:
|
|
260
|
+
raise ModelError("No choices in model response.")
|
|
261
|
+
msg = choices[0].get("message", {})
|
|
262
|
+
raw_text = msg.get("content") or ""
|
|
263
|
+
native_calls = msg.get("tool_calls") or []
|
|
264
|
+
tool_calls = _parse_native_tool_calls(native_calls) if native_calls else []
|
|
265
|
+
return ModelResponse(raw_text=raw_text, tool_calls=tool_calls, raw=data)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Ollama client (local HTTP server, tool support = False by default)
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
class OllamaClient(LLMClient):
|
|
273
|
+
"""
|
|
274
|
+
Client for a local Ollama server.
|
|
275
|
+
|
|
276
|
+
Default base URL: http://localhost:11434
|
|
277
|
+
Tool calling: disabled by default (emulated mode).
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
model: str,
|
|
285
|
+
base_url: Optional[str] = None,
|
|
286
|
+
native_tools: bool = False,
|
|
287
|
+
timeout: float = 120.0,
|
|
288
|
+
) -> None:
|
|
289
|
+
self.model = model
|
|
290
|
+
self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
291
|
+
self._native_tools = native_tools
|
|
292
|
+
self._timeout = timeout
|
|
293
|
+
|
|
294
|
+
def supports_native_tools(self) -> bool:
|
|
295
|
+
return self._native_tools
|
|
296
|
+
|
|
297
|
+
def generate(
|
|
298
|
+
self,
|
|
299
|
+
messages: List[Message],
|
|
300
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
301
|
+
) -> ModelResponse:
|
|
302
|
+
body: Dict[str, Any] = {
|
|
303
|
+
"model": self.model,
|
|
304
|
+
"messages": _messages_to_openai(messages),
|
|
305
|
+
"stream": False,
|
|
306
|
+
}
|
|
307
|
+
if tools and self._native_tools:
|
|
308
|
+
body["tools"] = tools
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
resp = httpx.post(
|
|
312
|
+
f"{self._base_url}/api/chat",
|
|
313
|
+
json=body,
|
|
314
|
+
timeout=self._timeout,
|
|
315
|
+
)
|
|
316
|
+
resp.raise_for_status()
|
|
317
|
+
except httpx.HTTPStatusError as exc:
|
|
318
|
+
raise ModelError(f"HTTP {exc.response.status_code}: {exc.response.text}") from exc
|
|
319
|
+
except httpx.RequestError as exc:
|
|
320
|
+
raise ModelError(f"Request failed: {exc}") from exc
|
|
321
|
+
|
|
322
|
+
data = resp.json()
|
|
323
|
+
msg = data.get("message", {})
|
|
324
|
+
raw_text = msg.get("content") or ""
|
|
325
|
+
return ModelResponse(raw_text=raw_text, raw=data)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Mock client (for testing / demos without real API)
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
class MockClient(LLMClient):
|
|
333
|
+
"""
|
|
334
|
+
A mock LLM client for unit tests.
|
|
335
|
+
|
|
336
|
+
Provide a list of pre-programmed responses; each call pops the next one.
|
|
337
|
+
Responses can be raw text strings or ModelResponse objects.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
responses: Optional[List[Any]] = None,
|
|
343
|
+
native_tools: bool = False,
|
|
344
|
+
) -> None:
|
|
345
|
+
self._responses: List[Any] = list(responses or [])
|
|
346
|
+
self._native_tools = native_tools
|
|
347
|
+
self.call_count = 0
|
|
348
|
+
self.call_history: List[List[Message]] = []
|
|
349
|
+
|
|
350
|
+
def supports_native_tools(self) -> bool:
|
|
351
|
+
return self._native_tools
|
|
352
|
+
|
|
353
|
+
def generate(
|
|
354
|
+
self,
|
|
355
|
+
messages: List[Message],
|
|
356
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
357
|
+
) -> ModelResponse:
|
|
358
|
+
self.call_history.append(messages)
|
|
359
|
+
self.call_count += 1
|
|
360
|
+
if not self._responses:
|
|
361
|
+
return ModelResponse(raw_text='{"type": "final", "content": "No more responses."}')
|
|
362
|
+
resp = self._responses.pop(0)
|
|
363
|
+
if isinstance(resp, ModelResponse):
|
|
364
|
+
return resp
|
|
365
|
+
return ModelResponse(raw_text=str(resp))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Client factory
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def get_client(
|
|
373
|
+
model: str,
|
|
374
|
+
api_key: Optional[str] = None,
|
|
375
|
+
base_url: Optional[str] = None,
|
|
376
|
+
native_tools: Optional[bool] = None,
|
|
377
|
+
) -> LLMClient:
|
|
378
|
+
"""
|
|
379
|
+
Create and return the appropriate LLMClient for *model*.
|
|
380
|
+
|
|
381
|
+
Model routing:
|
|
382
|
+
- Starts with "openrouter/" → OpenRouterClient (strips prefix)
|
|
383
|
+
- Starts with "ollama/" → OllamaClient (strips prefix)
|
|
384
|
+
- Starts with "mock/" → MockClient (for testing)
|
|
385
|
+
- Everything else → OpenAIClient
|
|
386
|
+
"""
|
|
387
|
+
if model.startswith("openrouter/"):
|
|
388
|
+
actual_model = model[len("openrouter/"):]
|
|
389
|
+
return OpenRouterClient(
|
|
390
|
+
model=actual_model,
|
|
391
|
+
api_key=api_key,
|
|
392
|
+
native_tools=native_tools,
|
|
393
|
+
)
|
|
394
|
+
if model.startswith("ollama/"):
|
|
395
|
+
actual_model = model[len("ollama/"):]
|
|
396
|
+
return OllamaClient(
|
|
397
|
+
model=actual_model,
|
|
398
|
+
base_url=base_url,
|
|
399
|
+
native_tools=native_tools or False,
|
|
400
|
+
)
|
|
401
|
+
if model.startswith("mock/"):
|
|
402
|
+
return MockClient(native_tools=native_tools or False)
|
|
403
|
+
# Default: OpenAI-compatible
|
|
404
|
+
return OpenAIClient(
|
|
405
|
+
model=model,
|
|
406
|
+
api_key=api_key,
|
|
407
|
+
base_url=base_url,
|
|
408
|
+
native_tools=native_tools,
|
|
409
|
+
)
|
toolproxy/loop.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loop controller for toolproxy.
|
|
3
|
+
|
|
4
|
+
Orchestrates the full agent execution cycle:
|
|
5
|
+
1. Inject system + user messages.
|
|
6
|
+
2. Ask the Planner what to do (tool call or final answer).
|
|
7
|
+
3. Execute tools via the Executor.
|
|
8
|
+
4. Append results to the conversation and loop.
|
|
9
|
+
5. Return AgentResponse (with optional trace).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Callable, List, Optional, Union
|
|
14
|
+
|
|
15
|
+
from .exceptions import MaxStepsExceededError
|
|
16
|
+
from .executor import Executor
|
|
17
|
+
from .planner import Planner
|
|
18
|
+
from .schemas import (
|
|
19
|
+
Action,
|
|
20
|
+
AgentResponse,
|
|
21
|
+
AgentTrace,
|
|
22
|
+
Message,
|
|
23
|
+
ToolCall,
|
|
24
|
+
ToolResult,
|
|
25
|
+
TraceEntry,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Callback type aliases
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
OnToolCallCb = Callable[[int, ToolCall], None]
|
|
33
|
+
OnToolResultCb = Callable[[int, ToolResult], None]
|
|
34
|
+
OnModelOutputCb = Callable[[int, str], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LoopController:
|
|
38
|
+
"""
|
|
39
|
+
Runs the main agent loop until a final answer or max_steps is reached.
|
|
40
|
+
|
|
41
|
+
Callbacks (all optional):
|
|
42
|
+
- on_tool_call(step, tool_call)
|
|
43
|
+
- on_tool_result(step, tool_result)
|
|
44
|
+
- on_model_output(step, raw_text)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
planner: Planner,
|
|
50
|
+
executor: Executor,
|
|
51
|
+
max_steps: int = 10,
|
|
52
|
+
return_trace: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._planner = planner
|
|
55
|
+
self._executor = executor
|
|
56
|
+
self._max_steps = max_steps
|
|
57
|
+
self._return_trace = return_trace
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Main run method
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def run(
|
|
64
|
+
self,
|
|
65
|
+
messages: List[Message],
|
|
66
|
+
on_tool_call: Optional[OnToolCallCb] = None,
|
|
67
|
+
on_tool_result: Optional[OnToolResultCb] = None,
|
|
68
|
+
on_model_output: Optional[OnModelOutputCb] = None,
|
|
69
|
+
) -> AgentResponse:
|
|
70
|
+
"""
|
|
71
|
+
Execute the agent loop and return the final AgentResponse.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
messages:
|
|
76
|
+
Initial conversation (system + user messages).
|
|
77
|
+
on_tool_call / on_tool_result / on_model_output:
|
|
78
|
+
Optional streaming callbacks fired at each step.
|
|
79
|
+
"""
|
|
80
|
+
conversation: List[Message] = list(messages)
|
|
81
|
+
trace = AgentTrace() if self._return_trace else None
|
|
82
|
+
steps_taken = 0
|
|
83
|
+
|
|
84
|
+
for step in range(1, self._max_steps + 1):
|
|
85
|
+
steps_taken = step
|
|
86
|
+
|
|
87
|
+
# Ask the planner
|
|
88
|
+
plan_result: Union[List[ToolCall], Action] = self._planner.plan(conversation)
|
|
89
|
+
|
|
90
|
+
# ----------------------------------------------------------
|
|
91
|
+
# Handle result
|
|
92
|
+
# ----------------------------------------------------------
|
|
93
|
+
if isinstance(plan_result, list):
|
|
94
|
+
# Native mode: list of tool calls
|
|
95
|
+
tool_calls = plan_result
|
|
96
|
+
if not tool_calls:
|
|
97
|
+
# Unexpected: no calls and no final answer — treat as done
|
|
98
|
+
return AgentResponse(
|
|
99
|
+
content="(No response from model)",
|
|
100
|
+
trace=trace,
|
|
101
|
+
steps_taken=steps_taken,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for tc in tool_calls:
|
|
105
|
+
if on_tool_call:
|
|
106
|
+
on_tool_call(step, tc)
|
|
107
|
+
|
|
108
|
+
result = self._executor.execute(tc)
|
|
109
|
+
|
|
110
|
+
if on_tool_result:
|
|
111
|
+
on_tool_result(step, result)
|
|
112
|
+
|
|
113
|
+
if trace is not None:
|
|
114
|
+
trace.steps.append(
|
|
115
|
+
TraceEntry(step=step, tool_call=tc, tool_result=result)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Append tool result to conversation
|
|
119
|
+
conversation.append(
|
|
120
|
+
Message(
|
|
121
|
+
role="tool",
|
|
122
|
+
content=result.as_message_content(),
|
|
123
|
+
tool_name=result.tool_name,
|
|
124
|
+
tool_call_id=result.call_id,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
elif isinstance(plan_result, Action):
|
|
129
|
+
action = plan_result
|
|
130
|
+
|
|
131
|
+
if on_model_output:
|
|
132
|
+
on_model_output(step, action.content or "")
|
|
133
|
+
|
|
134
|
+
if action.type == "final":
|
|
135
|
+
if trace is not None:
|
|
136
|
+
trace.steps.append(
|
|
137
|
+
TraceEntry(step=step, model_output=action.content)
|
|
138
|
+
)
|
|
139
|
+
return AgentResponse(
|
|
140
|
+
content=action.content or "",
|
|
141
|
+
trace=trace,
|
|
142
|
+
steps_taken=steps_taken,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Emulated tool call
|
|
146
|
+
if action.tool is None:
|
|
147
|
+
# Shouldn't happen (planner validates), but guard anyway
|
|
148
|
+
raise ValueError("Action type='tool_call' has no tool field.")
|
|
149
|
+
|
|
150
|
+
tc = action.tool
|
|
151
|
+
if on_tool_call:
|
|
152
|
+
on_tool_call(step, tc)
|
|
153
|
+
|
|
154
|
+
result = self._executor.execute(tc)
|
|
155
|
+
|
|
156
|
+
if on_tool_result:
|
|
157
|
+
on_tool_result(step, result)
|
|
158
|
+
|
|
159
|
+
if trace is not None:
|
|
160
|
+
trace.steps.append(
|
|
161
|
+
TraceEntry(step=step, tool_call=tc, tool_result=result)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Append tool result to conversation as an assistant + user pair
|
|
165
|
+
# so emulated mode can follow the result
|
|
166
|
+
conversation.append(
|
|
167
|
+
Message(
|
|
168
|
+
role="assistant",
|
|
169
|
+
content=f"[Calling tool: {tc.tool_name}]",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
conversation.append(
|
|
173
|
+
Message(
|
|
174
|
+
role="user",
|
|
175
|
+
content=result.as_message_content(),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Max steps exceeded
|
|
180
|
+
raise MaxStepsExceededError(self._max_steps)
|