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.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. 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
+