diff --git a/src/gcode/graph.py b/src/gcode/graph.py index 98c9c16..76943f0 100644 --- a/src/gcode/graph.py +++ b/src/gcode/graph.py @@ -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")