| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- from __future__ import annotations
- import asyncio
- import inspect
- import json
- import types
- from collections.abc import Callable
- from typing import Any, Union, get_args, get_origin, get_type_hints
- from pydantic import BaseModel
- from supply_agent.types import ToolDefinition
- def _unwrap_optional(py_type: Any) -> Any:
- """若为 Optional[T] / T | None,返回 T;否则原样返回。"""
- origin = get_origin(py_type)
- if origin is Union or origin is types.UnionType:
- args = [a for a in get_args(py_type) if a is not type(None)]
- if len(args) == 1:
- return args[0]
- return py_type
- def _python_type_to_json_schema(py_type: Any) -> dict[str, Any]:
- """Map Python types to JSON Schema types."""
- py_type = _unwrap_optional(py_type)
- origin = get_origin(py_type)
- if py_type is str or py_type is inspect.Parameter.empty:
- return {"type": "string"}
- if py_type is int:
- return {"type": "integer"}
- if py_type is float:
- return {"type": "number"}
- if py_type is bool:
- return {"type": "boolean"}
- if origin is list:
- args = get_args(py_type) or (Any,)
- item_type = args[0] if args else Any
- return {"type": "array", "items": _python_type_to_json_schema(item_type)}
- if origin is dict:
- return {"type": "object"}
- return {"type": "string"}
- def _coerce_args(func: Callable[..., Any], args: dict[str, Any]) -> dict[str, Any]:
- """按函数类型注解做轻量转换,兼容模型把整数写成字符串等情况。"""
- hints = get_type_hints(func)
- coerced: dict[str, Any] = {}
- for name, value in args.items():
- if name not in hints or value is None:
- coerced[name] = value
- continue
- target = _unwrap_optional(hints[name])
- try:
- if target is int and not isinstance(value, bool):
- coerced[name] = int(value)
- elif target is float and not isinstance(value, bool):
- coerced[name] = float(value)
- elif target is bool and isinstance(value, str):
- coerced[name] = value.strip().lower() in {"1", "true", "yes", "y"}
- else:
- coerced[name] = value
- except (TypeError, ValueError):
- coerced[name] = value
- return coerced
- def _build_parameters(func: Callable[..., Any]) -> dict[str, Any]:
- """Build JSON Schema parameters from function signature."""
- sig = inspect.signature(func)
- hints = get_type_hints(func)
- properties: dict[str, Any] = {}
- required: list[str] = []
- for name, param in sig.parameters.items():
- if name in ("self", "cls"):
- continue
- py_type = hints.get(name, str)
- prop: dict[str, Any] = _python_type_to_json_schema(py_type)
- if param.default is not inspect.Parameter.empty:
- if not isinstance(param.default, (BaseModel,)):
- prop["default"] = param.default
- else:
- required.append(name)
- properties[name] = prop
- return {"type": "object", "properties": properties, "required": required}
- def tool(
- func: Callable[..., Any] | None = None,
- *,
- name: str | None = None,
- description: str | None = None,
- ) -> Callable[..., Any]:
- """
- Decorator to register a function as an agent tool.
- Usage:
- @tool
- def search(query: str, limit: int = 10) -> str:
- '''Search the web for information.'''
- ...
- """
- def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
- fn._is_tool = True # type: ignore[attr-defined]
- fn._tool_name = name or fn.__name__ # type: ignore[attr-defined]
- fn._tool_description = description or (fn.__doc__ or "").strip() # type: ignore[attr-defined]
- fn._tool_parameters = _build_parameters(fn) # type: ignore[attr-defined]
- return fn
- if func is not None:
- return decorator(func)
- return decorator
- class Tool:
- """A callable tool with schema metadata."""
- def __init__(
- self,
- func: Callable[..., Any],
- name: str | None = None,
- description: str | None = None,
- ) -> None:
- self.func = func
- self.name = name or getattr(func, "_tool_name", func.__name__)
- self.description = description or getattr(
- func, "_tool_description", (func.__doc__ or "").strip()
- )
- self.parameters = getattr(func, "_tool_parameters", _build_parameters(func))
- @property
- def definition(self) -> ToolDefinition:
- return ToolDefinition(
- name=self.name,
- description=self.description,
- parameters=self.parameters,
- )
- def __call__(self, **kwargs: Any) -> Any:
- return self.func(**kwargs)
- async def acall(self, **kwargs: Any) -> Any:
- if asyncio.iscoroutinefunction(self.func):
- return await self.func(**kwargs)
- # 同步函数(如阻塞式 DB 调用)丢到线程池执行,避免整体卡住事件循环——
- # 否则一旦某次同步调用阻塞,async 调用方设置的超时(如 asyncio.wait_for)
- # 完全无法取消/中断它,会导致 Agent 无限期卡死。
- result = await asyncio.to_thread(self.func, **kwargs)
- if inspect.isawaitable(result):
- return await result
- return result
- def execute(self, arguments: str | dict[str, Any]) -> str:
- """Execute the tool with JSON or dict arguments, returning a string result."""
- try:
- if isinstance(arguments, str):
- args = json.loads(arguments) if arguments.strip() else {}
- else:
- args = arguments
- args = _coerce_args(self.func, args)
- result = self(**args)
- if inspect.isawaitable(result):
- raise RuntimeError(
- f"Tool '{self.name}' is async. Use ToolRegistry.aexecute() instead."
- )
- return str(result) if result is not None else ""
- except (json.JSONDecodeError, TypeError) as e:
- return json.dumps(
- {"error": str(e), "input_error": True},
- ensure_ascii=False,
- )
- except Exception as e:
- return json.dumps({"error": str(e)}, ensure_ascii=False)
- async def aexecute(self, arguments: str | dict[str, Any]) -> str:
- """Async execute the tool."""
- try:
- if isinstance(arguments, str):
- args = json.loads(arguments) if arguments.strip() else {}
- else:
- args = arguments
- args = _coerce_args(self.func, args)
- result = await self.acall(**args)
- return str(result) if result is not None else ""
- except (json.JSONDecodeError, TypeError) as e:
- return json.dumps(
- {"error": str(e), "input_error": True},
- ensure_ascii=False,
- )
- except Exception as e:
- return json.dumps({"error": str(e)}, ensure_ascii=False)
|