power-loop 0.2.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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Union,
|
|
10
|
+
get_args,
|
|
11
|
+
get_origin,
|
|
12
|
+
get_type_hints,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .interface import LLMMessage, LLMRequest, LLMResponse, LLMService, LLMTool
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
# Optional dependency (already used in other parts of this mono-repo).
|
|
19
|
+
from pydantic import BaseModel # type: ignore
|
|
20
|
+
except Exception: # pragma: no cover
|
|
21
|
+
BaseModel = None # type: ignore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ToolFn = Callable[[dict[str, Any]], Any] | Callable[[dict[str, Any]], Awaitable[Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ToolSpec:
|
|
30
|
+
"""
|
|
31
|
+
A lightweight tool definition (LangChain-like) that can be converted to OpenAI-compatible tool schema.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
parameters: dict[str, Any]
|
|
37
|
+
# Execution callback (optional). Many tools are "signals" (e.g., ResearchComplete) and don't need a function.
|
|
38
|
+
fn: ToolFn | None = None
|
|
39
|
+
# Optional raw callable reference (best-effort). Useful for debugging/introspection.
|
|
40
|
+
raw: Any = None
|
|
41
|
+
|
|
42
|
+
def to_llm_tool(self) -> LLMTool:
|
|
43
|
+
return {
|
|
44
|
+
"type": "function",
|
|
45
|
+
"function": {
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"description": self.description,
|
|
48
|
+
"parameters": dict(self.parameters or {}),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __call__(self, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
53
|
+
"""
|
|
54
|
+
Make ToolSpec callable for ergonomic manual execution:
|
|
55
|
+
- tool({"a":1,"b":2})
|
|
56
|
+
- tool(a=1,b=2)
|
|
57
|
+
"""
|
|
58
|
+
if self.fn is None:
|
|
59
|
+
raise TypeError(f"ToolSpec '{self.name}' is not executable (fn is None)")
|
|
60
|
+
merged: dict[str, Any] = {}
|
|
61
|
+
if args:
|
|
62
|
+
merged.update(args)
|
|
63
|
+
if kwargs:
|
|
64
|
+
merged.update(kwargs)
|
|
65
|
+
return self.fn(merged)
|
|
66
|
+
|
|
67
|
+
async def ainvoke(self, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
68
|
+
"""
|
|
69
|
+
Async-friendly execution helper (mirrors LangChain's ainvoke ergonomics).
|
|
70
|
+
"""
|
|
71
|
+
v = self(args=args, **kwargs)
|
|
72
|
+
if hasattr(v, "__await__"):
|
|
73
|
+
return await v
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
def invoke(self, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
77
|
+
"""
|
|
78
|
+
Sync execution helper.
|
|
79
|
+
"""
|
|
80
|
+
return self(args=args, **kwargs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ToolRegistry:
|
|
84
|
+
def __init__(self, tools: Sequence[Any], *, existing_names: Sequence[str] | None = None):
|
|
85
|
+
"""
|
|
86
|
+
Registry with unique tool names.
|
|
87
|
+
Accepts mixed inputs (ToolSpec / callable / BaseModel subclass).
|
|
88
|
+
"""
|
|
89
|
+
existing = set(existing_names or [])
|
|
90
|
+
normalized: list[ToolSpec] = []
|
|
91
|
+
for t in tools:
|
|
92
|
+
spec = tool_spec_from(t)
|
|
93
|
+
if spec.name in existing:
|
|
94
|
+
raise ValueError(f"duplicate tool name: {spec.name}")
|
|
95
|
+
existing.add(spec.name)
|
|
96
|
+
normalized.append(spec)
|
|
97
|
+
self._tools: dict[str, ToolSpec] = {t.name: t for t in normalized}
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def tools_by_name(self) -> dict[str, ToolSpec]:
|
|
101
|
+
return dict(self._tools)
|
|
102
|
+
|
|
103
|
+
def register(self, tool: Any) -> None:
|
|
104
|
+
spec = tool_spec_from(tool)
|
|
105
|
+
if spec.name in self._tools:
|
|
106
|
+
raise ValueError(f"duplicate tool name: {spec.name}")
|
|
107
|
+
self._tools[spec.name] = spec
|
|
108
|
+
|
|
109
|
+
def to_llm_tools(self) -> list[LLMTool]:
|
|
110
|
+
return [t.to_llm_tool() for t in self._tools.values()]
|
|
111
|
+
|
|
112
|
+
def get(self, name: str) -> ToolSpec | None:
|
|
113
|
+
return self._tools.get(name)
|
|
114
|
+
|
|
115
|
+
def require(self, name: str) -> ToolSpec:
|
|
116
|
+
t = self.get(name)
|
|
117
|
+
if t is None:
|
|
118
|
+
raise KeyError(f"unknown tool: {name}")
|
|
119
|
+
return t
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class BoundLLMService:
|
|
124
|
+
"""
|
|
125
|
+
A lightweight wrapper that mimics `model.bind_tools(tools)`:
|
|
126
|
+
it only binds tool schema + tool_choice defaults.
|
|
127
|
+
|
|
128
|
+
NOTE: executing tool calls still requires a "tool loop" (see `run_tool_loop_complete`).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
llm: LLMService
|
|
132
|
+
tools: Sequence[ToolSpec]
|
|
133
|
+
tool_choice: Any = "auto"
|
|
134
|
+
|
|
135
|
+
def _bind(self, request: LLMRequest) -> LLMRequest:
|
|
136
|
+
# Do not mutate original request object.
|
|
137
|
+
reg = ToolRegistry(self.tools)
|
|
138
|
+
return LLMRequest(
|
|
139
|
+
messages=list(request.messages or []),
|
|
140
|
+
system_prompt=request.system_prompt,
|
|
141
|
+
model=request.model,
|
|
142
|
+
temperature=request.temperature,
|
|
143
|
+
max_tokens=request.max_tokens,
|
|
144
|
+
parse_json=request.parse_json,
|
|
145
|
+
reason=request.reason,
|
|
146
|
+
tools=reg.to_llm_tools(),
|
|
147
|
+
tool_choice=self.tool_choice if request.tool_choice is None else request.tool_choice,
|
|
148
|
+
extra=dict(request.extra or {}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def complete(self, request: LLMRequest) -> LLMResponse:
|
|
152
|
+
return await self.llm.complete(self._bind(request))
|
|
153
|
+
|
|
154
|
+
async def stream(self, request: LLMRequest):
|
|
155
|
+
async for ch in self.llm.stream(self._bind(request)):
|
|
156
|
+
yield ch
|
|
157
|
+
|
|
158
|
+
async def close(self) -> None:
|
|
159
|
+
await self.llm.close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _type_to_schema(tp: Any) -> dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Best-effort mapping from Python type hints to JSON schema.
|
|
165
|
+
Keep it intentionally small; callers can always pass `parameters=...` explicitly.
|
|
166
|
+
"""
|
|
167
|
+
origin = get_origin(tp)
|
|
168
|
+
args = get_args(tp)
|
|
169
|
+
|
|
170
|
+
# Optional[T] == Union[T, None]
|
|
171
|
+
if origin is Union and args:
|
|
172
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
173
|
+
if len(non_none) == 1:
|
|
174
|
+
return _type_to_schema(non_none[0])
|
|
175
|
+
return {"anyOf": [_type_to_schema(a) for a in non_none]}
|
|
176
|
+
|
|
177
|
+
# Literal
|
|
178
|
+
if str(origin) == "typing.Literal" and args:
|
|
179
|
+
return {"enum": list(args)}
|
|
180
|
+
|
|
181
|
+
if tp in (str,):
|
|
182
|
+
return {"type": "string"}
|
|
183
|
+
if tp in (int,):
|
|
184
|
+
return {"type": "integer"}
|
|
185
|
+
if tp in (float,):
|
|
186
|
+
return {"type": "number"}
|
|
187
|
+
if tp in (bool,):
|
|
188
|
+
return {"type": "boolean"}
|
|
189
|
+
|
|
190
|
+
if origin in (list, list) and args:
|
|
191
|
+
return {"type": "array", "items": _type_to_schema(args[0])}
|
|
192
|
+
if origin in (dict, dict):
|
|
193
|
+
# keep open; providers typically accept object without deep constraints
|
|
194
|
+
return {"type": "object"}
|
|
195
|
+
|
|
196
|
+
# fallback
|
|
197
|
+
return {"type": "string"}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def schema_from_callable(fn: Any) -> dict[str, Any]:
|
|
201
|
+
"""
|
|
202
|
+
Build a JSON schema from a callable signature + type hints.
|
|
203
|
+
"""
|
|
204
|
+
sig = inspect.signature(fn)
|
|
205
|
+
try:
|
|
206
|
+
# Resolve annotations (handles `from __future__ import annotations`).
|
|
207
|
+
hints = get_type_hints(fn)
|
|
208
|
+
except Exception:
|
|
209
|
+
try:
|
|
210
|
+
hints = getattr(fn, "__annotations__", {}) or {}
|
|
211
|
+
except Exception:
|
|
212
|
+
hints = {}
|
|
213
|
+
|
|
214
|
+
properties: dict[str, Any] = {}
|
|
215
|
+
required: list[str] = []
|
|
216
|
+
|
|
217
|
+
for name, p in sig.parameters.items():
|
|
218
|
+
if name in ("self", "cls"):
|
|
219
|
+
continue
|
|
220
|
+
if p.kind in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
schema = _type_to_schema(hints.get(name, str))
|
|
224
|
+
properties[name] = schema
|
|
225
|
+
if p.default is inspect._empty:
|
|
226
|
+
# required unless explicitly Optional[...] (best-effort: treat as required anyway)
|
|
227
|
+
required.append(name)
|
|
228
|
+
|
|
229
|
+
out: dict[str, Any] = {"type": "object", "properties": properties}
|
|
230
|
+
if required:
|
|
231
|
+
out["required"] = required
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _schema_from_langchain_like_tool(obj: Any) -> dict[str, Any]:
|
|
236
|
+
"""
|
|
237
|
+
Best-effort schema extraction for LangChain tool objects
|
|
238
|
+
(e.g., StructuredTool / BaseTool instances).
|
|
239
|
+
"""
|
|
240
|
+
# Preferred: explicit args schema object.
|
|
241
|
+
args_schema = getattr(obj, "args_schema", None)
|
|
242
|
+
if args_schema is not None:
|
|
243
|
+
try:
|
|
244
|
+
if hasattr(args_schema, "model_json_schema"):
|
|
245
|
+
schema = args_schema.model_json_schema()
|
|
246
|
+
elif hasattr(args_schema, "schema"):
|
|
247
|
+
schema = args_schema.schema()
|
|
248
|
+
else:
|
|
249
|
+
schema = None
|
|
250
|
+
if isinstance(schema, dict):
|
|
251
|
+
return schema
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
# Fallback: tool-provided input schema object.
|
|
256
|
+
get_input_schema = getattr(obj, "get_input_schema", None)
|
|
257
|
+
if callable(get_input_schema):
|
|
258
|
+
try:
|
|
259
|
+
input_schema = get_input_schema()
|
|
260
|
+
if hasattr(input_schema, "model_json_schema"):
|
|
261
|
+
schema = input_schema.model_json_schema()
|
|
262
|
+
elif hasattr(input_schema, "schema"):
|
|
263
|
+
schema = input_schema.schema()
|
|
264
|
+
else:
|
|
265
|
+
schema = None
|
|
266
|
+
if isinstance(schema, dict):
|
|
267
|
+
return schema
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# Conservative fallback for schema-less tools.
|
|
272
|
+
return {"type": "object", "properties": {}}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def tool_spec_from(
|
|
276
|
+
obj: Any,
|
|
277
|
+
*,
|
|
278
|
+
name: str | None = None,
|
|
279
|
+
description: str | None = None,
|
|
280
|
+
parameters: dict[str, Any] | None = None,
|
|
281
|
+
fn: ToolFn | None = None,
|
|
282
|
+
) -> ToolSpec:
|
|
283
|
+
"""
|
|
284
|
+
Convert a callable / ToolSpec / (optional) pydantic BaseModel into ToolSpec.
|
|
285
|
+
"""
|
|
286
|
+
if isinstance(obj, ToolSpec):
|
|
287
|
+
return obj
|
|
288
|
+
|
|
289
|
+
# pydantic BaseModel subclass as argument schema (signal tools often only need schema)
|
|
290
|
+
if BaseModel is not None:
|
|
291
|
+
try:
|
|
292
|
+
if isinstance(obj, type) and issubclass(obj, BaseModel): # type: ignore[arg-type]
|
|
293
|
+
schema = obj.model_json_schema() # type: ignore[union-attr]
|
|
294
|
+
# Merge explicit description + docstring (both matter).
|
|
295
|
+
doc = (inspect.getdoc(obj) or "").strip()
|
|
296
|
+
desc = (description or "").strip()
|
|
297
|
+
merged_desc = "\n\n".join([s for s in [desc, doc] if s])
|
|
298
|
+
return ToolSpec(
|
|
299
|
+
name=str(name or getattr(obj, "__name__", "Tool")),
|
|
300
|
+
description=merged_desc or "",
|
|
301
|
+
parameters=parameters or schema,
|
|
302
|
+
fn=fn,
|
|
303
|
+
raw=obj,
|
|
304
|
+
)
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# Plain class as a "signal tool" (schema-only), inspired by agent-psychology's BaseModel tools.
|
|
309
|
+
# We only treat it as a tool when it is clearly intended as a declaration:
|
|
310
|
+
# - no explicit parameters provided
|
|
311
|
+
# - no execution fn provided
|
|
312
|
+
# Otherwise, fall through to the callable branch (which will treat it as a constructor).
|
|
313
|
+
if isinstance(obj, type) and parameters is None and fn is None:
|
|
314
|
+
doc = (inspect.getdoc(obj) or "").strip()
|
|
315
|
+
desc = (description or "").strip()
|
|
316
|
+
merged_desc = "\n\n".join([s for s in [desc, doc] if s])
|
|
317
|
+
return ToolSpec(
|
|
318
|
+
name=str(name or getattr(obj, "__name__", "Tool")),
|
|
319
|
+
description=merged_desc or "",
|
|
320
|
+
parameters={"type": "object", "properties": {}},
|
|
321
|
+
fn=None,
|
|
322
|
+
raw=obj,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# callable (function / instance with __call__)
|
|
326
|
+
if callable(obj):
|
|
327
|
+
target = obj
|
|
328
|
+
if name:
|
|
329
|
+
spec_name = str(name)
|
|
330
|
+
else:
|
|
331
|
+
tool_name = getattr(obj, "name", None)
|
|
332
|
+
spec_name = str(tool_name) if tool_name else str(getattr(obj, "__name__", obj.__class__.__name__))
|
|
333
|
+
# Merge explicit description + docstring (both matter).
|
|
334
|
+
doc = (inspect.getdoc(obj) or "").strip()
|
|
335
|
+
desc = (description or "").strip()
|
|
336
|
+
merged_desc = "\n\n".join([s for s in [desc, doc] if s])
|
|
337
|
+
params = parameters or schema_from_callable(target)
|
|
338
|
+
|
|
339
|
+
def _wrapped(args: dict[str, Any]) -> Any:
|
|
340
|
+
# Prefer kwargs call (common tool signature). Fallback to single-arg call for "dict tools".
|
|
341
|
+
try:
|
|
342
|
+
return target(**(args or {}))
|
|
343
|
+
except TypeError:
|
|
344
|
+
return target(args or {})
|
|
345
|
+
|
|
346
|
+
return ToolSpec(
|
|
347
|
+
name=spec_name,
|
|
348
|
+
description=merged_desc or "",
|
|
349
|
+
parameters=params,
|
|
350
|
+
fn=fn or _wrapped,
|
|
351
|
+
raw=target,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# LangChain-like tool objects (e.g., StructuredTool / BaseTool instances)
|
|
355
|
+
# are not always directly callable, but expose name/description + invoke APIs.
|
|
356
|
+
has_tool_identity = hasattr(obj, "name") and hasattr(obj, "description")
|
|
357
|
+
has_tool_executor = callable(getattr(obj, "ainvoke", None)) or callable(getattr(obj, "invoke", None))
|
|
358
|
+
if has_tool_identity and has_tool_executor:
|
|
359
|
+
spec_name = str(name or getattr(obj, "name", obj.__class__.__name__))
|
|
360
|
+
doc = (inspect.getdoc(obj) or "").strip()
|
|
361
|
+
desc = (description or str(getattr(obj, "description", "") or "").strip()).strip()
|
|
362
|
+
merged_desc = "\n\n".join([s for s in [desc, doc] if s])
|
|
363
|
+
params = parameters or _schema_from_langchain_like_tool(obj)
|
|
364
|
+
|
|
365
|
+
async def _invoke_langchain_tool(args: dict[str, Any]) -> Any:
|
|
366
|
+
payload = args or {}
|
|
367
|
+
ainvoke = getattr(obj, "ainvoke", None)
|
|
368
|
+
if callable(ainvoke):
|
|
369
|
+
return await ainvoke(payload)
|
|
370
|
+
invoke = getattr(obj, "invoke", None)
|
|
371
|
+
if callable(invoke):
|
|
372
|
+
return invoke(payload)
|
|
373
|
+
raise TypeError(f"Tool object is not invokable: {type(obj)}")
|
|
374
|
+
|
|
375
|
+
return ToolSpec(
|
|
376
|
+
name=spec_name,
|
|
377
|
+
description=merged_desc or "",
|
|
378
|
+
parameters=params,
|
|
379
|
+
fn=fn or _invoke_langchain_tool,
|
|
380
|
+
raw=obj,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
raise TypeError(f"Unsupported tool spec type: {type(obj)}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def tool(
|
|
387
|
+
_fn: Callable[..., Any] | None = None,
|
|
388
|
+
*,
|
|
389
|
+
name: str | None = None,
|
|
390
|
+
description: str | None = None,
|
|
391
|
+
parameters: dict[str, Any] | None = None,
|
|
392
|
+
) -> Any:
|
|
393
|
+
"""
|
|
394
|
+
Decorator to define a tool quickly (LangChain-like), without LangChain dependency.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
@tool(description="Add two numbers")
|
|
398
|
+
def add(a: float, b: float) -> float:
|
|
399
|
+
return a + b
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
def _wrap(fn: Callable[..., Any]) -> ToolSpec:
|
|
403
|
+
return tool_spec_from(
|
|
404
|
+
fn,
|
|
405
|
+
name=name,
|
|
406
|
+
description=description,
|
|
407
|
+
parameters=parameters,
|
|
408
|
+
# Let `tool_spec_from` wrap the callable so it can be executed with `args: dict`.
|
|
409
|
+
fn=None,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if _fn is not None:
|
|
413
|
+
return _wrap(_fn)
|
|
414
|
+
return _wrap
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def bind_tools(
|
|
418
|
+
llm: LLMService,
|
|
419
|
+
tools: Sequence[Any],
|
|
420
|
+
*,
|
|
421
|
+
tool_choice: Any = "auto",
|
|
422
|
+
) -> BoundLLMService:
|
|
423
|
+
"""
|
|
424
|
+
Convenience: accept mixed tool declarations (ToolSpec / decorated function / callable / BaseModel subclass),
|
|
425
|
+
normalize them, then bind onto the LLMService.
|
|
426
|
+
"""
|
|
427
|
+
reg = ToolRegistry(tools)
|
|
428
|
+
normalized: list[ToolSpec] = list(reg.tools_by_name.values())
|
|
429
|
+
return BoundLLMService(llm=llm, tools=normalized, tool_choice=tool_choice)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _best_effort_json_loads(s: str) -> dict[str, Any] | None:
|
|
433
|
+
s = (s or "").strip()
|
|
434
|
+
if not s:
|
|
435
|
+
return None
|
|
436
|
+
try:
|
|
437
|
+
obj = json.loads(s)
|
|
438
|
+
if isinstance(obj, dict):
|
|
439
|
+
return obj
|
|
440
|
+
# tool args are expected to be an object; keep best-effort
|
|
441
|
+
return {"_": obj}
|
|
442
|
+
except Exception:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def extract_tool_calls(
|
|
447
|
+
resp: LLMResponse,
|
|
448
|
+
) -> list[dict[str, Any]]:
|
|
449
|
+
"""
|
|
450
|
+
Normalize tool calls from `LLMResponse` into a simple, execution-friendly shape.
|
|
451
|
+
|
|
452
|
+
Output:
|
|
453
|
+
- [{"id": "...", "name": "...", "args": {...}, "args_raw": "..."}]
|
|
454
|
+
This is intentionally similar to how `agent-psychology` prepares tool execution.
|
|
455
|
+
"""
|
|
456
|
+
out: list[dict[str, Any]] = []
|
|
457
|
+
for tc in resp.get_tool_calls():
|
|
458
|
+
if not isinstance(tc, dict):
|
|
459
|
+
continue
|
|
460
|
+
fn = tc.get("function") or {}
|
|
461
|
+
if not isinstance(fn, dict):
|
|
462
|
+
# best-effort: tool_calls might be pydantic objects already dumped upstream
|
|
463
|
+
try:
|
|
464
|
+
fn = dict(getattr(fn, "__dict__", {}) or {})
|
|
465
|
+
except Exception:
|
|
466
|
+
fn = {}
|
|
467
|
+
name = fn.get("name") or ""
|
|
468
|
+
args_raw = fn.get("arguments", "") or ""
|
|
469
|
+
args = _best_effort_json_loads(str(args_raw)) or {}
|
|
470
|
+
out.append(
|
|
471
|
+
{
|
|
472
|
+
"id": str(tc.get("id") or ""),
|
|
473
|
+
"name": str(name),
|
|
474
|
+
"args": args,
|
|
475
|
+
"args_raw": str(args_raw),
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
return out
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def tool_message(
|
|
482
|
+
*,
|
|
483
|
+
tool_call_id: str,
|
|
484
|
+
content: Any,
|
|
485
|
+
) -> LLMMessage:
|
|
486
|
+
"""
|
|
487
|
+
Create a `role="tool"` message for feeding tool execution results back into the LLM.
|
|
488
|
+
"""
|
|
489
|
+
if isinstance(content, str):
|
|
490
|
+
payload = content
|
|
491
|
+
else:
|
|
492
|
+
try:
|
|
493
|
+
payload = json.dumps(content, ensure_ascii=False)
|
|
494
|
+
except Exception:
|
|
495
|
+
payload = str(content)
|
|
496
|
+
return {"role": "tool", "tool_call_id": str(tool_call_id or ""), "content": payload}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def tool_messages_from_observations(
|
|
500
|
+
*,
|
|
501
|
+
tool_calls: Sequence[dict[str, Any]],
|
|
502
|
+
observations: Sequence[Any],
|
|
503
|
+
) -> list[LLMMessage]:
|
|
504
|
+
"""
|
|
505
|
+
Build tool messages from executed observations.
|
|
506
|
+
|
|
507
|
+
Typical usage:
|
|
508
|
+
- tool_calls = extract_tool_calls(resp)
|
|
509
|
+
- observations = await asyncio.gather(...)
|
|
510
|
+
- messages += tool_messages_from_observations(tool_calls=tool_calls, observations=observations)
|
|
511
|
+
"""
|
|
512
|
+
msgs: list[LLMMessage] = []
|
|
513
|
+
for tc, obs in zip(tool_calls, observations, strict=False):
|
|
514
|
+
msgs.append(tool_message(tool_call_id=str(tc.get("id") or ""), content=obs))
|
|
515
|
+
return msgs
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _coerce_scalar(expected_type: str | None, value: Any) -> Any:
|
|
519
|
+
if expected_type is None:
|
|
520
|
+
return value
|
|
521
|
+
|
|
522
|
+
# Keep None as-is.
|
|
523
|
+
if value is None:
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
# Integer
|
|
527
|
+
if expected_type == "integer":
|
|
528
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
529
|
+
return value
|
|
530
|
+
if isinstance(value, float):
|
|
531
|
+
return int(value)
|
|
532
|
+
if isinstance(value, str):
|
|
533
|
+
s = value.strip()
|
|
534
|
+
try:
|
|
535
|
+
# Handles "10" and also "10.0" best-effort.
|
|
536
|
+
return int(float(s))
|
|
537
|
+
except Exception:
|
|
538
|
+
return value
|
|
539
|
+
return value
|
|
540
|
+
|
|
541
|
+
# Number
|
|
542
|
+
if expected_type == "number":
|
|
543
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
544
|
+
return float(value)
|
|
545
|
+
if isinstance(value, str):
|
|
546
|
+
s = value.strip()
|
|
547
|
+
try:
|
|
548
|
+
return float(s)
|
|
549
|
+
except Exception:
|
|
550
|
+
return value
|
|
551
|
+
return value
|
|
552
|
+
|
|
553
|
+
# Boolean
|
|
554
|
+
if expected_type == "boolean":
|
|
555
|
+
if isinstance(value, bool):
|
|
556
|
+
return value
|
|
557
|
+
if isinstance(value, (int, float)):
|
|
558
|
+
return bool(value)
|
|
559
|
+
if isinstance(value, str):
|
|
560
|
+
s = value.strip().lower()
|
|
561
|
+
if s in {"true", "t", "1", "yes", "y", "on"}:
|
|
562
|
+
return True
|
|
563
|
+
if s in {"false", "f", "0", "no", "n", "off"}:
|
|
564
|
+
return False
|
|
565
|
+
return value
|
|
566
|
+
|
|
567
|
+
# String
|
|
568
|
+
if expected_type == "string":
|
|
569
|
+
if isinstance(value, str):
|
|
570
|
+
return value
|
|
571
|
+
return str(value)
|
|
572
|
+
|
|
573
|
+
return value
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _expected_type_from_schema(schema: Any) -> str | None:
|
|
577
|
+
if not isinstance(schema, dict):
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
t = schema.get("type")
|
|
581
|
+
if isinstance(t, str):
|
|
582
|
+
return t
|
|
583
|
+
|
|
584
|
+
# Union-like shapes (best-effort)
|
|
585
|
+
any_of = schema.get("anyOf")
|
|
586
|
+
if isinstance(any_of, list) and any_of:
|
|
587
|
+
for opt in any_of:
|
|
588
|
+
et = _expected_type_from_schema(opt)
|
|
589
|
+
if et:
|
|
590
|
+
return et
|
|
591
|
+
|
|
592
|
+
one_of = schema.get("oneOf")
|
|
593
|
+
if isinstance(one_of, list) and one_of:
|
|
594
|
+
for opt in one_of:
|
|
595
|
+
et = _expected_type_from_schema(opt)
|
|
596
|
+
if et:
|
|
597
|
+
return et
|
|
598
|
+
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _coerce_args_by_schema(schema: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
|
|
603
|
+
properties = schema.get("properties")
|
|
604
|
+
if not isinstance(properties, dict) or not isinstance(args, dict):
|
|
605
|
+
return args
|
|
606
|
+
|
|
607
|
+
out: dict[str, Any] = dict(args)
|
|
608
|
+
for key, prop_schema in properties.items():
|
|
609
|
+
if key not in out:
|
|
610
|
+
continue
|
|
611
|
+
expected = _expected_type_from_schema(prop_schema)
|
|
612
|
+
v = out.get(key)
|
|
613
|
+
|
|
614
|
+
if expected == "array":
|
|
615
|
+
if isinstance(v, str):
|
|
616
|
+
s = v.strip()
|
|
617
|
+
# If the model gave a JSON array as a string, try parsing.
|
|
618
|
+
if s.startswith("[") and s.endswith("]"):
|
|
619
|
+
try:
|
|
620
|
+
parsed = json.loads(s)
|
|
621
|
+
if isinstance(parsed, list):
|
|
622
|
+
out[key] = parsed
|
|
623
|
+
continue
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
# Otherwise leave arrays as-is.
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
# Scalar types
|
|
630
|
+
out[key] = _coerce_scalar(expected, v)
|
|
631
|
+
return out
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
async def execute_tool_safely(tool: ToolSpec, args: dict[str, Any]) -> Any:
|
|
635
|
+
"""
|
|
636
|
+
Safely execute a tool with error handling.
|
|
637
|
+
Mirrors the spirit of agent-psychology's execute_tool_safely, without LangChain dependency.
|
|
638
|
+
"""
|
|
639
|
+
try:
|
|
640
|
+
coerced_args = _coerce_args_by_schema(dict(tool.parameters or {}), dict(args or {}))
|
|
641
|
+
return await tool.ainvoke(coerced_args)
|
|
642
|
+
except Exception as e:
|
|
643
|
+
return {"error": f"tool_error:{tool.name}: {e}"}
|
|
644
|
+
|
|
645
|
+
|