Explorar o código

feat(Agent运行时): 补齐千问配置与工具类型解析

为千问 OpenAI 兼容客户端增加 thinking、请求超时和瞬态重试配置,并保留调用级 extra_body 覆盖。

解析 postponed annotations 与 PEP 604 联合类型,补充 OpenAI/PyYAML 运行依赖和对应回归测试。
SamLee hai 1 día
pai
achega
7f1cc35ce8

+ 15 - 4
agent/agent/llm/qwen.py

@@ -21,6 +21,9 @@ def create_qwen_llm_call(
     model: str = DEFAULT_QWEN_MODEL,
     base_url: Optional[str] = None,
     api_key: Optional[str] = None,
+    enable_thinking: Optional[bool] = None,
+    request_timeout_seconds: float = 300.0,
+    max_retries: int = 2,
 ) -> Callable:
     """
     Create a Qwen LLM call function using the OpenAI SDK.
@@ -37,7 +40,9 @@ def create_qwen_llm_call(
     # SDK 会自动处理 /chat/completions 的拼接
     client = AsyncOpenAI(
         api_key=api_key,
-        base_url=base_url
+        base_url=base_url,
+        timeout=request_timeout_seconds,
+        max_retries=max_retries,
     )
 
     pricing_calc = PricingCalculator()
@@ -50,7 +55,13 @@ def create_qwen_llm_call(
         max_tokens: int = 16384,
         **kwargs
     ) -> Dict[str, Any]:
-        
+        request_kwargs = dict(kwargs)
+        extra_body = dict(request_kwargs.pop("extra_body", {}) or {})
+        if enable_thinking is not None:
+            extra_body.setdefault("enable_thinking", enable_thinking)
+        if extra_body:
+            request_kwargs["extra_body"] = extra_body
+
         try:
             response = await client.chat.completions.create(
                 model=model,
@@ -58,7 +69,7 @@ def create_qwen_llm_call(
                 tools=tools,
                 temperature=temperature,
                 max_tokens=max_tokens,
-                **kwargs
+                **request_kwargs
             )
 
             # 获取内容
@@ -207,4 +218,4 @@ async def qwen_llm_call(
         "finish_reason": finish_reason,
         "cost": cost,
         "usage": usage,
-    }
+    }

+ 14 - 3
agent/agent/tools/schema.py

@@ -11,7 +11,8 @@ Schema Generator - 从函数签名自动生成 OpenAI Tool Schema
 
 import inspect
 import logging
-from typing import Any, Dict, List, Literal, Optional, Union, get_args, get_origin
+import types
+from typing import Any, Dict, List, Literal, Optional, Union, get_args, get_origin, get_type_hints
 
 logger = logging.getLogger(__name__)
 
@@ -84,6 +85,13 @@ class SchemaGenerator:
         # 解析函数签名
         sig = inspect.signature(func)
         func_name = func.__name__
+        try:
+            type_hints = get_type_hints(func)
+        except (NameError, TypeError):
+            # Some dynamically registered callables may refer to names which
+            # are intentionally unavailable at registration time. Preserve
+            # the previous best-effort behavior for those callables.
+            type_hints = {}
 
         # 解析 docstring
         if HAS_DOCSTRING_PARSER:
@@ -109,7 +117,10 @@ class SchemaGenerator:
                 continue
 
             # 获取类型注解
-            param_type = param.annotation if param.annotation != inspect.Parameter.empty else str
+            param_type = type_hints.get(
+                param_name,
+                param.annotation if param.annotation != inspect.Parameter.empty else str,
+            )
 
             # 生成参数 Schema
             param_schema = cls._type_to_schema(param_type)
@@ -161,7 +172,7 @@ class SchemaGenerator:
             return {"enum": values}
 
         # 处理 Union[T, ...] 和 Optional[T]
-        if origin is Union:
+        if origin in (Union, types.UnionType):
             if len(args) == 2 and type(None) in args:
                 # Optional[T] = Union[T, None]
                 inner = args[0] if args[1] is type(None) else args[1]

+ 2 - 0
agent/pyproject.toml

@@ -6,9 +6,11 @@ readme = "README.md"
 requires-python = ">=3.11"
 dependencies = [
     "httpx[socks]>=0.28.0",
+    "openai>=2.16.0,<3",
     "python-dotenv>=1.0.0",
     "pydantic",
     "pillow>=10.0.0",
+    "pyyaml>=6.0.0",
     "python-dateutil>=2.9.0",
     "filelock>=3.15.0",
 ]

+ 81 - 0
agent/tests/test_qwen_runtime_options.py

@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from agent.llm.qwen import create_qwen_llm_call
+
+
+@pytest.mark.asyncio
+async def test_qwen_factory_applies_timeout_and_non_thinking_mode(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    client_options: dict[str, Any] = {}
+    request_options: dict[str, Any] = {}
+
+    async def create(**kwargs: Any) -> Any:
+        request_options.update(kwargs)
+        return SimpleNamespace(
+            choices=[
+                SimpleNamespace(
+                    message=SimpleNamespace(content="ok", tool_calls=None),
+                    finish_reason="stop",
+                )
+            ],
+            usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2),
+        )
+
+    def fake_client(**kwargs: Any) -> Any:
+        client_options.update(kwargs)
+        return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
+
+    monkeypatch.setattr("agent.llm.qwen.AsyncOpenAI", fake_client)
+    call = create_qwen_llm_call(
+        api_key="secret",
+        enable_thinking=False,
+        request_timeout_seconds=45,
+        max_retries=2,
+    )
+
+    result = await call(messages=[{"role": "user", "content": "hello"}])
+
+    assert client_options["timeout"] == 45
+    assert client_options["max_retries"] == 2
+    assert request_options["extra_body"] == {"enable_thinking": False}
+    assert result["content"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_qwen_factory_preserves_call_specific_extra_body(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    request_options: dict[str, Any] = {}
+
+    async def create(**kwargs: Any) -> Any:
+        request_options.update(kwargs)
+        return SimpleNamespace(
+            choices=[
+                SimpleNamespace(
+                    message=SimpleNamespace(content="ok", tool_calls=None),
+                    finish_reason="stop",
+                )
+            ],
+            usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
+        )
+
+    monkeypatch.setattr(
+        "agent.llm.qwen.AsyncOpenAI",
+        lambda **_kwargs: SimpleNamespace(
+            chat=SimpleNamespace(completions=SimpleNamespace(create=create))
+        ),
+    )
+    call = create_qwen_llm_call(api_key="secret", enable_thinking=False)
+
+    await call(
+        messages=[{"role": "user", "content": "hello"}],
+        extra_body={"enable_thinking": True, "custom": "kept"},
+    )
+
+    assert request_options["extra_body"] == {"enable_thinking": True, "custom": "kept"}

+ 22 - 0
agent/tests/test_tool_schema_annotations.py

@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from agent.tools.schema import SchemaGenerator
+
+
+def test_schema_generator_resolves_postponed_nested_annotations() -> None:
+    async def sample(
+        contracts: list[dict[str, object]],
+        replacement: dict[str, object] | None = None,
+    ) -> None:
+        pass
+
+    properties = SchemaGenerator.generate(sample)["function"]["parameters"]["properties"]
+
+    assert properties["contracts"] == {
+        "type": "array",
+        "items": {"type": "object"},
+    }
+    assert properties["replacement"] == {
+        "type": "object",
+        "default": None,
+    }

+ 4 - 0
agent/uv.lock

@@ -689,10 +689,12 @@ source = { editable = "." }
 dependencies = [
     { name = "filelock" },
     { name = "httpx", extra = ["socks"] },
+    { name = "openai" },
     { name = "pillow" },
     { name = "pydantic" },
     { name = "python-dateutil" },
     { name = "python-dotenv" },
+    { name = "pyyaml" },
 ]
 
 [package.optional-dependencies]
@@ -734,6 +736,7 @@ requires-dist = [
     { name = "langchain-core", marker = "extra == 'browser'", specifier = ">=0.3.0" },
     { name = "lark-oapi", marker = "extra == 'all'", specifier = "==1.5.3" },
     { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" },
+    { name = "openai", specifier = ">=2.16.0,<3" },
     { name = "pillow", specifier = ">=10.0.0" },
     { name = "pydantic" },
     { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@@ -741,6 +744,7 @@ requires-dist = [
     { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
     { name = "python-dateutil", specifier = ">=2.9.0" },
     { name = "python-dotenv", specifier = ">=1.0.0" },
+    { name = "pyyaml", specifier = ">=6.0.0" },
     { name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.32.0" },
     { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.32.0" },
     { name = "websockets", marker = "extra == 'all'", specifier = ">=13.0" },