base.py 6.9 KB

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