loop.py 12 KB

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