loop.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. from __future__ import annotations
  2. import json
  3. from collections.abc import AsyncIterator, Callable, Iterator
  4. from typing import TYPE_CHECKING, Literal
  5. from supply_agent.llm.client import LLMClient, MalformedFunctionCallError
  6. from supply_agent.tools.registry import ToolRegistry
  7. from supply_agent.types import (
  8. AgentEvent,
  9. AgentEventType,
  10. AgentResult,
  11. CompletionGuard,
  12. Message,
  13. Role,
  14. ToolCall,
  15. ToolDefinition,
  16. ToolResult,
  17. )
  18. if TYPE_CHECKING:
  19. from supply_agent.logging.logger import AgentLogger
  20. # Outcome of one assistant turn before tools / completion.
  21. _TurnKind = Literal["done", "tools", "continue"]
  22. _DEFAULT_COMPLETION_GUARD_FEEDBACK = (
  23. "当前结果尚未满足结束条件。请根据已有上下文继续任务;"
  24. "满足结束条件后再输出最终回答。"
  25. )
  26. class AgentLoop:
  27. """
  28. ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
  29. Sync/async and stream/non-stream entry points share the same turn and
  30. tool-handling logic so skill injection and max-iteration behavior stay
  31. consistent.
  32. """
  33. def __init__(
  34. self,
  35. llm: LLMClient,
  36. tools: ToolRegistry,
  37. system_message: Message,
  38. messages: list[Message],
  39. max_iterations: int = 20,
  40. temperature: float | None = None,
  41. logger: AgentLogger | None = None,
  42. active_skills: list[str] | None = None,
  43. system_message_builder: Callable[[], Message] | None = None,
  44. completion_guard: CompletionGuard | None = None,
  45. ) -> None:
  46. self.llm = llm
  47. self.tools = tools
  48. self.system_message = system_message
  49. self.system_message_builder = system_message_builder
  50. self.messages = messages
  51. self.max_iterations = max_iterations
  52. self.temperature = temperature
  53. self.logger = logger
  54. self.completion_guard = completion_guard
  55. # Use `is not None` so an empty shared list from Agent is kept by identity.
  56. self.active_skills = active_skills if active_skills is not None else []
  57. self.tool_calls_made = 0
  58. def _all_messages(self) -> list[Message]:
  59. return [self.system_message, *self.messages]
  60. def _on_skill_loaded(self, arguments: str) -> None:
  61. """Refresh system message after a skill is loaded."""
  62. try:
  63. args = json.loads(arguments)
  64. skill_name = args.get("name", "")
  65. if skill_name and skill_name not in self.active_skills:
  66. self.active_skills.append(skill_name)
  67. except json.JSONDecodeError:
  68. pass
  69. if self.system_message_builder:
  70. self.system_message = self.system_message_builder()
  71. def _append_malformed_tool_feedback(self) -> None:
  72. self.messages.append(
  73. Message(
  74. role=Role.USER,
  75. content=(
  76. "上一步连续生成了无效工具参数。请继续任务,下一步只调用一个"
  77. "最必要的工具,严格使用其 schema,不得添加未定义字段。"
  78. ),
  79. )
  80. )
  81. def _available_tool_definitions(self) -> list[ToolDefinition] | None:
  82. return self.tools.definitions or None
  83. def _build_result(self, content: str, iterations: int) -> AgentResult:
  84. return AgentResult(
  85. content=content,
  86. messages=self.messages,
  87. iterations=iterations,
  88. tool_calls_made=self.tool_calls_made,
  89. skills_used=list(self.active_skills),
  90. )
  91. def _record_tool_result(
  92. self,
  93. tool_call: ToolCall,
  94. result: ToolResult,
  95. iterations: int,
  96. ) -> None:
  97. if self.logger:
  98. self.logger.log_tool_call(
  99. iterations,
  100. tool_call.name,
  101. tool_call.arguments,
  102. result.content,
  103. result.is_error,
  104. tool_call_id=tool_call.id,
  105. )
  106. if tool_call.name == "load_skill" and not result.is_error:
  107. self._on_skill_loaded(tool_call.arguments)
  108. if self.logger:
  109. self.logger.log_skill_loaded(iterations, tool_call.arguments)
  110. self.messages.append(
  111. Message(
  112. role=Role.TOOL,
  113. content=result.content,
  114. tool_call_id=result.tool_call_id,
  115. name=result.name,
  116. )
  117. )
  118. def _resolve_tool_call_sync(self, tool_call: ToolCall) -> ToolResult:
  119. return self.tools.execute(
  120. tool_call.id, tool_call.name, tool_call.arguments
  121. )
  122. async def _resolve_tool_call_async(self, tool_call: ToolCall) -> ToolResult:
  123. return await self.tools.aexecute(
  124. tool_call.id, tool_call.name, tool_call.arguments
  125. )
  126. def _handle_assistant_message(
  127. self,
  128. response: Message,
  129. iterations: int,
  130. ) -> tuple[_TurnKind, AgentResult | None]:
  131. """Classify an assistant turn: finish, or run tools."""
  132. self.messages.append(response)
  133. if response.tool_calls:
  134. return "tools", None
  135. feedback = self._completion_guard_feedback(response)
  136. if feedback is not None:
  137. self._append_completion_guard_feedback(feedback)
  138. return "continue", None
  139. return "done", self._build_result(response.content or "", iterations)
  140. def _completion_guard_feedback(self, response: Message) -> str | None:
  141. if self.completion_guard is None:
  142. return None
  143. feedback = self.completion_guard(response, tuple(self.messages))
  144. if feedback is None:
  145. return None
  146. if not isinstance(feedback, str):
  147. raise TypeError("completion_guard 必须返回 str 或 None")
  148. return feedback.strip() or _DEFAULT_COMPLETION_GUARD_FEEDBACK
  149. def _append_completion_guard_feedback(self, feedback: str) -> None:
  150. self.messages.append(Message(role=Role.USER, content=feedback))
  151. def _nudge_best_answer_sync(self, iterations: int) -> AgentResult:
  152. self.messages.append(
  153. Message(
  154. role=Role.USER,
  155. content=(
  156. "Maximum iterations reached. Please provide your best answer now."
  157. ),
  158. )
  159. )
  160. final = self.llm.chat(
  161. self._all_messages(),
  162. temperature=self.temperature,
  163. iteration=iterations + 1,
  164. )
  165. self.messages.append(final)
  166. return self._build_result(final.content or "", iterations)
  167. async def _nudge_best_answer_async(self, iterations: int) -> AgentResult:
  168. self.messages.append(
  169. Message(
  170. role=Role.USER,
  171. content=(
  172. "Maximum iterations reached. Please provide your best answer now."
  173. ),
  174. )
  175. )
  176. final = await self.llm.achat(
  177. self._all_messages(),
  178. temperature=self.temperature,
  179. iteration=iterations + 1,
  180. )
  181. self.messages.append(final)
  182. return self._build_result(final.content or "", iterations)
  183. def run(self) -> AgentResult:
  184. iterations = 0
  185. while iterations < self.max_iterations:
  186. iterations += 1
  187. try:
  188. response = self.llm.chat(
  189. self._all_messages(),
  190. tools=self._available_tool_definitions(),
  191. temperature=self.temperature,
  192. iteration=iterations,
  193. )
  194. except MalformedFunctionCallError:
  195. self._append_malformed_tool_feedback()
  196. continue
  197. kind, done = self._handle_assistant_message(response, iterations)
  198. if kind == "done" and done is not None:
  199. return done
  200. if kind == "continue":
  201. continue
  202. assert response.tool_calls is not None
  203. for tc in response.tool_calls:
  204. self.tool_calls_made += 1
  205. result = self._resolve_tool_call_sync(tc)
  206. self._record_tool_result(tc, result, iterations)
  207. return self._nudge_best_answer_sync(iterations)
  208. async def arun(self) -> AgentResult:
  209. iterations = 0
  210. while iterations < self.max_iterations:
  211. iterations += 1
  212. try:
  213. response = await self.llm.achat(
  214. self._all_messages(),
  215. tools=self._available_tool_definitions(),
  216. temperature=self.temperature,
  217. iteration=iterations,
  218. )
  219. except MalformedFunctionCallError:
  220. self._append_malformed_tool_feedback()
  221. continue
  222. kind, done = self._handle_assistant_message(response, iterations)
  223. if kind == "done" and done is not None:
  224. return done
  225. if kind == "continue":
  226. continue
  227. assert response.tool_calls is not None
  228. for tc in response.tool_calls:
  229. self.tool_calls_made += 1
  230. result = await self._resolve_tool_call_async(tc)
  231. self._record_tool_result(tc, result, iterations)
  232. return await self._nudge_best_answer_async(iterations)
  233. def stream(self) -> Iterator[AgentEvent]:
  234. iterations = 0
  235. while iterations < self.max_iterations:
  236. iterations += 1
  237. yield AgentEvent(
  238. type=AgentEventType.THINKING,
  239. data={"iteration": iterations},
  240. )
  241. try:
  242. response = self.llm.chat(
  243. self._all_messages(),
  244. tools=self._available_tool_definitions(),
  245. temperature=self.temperature,
  246. iteration=iterations,
  247. )
  248. except MalformedFunctionCallError:
  249. self._append_malformed_tool_feedback()
  250. continue
  251. kind, done = self._handle_assistant_message(response, iterations)
  252. if kind == "done" and done is not None:
  253. yield AgentEvent(
  254. type=AgentEventType.MESSAGE,
  255. data={"content": done.content},
  256. )
  257. yield AgentEvent(
  258. type=AgentEventType.DONE,
  259. data=done.model_dump(),
  260. )
  261. return
  262. if kind == "continue":
  263. continue
  264. assert response.tool_calls is not None
  265. for tc in response.tool_calls:
  266. self.tool_calls_made += 1
  267. yield AgentEvent(
  268. type=AgentEventType.TOOL_CALL,
  269. data={
  270. "name": tc.name,
  271. "arguments": tc.arguments,
  272. "id": tc.id,
  273. },
  274. )
  275. result = self._resolve_tool_call_sync(tc)
  276. self._record_tool_result(tc, result, iterations)
  277. yield AgentEvent(
  278. type=AgentEventType.TOOL_RESULT,
  279. data={
  280. "name": result.name,
  281. "content": result.content,
  282. "is_error": result.is_error,
  283. },
  284. )
  285. final = self._nudge_best_answer_sync(iterations)
  286. yield AgentEvent(
  287. type=AgentEventType.MESSAGE,
  288. data={"content": final.content},
  289. )
  290. yield AgentEvent(
  291. type=AgentEventType.DONE,
  292. data=final.model_dump(),
  293. )
  294. async def astream(self) -> AsyncIterator[AgentEvent]:
  295. iterations = 0
  296. while iterations < self.max_iterations:
  297. iterations += 1
  298. yield AgentEvent(
  299. type=AgentEventType.THINKING,
  300. data={"iteration": iterations},
  301. )
  302. try:
  303. response = await self.llm.achat(
  304. self._all_messages(),
  305. tools=self._available_tool_definitions(),
  306. temperature=self.temperature,
  307. iteration=iterations,
  308. )
  309. except MalformedFunctionCallError:
  310. self._append_malformed_tool_feedback()
  311. continue
  312. kind, done = self._handle_assistant_message(response, iterations)
  313. if kind == "done" and done is not None:
  314. yield AgentEvent(
  315. type=AgentEventType.MESSAGE,
  316. data={"content": done.content},
  317. )
  318. yield AgentEvent(
  319. type=AgentEventType.DONE,
  320. data=done.model_dump(),
  321. )
  322. return
  323. if kind == "continue":
  324. continue
  325. assert response.tool_calls is not None
  326. for tc in response.tool_calls:
  327. self.tool_calls_made += 1
  328. yield AgentEvent(
  329. type=AgentEventType.TOOL_CALL,
  330. data={
  331. "name": tc.name,
  332. "arguments": tc.arguments,
  333. "id": tc.id,
  334. },
  335. )
  336. result = await self._resolve_tool_call_async(tc)
  337. self._record_tool_result(tc, result, iterations)
  338. yield AgentEvent(
  339. type=AgentEventType.TOOL_RESULT,
  340. data={
  341. "name": result.name,
  342. "content": result.content,
  343. "is_error": result.is_error,
  344. },
  345. )
  346. final = await self._nudge_best_answer_async(iterations)
  347. yield AgentEvent(
  348. type=AgentEventType.MESSAGE,
  349. data={"content": final.content},
  350. )
  351. yield AgentEvent(
  352. type=AgentEventType.DONE,
  353. data=final.model_dump(),
  354. )