Pārlūkot izejas kodu

feat(provider): 原生 function-calling 阶段1 — chat_with_tools + 工具 JSON schema

评估建议①的根治第一步(docs/NATIVE_FUNCTION_CALLING_DESIGN.md):用结构化 tool_calls
取代 react-over-text 的"发文本求 JSON、正则抠",从根上消灭 0 执行/格式飘/路径别名类
bug(run_541390a1ea49)。

阶段1(纯增量、零回归,react loop 不动):
- OpenAICompatProvider.chat_with_tools(messages, tools) → {content, tool_calls(结构化),
  usage, finish_reason};supports_function_calling()。复用现有 HTTP/错误/网络重试。
- _parse_tool_calls:兼容 arguments 是 dict / JSON 字符串 / 坏串 / 空。
- compiler._tools_json_schema(cfg):从工具 schema 类 __init__ 注解生成 OpenAI tools 规格;
  _json_type_of 处理 `from __future__ annotations` 的字符串注解(int→integer 等)。
回归 test_function_calling(8)+ lambdagent 606 全绿。阶段2(FC react 执行+路由)待接线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju 2 mēneši atpakaļ
vecāks
revīzija
a7f1a639f0

+ 17 - 0
RELEASE_NOTES_v1.4.2.md

@@ -0,0 +1,17 @@
+# lambdagentpaas v1.4.2
+
+发布日期:2026-06-17
+
+## 修复:专项 agent 产物落进本 run 工作区(目录一致性)
+
+专项 react 智能体(审稿/批改/文献地图等)的产物原本写进 agent 家目录(work_dir),
+而 run 查看器与运行记录看的是本 run 的 workspace_path —— 导致:
+- run 工作区查看器是空的(看不到产物);
+- 跨 run 互相覆盖(每次写同一个 work_dir)。
+
+根因:会话 CWD(Bash/文件工具相对路径基准)= work_dir,但给模型的 [工作目录] 提示
+= 本 run workspace_path,两者分叉。
+
+修复:会话 CWD 对齐 [工作目录] 提示 —— 工作区模式=所选文件夹 F;专项 agent=本 run
+的 workspace_path(per-run 隔离、查看器可见、不再覆盖);读输入仍走 [数据来源目录]
+绝对路径。回归 agentpaas 344(+1 skip)。

+ 82 - 0
docs/NATIVE_FUNCTION_CALLING_DESIGN.md

