|
|
|
|
@ -1,4 +1,13 @@
|
|
|
|
|
"""LangGraph ReAct heart: START → llm_call ⇄ tool_node → END. No global llm/agent."""
|
|
|
|
|
"""LangGraph ReAct 核心:START → llm_call ⇄ tool_node → END。
|
|
|
|
|
|
|
|
|
|
Function Call 在这里怎么走:
|
|
|
|
|
1. 模型不会自己执行 Python,只会在回复里写出 tool_calls(函数名 + 参数)。
|
|
|
|
|
2. _should_continue 看到 tool_calls,就把控制权交给 tool_node。
|
|
|
|
|
3. tool_node 按名字找到真正的函数并执行,结果写成 ToolMessage 交回模型。
|
|
|
|
|
4. 再进 llm_call:模型看观察结果,决定继续调工具还是直接回答。
|
|
|
|
|
|
|
|
|
|
这就是 ReAct:Reason(想)→ Act(调工具)→ Observe(看结果)→ 再 Reason。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
@ -16,12 +25,20 @@ from gcode.models.openai_compatible import extract_reasoning, extract_text
|
|
|
|
|
from gcode.prompt import dynamic_context, static_system_prompt
|
|
|
|
|
from gcode.session import Session
|
|
|
|
|
|
|
|
|
|
# 同一轮对话最多问模型这么多次,防止「调工具 → 再想 → 再调」死循环烧钱。
|
|
|
|
|
# 40 不是官方标准,也不是 LangGraph / OpenAI 的规定,只是本项目初始化时拍的经验上限:
|
|
|
|
|
# - 日常改文件大概 5~15 次就够(读 → 改 → 跑 → 修)
|
|
|
|
|
# - 再大的任务应拆成下一轮,而不是一轮里无限转
|
|
|
|
|
# 容易混淆的官方数字:LangGraph 的 recursion_limit(默认曾是 25,1.0.6+ 改为 1000)
|
|
|
|
|
# 数的是图节点步数(llm_call 和 tool_node 各算一步),不是「问模型几次」。
|
|
|
|
|
# 本常量只拦 llm_call;图本身没改 recursion_limit,复杂一轮仍可能先撞框架上限。
|
|
|
|
|
MAX_LLM_CALLS = 40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MessagesState(TypedDict):
|
|
|
|
|
# operator.add:节点返回的新消息会「追加」到列表,而不是覆盖整段历史。
|
|
|
|
|
messages: Annotated[list[AnyMessage], operator.add]
|
|
|
|
|
llm_calls: int
|
|
|
|
|
llm_calls: int # 本轮已经调用模型的次数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Agent:
|
|
|
|
|
@ -35,7 +52,9 @@ class Agent:
|
|
|
|
|
has_browser: bool = False,
|
|
|
|
|
cwd: Path | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
# 运行时按名字找函数:模型只输出 "read_file",这里才能对上真正的 Python 工具。
|
|
|
|
|
self._tools = {t.name: t for t in tools}
|
|
|
|
|
# 把工具的 schema(名字、参数、说明)告诉模型,它才会在回复里生成 tool_calls。
|
|
|
|
|
self._model = model.bind_tools(tools)
|
|
|
|
|
self._session = session
|
|
|
|
|
self._model_id = model_id
|
|
|
|
|
@ -46,24 +65,28 @@ class Agent:
|
|
|
|
|
self._graph = self._build(checkpointer)
|
|
|
|
|
|
|
|
|
|
def _build(self, checkpointer: Any) -> Any:
|
|
|
|
|
# 搭一张状态图:两个节点,一条条件边决定「调工具」还是「结束」。
|
|
|
|
|
builder = StateGraph(MessagesState)
|
|
|
|
|
builder.add_node("llm_call", self._llm_call)
|
|
|
|
|
builder.add_node("tool_node", self._tool_node)
|
|
|
|
|
builder.add_edge(START, "llm_call")
|
|
|
|
|
builder.add_conditional_edges(
|
|
|
|
|
"llm_call",
|
|
|
|
|
self._should_continue,
|
|
|
|
|
self._should_continue, # 看最后一条消息有没有 tool_calls
|
|
|
|
|
{"tool_node": "tool_node", END: END},
|
|
|
|
|
)
|
|
|
|
|
# 工具跑完必须再回模型,否则观察结果没人读。
|
|
|
|
|
builder.add_edge("tool_node", "llm_call")
|
|
|
|
|
return builder.compile(checkpointer=checkpointer)
|
|
|
|
|
|
|
|
|
|
def _graph_config(self) -> dict[str, Any] | None:
|
|
|
|
|
# checkpointer 需要 thread_id 才能把同一会话的消息存下来、下次接着聊。
|
|
|
|
|
if self._session and self._session.checkpointer:
|
|
|
|
|
return self._session.config()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def invoke_turn(self, user_text: str) -> list[AnyMessage]:
|
|
|
|
|
"""整轮跑完再返回全部消息(测试 / 非流式用)。"""
|
|
|
|
|
payload: MessagesState = {
|
|
|
|
|
"messages": [HumanMessage(content=user_text)],
|
|
|
|
|
"llm_calls": 0,
|
|
|
|
|
@ -73,6 +96,7 @@ class Agent:
|
|
|
|
|
return list(result["messages"])
|
|
|
|
|
|
|
|
|
|
async def stream_turn(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
|
|
|
|
"""边跑边吐事件:思考文字、工具开始/结束,给 TUI 用。"""
|
|
|
|
|
payload: MessagesState = {
|
|
|
|
|
"messages": [HumanMessage(content=user_text)],
|
|
|
|
|
"llm_calls": 0,
|
|
|
|
|
@ -88,6 +112,7 @@ class Agent:
|
|
|
|
|
yield AgentEvent(type="error", text=f"{type(exc).__name__}: {exc}")
|
|
|
|
|
|
|
|
|
|
async def _llm_call(self, state: MessagesState) -> dict[str, Any]:
|
|
|
|
|
"""问模型一次。返回值只含「本节点新增」的字段,messages 会被 append。"""
|
|
|
|
|
calls = int(state.get("llm_calls") or 0)
|
|
|
|
|
if calls >= MAX_LLM_CALLS:
|
|
|
|
|
return {
|
|
|
|
|
@ -103,10 +128,12 @@ class Agent:
|
|
|
|
|
messages = _repair_incomplete_tool_calls(messages)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# response 可能是普通文字,也可能带 tool_calls(只声明要调谁,还没执行)。
|
|
|
|
|
response = await self._model.ainvoke(messages)
|
|
|
|
|
return {"messages": [response], "llm_calls": calls + 1}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
err = str(exc)
|
|
|
|
|
# 常见 400:历史里有 tool_calls 却缺对应的 ToolMessage,补占位后再试一次。
|
|
|
|
|
if "tool_calls" in err and "tool_call_id" in err:
|
|
|
|
|
try:
|
|
|
|
|
repaired = _repair_incomplete_tool_calls(messages)
|
|
|
|
|
@ -118,6 +145,7 @@ class Agent:
|
|
|
|
|
return {"messages": [AIMessage(content=friendly)], "llm_calls": calls + 1}
|
|
|
|
|
|
|
|
|
|
async def _tool_node(self, state: MessagesState) -> dict[str, Any]:
|
|
|
|
|
"""真正执行函数:读上一轮 AIMessage.tool_calls,按名字 ainvoke。"""
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
last = state["messages"][-1]
|
|
|
|
|
@ -141,15 +169,18 @@ class Agent:
|
|
|
|
|
observation = f"工具 '{name}' 调用失败: {type(exc).__name__}: {exc}"
|
|
|
|
|
if not isinstance(observation, str):
|
|
|
|
|
observation = str(observation)
|
|
|
|
|
# tool_call_id 必须对上模型那次声明,否则下一轮 llm_call 会 400。
|
|
|
|
|
return ToolMessage(content=observation, tool_call_id=call_id, name=name)
|
|
|
|
|
|
|
|
|
|
if len(tool_calls) <= 1:
|
|
|
|
|
results = [await one(tool_calls[0])] if tool_calls else []
|
|
|
|
|
else:
|
|
|
|
|
# 模型一轮里可能同时要读文件 + 列目录,并行跑。
|
|
|
|
|
results = list(await asyncio.gather(*[one(tc) for tc in tool_calls]))
|
|
|
|
|
return {"messages": results}
|
|
|
|
|
|
|
|
|
|
def _should_continue(self, state: MessagesState) -> Literal["tool_node", "__end__"]:
|
|
|
|
|
"""条件边:有 tool_calls → 去执行;没有 → 本轮结束(纯文字回答)。"""
|
|
|
|
|
messages = state.get("messages") or []
|
|
|
|
|
if not messages:
|
|
|
|
|
return END
|
|
|
|
|
@ -160,6 +191,11 @@ class Agent:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assemble_messages(static: str, dyn: str, history: list[AnyMessage]) -> list[AnyMessage]:
|
|
|
|
|
"""拼出发给模型的完整上下文:固定系统提示 + 动态环境 + 对话历史。
|
|
|
|
|
|
|
|
|
|
动态部分(cwd、模型 id)每次 llm_call 都重算,所以嵌进第一条用户消息,
|
|
|
|
|
而不是单独一条 SystemMessage(避免历史里叠很多条过期系统提示)。
|
|
|
|
|
"""
|
|
|
|
|
out: list[AnyMessage] = [SystemMessage(content=static)]
|
|
|
|
|
if not history:
|
|
|
|
|
out.append(HumanMessage(content=dyn))
|
|
|
|
|
@ -177,6 +213,7 @@ def _assemble_messages(static: str, dyn: str, history: list[AnyMessage]) -> list
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _strip_images(messages: list[AnyMessage]) -> list[AnyMessage]:
|
|
|
|
|
"""当前走纯文本接口:用户消息里如果夹了图片块,只留下 text。"""
|
|
|
|
|
cleaned: list[AnyMessage] = []
|
|
|
|
|
for msg in messages:
|
|
|
|
|
if isinstance(msg, HumanMessage) and isinstance(msg.content, list):
|
|
|
|
|
@ -193,6 +230,11 @@ def _strip_images(messages: list[AnyMessage]) -> list[AnyMessage]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repair_incomplete_tool_calls(messages: list[AnyMessage]) -> list[AnyMessage]:
|
|
|
|
|
"""补齐「声明了 tool_calls 但没有 ToolMessage」的缺口。
|
|
|
|
|
|
|
|
|
|
典型场景:上次对话做到一半退出,checkpoint 里留下 AIMessage.tool_calls,
|
|
|
|
|
却没有对应结果。OpenAI 兼容接口要求每条 tool_call 都有一条同 id 的 ToolMessage。
|
|
|
|
|
"""
|
|
|
|
|
declared: set[str] = set()
|
|
|
|
|
for msg in messages:
|
|
|
|
|
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
|
|
|
|
|
@ -228,6 +270,7 @@ def _repair_incomplete_tool_calls(messages: list[AnyMessage]) -> list[AnyMessage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _map_stream_event(ev: dict[str, Any]) -> AgentEvent | None:
|
|
|
|
|
"""把 LangGraph 底层事件收成 TUI 认识的几种:thinking / text / tool_start / tool_end。"""
|
|
|
|
|
kind = ev.get("event") or ""
|
|
|
|
|
meta = ev.get("metadata") or {}
|
|
|
|
|
node = meta.get("langgraph_node")
|
|
|
|
|
|