diff --git a/changelog.md b/changelog.md index 907f7c7..3e7c0e5 100644 --- a/changelog.md +++ b/changelog.md @@ -8,11 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- **Inline REST LLM tools** — Global and CN LLM vendors now accept dictionary-based `tools` definitions and serialize them to `llm.tools`. Added public `LlmToolConfig`, `LlmToolExecutionConfig`, `LlmToolFunctionConfig`, `LlmToolFunctionParametersConfig`, and `LlmToolServerConfig` aliases. Tool execution must be enabled explicitly with `Agent.with_tools()`. +- **Inline REST LLM tools** — Global and CN LLM vendors now accept dictionary-based definitions or the exported typed `LlmToolConfig` models and serialize them to `llm.tools`. Added public `LlmToolConfig`, `LlmToolExecutionConfig`, `LlmToolFunctionConfig`, `LlmToolFunctionParametersConfig`, and `LlmToolServerConfig` aliases. Tool execution must be enabled explicitly with `Agent.with_tools()`. ### Changed - **Generated filler words configuration** — Generated filler word settings are now optional. The service can use default generator settings when `generated_config` is omitted, and `llm_provider`, `prompt`, and `fallback_strategy` may be omitted individually. +- **ASR hotwords** — `keywords` on `AresSTT` and `FengmingSTT` now serialize as top-level `asr.keywords`, matching the current OpenAPI schema. Vendor-specific `additional_params` remain under `asr.params`; nested `additional_params["keywords"]` is rejected to prevent ambiguous requests. ## [v2.7.2] — 2026-08-26 @@ -65,7 +66,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - **Qwen Omni Realtime MLLM (CN)** — `QwenOmni` (`mllm.vendor`: `qwen_omni`), exported from `agora_agent.cn`. - **MLLM support in AgentKit vendor namespaces** — Global and CN vendor catalogs now expose an `mllm` namespace, MLLM vendors are validated against the client area, and `CNAgent`/`GlobalAgent` expose typed `with_mllm()`. - **Typecast TTS** — `TypecastTTS` (`tts.vendor`: `typecast`). -- **ASR hotwords** — `keywords` on `AresSTT` and `FengmingSTT`, serialized as top-level `asr.keywords`. Vendor-specific `additional_params` remain under `asr.params`. - **Configurable API base URL** — Setting `AGORA_AGENTS_API_BASE_URL` overrides regional domain selection; when set, the domain pool skips DNS-based domain selection and region cycling and always uses the configured base URL. ### Changed diff --git a/docs/reference/agent.md b/docs/reference/agent.md index cdcbae8..50e1915 100644 --- a/docs/reference/agent.md +++ b/docs/reference/agent.md @@ -173,7 +173,7 @@ When `enable_rtm=True`, AgentKit defaults `parameters.data_channel` to `"rtm"` u ### `with_tools(enabled: bool = True) -> Agent` -Enable or disable MCP tool invocation by setting `advanced_features.enable_tools`. +Enable or disable MCP and inline REST tool invocation by setting `advanced_features.enable_tools`. ### `with_parameters(parameters: SessionParams) -> Agent` diff --git a/poetry.lock b/poetry.lock index 46f7b7b..43616c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -562,4 +562,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "8551b871abee465e23fb0966d51f2c155fd257b55bdcb0c02d095de19f92f358" +content-hash = "cfdc5fc807ddde3c94c596644fc8fe11896a96961cd4d8d271c75afe5fe1c53d" diff --git a/pyproject.toml b/pyproject.toml index 76b656e..86e4a47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,7 @@ Repository = 'https://github.com/AgoraIO-Conversational-AI/agent-server-sdk-pyth [tool.poetry.dependencies] python = "^3.8" httpx = ">=0.21.2" -pydantic = ">= 1.9.2" -pydantic-core = ">=2.18.2" +pydantic = ">=2.7.1,<3" typing_extensions = ">= 4.0.0" [tool.poetry.group.dev.dependencies] diff --git a/src/agora_agent/agentkit/agent.py b/src/agora_agent/agentkit/agent.py index f1d4587..a8dae1c 100644 --- a/src/agora_agent/agentkit/agent.py +++ b/src/agora_agent/agentkit/agent.py @@ -628,7 +628,7 @@ def with_advanced_features(self, features: AdvancedFeatures) -> "Agent": return new_agent def with_tools(self, enabled: bool = True) -> "Agent": - """Returns a new Agent with MCP tool invocation enabled or disabled.""" + """Returns a new Agent with MCP and inline tool invocation enabled or disabled.""" new_agent = self._clone() if new_agent._advanced_features is None: new_agent._advanced_features = StartAgentsRequestPropertiesAdvancedFeatures(enable_tools=enabled) diff --git a/src/agora_agent/agentkit/vendors/cn.py b/src/agora_agent/agentkit/vendors/cn.py index 410b5b0..13fe420 100644 --- a/src/agora_agent/agentkit/vendors/cn.py +++ b/src/agora_agent/agentkit/vendors/cn.py @@ -8,6 +8,7 @@ from .llm import ( _OPENAI_MANAGED_MODELS, LlmGreetingConfigs, + LlmToolInput, _dump_optional_model, _ensure_mcp_transport, ) @@ -48,6 +49,14 @@ class FengmingSTTOptions(BaseModel): keywords: Optional[List[str]] = Field(default=None, description="Hotwords that improve ASR accuracy") additional_params: Optional[Dict[str, Any]] = Field(default=None) + @model_validator(mode="after") + def _reject_nested_keywords(self) -> "FengmingSTTOptions": + if self.additional_params is not None and "keywords" in self.additional_params: + raise ValueError( + "FengmingSTT additional_params must not contain keywords; use the top-level keywords field" + ) + return self + class FengmingSTT(FengmingSTTOptions, _BaseSTTCompat): def to_config(self) -> Dict[str, Any]: @@ -515,7 +524,7 @@ class AliyunLLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default="aliyun") mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -602,7 +611,7 @@ class BytedanceLLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default="bytedance") mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -689,7 +698,7 @@ class DeepSeekLLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default="deepseek") mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -776,7 +785,7 @@ class TencentLLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default="tencent") mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") diff --git a/src/agora_agent/agentkit/vendors/llm.py b/src/agora_agent/agentkit/vendors/llm.py index 24109e3..b9ddf1d 100644 --- a/src/agora_agent/agentkit/vendors/llm.py +++ b/src/agora_agent/agentkit/vendors/llm.py @@ -1,9 +1,11 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union +from ...types.llm_tool import LlmTool from .base import BaseLLM from pydantic import BaseModel, ConfigDict, Field, model_validator LlmGreetingConfigs = Dict[str, Any] +LlmToolInput = Union[Dict[str, Any], LlmTool] _OPENAI_MANAGED_MODELS = {"gpt-4o-mini", "gpt-4.1-mini", "gpt-5-nano", "gpt-5-mini"} @@ -23,6 +25,10 @@ def _dump_optional_model(value: Any) -> Any: return value.model_dump(exclude_none=True) if hasattr(value, "dict"): return value.dict(exclude_none=True) + if isinstance(value, list): + return [_dump_optional_model(item) for item in value] + if isinstance(value, dict): + return {key: _dump_optional_model(item) for key, item in value.items()} return value @@ -47,7 +53,7 @@ class OpenAIOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -139,7 +145,7 @@ class AzureOpenAIOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @@ -216,7 +222,7 @@ class AnthropicOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @@ -288,7 +294,7 @@ class GeminiOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @@ -364,7 +370,7 @@ class GroqOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -443,7 +449,7 @@ class CustomLLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @model_validator(mode="after") @@ -526,7 +532,7 @@ class VertexAILLMOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @@ -608,7 +614,7 @@ class AmazonBedrockOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0, description="Maximum number of conversation history messages to cache") @@ -679,7 +685,7 @@ class DifyOptions(BaseModel): template_variables: Optional[Dict[str, str]] = Field(default=None) vendor: Optional[str] = Field(default=None) mcp_servers: Optional[List[Dict[str, Any]]] = Field(default=None) - tools: Optional[List[Dict[str, Any]]] = Field(default=None) + tools: Optional[List[LlmToolInput]] = Field(default=None) max_history: Optional[int] = Field(default=None, gt=0) diff --git a/src/agora_agent/agentkit/vendors/stt.py b/src/agora_agent/agentkit/vendors/stt.py index 377d1ba..736390a 100644 --- a/src/agora_agent/agentkit/vendors/stt.py +++ b/src/agora_agent/agentkit/vendors/stt.py @@ -255,6 +255,12 @@ class AresSTTOptions(BaseModel): keywords: Optional[List[str]] = Field(default=None, description="Hotwords that improve ASR accuracy") additional_params: Optional[Dict[str, Any]] = Field(default=None) + @model_validator(mode="after") + def _reject_nested_keywords(self) -> "AresSTTOptions": + if self.additional_params is not None and "keywords" in self.additional_params: + raise ValueError("AresSTT additional_params must not contain keywords; use the top-level keywords field") + return self + class AresSTT(AresSTTOptions, BaseSTT): def to_config(self) -> Dict[str, Any]: diff --git a/src/agora_agent/agents/types/start_agents_request_properties_advanced_features.py b/src/agora_agent/agents/types/start_agents_request_properties_advanced_features.py index e75f098..667e20e 100644 --- a/src/agora_agent/agents/types/start_agents_request_properties_advanced_features.py +++ b/src/agora_agent/agents/types/start_agents_request_properties_advanced_features.py @@ -29,7 +29,7 @@ class StartAgentsRequestPropertiesAdvancedFeatures(UncheckedBaseModel): enable_tools: typing.Optional[bool] = pydantic.Field(default=None) """ - Enable tool invocation. When enabled, the agent can invoke tools provided by the MCP server to implement advanced functionality. + Enable invocation for MCP servers and inline REST tools. """ if IS_PYDANTIC_V2: diff --git a/tests/custom/test_asr_keywords.py b/tests/custom/test_asr_keywords.py index f9b879d..a76c2b6 100644 --- a/tests/custom/test_asr_keywords.py +++ b/tests/custom/test_asr_keywords.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from agora_agent import AresSTT, FengmingSTT @@ -21,3 +24,9 @@ def test_fengming_keywords_are_top_level() -> None: "keywords": ["Agora"], "params": {"custom": True}, } + + +@pytest.mark.parametrize("vendor", [AresSTT, FengmingSTT]) +def test_keywords_cannot_be_duplicated_in_vendor_params(vendor: type) -> None: + with pytest.raises(ValidationError, match="use the top-level keywords field"): + vendor(additional_params={"keywords": ["Agora"]}) diff --git a/tests/custom/test_llm_tools.py b/tests/custom/test_llm_tools.py index 879c241..7fc54db 100644 --- a/tests/custom/test_llm_tools.py +++ b/tests/custom/test_llm_tools.py @@ -1,6 +1,14 @@ from test_helpers import test_client -from agora_agent import AdvancedFeatures, Agent, OpenAI +from agora_agent import ( + AdvancedFeatures, + Agent, + LlmToolConfig, + LlmToolFunctionConfig, + LlmToolFunctionParametersConfig, + LlmToolServerConfig, + OpenAI, +) from agora_agent.agentkit.vendors.cn import AliyunLLM @@ -15,6 +23,16 @@ def _tool() -> dict: } +def _typed_tool() -> LlmToolConfig: + return LlmToolConfig( + function=LlmToolFunctionConfig( + name="lookup_order", + parameters=LlmToolFunctionParametersConfig(properties={}), + ), + server=LlmToolServerConfig(method="GET", url="https://example.com/orders"), + ) + + def test_global_llm_tools_use_dict_shape_and_require_explicit_enablement() -> None: tool = _tool() vendor = OpenAI( @@ -47,6 +65,26 @@ def test_cn_llm_uses_the_same_tools_shape_as_global_llm() -> None: assert config["tools"] == [tool] +def test_global_and_cn_llms_accept_exported_typed_tools() -> None: + expected = [_tool()] + + global_config = OpenAI( + api_key="openai-key", + base_url="https://api.openai.com/v1/chat/completions", + model="gpt-4o-mini", + tools=[_typed_tool()], + ).to_config() + cn_config = AliyunLLM( + api_key="aliyun-key", + base_url="https://example.com/v1/chat/completions", + model="qwen-plus", + tools=[_typed_tool()], + ).to_config() + + assert global_config["tools"] == expected + assert cn_config["tools"] == expected + + def test_with_tools_preserves_other_advanced_features() -> None: agent = Agent( test_client(), advanced_features=AdvancedFeatures(enable_sal=True)