@@ -0,0 +1,82 @@
+# 原生 function-calling 改造设计(评估建议 ①)
+
+状态:设计 → 分阶段实现
+日期:2026-06-17 · 基线 v1.4.2
+目标:用各 provider 的**结构化 tool_calls** 取代 react-over-text 的"发文本求 JSON、正则抠",
+从根上消灭"0 执行 / `input` 裸串 / `path` vs `file_path` / 相对路径落错"这一整类 bug
+(run_541390a1ea49 实锤)。
+
+## 1. 现状(为什么脆)
+
+```
+react loop: 构造文本 prompt("请输出一个 JSON 工具调用")
+        → think.apply(text) → provider.chat(messages) → 返回 **纯 text**
+        → _extract_tool_call(text) 正则/JSON 扫描抠 {"tool":...,"input":...}
+        → 执行 → 把结果拼成文本 "[工具执行结果]..." 再喂回
+```
+provider.chat 只发 `{model, messages, temperature, max_tokens}`,**不发 tools**,只返回 `content`。
+模型把工具调用当散文手写 → 格式飘 → 解析失败/参数错。
+
+## 2. 目标架构
+
+```
+FC react: provider.chat_with_tools(messages, tools_schema)
+        → 模型走原生 tool_use → 返回 **结构化** {content, tool_calls:[{name,arguments}]}
+        → 直接执行 tool_calls(无需解析文本)
+        → 结果作 role=tool 消息回灌(标准 function-calling 协议)
+```
+- OpenAI 兼容(qwen/deepseek/openai/moonshot/zhipu):请求加 `tools` + `tool_choice`,
+  响应读 `choices[0].message.tool_calls`。
+- claude-code:已有 native 车道,不动。
+- 不支持 FC 的模型(部分 ollama 旧模型):回退现有 react-over-text 文本解析。
+
+## 3. 关键决策:新增"FC 增强"而非重写旧 loop
+
+旧 react loop 深耦合文本(state 累积、enforceLoop、give-up、session 重注入都基于文本)。
+全量替换风险极高。借鉴 native claude-code 的成功经验(新增干净一路 > 改旧封装),采用
+**provider 能力 + 配置开关双门控的 FC 路径**,旧文本路径保留作回退。
+
+判定:`react.nativeToolCalls: true`(opt-in,默认 off)**且** provider 支持 FC →
+走 FC;否则走现有文本 react。
+
+## 4. 分阶段实现(降风险、每阶段可独测)
+
+### 阶段 1(本次):provider 基础层 + 工具 JSON schema(低风险、纯增量)
+- `providers/openai_compat_provider.py`:
+  - 新增 `chat_with_tools(messages, tools, tool_choice="auto") -> dict`,返回
+    `{"content": str|None, "tool_calls": [{"id","name","arguments"(dict)}], "usage": {...}}`。
+    复用现有 HTTP/错误处理(含 `_is_transient_net_error` 重试、HTTPError)。
+  - `supports_function_calling()` → True(OpenAI 兼容默认支持;ollama 按模型)。
+- `fromconfig/compiler.py`:新增 `_tools_json_schema(cfg) -> list`,复用
+  `_generate_tool_schema_docs` 的签名内省,产出 OpenAI tools 规格
+  `[{"type":"function","function":{"name","description","parameters":{JSON schema}}}]`。
+  参数类型从 schema 类 `__init__` 注解推断(str/int/bool…),缺省→optional。
+- 单测 `test_function_calling`:
+  - `_tools_json_schema` 对 ReadFile/WriteFile/Bash 产出正确 name/required;
+  - `chat_with_tools` 用 mock HTTP 返回带 tool_calls 的响应 → 正确解析成
+    `{name, arguments(dict)}`;返回 content-only 时 tool_calls=[];arguments 是字符串 JSON 时能 parse。
+- **不动 react loop** → 零回归风险。交付一个被测过的可靠积木。
+
+### 阶段 2(下次):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。
+- ConversationLam:FC 模式消息序列含 assistant(tool_calls) + tool(result)。
+- 路由门控:`react.nativeToolCalls` + `provider.supports_function_calling()`。
+- 先在 qwen-plus 上 live 验证:同批任务工具执行成功率/落盘率对比文本路径。
+
+### 阶段 3(评估②联动):用 eval 量化收益
+- 复用 docs/AGENT_QUALITY_ASSESSMENT.md ② 的 eval 闭环,对比 FC vs 文本路径的
+  落盘率/达标率/步数/成本,数据驱动决定是否把 FC 设为默认。
+
+## 5. 风险与缓解
+- **旧 loop 不动**(阶段 1)→ 无回归;阶段 2 的 FC 路径 opt-in、可一键退回文本路径。
+- **provider 兼容差异**:dashscope 的 tools 字段与 OpenAI 基本一致;ollama 视模型。`tool_choice`
+  部分端点不认 → 缺省不传、仅传 tools。
+- **arguments 可能是字符串**(某些端点把 arguments 序列化成 JSON 字符串)→ 解析层兼容 dict/str。
+- **参数 schema 不全**:内省拿不到类型时退化为 `string`,不阻断。
+
+## 6. 验收
+- 阶段 1:`test_function_calling` 全绿;`chat_with_tools` 解析鲁棒。
+- 阶段 2:qwen-plus FC 路径 live 跑通一条多步任务,工具真执行、产物落盘、0 个"格式飘"错。
+- 整体:FC 路径下 run_541390a1ea49 类(0 执行)不再出现。

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

