Skip to content

fix: handle tool-incompatible models before agent execution#7164

Open
bugkeep wants to merge 4 commits intoAstrBotDevs:masterfrom
bugkeep:fix/tool-capability-routing-6857
Open

fix: handle tool-incompatible models before agent execution#7164
bugkeep wants to merge 4 commits intoAstrBotDevs:masterfrom
bugkeep:fix/tool-capability-routing-6857

Conversation

@bugkeep
Copy link
Copy Markdown

@bugkeep bugkeep commented Mar 30, 2026

Motivation / 动机

This PR fixes the local agent flow for models that do not support function/tool calling.

Previously, a tool-incompatible model could still enter the local agent pipeline, and the provider layer would silently remove tools and retry as plain text chat. That made the behavior inconsistent and could amplify follow-up handling unexpectedly.

This change makes the routing explicit before agent execution, so unsupported models are handled in a predictable way.

Closes #6857

Modifications / 改动点

  • 在主 Agent 构建阶段新增了基于模型能力的工具路由逻辑,不再等到 Provider 请求失败后再被动移除 tools
  • 新增 tool_capability_strategy 配置项,用于处理“当前模型不支持函数工具调用”的场景,支持三种策略:
    • fallback_provider:自动切换到 fallback_chat_models 中支持工具调用的模型
    • chat_only:降级为纯文本聊天模式
    • hard_fail:直接返回错误
  • 当策略为 fallback_provider 时,如果主模型不支持工具调用,会优先切换到兼容的回退模型继续执行。
  • 当策略为 chat_only 时,会清理请求中的工具状态和工具相关上下文,避免旧的工具调用信息继续污染后续对话。
  • chat_only 模式下,不再注册 active follow-up runner,避免重复消息或短时追问被继续放大为 follow-up 处理。
  • 新增 UnsupportedToolCapabilityError,用于 hard_fail 路径下返回更明确的错误类型。
  • 在配置默认值和配置元数据中接入 tool_capability_strategy,使该策略可以通过配置项直接使用。
  • 补充了针对三种策略行为的测试用例,覆盖:
    • 自动切换回退模型
    • 降级为纯文本聊天
    • 直接报错

核心修改文件:

  • astrbot/core/astr_main_agent.py

  • astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

  • astrbot/core/config/default.py

  • astrbot/core/exceptions.py

  • tests/test_astr_main_agent_tool_capability.py

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

Verification Steps:
image
image
image


