base.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. from __future__ import annotations
  2. import inspect
  3. import json
  4. import types
  5. from collections.abc import Callable
  6. from typing import Any, Union, get_args, get_origin, get_type_hints
  7. from pydantic import BaseModel
  8. from supply_agent.types import ToolDefinition
  9. def _unwrap_optional(py_type: Any) -> Any:
  10. """若为 Optional[T] / T | None,返回 T;否则原样返回。"""
  11. origin = get_origin(py_type)
  12. if origin is Union or origin is types.UnionType:
  13. args = [a for a in get_args(py_type) if a is not type(None)]
  14. if len(args) == 1:
  15. return args[0]
  16. return py_type
  17. def _python_type_to_json_schema(py_type: Any) -> dict[str, Any]:
  18. """Map Python types to JSON Schema types."""
  19. py_type = _unwrap_optional(py_type)
  20. origin = get_origin(py_type)
  21. if py_type is str or py_type is inspect.Parameter.empty:
  22. return {"type": "string"}
  23. if py_type is int:
  24. return {"type": "integer"}
  25. if py_type is float:
  26. return {"type": "number"}
  27. if py_type is bool:
  28. return {"type": "boolean"}
  29. if origin is list:
  30. args = get_args(py_type) or (Any,)
  31. item_type = args[0] if args else Any
  32. return {"type": "array", "items": _python_type_to_json_schema(item_type)}
  33. if origin is dict:
  34. return {"type": "object"}
  35. return {"type": "string"}
  36. def _coerce_args(func: Callable[..., Any], args: dict[str, Any]) -> dict[str, Any]:
  37. """按函数类型注解做轻量转换,兼容模型把整数写成字符串等情况。"""
  38. hints = get_type_hints(func)
  39. coerced: dict[str, Any] = {}
  40. for name, value in args.items():
  41. if name not in hints or value is None:
  42. coerced[name] = value
  43. continue
  44. target = _unwrap_optional(hints[name])
  45. try:
  46. if target is int and not isinstance(value, bool):
  47. coerced[name] = int(value)
  48. elif target is float and not isinstance(value, bool):
  49. coerced[name] = float(value)
  50. elif target is bool and isinstance(value, str):
  51. coerced[name] = value.strip().lower() in {"1", "true", "yes", "y"}
  52. else:
  53. coerced[name] = value
  54. except (TypeError, ValueError):
  55. coerced[name] = value
  56. return coerced
  57. def _build_parameters(func: Callable[..., Any]) -> dict[str, Any]:
  58. """Build JSON Schema parameters from function signature."""
  59. sig = inspect.signature(func)
  60. hints = get_type_hints(func)
  61. properties: dict[str, Any] = {}
  62. required: list[str] = []
  63. for name, param in sig.parameters.items():
  64. if name in ("self", "cls"):
  65. continue
  66. py_type = hints.get(name, str)
  67. prop: dict[str, Any] = _python_type_to_json_schema(py_type)
  68. if param.default is not inspect.Parameter.empty:
  69. if not isinstance(param.default, (BaseModel,)):
  70. prop["default"] = param.default
  71. else:
  72. required.append(name)
  73. properties[name] = prop
  74. return {"type": "object", "properties": properties, "required": required}
  75. def tool(
  76. func: Callable[..., Any] | None = None,
  77. *,
  78. name: str | None = None,
  79. description: str | None = None,
  80. ) -> Callable[..., Any]:
  81. """
  82. Decorator to register a function as an agent tool.
  83. Usage:
  84. @tool
  85. def search(query: str, limit: int = 10) -> str:
  86. '''Search the web for information.'''
  87. ...
  88. """
  89. def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
  90. fn._is_tool = True # type: ignore[attr-defined]
  91. fn._tool_name = name or fn.__name__ # type: ignore[attr-defined]
  92. fn._tool_description = description or (fn.__doc__ or "").strip() # type: ignore[attr-defined]
  93. fn._tool_parameters = _build_parameters(fn) # type: ignore[attr-defined]
  94. return fn
  95. if func is not None:
  96. return decorator(func)
  97. return decorator
  98. class Tool:
  99. """A callable tool with schema metadata."""
  100. def __init__(
  101. self,
  102. func: Callable[..., Any],
  103. name: str | None = None,
  104. description: str | None = None,
  105. ) -> None:
  106. self.func = func
  107. self.name = name or getattr(func, "_tool_name", func.__name__)
  108. self.description = description or getattr(
  109. func, "_tool_description", (func.__doc__ or "").strip()
  110. )
  111. self.parameters = getattr(func, "_tool_parameters", _build_parameters(func))
  112. @property
  113. def definition(self) -> ToolDefinition:
  114. return ToolDefinition(
  115. name=self.name,
  116. description=self.description,
  117. parameters=self.parameters,
  118. )
  119. def __call__(self, **kwargs: Any) -> Any:
  120. return self.func(**kwargs)
  121. async def acall(self, **kwargs: Any) -> Any:
  122. result = self.func(**kwargs)
  123. if inspect.isawaitable(result):
  124. return await result
  125. return result
  126. def execute(self, arguments: str | dict[str, Any]) -> str:
  127. """Execute the tool with JSON or dict arguments, returning a string result."""
  128. try:
  129. if isinstance(arguments, str):
  130. args = json.loads(arguments) if arguments.strip() else {}
  131. else:
  132. args = arguments
  133. args = _coerce_args(self.func, args)
  134. result = self(**args)
  135. if inspect.isawaitable(result):
  136. raise RuntimeError(
  137. f"Tool '{self.name}' is async. Use ToolRegistry.aexecute() instead."
  138. )
  139. return str(result) if result is not None else ""
  140. except (json.JSONDecodeError, TypeError) as e:
  141. return json.dumps(
  142. {"error": str(e), "input_error": True},
  143. ensure_ascii=False,
  144. )
  145. except Exception as e:
  146. return json.dumps({"error": str(e)}, ensure_ascii=False)
  147. async def aexecute(self, arguments: str | dict[str, Any]) -> str:
  148. """Async execute the tool."""
  149. try:
  150. if isinstance(arguments, str):
  151. args = json.loads(arguments) if arguments.strip() else {}
  152. else:
  153. args = arguments
  154. args = _coerce_args(self.func, args)
  155. result = await self.acall(**args)
  156. return str(result) if result is not None else ""
  157. except (json.JSONDecodeError, TypeError) as e:
  158. return json.dumps(
  159. {"error": str(e), "input_error": True},
  160. ensure_ascii=False,
  161. )
  162. except Exception as e:
  163. return json.dumps({"error": str(e)}, ensure_ascii=False)