feat: 让Gemini原生工具和函数工具tools兼容#8418
Conversation
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- When inserting
Comp.Plain(accumulated_text)atresult_chain.chain.insert(0, ...), consider whether this can disturb the original chronological order of components (e.g., pushing earlier assistant/system content after the preface); it may be safer to attach this text as a dedicated assistant turn or at a more precise position tied to the current response. - In the
role == "tool"branch,func_name = message.get("name") or message.get("tool_call_id")can now beNoneif both keys are missing, which may causetypes.Part.from_function_responseto behave unexpectedly; you may want to enforce one of these fields or raise a clear error when neither is present.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- When inserting `Comp.Plain(accumulated_text)` at `result_chain.chain.insert(0, ...)`, consider whether this can disturb the original chronological order of components (e.g., pushing earlier assistant/system content after the preface); it may be safer to attach this text as a dedicated assistant turn or at a more precise position tied to the current response.
- In the `role == "tool"` branch, `func_name = message.get("name") or message.get("tool_call_id")` can now be `None` if both keys are missing, which may cause `types.Part.from_function_response` to behave unexpectedly; you may want to enforce one of these fields or raise a clear error when neither is present.
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="231-234" />
<code_context>
)
+ # 将自定义工具追加进 tool_list
+ if tools and (func_desc := tools.get_func_desc_google_genai_style()):
+ if tool_list is None:
+ tool_list = []
+ tool_list.append(
+ types.Tool(function_declarations=func_desc["function_declarations"])
+ )
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid assuming all `types.Tool` instances have `google_search` and `code_execution` attributes in `has_native_tool`.
Directly accessing `t.google_search` and `t.code_execution` can raise `AttributeError` for tools (including future SDK variants or mocks) that don’t define these attributes. To make this resilient, use `getattr` as you already do for `url_context`, e.g.:
```python
has_native_tool = tool_list and any(
getattr(t, "google_search", None)
or getattr(t, "code_execution", None)
or getattr(t, "url_context", None)
for t in tool_list
)
```
</issue_to_address>
### Comment 2
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="422-432" />
<code_context>
- elif not native_tool_enabled and "tool_calls" in message:
+ # 允许在开启搜索时还原工具历史
+ elif "tool_calls" in message:
parts = []
for tool in message["tool_calls"]:
part = types.Part.from_function_call(
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
+ # 还原 Assistant 历史消息里工具调用的唯一 ID
+ if "id" in tool and part.function_call:
+ part.function_call.id = tool["id"]
+
</code_context>
<issue_to_address>
**suggestion:** Guard against non-JSON `tool["function"]["arguments"]` when reconstructing historical tool calls.
With this branch now running whenever `tool_calls` is present (including older or partially migrated logs), any non‑JSON `function["arguments"]` will cause `json.loads` to raise and break reconstruction. Consider a small guard (e.g., try/except with a fallback to using the raw arguments string) so malformed historical payloads don’t crash this path.
```suggestion
# 允许在开启搜索时还原工具历史
elif "tool_calls" in message:
parts = []
for tool in message["tool_calls"]:
+ # 兼容历史或异常日志中的非 JSON arguments,避免重放工具历史时报错
+ raw_args = tool.get("function", {}).get("arguments")
+ parsed_args = None
+
+ # 如果本身就是结构化对象则直接使用
+ if isinstance(raw_args, (dict, list)):
+ parsed_args = raw_args
+ else:
+ try:
+ parsed_args = json.loads(raw_args) if raw_args is not None else None
+ except (TypeError, json.JSONDecodeError):
+ # 回退到原始字符串,保证历史记录可被重建而不中断流程
+ parsed_args = raw_args
+
part = types.Part.from_function_call(
name=tool["function"]["name"],
- args=json.loads(tool["function"]["arguments"]),
+ args=parsed_args,
)
+ # 还原 Assistant 历史消息里工具调用的唯一 ID
+ if "id" in tool and part.function_call:
+ part.function_call.id = tool["id"]
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Code Review
This pull request enhances Gemini tool integration by allowing mixed usage of native and custom tools, restoring tool call and response IDs for strict Gemini validation, and preserving accumulated stream text when tool calls interrupt the stream. The review feedback highlights several compatibility improvements, such as using getattr and hasattr to prevent AttributeError on older versions of the google-genai SDK, and suggests also preserving accumulated_reasoning when stream interruption occurs to prevent losing the model's thinking process.
1. 属性防御性获取 原代码:t.google_search 和 t.code_execution 问题:如果用户本地的 google-genai SDK 版本较老,这两个属性可能根本不存在,直接抛出 AttributeError 导致插件死锁。 改法:全部换成 getattr(t, "xxx", None)。 2. SDK 版本向下兼容 原代码:part.function_call.id = tool["id"] 问题:id 字段是谷歌新版 SDK 强校验模式才引入的。如果用户没升级 SDK,强行给对象赋 .id 属性会直接崩溃。 改法:赋值前加上 hasattr(obj, "id") 的判断。 3. 思考(Reasoning)内容的留存 核心痛点:这个建议太关键了!Gemini 3 在决定调用本地工具前,往往会先进行一长串的“思考(Reasoning)”。如果只保留了 accumulated_text 而把 accumulated_reasoning 给丢了,AI 的思维链在历史记录里就断了。 改法:在流中断返回前,把 accumulated_reasoning 也同步塞进 llm_response.reasoning_content。 4. 历史记录反序列化安全 原代码:json.loads(tool["function"]["arguments"]) 问题:AstrBot 的历史数据库里可能残留了 OpenAI 格式的日志,或者某些插件传了已经解析好的 dict。直接 json.loads 如果报错会毁掉整条多轮对话。 改法:加上 try-except 和 isinstance 类型检查。
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="230-234" />
<code_context>
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
+ # 将自定义工具追加进 tool_list
+ if tools and (func_desc := tools.get_func_desc_google_genai_style()):
+ if tool_list is None:
+ tool_list = []
+ tool_list.append(
+ types.Tool(function_declarations=func_desc["function_declarations"])
+ )
</code_context>
<issue_to_address>
**issue (bug_risk):** Custom tools are appended twice to tool_list in the same function path.
An earlier block now always appends custom tools to `tool_list` when `tools` is set, and step 3 repeats the same condition and appends them again except for pre‑Gemini‑3 models with native tools. For Gemini‑3+ or when there are no native tools, this causes duplicate function declarations in `tool_list`. To keep a single, centralized append, remove this earlier block and rely on the later guarded one.
</issue_to_address>
### Comment 2
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="471-480" />
<code_context>
parts = []
for tool in message["tool_calls"]:
+ # 兼容历史或异常日志中的非 JSON arguments,避免重放工具历史时报错
+ raw_args = tool.get("function", {}).get("arguments")
+ parsed_args = None
+ if isinstance(raw_args, (dict, list)):
+ parsed_args = raw_args
+ else:
+ try:
+ parsed_args = (
+ json.loads(raw_args)
+ if raw_args is not None
+ else None
+ )
+ except (TypeError, json.JSONDecodeError):
+ parsed_args = raw_args
+
</code_context>
<issue_to_address>
**suggestion:** Silently accepting non-JSON or undecodable arguments may propagate bad data into function calls.
If `raw_args` is malformed JSON, it ends up being passed through as a raw string in `args`. Depending on how `types.Part.from_function_call` handles this, it may cause confusing downstream behavior. Consider either logging a clear warning on JSON decode failure (including tool name/id) or normalizing the value (e.g., defaulting to `{}`) so callers don’t need to handle unexpected types.
Suggested implementation:
```python
) and "tool_calls" in message:
parts = []
for tool in message["tool_calls"]:
# 兼容历史或异常日志中的非 JSON arguments,避免重放工具历史时报错
raw_args = tool.get("function", {}).get("arguments")
parsed_args = None
if isinstance(raw_args, (dict, list)):
parsed_args = raw_args
else:
try:
parsed_args = (
json.loads(raw_args)
if raw_args is not None
else None
)
except (TypeError, json.JSONDecodeError):
# 当 arguments 不是合法 JSON 时,记录告警并回退为 {}
tool_name = tool.get("function", {}).get("name")
tool_id = tool.get("id")
logger.warning(
"Gemini tool_call arguments JSON decode failed, "
"tool_name=%r, tool_id=%r, raw_args=%r; "
"falling back to empty dict.",
tool_name,
tool_id,
raw_args,
)
parsed_args = {}
# TODO: 使用 parsed_args 构造对应的 Part / function_call,
# 例如:
# parts.append(
# types.Part.from_function_call(
# name=tool.get("function", {}).get("name"),
# args=parsed_args or {},
# )
# )
# 将自定义工具追加进 tool_list
```
1. Ensure `json` and a module-level `logger` (e.g. from `logging.getLogger(__name__)` or the project's logging utility) are already imported/defined in `gemini_source.py`. If not, add:
- `import json`
- `import logging` and `logger = logging.getLogger(__name__)`, or adapt to your existing logging helper.
2. Replace the commented `TODO` block with the actual logic currently used in this file to build `types.Part` / function calls from `tool` data, wiring `parsed_args` into that call (likely replacing the previous usage of `raw_args`).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="230-234" />
<code_context>
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
+ # 将自定义工具追加进 tool_list
+ if tools and (func_desc := tools.get_func_desc_google_genai_style()):
+ if tool_list is None:
+ tool_list = []
+ tool_list.append(
+ types.Tool(function_declarations=func_desc["function_declarations"])
+ )
</code_context>
<issue_to_address>
**issue (bug_risk):** Gemini 3+ 自定义工具逻辑在前后两个代码块中重复执行,可能导致函数工具被追加两次。
当前在 `_prepare_query_config` 中,你先在这里根据 `tools.get_func_desc_google_genai_style()` 追加了一次自定义工具,后面“3. 追加自定义工具的逻辑(全文件仅保留这一处)”中在相同条件下又追加了一次。如果这两处都被执行,会产生重复的 `types.Tool`,导致函数重复暴露或工具选择异常。建议删除这里的追加逻辑,仅保留后面带 `is_gemini_3_or_later`/`has_native_before` 判定的那段,确保函数工具只在一个路径下追加。
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The
_supports_multi_toolcheck relies on'gemini-1'/'gemini-2'substrings, which may miss legacy models likemodels/gemini-pro; consider normalizing the model name and matching against all known 1.x/2.x identifiers (or using a configurable allowlist) to avoid accidentally enabling multi-tool on unsupported models. - When filtering
tool_id/tool_call_idby comparing them tofunc_name, you assume all locally generated IDs equal the function name; if the backend ever legitimately uses the function name as an ID this will strip real IDs, so it may be safer to tag local IDs at creation time (e.g., a prefix) and filter on that marker instead.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `_supports_multi_tool` check relies on `'gemini-1'` / `'gemini-2'` substrings, which may miss legacy models like `models/gemini-pro`; consider normalizing the model name and matching against all known 1.x/2.x identifiers (or using a configurable allowlist) to avoid accidentally enabling multi-tool on unsupported models.
- When filtering `tool_id` / `tool_call_id` by comparing them to `func_name`, you assume all locally generated IDs equal the function name; if the backend ever legitimately uses the function name as an ID this will strip real IDs, so it may be safer to tag local IDs at creation time (e.g., a prefix) and filter on that marker instead.
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="238" />
<code_context>
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
+ supports_multi_tool = self._supports_multi_tool(model_name)
+
+ if tools and (func_desc := tools.get_func_desc_google_genai_style()):
</code_context>
<issue_to_address>
**issue (complexity):** Consider flattening the multi-tool handling logic, making tool_config construction more explicit, and simplifying native_tool_enabled computation to reduce branching and mental overhead.
You can keep the new behavior but simplify some of the branching to reduce mental overhead.
### 1. Flatten multi-tool + plugin tool list construction
You can separate capability checks from list construction to avoid nested `if`/`else` and repeated `tool_list` fiddling:
```python
supports_multi_tool = self._supports_multi_tool(model_name)
func_desc = tools.get_func_desc_google_genai_style() if tools else None
has_native_tools = bool(tool_list)
has_plugin_tools = bool(func_desc)
if has_plugin_tools:
if has_native_tools and not supports_multi_tool:
logger.warning(
f"模型 {model_name} 不支持多工具混合编排。已启用原生工具,函数工具(本地插件)将被忽略。"
)
else:
if tool_list is None:
tool_list = []
tool_list.append(
types.Tool(function_declarations=func_desc["function_declarations"])
)
```
This keeps all behavior but flattens the conditions and makes the combinations “native vs plugin vs capability” easier to read.
### 2. Make `tool_config` construction explicit
The `kwargs_tool_config` dict is only used to add one optional field. You can keep the same behavior with clearer, positional construction:
```python
has_func_decl = tool_list and any(t.function_declarations for t in tool_list)
tool_config = None
if has_func_decl:
has_builtin_tools = any(
getattr(t, "google_search", None)
or getattr(t, "code_execution", None)
or getattr(t, "url_context", None)
for t in tool_list
)
fc_config = types.FunctionCallingConfig(
mode=(
types.FunctionCallingConfigMode.ANY
if tool_choice == "required"
else types.FunctionCallingConfigMode.AUTO
)
)
if supports_multi_tool and has_builtin_tools:
tool_config = types.ToolConfig(
function_calling_config=fc_config,
include_server_side_tool_invocations=True,
)
else:
tool_config = types.ToolConfig(function_calling_config=fc_config)
```
This removes the indirection of `kwargs_tool_config` while keeping all the new flags.
### 3. Simplify `native_tool_enabled` derivation
The relationship between `supports_multi_tool` and `native_tool_enabled` can be expressed as a single expression:
```python
model_name = cast(str, payloads.get("model", self.get_model()))
supports_multi_tool = self._supports_multi_tool(model_name)
native_tool_enabled = (
not supports_multi_tool
and (
self.provider_config.get("gm_native_coderunner", False)
or self.provider_config.get("gm_native_search", False)
)
)
```
This avoids the temporary `False` assignment and the subsequent `if` block, while preserving behavior and making later `if native_tool_enabled` checks easier to reason about.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| "当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包", | ||
| ) | ||
|
|
||
| supports_multi_tool = self._supports_multi_tool(model_name) |
There was a problem hiding this comment.
issue (complexity): Consider flattening the multi-tool handling logic, making tool_config construction more explicit, and simplifying native_tool_enabled computation to reduce branching and mental overhead.
You can keep the new behavior but simplify some of the branching to reduce mental overhead.
1. Flatten multi-tool + plugin tool list construction
You can separate capability checks from list construction to avoid nested if/else and repeated tool_list fiddling:
supports_multi_tool = self._supports_multi_tool(model_name)
func_desc = tools.get_func_desc_google_genai_style() if tools else None
has_native_tools = bool(tool_list)
has_plugin_tools = bool(func_desc)
if has_plugin_tools:
if has_native_tools and not supports_multi_tool:
logger.warning(
f"模型 {model_name} 不支持多工具混合编排。已启用原生工具,函数工具(本地插件)将被忽略。"
)
else:
if tool_list is None:
tool_list = []
tool_list.append(
types.Tool(function_declarations=func_desc["function_declarations"])
)This keeps all behavior but flattens the conditions and makes the combinations “native vs plugin vs capability” easier to read.
2. Make tool_config construction explicit
The kwargs_tool_config dict is only used to add one optional field. You can keep the same behavior with clearer, positional construction:
has_func_decl = tool_list and any(t.function_declarations for t in tool_list)
tool_config = None
if has_func_decl:
has_builtin_tools = any(
getattr(t, "google_search", None)
or getattr(t, "code_execution", None)
or getattr(t, "url_context", None)
for t in tool_list
)
fc_config = types.FunctionCallingConfig(
mode=(
types.FunctionCallingConfigMode.ANY
if tool_choice == "required"
else types.FunctionCallingConfigMode.AUTO
)
)
if supports_multi_tool and has_builtin_tools:
tool_config = types.ToolConfig(
function_calling_config=fc_config,
include_server_side_tool_invocations=True,
)
else:
tool_config = types.ToolConfig(function_calling_config=fc_config)This removes the indirection of kwargs_tool_config while keeping all the new flags.
3. Simplify native_tool_enabled derivation
The relationship between supports_multi_tool and native_tool_enabled can be expressed as a single expression:
model_name = cast(str, payloads.get("model", self.get_model()))
supports_multi_tool = self._supports_multi_tool(model_name)
native_tool_enabled = (
not supports_multi_tool
and (
self.provider_config.get("gm_native_coderunner", False)
or self.provider_config.get("gm_native_search", False)
)
)This avoids the temporary False assignment and the subsequent if block, while preserving behavior and making later if native_tool_enabled checks easier to reason about.
#8417 支持多工具混合编排与旧模型兼容,修复工具历史解析死循环
变更背景 (Description)
在当前的 Gemini 提供商适配器中,存在以下几个亟待解决的痛点:
include_server_side_tool_invocations = True。然而,如果在 Gemini 1.x 或 2.x(包括gemini-2.5-flash等)等旧模型上启用该参数,API 会直接抛出400 INVALID_ARGUMENT错误。_prepare_conversation中,解析assistant历史消息时采用了if-elif链。如果content是字符串(例如即使是空字符串""),解析器会直接跳过tool_calls的处理,导致工具链历史丢失,模型产生无限工具调用死循环。google-genaiPython SDK 中的助手方法Part.from_function_response不支持id参数(传参会引发TypeError),而如果不绑定正确的tool_call_id会导致历史关联断裂。此外,本地引擎产生的伪 ID(如与函数名相同的占位符)直接传回 API 也会触发400错误。本 PR 对上述问题进行了集中重构,在完美支持 Gemini 3+ 多工具流通的同时,向下兼容所有 legacy 模型,并彻底消除了工具链调用的死循环 Bug。
Modifications / 改动点
1. 引入版本感知的能力检测器 (Capability Detection)
_supports_multi_tool(self, model_name: str) -> bool方法。"gemini-1"和"gemini-2",将所有 legacy 模型(包括 2.5 系列)安全识别为旧版,从而仅对 Gemini 3.0 及未来更新的模型启用多工具混合编排及include_server_side_tool_invocations关联参数。旧模型则优雅降级为原有的互斥逻辑,杜绝 400 报错。2. 解耦并重构 Assistant 历史解析逻辑
_prepare_conversation中对assistant角色消息的处理逻辑。content文本/思维链字段的提取与tool_calls的解析完全解耦,确保即使存在文本,历史中的工具调用也绝不会被丢弃,彻底根治了工具调用的死循环问题。3. 精确对齐 Tool ID 并引入健壮性过滤
绕过 SDK 限制:不再使用
Part.from_function_response,而是通过直接实例化 Pydantic 模型(types.Part(function_response=types.FunctionResponse(...))与types.FunctionCall(...))的方法,安全地传递和还原id属性。ID 过滤:增加了
tool_id != func_name校验,自动过滤掉本地引擎生成的临时占位 ID,仅向 Gemini 服务器传递真实产生的 server-side ID,确保 API 调用百分百合规。This is NOT a breaking change. / 这不是一个破坏性变更。
Screenshots or Test Results / 运行截图或测试结果
Gemini3系列同时调用grounding google search和astrbot内置的tools未出现问题,.

Gemini2系列如果开启原生工具, 也可以正常屏蔽掉tools.

Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Add version-aware Gemini tool orchestration to support multi-tool workflows on Gemini 3+ while remaining compatible with legacy models and fixing tool history handling.
New Features:
Bug Fixes:
Enhancements: