Przeglądaj źródła

feat(react): 原生 function-calling 阶段2 — FC 执行车道 + 路由 + UI 开关

设计 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。react.nativeToolCalls 开 + provider 支持
FC(OpenAI 兼容:qwen/openai/deepseek)→ 走结构化 tool_calls 执行,根治文本解析类
0 执行/格式飘 bug;旧文本路径保留作 fallback(开关 off 或 provider 不支持)。

- compiler._run_fc_loop:FC ReAct 循环(chat_with_tools → 直接执行 tool_calls →
  role=tool 回灌),复用 _timeout_call 工具执行 + StepEvent on_step,与文本 react 同形。
- _compile_react 顶部门控分叉:命中返回 Tool(name.fc_react,_think_ref=provider 沿用
  token 会计);否则走原文本 loop。**默认 off → 现有 agent 零行为变化**。
- AgentEdit「行为」tab 加「原生 function-calling」开关(state/load/save/UI)。
- **live 验证**:qwen-plus 真返回结构化 tool_call(name=WriteFile,args 含 file_path,
  不飘)。回归 test_function_calling(13)+ lambdagent 611 全绿(文本路径零回归)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju 2 miesięcy temu
rodzic
commit
0ebee2feea

+ 1 - 1
docs/NATIVE_FUNCTION_CALLING_DESIGN.md

@@ -64,7 +64,7 @@ FC react: provider.chat_with_tools(messages, tools_schema)
     `{name, arguments(dict)}`;返回 content-only 时 tool_calls=[];arguments 是字符串 JSON 时能 parse。
 - **不动 react loop** → 零回归风险。交付一个被测过的可靠积木。
 
-### 阶段 2(下次):FC react 执行 + 路由
+### 阶段 2(已完成,live 验证):FC react 执行 + 路由
 - `_compile_react` 内分叉:FC 模式下,think 步用 `chat_with_tools(tools=_tools_json_schema(cfg))`,
   读 `tool_calls` 直接执行(跳过 `_extract_tool_call`);结果作 role=tool 消息回灌;
   复用现有 Tool 执行 / Guard / on_step / 取消 / enforceLoop。

+ 92 - 0
lambdagent/src/lambdagent/fromconfig/compiler.py

@@ -775,6 +775,71 @@ def _tools_json_schema(cfg: Dict) -> list:
     return out
 
 
