| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- from __future__ import annotations
- import json
- from collections.abc import AsyncIterator, Callable, Iterator
- from typing import TYPE_CHECKING
- from supply_agent.llm.client import LLMClient, MalformedFunctionCallError
- from supply_agent.tools.registry import ToolRegistry
- from supply_agent.types import (
- AgentEvent,
- AgentEventType,
- AgentResult,
- Message,
- Role,
- ToolCall,
- ToolDefinition,
- ToolResult,
- )
- if TYPE_CHECKING:
- from supply_agent.logging.logger import AgentLogger
- class AgentLoop:
- """
- ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
- Implements the standard tool-calling pattern used by modern agent frameworks.
- """
- def __init__(
- self,
- llm: LLMClient,
- tools: ToolRegistry,
- system_message: Message,
- messages: list[Message],
- max_iterations: int = 20,
- temperature: float | None = None,
- logger: AgentLogger | None = None,
- active_skills: list[str] | None = None,
- system_message_builder: Callable[[], Message] | None = None,
- completion_guard: Callable[[list[Message]], str | None] | None = None,
- tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
- tool_repeat_requires_change: dict[str, set[str]] | None = None,
- ) -> None:
- self.llm = llm
- self.tools = tools
- self.system_message = system_message
- self.system_message_builder = system_message_builder
- self.messages = messages
- self.max_iterations = max_iterations
- self.temperature = temperature
- self.logger = logger
- self.active_skills = active_skills or []
- self.completion_guard = completion_guard
- self.tool_call_budgets = tool_call_budgets or {}
- self.tool_repeat_requires_change = tool_repeat_requires_change or {}
- self._tool_budget_counts = {
- group: 0 for group in self.tool_call_budgets
- }
- self.tool_calls_made = 0
- def _all_messages(self) -> list[Message]:
- return [self.system_message, *self.messages]
- def _on_skill_loaded(self, arguments: str) -> None:
- """Refresh system message after a skill is loaded."""
- try:
- args = json.loads(arguments)
- skill_name = args.get("name", "")
- if skill_name and skill_name not in self.active_skills:
- self.active_skills.append(skill_name)
- except json.JSONDecodeError:
- pass
- if self.system_message_builder:
- self.system_message = self.system_message_builder()
- def _completion_block_reason(self) -> str | None:
- if self.completion_guard is None:
- return None
- return self.completion_guard(self.messages)
- def _append_completion_feedback(self, reason: str) -> None:
- self.messages.append(
- Message(
- role=Role.USER,
- content=(
- "完成守卫拒绝当前最终回答:"
- f"{reason}。请继续调用必要工具修复这些问题;"
- "只有守卫条件全部满足后才能输出最终答案。"
- ),
- )
- )
- def _append_malformed_tool_feedback(self) -> None:
- self.messages.append(
- Message(
- role=Role.USER,
- content=(
- "上一步连续生成了无效工具参数。请继续任务,下一步只调用一个"
- "最必要的工具,严格使用其 schema,不得添加未定义字段。"
- ),
- )
- )
- def _consume_tool_budget(self, tool_name: str) -> str | None:
- matching = [
- (group, limit)
- for group, (tool_names, limit) in self.tool_call_budgets.items()
- if tool_name in tool_names
- ]
- for group, limit in matching:
- used = self._tool_budget_counts[group]
- if used >= limit:
- return (
- f"工具预算已耗尽: group={group}, limit={limit}。"
- "请使用已有结果完成筛选、持久化和审计,不要继续扩大搜索。"
- )
- for group, _ in matching:
- self._tool_budget_counts[group] += 1
- return None
- def _repeat_policy_error(self, tool_name: str) -> str | None:
- dependencies = self.tool_repeat_requires_change.get(tool_name)
- if not dependencies:
- return None
- last_call_index = -1
- last_change_index = -1
- for index, message in enumerate(self.messages):
- if message.role != Role.TOOL or not message.name:
- continue
- try:
- payload = json.loads(message.content or "")
- except (TypeError, json.JSONDecodeError):
- continue
- if isinstance(payload, dict) and payload.get("error"):
- continue
- if message.name == tool_name:
- last_call_index = index
- if message.name in dependencies:
- last_change_index = index
- if last_call_index >= 0 and last_change_index < last_call_index:
- return (
- f"工具 {tool_name} 在状态未变化时不得重复调用。"
- f"必须先成功执行以下任一状态变更工具: {sorted(dependencies)}"
- )
- return None
- def _available_tool_definitions(self) -> list[ToolDefinition] | None:
- exhausted_tools: set[str] = set()
- for group, (tool_names, limit) in self.tool_call_budgets.items():
- if self._tool_budget_counts[group] >= limit:
- exhausted_tools.update(tool_names)
- definitions = [
- definition
- for definition in self.tools.definitions
- if definition.name not in exhausted_tools
- and self._repeat_policy_error(definition.name) is None
- ]
- return definitions or None
- def _refund_tool_budget_for_input_error(
- self,
- tool_name: str,
- result: ToolResult,
- ) -> None:
- try:
- payload = json.loads(result.content)
- except (TypeError, json.JSONDecodeError):
- return
- if not isinstance(payload, dict) or payload.get("input_error") is not True:
- return
- for group, (tool_names, _) in self.tool_call_budgets.items():
- if tool_name in tool_names and self._tool_budget_counts[group] > 0:
- self._tool_budget_counts[group] -= 1
- @staticmethod
- def _budget_error_result(tool_call: ToolCall, error: str) -> ToolResult:
- return ToolResult(
- tool_call_id=tool_call.id,
- name=tool_call.name,
- content=json.dumps(
- {
- "error": error,
- "budget_exhausted": True,
- "title": "工具预算拒绝调用",
- },
- ensure_ascii=False,
- ),
- is_error=True,
- )
- @staticmethod
- def _policy_error_result(tool_call: ToolCall, error: str) -> ToolResult:
- return ToolResult(
- tool_call_id=tool_call.id,
- name=tool_call.name,
- content=json.dumps(
- {
- "error": error,
- "policy_rejected": True,
- "title": "工具状态策略拒绝调用",
- },
- ensure_ascii=False,
- ),
- is_error=True,
- )
- def _max_iterations_result(self, iterations: int) -> AgentResult:
- reason = self._completion_block_reason()
- content = "Max iterations reached"
- if reason:
- content = f"Max iterations reached before completion: {reason}"
- return self._build_result(content, iterations)
- def run(self) -> AgentResult:
- iterations = 0
- while iterations < self.max_iterations:
- iterations += 1
- try:
- response = self.llm.chat(
- self._all_messages(),
- tools=self._available_tool_definitions(),
- temperature=self.temperature,
- iteration=iterations,
- )
- except MalformedFunctionCallError:
- self._append_malformed_tool_feedback()
- continue
- self.messages.append(response)
- if not response.tool_calls:
- reason = self._completion_block_reason()
- if reason:
- self._append_completion_feedback(reason)
- continue
- return self._build_result(response.content or "", iterations)
- for tc in response.tool_calls:
- self.tool_calls_made += 1
- policy_error = self._repeat_policy_error(tc.name)
- budget_error = (
- None if policy_error else self._consume_tool_budget(tc.name)
- )
- result = (
- self._policy_error_result(tc, policy_error)
- if policy_error
- else (
- self._budget_error_result(tc, budget_error)
- if budget_error
- else self.tools.execute(tc.id, tc.name, tc.arguments)
- )
- )
- if not policy_error and not budget_error:
- self._refund_tool_budget_for_input_error(tc.name, result)
- if self.logger:
- self.logger.log_tool_call(
- iterations,
- tc.name,
- tc.arguments,
- result.content,
- result.is_error,
- tool_call_id=tc.id,
- )
- if tc.name == "load_skill" and not result.is_error:
- self._on_skill_loaded(tc.arguments)
- if self.logger:
- self.logger.log_skill_loaded(iterations, tc.arguments)
- self.messages.append(
- Message(
- role=Role.TOOL,
- content=result.content,
- tool_call_id=result.tool_call_id,
- name=result.name,
- )
- )
- if self.completion_guard is not None:
- return self._max_iterations_result(iterations)
- self.messages.append(
- Message(
- role=Role.USER,
- content="Maximum iterations reached. Please provide your best answer now.",
- )
- )
- final = self.llm.chat(
- self._all_messages(),
- temperature=self.temperature,
- iteration=iterations + 1,
- )
- self.messages.append(final)
- return self._build_result(final.content or "", iterations)
- async def arun(self) -> AgentResult:
- iterations = 0
- while iterations < self.max_iterations:
- iterations += 1
- try:
- response = await self.llm.achat(
- self._all_messages(),
- tools=self._available_tool_definitions(),
- temperature=self.temperature,
- iteration=iterations,
- )
- except MalformedFunctionCallError:
- self._append_malformed_tool_feedback()
- continue
- self.messages.append(response)
- if not response.tool_calls:
- reason = self._completion_block_reason()
- if reason:
- self._append_completion_feedback(reason)
- continue
- return self._build_result(response.content or "", iterations)
- for tc in response.tool_calls:
- self.tool_calls_made += 1
- policy_error = self._repeat_policy_error(tc.name)
- budget_error = (
- None if policy_error else self._consume_tool_budget(tc.name)
- )
- result = (
- self._policy_error_result(tc, policy_error)
- if policy_error
- else (
- self._budget_error_result(tc, budget_error)
- if budget_error
- else await self.tools.aexecute(
- tc.id, tc.name, tc.arguments
- )
- )
- )
- if not policy_error and not budget_error:
- self._refund_tool_budget_for_input_error(tc.name, result)
- if self.logger:
- self.logger.log_tool_call(
- iterations,
- tc.name,
- tc.arguments,
- result.content,
- result.is_error,
- tool_call_id=tc.id,
- )
- if tc.name == "load_skill" and not result.is_error:
- self._on_skill_loaded(tc.arguments)
- if self.logger:
- self.logger.log_skill_loaded(iterations, tc.arguments)
- self.messages.append(
- Message(
- role=Role.TOOL,
- content=result.content,
- tool_call_id=result.tool_call_id,
- name=result.name,
- )
- )
- if self.completion_guard is not None:
- return self._max_iterations_result(iterations)
- self.messages.append(
- Message(
- role=Role.USER,
- content="Maximum iterations reached. Please provide your best answer now.",
- )
- )
- final = await self.llm.achat(
- self._all_messages(),
- temperature=self.temperature,
- iteration=iterations + 1,
- )
- self.messages.append(final)
- return self._build_result(final.content or "", iterations)
- def stream(self) -> Iterator[AgentEvent]:
- iterations = 0
- while iterations < self.max_iterations:
- iterations += 1
- yield AgentEvent(
- type=AgentEventType.THINKING,
- data={"iteration": iterations},
- )
- try:
- response = self.llm.chat(
- self._all_messages(),
- tools=self._available_tool_definitions(),
- temperature=self.temperature,
- iteration=iterations,
- )
- except MalformedFunctionCallError:
- self._append_malformed_tool_feedback()
- continue
- self.messages.append(response)
- if not response.tool_calls:
- reason = self._completion_block_reason()
- if reason:
- self._append_completion_feedback(reason)
- continue
- yield AgentEvent(
- type=AgentEventType.MESSAGE,
- data={"content": response.content or ""},
- )
- yield AgentEvent(
- type=AgentEventType.DONE,
- data=self._build_result(response.content or "", iterations).model_dump(),
- )
- return
- for tc in response.tool_calls:
- self.tool_calls_made += 1
- yield AgentEvent(
- type=AgentEventType.TOOL_CALL,
- data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
- )
- policy_error = self._repeat_policy_error(tc.name)
- budget_error = (
- None if policy_error else self._consume_tool_budget(tc.name)
- )
- result = (
- self._policy_error_result(tc, policy_error)
- if policy_error
- else (
- self._budget_error_result(tc, budget_error)
- if budget_error
- else self.tools.execute(tc.id, tc.name, tc.arguments)
- )
- )
- if not policy_error and not budget_error:
- self._refund_tool_budget_for_input_error(tc.name, result)
- if self.logger:
- self.logger.log_tool_call(
- iterations,
- tc.name,
- tc.arguments,
- result.content,
- result.is_error,
- tool_call_id=tc.id,
- )
- yield AgentEvent(
- type=AgentEventType.TOOL_RESULT,
- data={"name": result.name, "content": result.content, "is_error": result.is_error},
- )
- self.messages.append(
- Message(
- role=Role.TOOL,
- content=result.content,
- tool_call_id=result.tool_call_id,
- name=result.name,
- )
- )
- yield AgentEvent(
- type=AgentEventType.DONE,
- data=self._max_iterations_result(iterations).model_dump(),
- )
- async def astream(self) -> AsyncIterator[AgentEvent]:
- iterations = 0
- while iterations < self.max_iterations:
- iterations += 1
- yield AgentEvent(
- type=AgentEventType.THINKING,
- data={"iteration": iterations},
- )
- try:
- response = await self.llm.achat(
- self._all_messages(),
- tools=self._available_tool_definitions(),
- temperature=self.temperature,
- iteration=iterations,
- )
- except MalformedFunctionCallError:
- self._append_malformed_tool_feedback()
- continue
- self.messages.append(response)
- if not response.tool_calls:
- reason = self._completion_block_reason()
- if reason:
- self._append_completion_feedback(reason)
- continue
- yield AgentEvent(
- type=AgentEventType.MESSAGE,
- data={"content": response.content or ""},
- )
- yield AgentEvent(
- type=AgentEventType.DONE,
- data=self._build_result(response.content or "", iterations).model_dump(),
- )
- return
- for tc in response.tool_calls:
- self.tool_calls_made += 1
- yield AgentEvent(
- type=AgentEventType.TOOL_CALL,
- data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
- )
- policy_error = self._repeat_policy_error(tc.name)
- budget_error = (
- None if policy_error else self._consume_tool_budget(tc.name)
- )
- result = (
- self._policy_error_result(tc, policy_error)
- if policy_error
- else (
- self._budget_error_result(tc, budget_error)
- if budget_error
- else await self.tools.aexecute(
- tc.id, tc.name, tc.arguments
- )
- )
- )
- if not policy_error and not budget_error:
- self._refund_tool_budget_for_input_error(tc.name, result)
- if self.logger:
- self.logger.log_tool_call(
- iterations,
- tc.name,
- tc.arguments,
- result.content,
- result.is_error,
- tool_call_id=tc.id,
- )
- yield AgentEvent(
- type=AgentEventType.TOOL_RESULT,
- data={"name": result.name, "content": result.content, "is_error": result.is_error},
- )
- self.messages.append(
- Message(
- role=Role.TOOL,
- content=result.content,
- tool_call_id=result.tool_call_id,
- name=result.name,
- )
- )
- yield AgentEvent(
- type=AgentEventType.DONE,
- data=self._max_iterations_result(iterations).model_dump(),
- )
- def _build_result(self, content: str, iterations: int) -> AgentResult:
- return AgentResult(
- content=content,
- messages=self.messages,
- iterations=iterations,
- tool_calls_made=self.tool_calls_made,
- skills_used=list(self.active_skills),
- )
|