@@ -719,6 +719,62 @@ def _generate_tool_schema_docs(cfg: Dict) -> str:
     return "\n".join(lines) if len(lines) > 2 else ""
 
 
+_PY_TO_JSON_TYPE = {str: "string", int: "integer", float: "number",
+                    bool: "boolean", list: "array", dict: "object"}
+# `from __future__ import annotations` 让注解变成字符串("int" 而非 int 类型),按名兜。
+_JSON_TYPE_BY_NAME = {"str": "string", "int": "integer", "float": "number",
+                      "bool": "boolean", "list": "array", "dict": "object",
+                      "List": "array", "Dict": "object", "Optional[str]": "string"}
+
+
+def _json_type_of(annotation) -> str:
+    if isinstance(annotation, str):
+        return _JSON_TYPE_BY_NAME.get(annotation, "string")
+    return _PY_TO_JSON_TYPE.get(annotation, "string")
+
+
+def _tools_json_schema(cfg: Dict) -> list:
+    """从工具 schema 类的 __init__ 注解生成 OpenAI function-calling tools 规格,
+    供原生 tool_calls 用(替代 _generate_tool_schema_docs 的文本文档)。
+    见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md 阶段 1。"""
+    import inspect
+    try:
+        from lambdagent.builtin_tools.registry import BUILTIN_TOOLS
+    except ImportError:
+        return []
+    names = (cfg.get("mcp", {}) or {}).get("localTools", []) or []
+    out = []
+    for name in names:
+        if name == "terminate":
+            out.append({"type": "function", "function": {
+                "name": "terminate", "description": "结束任务并返回结果摘要",
+                "parameters": {"type": "object",
+                               "properties": {"summary": {"type": "string",
+                                              "description": "结果摘要"}},
+                               "required": []}}})
+            continue
+        tool = BUILTIN_TOOLS.get(name)
+        schema_cls = getattr(tool, "schema", None) if tool else None
+        if not schema_cls:
+            continue
+        try:
+            sig = inspect.signature(schema_cls.__init__)
+        except (TypeError, ValueError):
+            continue
+        props, required = {}, []
+        for pn, p in sig.parameters.items():
+            if pn == "self":
+                continue
+            props[pn] = {"type": _json_type_of(p.annotation)}
+            if p.default is inspect.Parameter.empty:
+                required.append(pn)
+        out.append({"type": "function", "function": {
+            "name": name,
+            "description": getattr(tool, "description", "") or name,
+            "parameters": {"type": "object", "properties": props, "required": required}}})
+    return out
+
+
 def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     """
     type: react -> Loop(react_step, condition, max_steps)

+ 72 - 0
lambdagent/src/lambdagent/providers/openai_compat_provider.py

@@ -33,6 +33,26 @@ _TRANSIENT_NET_MARKERS = (
 )
 
 
+def _parse_tool_calls(raw) -> list:
+    """把 OpenAI 响应的 message.tool_calls 规整成 [{"id","name","arguments"(dict)}]。
+    arguments 端点上可能是 dict 或 JSON 字符串(甚至空/坏串)——都兼容。"""
+    out = []
+    for tc in (raw or []):
+        if not isinstance(tc, dict):
+            continue
+        fn = tc.get("function", {}) or {}
+        args = fn.get("arguments")
+        if isinstance(args, str):
+            try:
+                args = json.loads(args) if args.strip() else {}
+            except (json.JSONDecodeError, ValueError):
+                args = {}
+        if not isinstance(args, dict):
+            args = {}
+        out.append({"id": tc.get("id", ""), "name": fn.get("name", ""), "arguments": args})
+    return out
+
+
 def _is_transient_net_error(exc: Exception) -> bool:
     if isinstance(exc, (ConnectionError, http.client.RemoteDisconnected,
                         http.client.IncompleteRead, TimeoutError)):
@@ -158,6 +178,58 @@ class OpenAICompatProvider(LLMProvider):
                                 self._provider_name,
                                 retryable=_is_transient_net_error(e))
 
+    def supports_function_calling(self) -> bool:
+        """OpenAI 兼容端默认支持原生 tool_calls。不支持的端点会忽略 tools 字段、
+        返回 content,FC loop 有 content 兜底,所以保守返回 True 即可。"""
+        return True
+
+    def chat_with_tools(self, messages, tools=None, tool_choice="auto", model="",
+                        temperature=None, max_tokens=None) -> dict:
+        """原生 function-calling。发送 tools(OpenAI 规格),返回结构化结果:
+        {"content": str|None, "tool_calls": [{"id","name","arguments"(dict)}],
+         "usage": {...}, "finish_reason": str}。无需解析文本 JSON。"""
+        url = f"{self.base_url.rstrip('/')}/chat/completions"
+        payload = {
+            "model": model or self.config.model,
+            "messages": messages,
+            "temperature": self.config.temperature if temperature is None else temperature,
+            "max_tokens": self.config.max_tokens if max_tokens is None else max_tokens,
+        }
+        if tools:
+            payload["tools"] = tools
+            if tool_choice:
+                payload["tool_choice"] = tool_choice
+        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+        headers = {"Content-Type": "application/json"}
+        if self.api_key:
+            headers["Authorization"] = f"Bearer {self.api_key}"
+        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+        try:
+            with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
+                data = json.loads(resp.read())
+        except urllib.error.HTTPError as e:
+            error_body = e.read().decode()[:500]
+            raise ProviderError(f"{self._provider_name} API error {e.code}: {error_body}",
+                                self._provider_name, retryable=e.code >= 500)
+        except urllib.error.URLError as e:
+            raise ProviderError(f"{self._provider_name} connection error: {e}",
+                                self._provider_name, retryable=True)
+        except Exception as e:
+            raise ProviderError(f"{self._provider_name} error: {e}",
+                                self._provider_name, retryable=_is_transient_net_error(e))
+        choice = (data.get("choices") or [{}])[0]
+        msg = choice.get("message", {}) or {}
+        usage = data.get("usage", {}) or {}
+        self._usage_input  += usage.get("prompt_tokens", 0)
+        self._usage_output += usage.get("completion_tokens", 0)
+        return {
+            "content": (msg.get("content") or None),
+            "tool_calls": _parse_tool_calls(msg.get("tool_calls")),
+            "usage": {"input_tokens": usage.get("prompt_tokens", 0),
+                      "output_tokens": usage.get("completion_tokens", 0)},
+            "finish_reason": choice.get("finish_reason", ""),
+        }
+
     # ── L03: Session-level usage API (matches ClaudeCodeProvider interface) ──
 
     def get_usage(self) -> dict:

+ 104 - 0
lambdagent/tests/test_function_calling.py

@@ -0,0 +1,104 @@
+"""原生 function-calling 阶段 1:provider chat_with_tools + 工具 JSON schema。
+
+替代 react-over-text 的"发文本求 JSON、正则抠"——从根上消灭 0 执行/格式飘类 bug。
+见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。阶段 1 只测基础层,不动 react loop(零回归)。
+"""
+from __future__ import annotations
+
+import json
+import unittest
+from unittest.mock import patch, MagicMock
+
+from lambdagent.providers.openai_compat_provider import (
+    OpenAICompatProvider, _parse_tool_calls,
+)
+from lambdagent.providers.base import ProviderConfig
+from lambdagent.fromconfig.compiler import _tools_json_schema
+
+
+class TestToolCallParsing(unittest.TestCase):
+    def test_dict_args(self):
+        out = _parse_tool_calls([{"id": "c1", "function": {
+            "name": "ReadFile", "arguments": {"file_path": "/a.txt"}}}])
+        self.assertEqual(out, [{"id": "c1", "name": "ReadFile",
+                                "arguments": {"file_path": "/a.txt"}}])
+
+    def test_string_json_args(self):
+        out = _parse_tool_calls([{"id": "c2", "function": {
+            "name": "Bash", "arguments": '{"command": "ls -la"}'}}])
+        self.assertEqual(out[0]["arguments"], {"command": "ls -la"})
+
+    def test_bad_or_empty_args(self):
+        self.assertEqual(_parse_tool_calls([{"function": {"name": "X", "arguments": "not json"}}])[0]["arguments"], {})
+        self.assertEqual(_parse_tool_calls([{"function": {"name": "X", "arguments": ""}}])[0]["arguments"], {})
+        self.assertEqual(_parse_tool_calls(None), [])
+        self.assertEqual(_parse_tool_calls([]), [])
+
+
+class TestToolsJsonSchema(unittest.TestCase):
+    def test_schema_for_file_tools(self):
+        cfg = {"mcp": {"localTools": ["ReadFile", "WriteFile", "terminate"]}}
+        specs = _tools_json_schema(cfg)
+        by_name = {s["function"]["name"]: s["function"] for s in specs}
+        self.assertIn("ReadFile", by_name)
+        self.assertIn("WriteFile", by_name)
+        self.assertIn("terminate", by_name)
+        # ReadFile: file_path required, offset/limit optional
+        rf = by_name["ReadFile"]
+        self.assertEqual(rf["parameters"]["type"], "object")
+        self.assertIn("file_path", rf["parameters"]["properties"])
+        self.assertIn("file_path", rf["parameters"]["required"])
+        self.assertNotIn("offset", rf["parameters"]["required"])
+        # 类型推断:offset → integer
+        self.assertEqual(rf["parameters"]["properties"]["offset"]["type"], "integer")
+        # WriteFile: file_path + content 都 required
+        wf = by_name["WriteFile"]
+        self.assertIn("content", wf["parameters"]["required"])
+
+    def test_empty_when_no_tools(self):
+        self.assertEqual(_tools_json_schema({}), [])
+
+
+class TestChatWithTools(unittest.TestCase):
+    def _provider(self):
+        p = OpenAICompatProvider.__new__(OpenAICompatProvider)
+        p.config = ProviderConfig(model="qwen-plus", temperature=0.0, max_tokens=4096, timeout=60)
+        p._provider_name = "dashscope"
+        p.base_url = "https://x/v1"
+        p.api_key = "k"
+        p._usage_input = 0
+        p._usage_output = 0
+        return p
+
+    def _mock_resp(self, payload):
+        cm = MagicMock()
+        cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
+        return cm
+
+    def test_returns_structured_tool_calls(self):
+        p = self._provider()
+        payload = {"choices": [{"finish_reason": "tool_calls", "message": {
+            "content": None,
+            "tool_calls": [{"id": "c1", "function": {
+                "name": "WriteFile", "arguments": '{"file_path":"/out.md","content":"x"}'}}]}}],
+            "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
+        with patch("urllib.request.urlopen", return_value=self._mock_resp(payload)):
+            r = p.chat_with_tools([{"role": "user", "content": "write it"}],
+                                  tools=[{"type": "function", "function": {"name": "WriteFile"}}])
+        self.assertEqual(r["tool_calls"][0]["name"], "WriteFile")
+        self.assertEqual(r["tool_calls"][0]["arguments"], {"file_path": "/out.md", "content": "x"})
+        self.assertIsNone(r["content"])
+        self.assertEqual(r["usage"]["input_tokens"], 100)
+        self.assertEqual(p._usage_input, 100)  # 累计进 get_usage
+
+    def test_content_only_no_tool_calls(self):
+        p = self._provider()
+        payload = {"choices": [{"finish_reason": "stop", "message": {"content": "答案"}}],
+                   "usage": {"prompt_tokens": 5, "completion_tokens": 3}}
+        with patch("urllib.request.urlopen", return_value=self._mock_resp(payload)):
+            r = p.chat_with_tools([{"role": "user", "content": "hi"}], tools=None)
+        self.assertEqual(r["content"], "答案")
+        self.assertEqual(r["tool_calls"], [])
+
+    def test_supports_fc(self):
+        self.assertTrue(self._provider().supports_function_calling())