Checklist / 检查清单

  • [ x ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • [ x ] 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • [ x ] 🤓 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.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • [ x ] 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Handle models that do not support tool calls earlier in the local agent pipeline by routing them according to a configurable strategy instead of silently stripping tools at the provider layer.

New Features:

  • Introduce a configurable tool_capability_strategy to control behavior when the selected model does not support tool calls, supporting fallback_provider, chat_only, and hard_fail modes.

Enhancements:

  • Resolve tool-call capability and select a suitable fallback provider or degrade to chat-only before main agent execution, keeping conversation context consistent and avoiding unintended tool-related behavior.
  • Expose tool_capability_strategy in default configuration and provider templates so it can be set per chat provider for local agents.
  • Prevent follow-up runners from being registered when degrading to chat-only mode for tool-incompatible models to avoid unnecessary follow-up amplification.

Tests:

  • Add unit tests covering fallback to a tool-capable provider, chat-only degradation with tool state/context cleanup, and hard-fail behavior for unsupported tool capability.

@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. area:core The bug / feature is about astrbot's core, backend area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Mar 30, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • IDE-specific .vs/* JSON files were added to the repo; these should be removed from the PR and ignored via .gitignore so they don’t pollute the project with local workspace state.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- IDE-specific `.vs/*` JSON files were added to the repo; these should be removed from the PR and ignored via `.gitignore` so they don’t pollute the project with local workspace state.

## Individual Comments

### Comment 1
<location path="astrbot/core/astr_main_agent.py" line_range="799-802" />
<code_context>
+    if not model_name:
+        return None
+
+    metadata = LLM_METADATAS.get(model_name)
+    if metadata is None:
+        return None
+    return bool(metadata["tool_call"])
+
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against missing `tool_call` key in `LLM_METADATAS` entries to avoid `KeyError`.

Using `metadata["tool_call"]` will raise a `KeyError` if that key is missing for any model. Prefer `metadata.get("tool_call")` (with a default like `None` or `False`) before casting to `bool`, so the behavior matches the `metadata is None` case instead of breaking the flow.
</issue_to_address>

### Comment 2
<location path="tests/test_astr_main_agent_tool_capability.py" line_range="56-57" />
<code_context>
+        return self.providers.get(provider_id)
+
+
+def _make_metadata(tool_call: bool) -> dict[str, Any]:
+    return {
+        "id": "test-model",
+        "reasoning": False,
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test for models with unknown tool capability metadata to cover the `None` branch in `_get_tool_call_support`.

Currently helpers always set an explicit `tool_call` flag in `LLM_METADATAS`, so tests only hit `_get_tool_call_support` for `True`/`False`. We should also cover the case where the model has no metadata entry (or missing metadata despite `modalities` allowing tools), where `_get_tool_call_support` returns `None`.

Please add a test that:
- uses a provider with `modalities=["text", "tool_use"]`,
- omits the model from `LLM_METADATAS` (or deletes its entry),
- calls `_resolve_tool_capability_strategy` with tools.

This will assert that `None` is treated as "unknown but allowed", the provider is kept, and `allow_follow_up=True`, guarding against regressions when metadata is incomplete.

Suggested implementation:

```python
def _make_metadata(
    tool_call: bool,
    *,
    input_modalities: list[str] | None = None,
    output_modalities: list[str] | None = None,
    model_id: str = "test-model",
) -> dict[str, Any]:
    """
    Helper to construct LLM metadata for tests.

    Existing callers can keep using the `tool_call` positional argument only.
    New tests can override modalities and model id as needed.
    """
    if input_modalities is None:
        input_modalities = ["text"]
    if output_modalities is None:
        output_modalities = ["text"]

    return {
        "id": model_id,
        "reasoning": False,
        "tool_call": tool_call,
        "knowledge": "none",
        "release_date": "",
        "modalities": {"input": input_modalities, "output": output_modalities},
        "open_weights": False,
        "limit": {"context": 0, "output": 0},
    }


def test_unknown_tool_capability_metadata_allows_follow_up_and_keeps_provider(
    monkeypatch: "pytest.MonkeyPatch",
) -> None:
    """
    Models with tool-use modality but no explicit tool_call metadata should be treated
    as "unknown but allowed": the provider is kept and allow_follow_up=True.

    This guards the `_get_tool_call_support` branch that returns `None`.
    """
    # Import from the module under test; adjust the import path if different.
    from astr.main.agent.tool_capability import (
        _resolve_tool_capability_strategy,
        LLM_METADATAS,
    )

    # Use a model id that is *not* present in LLM_METADATAS
    model_id = "test-model-unknown-metadata"

    # Ensure the metadata entry is absent, even if other tests added it.
    LLM_METADATAS.pop(model_id, None)

    # Build a provider whose model supports tool_use in its modalities.
    # Reuse whatever Provider/Model construction helpers exist in this test module
    # or the production code; the important part is the `modalities=["text", "tool_use"]`.
    provider_id = "provider-with-tool-modalities"

    # NOTE: The following provider construction assumes a simple Provider/Model API.
    # If your actual Provider/Model types differ, adapt this block accordingly.
    from astr.main.provider import Provider, Model  # adjust import if needed

    provider = Provider(
        id=provider_id,
        models=[
            Model(
                id=model_id,
                modalities=["text", "tool_use"],
            )
        ],
    )

    plugin_context = DummyPluginContext(providers={provider_id: provider})

    tools = _make_tool_set()

    strategy = _resolve_tool_capability_strategy(
        plugin_context=plugin_context,
        model_id=model_id,
        tools=tools,
    )

    # When metadata is unknown but tools are allowed by modality:
    # - provider should still be considered
    # - follow-up tool calls should be allowed
    assert provider_id in strategy.providers
    assert strategy.allow_follow_up is True


def _make_tool_set() -> ToolSet:

```

To make this compile and pass with your actual codebase, you will likely need to:

1. **Adjust imports in the test file**:
   - Ensure `pytest` is imported if not already: `import pytest`.
   - Fix the import path for `_resolve_tool_capability_strategy` and `LLM_METADATAS` if they live in a different module than `astr.main.agent.tool_capability`.
   - Replace `from astr.main.provider import Provider, Model` with the correct import(s) for your Provider/Model types, or swap in the appropriate factory/helper already used elsewhere in this test file.

2. **Match your Provider/Model API**:
   - If `Provider` or `Model` constructors differ (e.g., additional required fields, nested capabilities object instead of a flat `modalities` list), update the `provider = Provider(...)` block to:
     - Set `modalities=["text", "tool_use"]` (or the equivalent capabilities structure) for the model.
     - Satisfy any other required arguments (e.g., `name`, `metadata`, `pricing`, etc.).

3. **Align `_resolve_tool_capability_strategy` call signature**:
   - If the function expects different parameter names or additional arguments (e.g., `provider_filters`, `tool_choice`), add them to the call in the test using values consistent with your existing tests.

4. **Align `strategy` shape**:
   - If the object returned by `_resolve_tool_capability_strategy` does not expose `.providers` or `.allow_follow_up` directly, adapt the assertions to match your actual structure (e.g., `strategy.providers_by_id`, `strategy.allow_follow_up_tool_calls`).

5. **Optional: if you don't use `Provider`/`Model` directly in tests**:
   - If existing tests construct providers via helpers (e.g., `_make_provider(...)`), prefer reusing those helpers here to ensure consistent setup, while still configuring the model's modalities to include `"tool_use"` and omitting its metadata from `LLM_METADATAS`.

Once these adjustments are made to fit your real types and imports, this test will exercise the `None` branch in `_get_tool_call_support` and verify that unknown but allowed tool capability metadata behaves as intended.
</issue_to_address>

### Comment 3
<location path="tests/test_astr_main_agent_tool_capability.py" line_range="158-178" />
<code_context>
+    ]
+
+
+def test_tool_capability_strategy_hard_fail_raises(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    primary = DummyProvider("primary", "deepseek-r1:7b", ["text", "tool_use"])
+    plugin_context = DummyPluginContext({"primary": primary})
+    req = ProviderRequest(func_tool=_make_tool_set())
+    config = MainAgentBuildConfig(
+        tool_call_timeout=30,
+        tool_capability_strategy="hard_fail",
+        provider_settings={},
+    )
+
+    monkeypatch.setitem(LLM_METADATAS, "deepseek-r1:7b", _make_metadata(False))
+
+    with pytest.raises(UnsupportedToolCapabilityError):
+        _resolve_tool_capability_strategy(
+            provider=primary,
+            req=req,
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen the `hard_fail` test to assert that tool state is not mutated when an `UnsupportedToolCapabilityError` is raised.

Right now this test only checks that `UnsupportedToolCapabilityError` is raised. It would be helpful to also assert that `_resolve_tool_capability_strategy` does not mutate `req` before raising—for example, that `req.func_tool` remains set and `req.contexts` is unchanged after the `with pytest.raises(...)` block. That documents that `hard_fail` performs pure validation rather than mutating or downgrading tool state.

```suggestion
def test_tool_capability_strategy_hard_fail_raises(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    primary = DummyProvider("primary", "deepseek-r1:7b", ["text", "tool_use"])
    plugin_context = DummyPluginContext({"primary": primary})
    req = ProviderRequest(func_tool=_make_tool_set())
    config = MainAgentBuildConfig(
        tool_call_timeout=30,
        tool_capability_strategy="hard_fail",
        provider_settings={},
    )

    # Capture initial tool and context state to ensure they are not mutated
    original_func_tool = req.func_tool
    original_contexts = list(req.contexts)

    monkeypatch.setitem(LLM_METADATAS, "deepseek-r1:7b", _make_metadata(False))

    with pytest.raises(UnsupportedToolCapabilityError):
        _resolve_tool_capability_strategy(
            provider=primary,
            req=req,
            plugin_context=plugin_context,
            config=config,
        )

    # Hard-fail strategy should not mutate tool or context state
    assert req.func_tool is original_func_tool
    assert req.contexts == original_contexts
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +799 to +802
metadata = LLM_METADATAS.get(model_name)
if metadata is None:
return None
return bool(metadata["tool_call"])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Guard against missing tool_call key in LLM_METADATAS entries to avoid KeyError.

Using metadata["tool_call"] will raise a KeyError if that key is missing for any model. Prefer metadata.get("tool_call") (with a default like None or False) before casting to bool, so the behavior matches the metadata is None case instead of breaking the flow.

Comment on lines +56 to +57
def _make_metadata(tool_call: bool) -> dict[str, Any]:
return {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for models with unknown tool capability metadata to cover the None branch in _get_tool_call_support.

Currently helpers always set an explicit tool_call flag in LLM_METADATAS, so tests only hit _get_tool_call_support for True/False. We should also cover the case where the model has no metadata entry (or missing metadata despite modalities allowing tools), where _get_tool_call_support returns None.

Please add a test that:

  • uses a provider with modalities=["text", "tool_use"],
  • omits the model from LLM_METADATAS (or deletes its entry),
  • calls _resolve_tool_capability_strategy with tools.

This will assert that None is treated as "unknown but allowed", the provider is kept, and allow_follow_up=True, guarding against regressions when metadata is incomplete.

Suggested implementation:

def _make_metadata(
    tool_call: bool,
    *,
    input_modalities: list[str] | None = None,
    output_modalities: list[str] | None = None,
    model_id: str = "test-model",
) -> dict[str, Any]:
    """
    Helper to construct LLM metadata for tests.

    Existing callers can keep using the `tool_call` positional argument only.
    New tests can override modalities and model id as needed.
    """
    if input_modalities is None:
        input_modalities = ["text"]
    if output_modalities is None:
        output_modalities = ["text"]

    return {
        "id": model_id,
        "reasoning": False,
        "tool_call": tool_call,
        "knowledge": "none",
        "release_date": "",
        "modalities": {"input": input_modalities, "output": output_modalities},
        "open_weights": False,
        "limit": {"context": 0, "output": 0},
    }


def test_unknown_tool_capability_metadata_allows_follow_up_and_keeps_provider(
    monkeypatch: "pytest.MonkeyPatch",
) -> None:
    """
    Models with tool-use modality but no explicit tool_call metadata should be treated
    as "unknown but allowed": the provider is kept and allow_follow_up=True.

    This guards the `_get_tool_call_support` branch that returns `None`.
    """
    # Import from the module under test; adjust the import path if different.
    from astr.main.agent.tool_capability import (
        _resolve_tool_capability_strategy,
        LLM_METADATAS,
    )

    # Use a model id that is *not* present in LLM_METADATAS
    model_id = "test-model-unknown-metadata"

    # Ensure the metadata entry is absent, even if other tests added it.
    LLM_METADATAS.pop(model_id, None)

    # Build a provider whose model supports tool_use in its modalities.
    # Reuse whatever Provider/Model construction helpers exist in this test module
    # or the production code; the important part is the `modalities=["text", "tool_use"]`.
    provider_id = "provider-with-tool-modalities"

    # NOTE: The following provider construction assumes a simple Provider/Model API.
    # If your actual Provider/Model types differ, adapt this block accordingly.
    from astr.main.provider import Provider, Model  # adjust import if needed

    provider = Provider(
        id=provider_id,
        models=[
            Model(
                id=model_id,
                modalities=["text", "tool_use"],
            )
        ],
    )

    plugin_context = DummyPluginContext(providers={provider_id: provider})

    tools = _make_tool_set()

    strategy = _resolve_tool_capability_strategy(
        plugin_context=plugin_context,
        model_id=model_id,
        tools=tools,
    )

    # When metadata is unknown but tools are allowed by modality:
    # - provider should still be considered
    # - follow-up tool calls should be allowed
    assert provider_id in strategy.providers
    assert strategy.allow_follow_up is True


def _make_tool_set() -> ToolSet:

To make this compile and pass with your actual codebase, you will likely need to:

  1. Adjust imports in the test file:

    • Ensure pytest is imported if not already: import pytest.
    • Fix the import path for _resolve_tool_capability_strategy and LLM_METADATAS if they live in a different module than astr.main.agent.tool_capability.
    • Replace from astr.main.provider import Provider, Model with the correct import(s) for your Provider/Model types, or swap in the appropriate factory/helper already used elsewhere in this test file.
  2. Match your Provider/Model API:

    • If Provider or Model constructors differ (e.g., additional required fields, nested capabilities object instead of a flat modalities list), update the provider = Provider(...) block to:
      • Set modalities=["text", "tool_use"] (or the equivalent capabilities structure) for the model.
      • Satisfy any other required arguments (e.g., name, metadata, pricing, etc.).
  3. Align _resolve_tool_capability_strategy call signature:

    • If the function expects different parameter names or additional arguments (e.g., provider_filters, tool_choice), add them to the call in the test using values consistent with your existing tests.
  4. Align strategy shape:

    • If the object returned by _resolve_tool_capability_strategy does not expose .providers or .allow_follow_up directly, adapt the assertions to match your actual structure (e.g., strategy.providers_by_id, strategy.allow_follow_up_tool_calls).
  5. Optional: if you don't use Provider/Model directly in tests:

    • If existing tests construct providers via helpers (e.g., _make_provider(...)), prefer reusing those helpers here to ensure consistent setup, while still configuring the model's modalities to include "tool_use" and omitting its metadata from LLM_METADATAS.

Once these adjustments are made to fit your real types and imports, this test will exercise the None branch in _get_tool_call_support and verify that unknown but allowed tool capability metadata behaves as intended.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a tool capability strategy to handle models that do not support tool calls, allowing for fallback providers, chat-only degradation, or hard failures. The implementation includes configuration updates, request sanitization, and new exception handling. Feedback suggests using an Enum for strategy names to improve maintainability and adding a test case for scenarios where fallback providers also lack tool support.

Comment on lines +836 to +837
if strategy in {"fallback_provider", "chat_only", "hard_fail"}:
return strategy
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve maintainability, consider defining the valid tool capability strategy names as constants or an Enum in a shared location, rather than hardcoding them as a set here. This would prevent inconsistencies, as these names are also used in astrbot/core/config/default.py for the configuration schema.

For example, you could define an Enum:

from enum import Enum

class ToolCapabilityStrategy(str, Enum):
    FALLBACK_PROVIDER = "fallback_provider"
    CHAT_ONLY = "chat_only"
    HARD_FAIL = "hard_fail"

VALID_TOOL_CAPABILITY_STRATEGIES = {s.value for s in ToolCapabilityStrategy}

And then use VALID_TOOL_CAPABILITY_STRATEGIES for validation. This would make the code more robust and easier to update.

req=req,
plugin_context=plugin_context,
config=config,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current tests cover the main paths for the three strategies. It would be beneficial to also test the edge case for the fallback_provider strategy where no suitable fallback provider (i.e., one that supports tool calls) is found. In this scenario, the logic should gracefully degrade to the chat_only strategy.

Adding a test case for this scenario would improve test coverage and ensure the robustness of the fallback mechanism.

Here is a suggested test case:

def test_tool_capability_strategy_fallback_degrades_to_chat_only(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """
    Tests that when 'fallback_provider' strategy is used but no suitable
    fallback is found, it degrades to 'chat_only' behavior.
    """
    primary = DummyProvider("primary", "model-no-tools", ["text", "tool_use"])
    # A fallback provider that also does not support tool calls
    fallback = DummyProvider("fallback", "fallback-no-tools", ["text", "tool_use"])
    plugin_context = DummyPluginContext(
        {
            "primary": primary,
            "fallback": fallback,
        }
    )
    req = ProviderRequest(
        func_tool=_make_tool_set(),
        model="model-no-tools",
        contexts=[
            {"role": "assistant", "tool_calls": [{"id": "call_1"}], "content": ""},
            {"role": "tool", "content": "tool output"},
        ],
    )
    config = MainAgentBuildConfig(
        tool_call_timeout=30,
        tool_capability_strategy="fallback_provider",
        provider_settings={"fallback_chat_models": ["fallback"]},
    )

    monkeypatch.setitem(LLM_METADATAS, "model-no-tools", _make_metadata(False))
    monkeypatch.setitem(LLM_METADATAS, "fallback-no-tools", _make_metadata(False))

    selected_provider, allow_follow_up = _resolve_tool_capability_strategy(
        provider=primary,
        req=req,
        plugin_context=plugin_context,
        config=config,
    )

    assert selected_provider is primary, "Provider should not change"
    assert allow_follow_up is False, "Follow-up should be disabled"
    assert req.func_tool is None, "Tool set should be cleared"
    assert req.contexts == [], "Tool-related context should be stripped"

@dosubot
Copy link
Copy Markdown

dosubot bot commented Mar 30, 2026

Related Documentation

1 document(s) may need updating based on files changed in this PR:

AstrBotTeam's Space

pr4697的改动
View Suggested Changes
@@ -2105,7 +2105,133 @@
 
 ---
 
-### 16. 其他优化
+### 16. 工具不兼容模型处理增强(PR #7164)
+
+#### 功能说明
+[PR #7164](https://github.com/AstrBotDevs/AstrBot/pull/7164) 修复了工具不兼容模型可能进入本地 Agent 执行流程的问题,防止提供商层静默移除工具并重试为纯文本聊天。该修复在 Agent 执行前显式检查模型工具能力,确保对不支持工具调用的模型采取可预测的处理策略。
+
+#### 新增配置选项:tool_capability_strategy
+
+新增 `tool_capability_strategy` 配置项,用于控制当所选模型不支持工具调用时的系统行为:
+
+- **配置路径**:`provider_settings.tool_capability_strategy`
+- **可选值**:
+  - `fallback_provider`(默认):自动切换到支持工具调用的备用提供商
+  - `chat_only`:降级为纯文本聊天模式,清除请求中的工具状态
+  - `hard_fail`:抛出 `UnsupportedToolCapabilityError` 异常
+- **默认行为**:系统默认尝试切换到备用提供商,若无可用的备用提供商,则自动降级为 `chat_only` 模式
+
+#### 核心改进
+
+##### 1. 工具能力检测机制
+新增 `_resolve_tool_capability_strategy()` 函数,在 Agent 执行前检查模型工具能力并应用配置的策略:
+
+- **检测依据**:
+  - 提供商配置的 `modalities` 字段(检查是否包含 `tool_use`)
+  - LLM 元数据(`LLM_METADATAS`)中的 `tool_call` 字段
+- **检测时机**:在主 Agent 构建阶段,工具加载和去重逻辑之后,模型请求发送之前
+- **优先级**:优先检查提供商 `modalities` 配置,再查询 LLM 元数据
+
+##### 2. 工具能力查询(_get_tool_call_support)
+新增 `_get_tool_call_support()` 函数,用于判断模型是否支持工具调用:
+
+- **返回值**:
+  - `True`:明确支持工具调用
+  - `False`:明确不支持工具调用
+  - `None`:无法确定(元数据中无该模型信息)
+- **查询逻辑**:
+  1. 检查提供商配置的 `modalities` 字段,如果未包含 `tool_use` 则返回 `False`
+  2. 从 LLM 元数据中查询模型的 `tool_call` 字段
+  3. 如果元数据中无该模型信息,返回 `None`
+
+##### 3. 工具状态清理(_strip_tool_state_from_request)
+新增 `_strip_tool_state_from_request()` 函数,用于在 `chat_only` 模式下安全清理请求中的工具相关状态:
+
+- **清理内容**:
+  - 移除 `func_tool` 和 `tool_calls_result` 字段
+  - 从上下文中移除 `role="tool"` 的消息
+  - 移除 assistant 消息中的 `tool_calls` 和 `tool_call_id` 字段
+  - 移除内容为空的 assistant 消息
+- **上下文净化**:确保对话历史中不包含工具调用相关的中间状态,避免后续请求出现格式错误
+
+##### 4. 新增异常类型(UnsupportedToolCapabilityError)
+在 `astrbot/core/exceptions.py` 中新增 `UnsupportedToolCapabilityError` 异常类型,用于 `hard_fail` 策略:
+
+```python
+class UnsupportedToolCapabilityError(AstrBotError):
+    """Raised when the selected model cannot satisfy the requested tool mode."""
+```
+
+##### 5. allow_follow_up 标志
+`MainAgentBuildResult` 新增 `allow_follow_up` 标志,用于控制是否允许跟进消息:
+
+- **默认值**:`True`
+- **chat_only 模式**:设置为 `False`,防止在降级为纯文本聊天时继续注册 follow-up runner
+- **用途**:避免在纯文本聊天模式下重复消息或短时追问被放大为 follow-up 处理
+
+#### 行为变更
+
+##### fallback_provider 策略(默认)
+当模型不支持工具调用且配置了备用提供商时:
+
+1. 系统自动查找支持工具调用的备用提供商
+2. 切换到备用提供商并设置对应的模型
+3. 记录信息日志,提示用户已切换提供商
+4. 如果找到的备用提供商工具能力未知(元数据中无信息),记录警告日志并尝试使用
+5. 如果所有备用提供商均不支持工具调用,自动降级为 `chat_only` 模式
+
+##### chat_only 策略
+当策略为 `chat_only` 或 `fallback_provider` 无可用备用提供商时:
+
+1. 调用 `_strip_tool_state_from_request()` 清理请求中的工具状态
+2. 移除上下文中的工具调用相关消息
+3. 设置 `allow_follow_up=False`,避免注册 follow-up runner
+4. 记录信息日志,提示已降级为纯文本聊天模式
+5. 后续请求作为普通文本聊天处理
+
+##### hard_fail 策略
+当策略为 `hard_fail` 时:
+
+1. 检测到模型不支持工具调用后立即抛出 `UnsupportedToolCapabilityError` 异常
+2. 异常消息包含模型名称和提供商 ID,便于用户定位问题
+3. Agent 执行流程中止,不进行任何自动降级或切换
+
+#### 修改文件
+
+- **`astrbot/core/astr_main_agent.py`**:核心 Agent 逻辑,新增工具能力路由
+- **`astrbot/core/config/default.py`**:新增 `tool_capability_strategy` 配置选项
+- **`astrbot/core/exceptions.py`**:新增 `UnsupportedToolCapabilityError` 异常
+- **`astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py`**:集成工具能力策略到 pipeline
+- **`tests/test_astr_main_agent_tool_capability.py`**:全面测试覆盖三种策略
+
+#### 测试覆盖
+
+PR #7164 新增了完整的测试覆盖,验证三种策略的行为:
+
+1. **fallback_provider 策略测试**:
+   - 主模型不支持工具调用时自动切换到备用提供商
+   - 验证提供商和模型是否正确切换
+   - 验证工具状态是否保留
+
+2. **chat_only 策略测试**:
+   - 清理请求中的工具状态(`func_tool`、`tool_calls_result`)
+   - 清理上下文中的工具调用消息(`role="tool"` 和 assistant 的 `tool_calls`)
+   - 验证 `allow_follow_up` 标志设置为 `False`
+
+3. **hard_fail 策略测试**:
+   - 验证抛出 `UnsupportedToolCapabilityError` 异常
+   - 验证异常消息包含模型名称和提供商 ID
+
+#### 用户收益
+
+- **一致性和可预测性**:确保工具不兼容模型的处理策略在 Agent 执行前明确,避免提供商层的静默回退
+- **灵活配置**:用户可根据实际需求选择自动切换、降级或直接报错
+- **增强可靠性**:防止工具不兼容模型进入 Agent 执行流程后引发的错误和异常行为
+- **改善日志可观测性**:通过明确的日志记录工具能力检测和策略执行过程,便于诊断和优化
+
+---
+
+### 17. 其他优化
 - JWT 处理和错误处理机制增强,提升系统安全性和稳定性
 - UI 细节优化,提升用户体验
 - 日志与异常处理增强,便于问题追踪

[Accept] [Decline]

Note: You must be authenticated to accept/decline updates.

How did I do? Any feedback?  Join Discord

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]QQ 官方机器人下使用 Ollama deepseek-r1:7b 时出现重复收消息、follow-up 串台、人格设定失效与上下文污染

1 participant