+def _run_fc_loop(provider, system_prompt: str, user_input: str, tools: Dict,
+                 tools_schema: list, *, max_steps: int, tool_timeout: int,
+                 on_step=None, cancel=None) -> str:
+    """原生 function-calling 的 ReAct 循环(阶段 2)。用结构化 tool_calls 直接执行,
+    不解析文本——根治 0 执行/格式飘/路径别名类 bug。见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。
+    复用现有工具执行(_timeout_call)+ on_step 事件(StepEvent),与文本 react 同形。"""
+    try:
+        from lambdagent.agentruntime.react_engine import (
+            StepEvent, STEP_THINK, STEP_TOOL_CALL, STEP_TOOL_RESULT)
+    except ImportError:
+        StepEvent = None
+
+    def _emit(etype, step, content, tool=""):
+        if on_step and StepEvent is not None:
+            try:
+                on_step(StepEvent(type=etype, step=step, content=str(content)[:2000], tool=tool))
+            except Exception:
+                pass
+
+    messages = [{"role": "system", "content": system_prompt},
+                {"role": "user", "content": user_input}]
+    final = ""
+    for step in range(max_steps):
+        if cancel is not None and cancel.is_cancelled():
+            raise cancel.CancelledRun()
+        resp = provider.chat_with_tools(messages, tools_schema)
+        content = resp.get("content")
+        tcs = resp.get("tool_calls") or []
+        if content:
+            _emit(STEP_THINK, step, content)
+        if not tcs:
+            final = content or final
+            break  # 模型给了最终答案、无工具调用 → 结束
+        # 回放 assistant 的 tool_calls(OpenAI 协议要求)
+        messages.append({"role": "assistant", "content": content or "",
+            "tool_calls": [{"id": tc["id"] or f"call_{step}_{i}", "type": "function",
+                            "function": {"name": tc["name"],
+                                         "arguments": json.dumps(tc["arguments"], ensure_ascii=False)}}
+                           for i, tc in enumerate(tcs)]})
+        terminated = False
+        for i, tc in enumerate(tcs):
+            name, args = tc["name"], (tc["arguments"] if isinstance(tc["arguments"], dict) else {})
+            tcid = tc["id"] or f"call_{step}_{i}"
+            if name == "terminate":
+                final = (args.get("summary") or "").strip() or content or final
+                terminated = True
+                messages.append({"role": "tool", "tool_call_id": tcid, "content": "ok"})
+                continue
+            _emit(STEP_TOOL_CALL, step, json.dumps(args, ensure_ascii=False), tool=name)
+            tool = tools.get(name)
+            if tool is None:
+                obs = f"[TOOL_ERROR] unknown tool: {name}"
+            else:
+                try:
+                    obs = str(_timeout_call(tool, args, tool_timeout))
+                except Exception as e:
+                    obs = f"[TOOL_ERROR] {e}"
+            _emit(STEP_TOOL_RESULT, step, obs, tool=name)
+            messages.append({"role": "tool", "tool_call_id": tcid,
+                             "content": obs[:_MAX_OBS_LENGTH]})
+        if terminated:
+            break
+    return final
+
+
 def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     """
     type: react -> Loop(react_step, condition, max_steps)
@@ -816,6 +881,33 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     verbose = react_cfg.get("verbose", False)
     agent_name = cfg.get("name", cfg.get("agentId", "agent"))
 
+    # ── 原生 function-calling 车道(react.nativeToolCalls,阶段 2)──
+    # FC-capable provider(OpenAI 兼容:qwen/openai/deepseek…)+ 开关 → 用结构化
+    # tool_calls 直接执行,根治"文本求 JSON、正则抠"那一类 0 执行/格式飘 bug。
+    # 旧文本路径(下方)保留作 fallback:provider 不支持 FC 或开关 off 时走它。
+    # 见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。
+    if react_cfg.get("nativeToolCalls"):
+        _prov = getattr(think, "provider", None)
+        if (_prov is not None and hasattr(_prov, "chat_with_tools")
+                and getattr(_prov, "supports_function_calling", lambda: False)()):
+            try:
+                from lambdagent.agentruntime import cancel as _fc_cancel
+            except Exception:
+                _fc_cancel = None
+            _fc_schema = _tools_json_schema(cfg)
+            _fc_sys = cfg.get("systemPrompt", "")
+            _fc_on_step = overrides.get("on_step")
+
+            def _fc_apply(user_input, _p=_prov, _sys=_fc_sys, _tools=tools,
+                          _schema=_fc_schema, _ms=max_steps, _tt=tool_timeout,
+                          _os=_fc_on_step, _c=_fc_cancel):
+                return _run_fc_loop(_p, _sys, str(user_input), _tools, _schema,
+                                    max_steps=_ms, tool_timeout=_tt, on_step=_os, cancel=_c)
+
+            fc_term = Tool(f"{agent_name}.fc_react", _fc_apply)
+            fc_term._think_ref = think  # token/usage 会计沿用 _find_usage_source
+            return fc_term
+
     # enforceLoop: hard floor on tool execution before terminate is allowed.
     # Two modes:
     #

+ 58 - 0
lambdagent/tests/test_function_calling.py

@@ -102,3 +102,61 @@ class TestChatWithTools(unittest.TestCase):
 
     def test_supports_fc(self):
         self.assertTrue(self._provider().supports_function_calling())
+
+
+class _FakeFCProvider:
+    """mock chat_with_tools,按序返回 canned 响应。"""
+    def __init__(self, responses):
+        self.responses = responses; self.i = 0
+    def chat_with_tools(self, messages, tools_schema=None, **kw):
+        r = self.responses[min(self.i, len(self.responses) - 1)]; self.i += 1; return r
+    def supports_function_calling(self): return True
+    def get_usage(self): return {"input_tokens": 0, "output_tokens": 0}
+
+
+class TestFCLoop(unittest.TestCase):
+    def test_executes_tool_then_terminates(self):
+        from lambdagent.fromconfig.compiler import _run_fc_loop
+        calls = []
+        tools = {"WriteFile": lambda args: calls.append(args) or "wrote ok"}
+        prov = _FakeFCProvider([
+            {"content": None, "tool_calls": [{"id": "c1", "name": "WriteFile",
+              "arguments": {"file_path": "/a.md", "content": "x"}}]},
+            {"content": None, "tool_calls": [{"id": "c2", "name": "terminate",
+              "arguments": {"summary": "完成"}}]},
+        ])
+        out = _run_fc_loop(prov, "sys", "do it", tools, [], max_steps=5, tool_timeout=10)
+        self.assertEqual(calls, [{"file_path": "/a.md", "content": "x"}])  # 工具真执行
+        self.assertEqual(out, "完成")
+
+    def test_content_only_is_final(self):
+        from lambdagent.fromconfig.compiler import _run_fc_loop
+        prov = _FakeFCProvider([{"content": "答案", "tool_calls": []}])
+        self.assertEqual(_run_fc_loop(prov, "s", "q", {}, [], max_steps=5, tool_timeout=10), "答案")
+
+    def test_unknown_tool_then_terminate(self):
+        from lambdagent.fromconfig.compiler import _run_fc_loop
+        prov = _FakeFCProvider([
+            {"content": None, "tool_calls": [{"id": "c1", "name": "Nope", "arguments": {}}]},
+            {"content": None, "tool_calls": [{"id": "c2", "name": "terminate", "arguments": {"summary": "end"}}]},
+        ])
+        # 未知工具 → error obs 回灌,循环继续到 terminate(不崩)
+        self.assertEqual(_run_fc_loop(prov, "s", "q", {}, [], max_steps=5, tool_timeout=10), "end")
+
+
+class TestFCRouting(unittest.TestCase):
+    def _cfg(self, fc):
+        return {"name": "t", "type": "react", "systemPrompt": "s",
+                "model": {"provider": "dashscope", "name": "qwen-plus"},
+                "react": {"maxSteps": 5, "nativeToolCalls": fc},
+                "mcp": {"localTools": ["ReadFile", "terminate"]}}
+
+    def test_fc_term_when_flag_and_capable(self):
+        from lambdagent.fromconfig.compiler import _compile_react
+        term = _compile_react(self._cfg(True), {})
+        self.assertTrue(term._name.endswith("fc_react"))
+
+    def test_text_term_when_flag_off(self):
+        from lambdagent.fromconfig.compiler import _compile_react
+        term = _compile_react(self._cfg(False), {})
+        self.assertFalse(term._name.endswith("fc_react"))

+ 16 - 0
webui/src/pages/AgentEdit.tsx

@@ -101,6 +101,7 @@ export default function AgentEdit() {
   const [toolTimeout, setToolTimeout]     = useState('60')
   const [thinkTimeout, setThinkTimeout]   = useState('120')
   const [progressDiscipline, setProgressDiscipline] = useState(false)
+  const [nativeToolCalls, setNativeToolCalls] = useState(false)
 
   // memory & runtime
   const [memoryEnabled, setMemoryEnabled] = useState(false)
@@ -183,6 +184,7 @@ export default function AgentEdit() {
     setToolTimeout(String(r.toolTimeout   ?? 60))
     setThinkTimeout(String(r.thinkTimeout ?? 120))
     setProgressDiscipline(Boolean(r.progressDiscipline))
+    setNativeToolCalls(Boolean(r.nativeToolCalls))
 
     setMemoryEnabled(Boolean(mem.enabled))
     setMemorySize(String(mem.size ?? 20))
@@ -233,6 +235,7 @@ export default function AgentEdit() {
         toolTimeout:  parseInt(toolTimeout)   || 60,
         thinkTimeout: parseInt(thinkTimeout)  || 120,
         progressDiscipline,
+        nativeToolCalls,
       }
       cfg.memory = {
         ...((cfg.memory as object) ?? {}),
@@ -444,6 +447,19 @@ export default function AgentEdit() {
                 </span>
               </span>
             </label>
+            <label className="flex items-start gap-2 cursor-pointer pt-2">
+              <input type="checkbox" className="mt-0.5" checked={nativeToolCalls}
+                     onChange={e => setNativeToolCalls(e.target.checked)} />
+              <span className="text-sm">
+                <span className="font-medium text-gray-800">原生 function-calling(实验)</span>
+                <span className="block text-xs text-gray-500 mt-0.5">
+                  用模型的结构化 tool_calls 执行工具,取代"让模型手写 JSON、平台正则抠"——
+                  根治工具不执行、参数名漂移(path/file_path)、裸字符串等问题。
+                  仅对支持的模型生效(qwen / openai / deepseek 等);claude-code 已走原生车道。
+                  不支持的模型自动回退文本模式。建议给 qwen-plus 等开启。
+                </span>
+              </span>
+            </label>
           </div>
         )}