loop.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. from __future__ import annotations
  2. import json
  3. from collections.abc import AsyncIterator, Callable, Iterator
  4. from typing import TYPE_CHECKING
  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. class AgentLoop:
  20. """
  21. ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
  22. Implements the standard tool-calling pattern used by modern agent frameworks.
  23. """
  24. def __init__(
  25. self,
  26. llm: LLMClient,
  27. tools: ToolRegistry,
  28. system_message: Message,
  29. messages: list[Message],
  30. max_iterations: int = 20,
  31. temperature: float | None = None,
  32. logger: AgentLogger | None = None,
  33. active_skills: list[str] | None = None,
  34. system_message_builder: Callable[[], Message] | None = None,
  35. completion_guard: Callable[[list[Message]], str | None] | None = None,
  36. tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
  37. tool_repeat_requires_change: dict[str, set[str]] | None = None,
  38. ) -> None:
  39. self.llm = llm
  40. self.tools = tools
  41. self.system_message = system_message
  42. self.system_message_builder = system_message_builder
  43. self.messages = messages
  44. self.max_iterations = max_iterations
  45. self.temperature = temperature
  46. self.logger = logger
  47. self.active_skills = active_skills or []
  48. self.completion_guard = completion_guard
  49. self.tool_call_budgets = tool_call_budgets or {}
  50. self.tool_repeat_requires_change = tool_repeat_requires_change or {}
  51. self._tool_budget_counts = {
  52. group: 0 for group in self.tool_call_budgets
  53. }
  54. self.tool_calls_made = 0
  55. def _all_messages(self) -> list[Message]:
  56. return [self.system_message, *self.messages]
  57. def _on_skill_loaded(self, arguments: str) -> None:
  58. """Refresh system message after a skill is loaded."""
  59. try:
  60. args = json.loads(arguments)
  61. skill_name = args.get("name", "")
  62. if skill_name and skill_name not in self.active_skills:
  63. self.active_skills.append(skill_name)
  64. except json.JSONDecodeError:
  65. pass
  66. if self.system_message_builder:
  67. self.system_message = self.system_message_builder()
  68. def _completion_block_reason(self) -> str | None:
  69. if self.completion_guard is None:
  70. return None
  71. return self.completion_guard(self.messages)
  72. def _append_completion_feedback(self, reason: str) -> None:
  73. self.messages.append(
  74. Message(
  75. role=Role.USER,
  76. content=(
  77. "完成守卫拒绝当前最终回答:"
  78. f"{reason}。请继续调用必要工具修复这些问题;"
  79. "只有守卫条件全部满足后才能输出最终答案。"
  80. ),
  81. )
  82. )
  83. def _append_malformed_tool_feedback(self) -> None:
  84. self.messages.append(
  85. Message(
  86. role=Role.USER,
  87. content=(
  88. "上一步连续生成了无效工具参数。请继续任务,下一步只调用一个"
  89. "最必要的工具,严格使用其 schema,不得添加未定义字段。"
  90. ),
  91. )
  92. )
  93. def _consume_tool_budget(self, tool_name: str) -> str | None:
  94. matching = [
  95. (group, limit)
  96. for group, (tool_names, limit) in self.tool_call_budgets.items()
  97. if tool_name in tool_names
  98. ]
  99. for group, limit in matching:
  100. used = self._tool_budget_counts[group]
  101. if used >= limit:
  102. return (
  103. f"工具预算已耗尽: group={group}, limit={limit}。"
  104. "请使用已有结果完成筛选、持久化和审计,不要继续扩大搜索。"
  105. )
  106. for group, _ in matching:
  107. self._tool_budget_counts[group] += 1
  108. return None
  109. def _repeat_policy_error(self, tool_name: str) -> str | None:
  110. dependencies = self.tool_repeat_requires_change.get(tool_name)
  111. if not dependencies:
  112. return None
  113. last_call_index = -1
  114. last_change_index = -1
  115. for index, message in enumerate(self.messages):
  116. if message.role != Role.TOOL or not message.name:
  117. continue
  118. try:
  119. payload = json.loads(message.content or "")
  120. except (TypeError, json.JSONDecodeError):
  121. continue
  122. if isinstance(payload, dict) and payload.get("error"):
  123. continue
  124. if message.name == tool_name:
  125. last_call_index = index
  126. if message.name in dependencies:
  127. last_change_index = index
  128. if last_call_index >= 0 and last_change_index < last_call_index:
  129. return (
  130. f"工具 {tool_name} 在状态未变化时不得重复调用。"
  131. f"必须先成功执行以下任一状态变更工具: {sorted(dependencies)}"
  132. )
  133. return None
  134. def _available_tool_definitions(self) -> list[ToolDefinition] | None:
  135. exhausted_tools: set[str] = set()
  136. for group, (tool_names, limit) in self.tool_call_budgets.items():
  137. if self._tool_budget_counts[group] >= limit:
  138. exhausted_tools.update(tool_names)
  139. definitions = [
  140. definition
  141. for definition in self.tools.definitions
  142. if definition.name not in exhausted_tools
  143. and self._repeat_policy_error(definition.name) is None
  144. ]
  145. return definitions or None
  146. def _refund_tool_budget_for_input_error(
  147. self,
  148. tool_name: str,
  149. result: ToolResult,
  150. ) -> None:
  151. try:
  152. payload = json.loads(result.content)
  153. except (TypeError, json.JSONDecodeError):
  154. return
  155. if not isinstance(payload, dict) or payload.get("input_error") is not True:
  156. return
  157. for group, (tool_names, _) in self.tool_call_budgets.items():
  158. if tool_name in tool_names and self._tool_budget_counts[group] > 0:
  159. self._tool_budget_counts[group] -= 1
  160. @staticmethod
  161. def _budget_error_result(tool_call: ToolCall, error: str) -> ToolResult:
  162. return ToolResult(
  163. tool_call_id=tool_call.id,
  164. name=tool_call.name,
  165. content=json.dumps(
  166. {
  167. "error": error,
  168. "budget_exhausted": True,
  169. "title": "工具预算拒绝调用",
  170. },
  171. ensure_ascii=False,
  172. ),
  173. is_error=True,
  174. )
  175. @staticmethod
  176. def _policy_error_result(tool_call: ToolCall, error: str) -> ToolResult:
  177. return ToolResult(
  178. tool_call_id=tool_call.id,
  179. name=tool_call.name,
  180. content=json.dumps(
  181. {
  182. "error": error,
  183. "policy_rejected": True,
  184. "title": "工具状态策略拒绝调用",
  185. },
  186. ensure_ascii=False,
  187. ),
  188. is_error=True,
  189. )
  190. def _max_iterations_result(self, iterations: int) -> AgentResult:
  191. reason = self._completion_block_reason()
  192. content = "Max iterations reached"
  193. if reason:
  194. content = f"Max iterations reached before completion: {reason}"
  195. return self._build_result(content, iterations)
  196. def run(self) -> AgentResult:
  197. iterations = 0
  198. while iterations < self.max_iterations:
  199. iterations += 1
  200. try:
  201. response = self.llm.chat(
  202. self._all_messages(),
  203. tools=self._available_tool_definitions(),
  204. temperature=self.temperature,
  205. iteration=iterations,
  206. )
  207. except MalformedFunctionCallError:
  208. self._append_malformed_tool_feedback()
  209. continue
  210. self.messages.append(response)
  211. if not response.tool_calls:
  212. reason = self._completion_block_reason()
  213. if reason:
  214. self._append_completion_feedback(reason)
  215. continue
  216. return self._build_result(response.content or "", iterations)
  217. for tc in response.tool_calls:
  218. self.tool_calls_made += 1
  219. policy_error = self._repeat_policy_error(tc.name)
  220. budget_error = (
  221. None if policy_error else self._consume_tool_budget(tc.name)
  222. )
  223. result = (
  224. self._policy_error_result(tc, policy_error)
  225. if policy_error
  226. else (
  227. self._budget_error_result(tc, budget_error)
  228. if budget_error
  229. else self.tools.execute(tc.id, tc.name, tc.arguments)
  230. )
  231. )
  232. if not policy_error and not budget_error:
  233. self._refund_tool_budget_for_input_error(tc.name, result)
  234. if self.logger:
  235. self.logger.log_tool_call(
  236. iterations,
  237. tc.name,
  238. tc.arguments,
  239. result.content,
  240. result.is_error,
  241. tool_call_id=tc.id,
  242. )
  243. if tc.name == "load_skill" and not result.is_error:
  244. self._on_skill_loaded(tc.arguments)
  245. if self.logger:
  246. self.logger.log_skill_loaded(iterations, tc.arguments)
  247. self.messages.append(
  248. Message(
  249. role=Role.TOOL,
  250. content=result.content,
  251. tool_call_id=result.tool_call_id,
  252. name=result.name,
  253. )
  254. )
  255. if self.completion_guard is not None:
  256. return self._max_iterations_result(iterations)
  257. self.messages.append(
  258. Message(
  259. role=Role.USER,
  260. content="Maximum iterations reached. Please provide your best answer now.",
  261. )
  262. )
  263. final = self.llm.chat(
  264. self._all_messages(),
  265. temperature=self.temperature,
  266. iteration=iterations + 1,
  267. )
  268. self.messages.append(final)
  269. return self._build_result(final.content or "", iterations)
  270. async def arun(self) -> AgentResult:
  271. iterations = 0
  272. while iterations < self.max_iterations:
  273. iterations += 1
  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. self.messages.append(response)
  285. if not response.tool_calls:
  286. reason = self._completion_block_reason()
  287. if reason:
  288. self._append_completion_feedback(reason)
  289. continue
  290. return self._build_result(response.content or "", iterations)
  291. for tc in response.tool_calls:
  292. self.tool_calls_made += 1
  293. policy_error = self._repeat_policy_error(tc.name)
  294. budget_error = (
  295. None if policy_error else self._consume_tool_budget(tc.name)
  296. )
  297. result = (
  298. self._policy_error_result(tc, policy_error)
  299. if policy_error
  300. else (
  301. self._budget_error_result(tc, budget_error)
  302. if budget_error
  303. else await self.tools.aexecute(
  304. tc.id, tc.name, tc.arguments
  305. )
  306. )
  307. )
  308. if not policy_error and not budget_error:
  309. self._refund_tool_budget_for_input_error(tc.name, result)
  310. if self.logger:
  311. self.logger.log_tool_call(
  312. iterations,
  313. tc.name,
  314. tc.arguments,
  315. result.content,
  316. result.is_error,
  317. tool_call_id=tc.id,
  318. )
  319. if tc.name == "load_skill" and not result.is_error:
  320. self._on_skill_loaded(tc.arguments)
  321. if self.logger:
  322. self.logger.log_skill_loaded(iterations, tc.arguments)
  323. self.messages.append(
  324. Message(
  325. role=Role.TOOL,
  326. content=result.content,
  327. tool_call_id=result.tool_call_id,
  328. name=result.name,
  329. )
  330. )
  331. if self.completion_guard is not None:
  332. return self._max_iterations_result(iterations)
  333. self.messages.append(
  334. Message(
  335. role=Role.USER,
  336. content="Maximum iterations reached. Please provide your best answer now.",
  337. )
  338. )
  339. final = await self.llm.achat(
  340. self._all_messages(),
  341. temperature=self.temperature,
  342. iteration=iterations + 1,
  343. )
  344. self.messages.append(final)
  345. return self._build_result(final.content or "", iterations)
  346. def stream(self) -> Iterator[AgentEvent]:
  347. iterations = 0
  348. while iterations < self.max_iterations:
  349. iterations += 1
  350. yield AgentEvent(
  351. type=AgentEventType.THINKING,
  352. data={"iteration": iterations},
  353. )
  354. try:
  355. response = self.llm.chat(
  356. self._all_messages(),
  357. tools=self._available_tool_definitions(),
  358. temperature=self.temperature,
  359. iteration=iterations,
  360. )
  361. except MalformedFunctionCallError:
  362. self._append_malformed_tool_feedback()
  363. continue
  364. self.messages.append(response)
  365. if not response.tool_calls:
  366. reason = self._completion_block_reason()
  367. if reason:
  368. self._append_completion_feedback(reason)
  369. continue
  370. yield AgentEvent(
  371. type=AgentEventType.MESSAGE,
  372. data={"content": response.content or ""},
  373. )
  374. yield AgentEvent(
  375. type=AgentEventType.DONE,
  376. data=self._build_result(response.content or "", iterations).model_dump(),
  377. )
  378. return
  379. for tc in response.tool_calls:
  380. self.tool_calls_made += 1
  381. yield AgentEvent(
  382. type=AgentEventType.TOOL_CALL,
  383. data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
  384. )
  385. policy_error = self._repeat_policy_error(tc.name)
  386. budget_error = (
  387. None if policy_error else self._consume_tool_budget(tc.name)
  388. )
  389. result = (
  390. self._policy_error_result(tc, policy_error)
  391. if policy_error
  392. else (
  393. self._budget_error_result(tc, budget_error)
  394. if budget_error
  395. else self.tools.execute(tc.id, tc.name, tc.arguments)
  396. )
  397. )
  398. if not policy_error and not budget_error:
  399. self._refund_tool_budget_for_input_error(tc.name, result)
  400. if self.logger:
  401. self.logger.log_tool_call(
  402. iterations,
  403. tc.name,
  404. tc.arguments,
  405. result.content,
  406. result.is_error,
  407. tool_call_id=tc.id,
  408. )
  409. yield AgentEvent(
  410. type=AgentEventType.TOOL_RESULT,
  411. data={"name": result.name, "content": result.content, "is_error": result.is_error},
  412. )
  413. self.messages.append(
  414. Message(
  415. role=Role.TOOL,
  416. content=result.content,
  417. tool_call_id=result.tool_call_id,
  418. name=result.name,
  419. )
  420. )
  421. yield AgentEvent(
  422. type=AgentEventType.DONE,
  423. data=self._max_iterations_result(iterations).model_dump(),
  424. )
  425. async def astream(self) -> AsyncIterator[AgentEvent]:
  426. iterations = 0
  427. while iterations < self.max_iterations:
  428. iterations += 1
  429. yield AgentEvent(
  430. type=AgentEventType.THINKING,
  431. data={"iteration": iterations},
  432. )
  433. try:
  434. response = await self.llm.achat(
  435. self._all_messages(),
  436. tools=self._available_tool_definitions(),
  437. temperature=self.temperature,
  438. iteration=iterations,
  439. )
  440. except MalformedFunctionCallError:
  441. self._append_malformed_tool_feedback()
  442. continue
  443. self.messages.append(response)
  444. if not response.tool_calls:
  445. reason = self._completion_block_reason()
  446. if reason:
  447. self._append_completion_feedback(reason)
  448. continue
  449. yield AgentEvent(
  450. type=AgentEventType.MESSAGE,
  451. data={"content": response.content or ""},
  452. )
  453. yield AgentEvent(
  454. type=AgentEventType.DONE,
  455. data=self._build_result(response.content or "", iterations).model_dump(),
  456. )
  457. return
  458. for tc in response.tool_calls:
  459. self.tool_calls_made += 1
  460. yield AgentEvent(
  461. type=AgentEventType.TOOL_CALL,
  462. data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
  463. )
  464. policy_error = self._repeat_policy_error(tc.name)
  465. budget_error = (
  466. None if policy_error else self._consume_tool_budget(tc.name)
  467. )
  468. result = (
  469. self._policy_error_result(tc, policy_error)
  470. if policy_error
  471. else (
  472. self._budget_error_result(tc, budget_error)
  473. if budget_error
  474. else await self.tools.aexecute(
  475. tc.id, tc.name, tc.arguments
  476. )
  477. )
  478. )
  479. if not policy_error and not budget_error:
  480. self._refund_tool_budget_for_input_error(tc.name, result)
  481. if self.logger:
  482. self.logger.log_tool_call(
  483. iterations,
  484. tc.name,
  485. tc.arguments,
  486. result.content,
  487. result.is_error,
  488. tool_call_id=tc.id,
  489. )
  490. yield AgentEvent(
  491. type=AgentEventType.TOOL_RESULT,
  492. data={"name": result.name, "content": result.content, "is_error": result.is_error},
  493. )
  494. self.messages.append(
  495. Message(
  496. role=Role.TOOL,
  497. content=result.content,
  498. tool_call_id=result.tool_call_id,
  499. name=result.name,
  500. )
  501. )
  502. yield AgentEvent(
  503. type=AgentEventType.DONE,
  504. data=self._max_iterations_result(iterations).model_dump(),
  505. )
  506. def _build_result(self, content: str, iterations: int) -> AgentResult:
  507. return AgentResult(
  508. content=content,
  509. messages=self.messages,
  510. iterations=iterations,
  511. tool_calls_made=self.tool_calls_made,
  512. skills_used=list(self.active_skills),
  513. )