"""Independent LangChain ReAct host for find_agent_v2. The host owns model construction, retries, usage accounting, tool adaptation and controlled nested-agent delegation. It never imports the legacy find-agent package. """ from __future__ import annotations import asyncio import json import os from collections.abc import Iterable from typing import Any from langchain.agents import create_agent from langchain.agents.middleware import ModelFallbackMiddleware, ModelRetryMiddleware, ToolRetryMiddleware from langchain_core.messages import AIMessage, BaseMessage, ToolMessage from langchain_core.tools import StructuredTool from langchain_openai import ChatOpenAI from pydantic import BaseModel, Field from find_agent_v2.observability import InputSlot, ObagentObserver from find_agent_v2.state import NodeRun from find_agent_v2.tools import ToolFn from supply_agent.config import Settings, get_settings class DelegateRequest(BaseModel): task: str = Field(description="交给子 Agent 的完整、独立任务") class DelegateArgs(BaseModel): requests: list[DelegateRequest] = Field( min_length=1, max_length=8, description="可并发执行的子 Agent 任务;有依赖的任务不要放在同一批", ) def _tool_name(fn: ToolFn) -> str: return str(getattr(fn, "_tool_name", fn.__name__)) def _as_langchain_tool(fn: ToolFn) -> StructuredTool: kwargs = { "name": _tool_name(fn), "description": str(getattr(fn, "_tool_description", fn.__doc__ or "")), } if asyncio.iscoroutinefunction(fn): return StructuredTool.from_function(coroutine=fn, **kwargs) return StructuredTool.from_function(func=fn, **kwargs) def _message_dict(message: BaseMessage) -> dict[str, Any]: data = message.model_dump(mode="json") data["type"] = message.type return data def _usage(messages: list[BaseMessage]) -> dict[str, int | float]: totals: dict[str, int | float] = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost": 0.0, } for message in messages: usage = getattr(message, "usage_metadata", None) or {} totals["input_tokens"] += int(usage.get("input_tokens") or 0) totals["output_tokens"] += int(usage.get("output_tokens") or 0) totals["total_tokens"] += int(usage.get("total_tokens") or 0) response = getattr(message, "response_metadata", None) or {} cost = response.get("cost") or (response.get("usage") or {}).get("cost") if cost is not None: totals["cost"] += float(cost) totals["cost"] = round(float(totals["cost"]), 8) return totals def _events(messages: list[BaseMessage]) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] for message in messages: if isinstance(message, AIMessage): events.append({ "type": "llm_output", "content": message.content, "tool_calls": message.tool_calls, "usage": message.usage_metadata, }) elif isinstance(message, ToolMessage): events.append({ "type": "tool_call", "name": message.name, "tool_call_id": message.tool_call_id, "result": message.content, "status": message.status, }) return events class FindAgentNodeHost: """Create a fresh LangChain Agent for every workflow or delegated node.""" def __init__( self, *, settings: Settings | None = None, models_by_role: dict[str, str] | None = None, default_model: str = "google/gemini-3-flash-preview", observer: ObagentObserver | None = None, ) -> None: self.settings = settings or get_settings() self.models_by_role = dict(models_by_role or {}) self.default_model = default_model self.observer = observer or ObagentObserver() self.usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost": 0.0} def reset_usage(self) -> None: self.usage = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost": 0.0, } def _model(self, role: str) -> ChatOpenAI: model_name = self.models_by_role.get(role, self.default_model) return ChatOpenAI( model=model_name, api_key=self.settings.openrouter_api_key, base_url=self.settings.openrouter_base_url, timeout=self.settings.openrouter_timeout_seconds, temperature=0.2, max_retries=0, # retries are observable LangChain middleware below default_headers={ "HTTP-Referer": self.settings.openrouter_site_url, "X-Title": self.settings.openrouter_site_name, }, ) def _middleware(self, role: str): items: list[Any] = [ ModelRetryMiddleware(max_retries=2, on_failure="error"), ToolRetryMiddleware(max_retries=2, on_failure="continue"), ] fallback = os.getenv("FIND_AGENT_V2_FALLBACK_MODEL", "").strip() if fallback and fallback != self.models_by_role.get(role, self.default_model): items.insert(0, ModelFallbackMiddleware(self._model_for_name(fallback))) return items def _model_for_name(self, model_name: str) -> ChatOpenAI: return ChatOpenAI( model=model_name, api_key=self.settings.openrouter_api_key, base_url=self.settings.openrouter_base_url, timeout=self.settings.openrouter_timeout_seconds, temperature=0.2, max_retries=0, ) def _delegate_tool( self, *, parent_node: str, round_index: int, system_prompt: str, tools: tuple[ToolFn, ...], max_iterations: int, slots: tuple[InputSlot, ...], ) -> StructuredTool: async def delegate_agents_v2(requests: list[DelegateRequest]) -> str: """并发委派多个相互独立的任务给同阶段子 Agent。""" normalized = [ item if isinstance(item, DelegateRequest) else DelegateRequest.model_validate(item) for item in requests ] async def run_one(index: int, request: DelegateRequest) -> dict[str, Any]: result = await self.run_node( node=parent_node, round_index=round_index, system_prompt=system_prompt, user_content=request.task, tools=tools, max_iterations=max_iterations, slots=slots, branch_key=f"delegate-{index}", allow_delegation=False, ) return { "branch": index, "content": result.content, "iterations": result.iterations, "tool_calls_made": result.tool_calls_made, } results = await asyncio.gather(*( run_one(index, request) for index, request in enumerate(normalized, start=1) ), return_exceptions=True) output = [] for index, result in enumerate(results, start=1): if isinstance(result, BaseException): output.append({"branch": index, "error": f"{type(result).__name__}: {result}"}) else: output.append(result) return json.dumps({"delegated": output}, ensure_ascii=False) return StructuredTool.from_function( coroutine=delegate_agents_v2, name="delegate_agents_v2", description="将多个无依赖任务并发委派给拥有相同阶段工具权限的子 Agent。", args_schema=DelegateArgs, ) async def run_node( self, *, node: str, round_index: int, system_prompt: str, user_content: str, tools: Iterable[ToolFn] = (), max_iterations: int = 12, slots: tuple[InputSlot, ...] = (), branch_key: str = "", allow_delegation: bool = True, ) -> NodeRun: tool_functions = tuple(tools) model_name = self.models_by_role.get(node, self.default_model) langchain_tools = [_as_langchain_tool(fn) for fn in tool_functions] if allow_delegation and node in {"search", "evidence", "evaluator"}: langchain_tools.append(self._delegate_tool( parent_node=node, round_index=round_index, system_prompt=system_prompt, tools=tool_functions, max_iterations=max_iterations, slots=slots, )) agent = create_agent( model=self._model(node), tools=langchain_tools, system_prompt=system_prompt, middleware=self._middleware(node), name=f"find_agent_v2_{node}", ) with self.observer.node(node=node, branch_key=branch_key) as observation: actual_user_content = observation.declare( fallback=user_content, system_prompt=system_prompt, slots=slots, tools=tuple(langchain_tools), model=model_name, refs=({"delegate": "same-stage-worker"} if allow_delegation else None), ) result = await agent.ainvoke( {"messages": [{"role": "user", "content": actual_user_content}]}, # create_agent counts model and tool nodes separately, and middleware # retries also consume supersteps. Keep this budget distinct from # the business-level no-progress guard in graph.py. config={"recursion_limit": max(64, max_iterations * 6)}, ) messages: list[BaseMessage] = list(result.get("messages") or []) usage = _usage(messages) for key in ("input_tokens", "output_tokens", "total_tokens"): self.usage[key] = int(self.usage[key]) + int(usage[key]) self.usage["cost"] = round(float(self.usage["cost"]) + float(usage["cost"]), 8) process = { "messages": [_message_dict(message) for message in messages], "events": _events(messages), "usage": usage, "iterations": sum(isinstance(message, AIMessage) for message in messages), "tool_calls_made": sum( len(message.tool_calls) for message in messages if isinstance(message, AIMessage) ), } last_ai = next((message for message in reversed(messages) if isinstance(message, AIMessage)), None) content = str(last_ai.content if last_ai is not None else "") observation.record_react(output=process, ok=True) observation.set_output({"agent输出": content, **process}, ok=True) return NodeRun( node=node, round_index=round_index, content=content, iterations=int(process["iterations"]), tool_calls_made=int(process["tool_calls_made"]), ) def normalize_models( *, model: str | None = None, planning: str | None = None, search: str | None = None, evidence: str | None = None, evaluation: str | None = None, report: str | None = None, ) -> dict[str, str]: base = model or "google/gemini-3-flash-preview" return { "supervisor": planning or base, "search": search or base, "evidence": evidence or base, "evaluator": evaluation or base, "report": report or base, }