Skip to content

Conversation

@Futureppo
Copy link
Contributor

@Futureppo Futureppo commented Jan 8, 2026

Modifications / 改动点

在 provider_settings 中引入了新的配置项 sanitize_context_by_modalities。

(默认开启)开启后,系统会在每次请求 LLM 前,检查当前 Provider 的 modalities 配置。
如果模型不支持 图片 (image, vision):自动移除历史消息中的图片块。如果模型不支持 工具调用 (tool_use, function_call):自动移除 tool 角色的消息以及 Assistant 消息中的 tool_calls/function_call 字段。处理了移除内容后可能留下的空消息问题。在 Dashboard 的配置元数据中添加了相关的中英文描述。

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

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

17411572a716ee29a864fdcac995df88 c5fa241d9f05ff5c0c75b62fcc9c083d

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ 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.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

由 Sourcery 提供的摘要

新增一个可选的上下文清理步骤,根据不同服务商的能力,从 LLM 的请求历史中移除不支持的模态内容。

新功能:

  • 引入服务商配置项 sanitize_context_by_modalities,可根据模型配置的模态,自动从请求上下文中移除不受支持的图片内容和工具调用结构。
  • 在默认服务商配置和服务商模板中暴露 sanitize_context_by_modalities,以便可通过控制台进行配置。

增强:

  • 在清理后过滤掉已变为空或仅包含工具调用的 assistant 消息,避免向模型发送空轮次对话。

文档:

  • 在控制台配置元数据中记录新的 sanitize_context_by_modalities 选项,并提供本地化的说明和提示。
Original summary in English

Summary by Sourcery

Add an optional context sanitization step that prunes unsupported modalities from LLM request histories based on provider capabilities.

New Features:

  • Introduce the provider setting sanitize_context_by_modalities to automatically remove unsupported image content and tool-calling structures from request contexts according to the model's configured modalities.
  • Expose sanitize_context_by_modalities in default provider settings and the provider template so it can be configured via the dashboard.

Enhancements:

  • Filter out now-empty or tool-only assistant messages after sanitization to avoid sending empty turns to the model.

Documentation:

  • Document the new sanitize_context_by_modalities option in dashboard configuration metadata with localized descriptions and hints.

新特性:

  • 新增 sanitize_context_by_modalities 提供方设置,用于根据当前模型所支持的模态,自动从请求上下文中移除不受支持的图片和工具调用结构。
  • 在默认配置和提供方模板元数据中暴露 sanitize_context_by_modalities 选项,以便在控制台中进行配置。

改进:

  • 在清理后移除空的或仅包含工具使用内容的 assistant 消息,避免向模型发送空轮次。
Original summary in English

由 Sourcery 提供的摘要

新增一个可选的上下文清理步骤,根据不同服务商的能力,从 LLM 的请求历史中移除不支持的模态内容。

新功能:

  • 引入服务商配置项 sanitize_context_by_modalities,可根据模型配置的模态,自动从请求上下文中移除不受支持的图片内容和工具调用结构。
  • 在默认服务商配置和服务商模板中暴露 sanitize_context_by_modalities,以便可通过控制台进行配置。

增强:

  • 在清理后过滤掉已变为空或仅包含工具调用的 assistant 消息,避免向模型发送空轮次对话。

文档:

  • 在控制台配置元数据中记录新的 sanitize_context_by_modalities 选项,并提供本地化的说明和提示。
Original summary in English

Summary by Sourcery

Add an optional context sanitization step that prunes unsupported modalities from LLM request histories based on provider capabilities.

New Features:

  • Introduce the provider setting sanitize_context_by_modalities to automatically remove unsupported image content and tool-calling structures from request contexts according to the model's configured modalities.
  • Expose sanitize_context_by_modalities in default provider settings and the provider template so it can be configured via the dashboard.

Enhancements:

  • Filter out now-empty or tool-only assistant messages after sanitization to avoid sending empty turns to the model.

Documentation:

  • Document the new sanitize_context_by_modalities option in dashboard configuration metadata with localized descriptions and hints.

@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Jan 8, 2026
Copy link
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 - 我发现了 2 个问题

给 AI Agent 的提示
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:286-287` </location>
<code_context>
+                    "tool_calls" in new_msg or "function_call" in new_msg
+                ):
+                    new_msg = dict(new_msg)
+                    if "tool_calls" in new_msg:
+                        removed_tool_calls += 1
+                    if "function_call" in new_msg:
+                        removed_tool_calls += 1
</code_context>

<issue_to_address>
**nitpick (bug_risk):** 当单条消息中存在多个 tool call 时,tool-call 移除日志会出现低估的情况。

这里的 `removed_tool_calls` 是按键(`tool_calls` / `function_call`)递增,而不是按实际的 tool call 数量递增(例如 `tool_calls` 列表中有多个条目)。如果用于监控,这会让该指标产生误导。可以考虑按 tool call 的数量递增(例如当 `tool_calls` 是列表时使用 `len(new_msg["tool_calls"])`),或者重命名变量,以明确它统计的是“包含 tool call 的消息数”,而不是“单个 tool call 的数量”。
</issue_to_address>

### Comment 2
<location> `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:209` </location>
<code_context>
                 )
                 req.func_tool = None

+    def _sanitize_context_by_modalities(
+        self,
+        provider: Provider,
</code_context>

<issue_to_address>
**issue (complexity):** 建议将 `_sanitize_context_by_modalities` 拆分为更小的辅助函数和简单的数据类,让主循环变成一条简单、易读的流水线。

通过抽取几个小而单一职责的辅助函数,再加上一个简单的统计结构体,在不改变行为的前提下,可以显著降低 `_sanitize_context_by_modalities` 的认知复杂度。

### 1. 抽取能力检测

把 modalities 的解析和能力标记提取到一个辅助函数中。这样可以把主函数中的早期分支噪音移出去:

```python
@dataclass
class ProviderCapabilities:
    supports_image: bool
    supports_tool_use: bool

def _get_supported_capabilities(self, provider: Provider) -> ProviderCapabilities:
    modalities = provider.provider_config.get("modalities")
    if not isinstance(modalities, list):
        return ProviderCapabilities(supports_image=True, supports_tool_use=True)

    normalized = {
        str(m).lower() for m in modalities if isinstance(m, str) and m
    }
    supports_image = bool({"image", "image_url", "vision"} & normalized)
    supports_tool_use = bool(
        {"tool_use", "tools", "tool", "function_call", "function"} & normalized
    )
    return ProviderCapabilities(
        supports_image=supports_image,
        supports_tool_use=supports_tool_use,
    )
```

然后在 `_sanitize_context_by_modalities` 的开头:

```python
caps = self._get_supported_capabilities(provider)
if caps.supports_image and caps.supports_tool_use:
    return
```

### 2. 将 `is_only_image_placeholder` 抽出

把它做成可复用的辅助函数,而不是内部函数:

```python
PLACEHOLDER_TEXTS = {"[图片]", "[image]", "[Image]", "[IMAGE]"}

def _is_only_image_placeholder(self, parts: list) -> bool:
    if not parts:
        return False
    for part in parts:
        if isinstance(part, dict):
            if str(part.get("type", "")).lower() != "text":
                return False
            text = part.get("text", "")
            if not isinstance(text, str) or text.strip() not in PLACEHOLDER_TEXTS:
                return False
        elif isinstance(part, str):
            if part.strip() not in PLACEHOLDER_TEXTS:
                return False
        else:
            return False
    return True
```

### 3. 使用一个精简的统计对象

用一个专门的结构体来追踪计数器,由各个辅助函数更新,而不是将计数逻辑和业务逻辑交织在一起:

```python
@dataclass
class SanitizeStats:
    removed_image_blocks: int = 0
    removed_tool_messages: int = 0
    removed_tool_calls: int = 0
```

### 4. 将逐条消息的清洗逻辑拆分成辅助函数

让每个辅助函数接收一条消息 + 能力信息 + 统计对象,并返回(可能已修改的)消息或 `None`(表示丢弃)。这样可以把主循环压平成简单流程,去掉嵌套分支。

#### Tool-use 清洗

```python
def _sanitize_tool_use_in_message(
    self,
    msg: dict,
    caps: ProviderCapabilities,
    stats: SanitizeStats,
) -> Optional[dict]:
    if caps.supports_tool_use:
        return msg

    role = msg.get("role")
    if role == "tool":
        stats.removed_tool_messages += 1
        return None

    if role == "assistant" and ("tool_calls" in msg or "function_call" in msg):
        new_msg = dict(msg)
        if "tool_calls" in new_msg:
            stats.removed_tool_calls += 1
        if "function_call" in new_msg:
            stats.removed_tool_calls += 1
        new_msg.pop("tool_calls", None)
        new_msg.pop("function_call", None)
        new_msg.pop("tool_call_id", None)
        return new_msg

    return msg
```

#### 图片清洗

```python
def _sanitize_images_in_message(
    self,
    msg: dict,
    caps: ProviderCapabilities,
    stats: SanitizeStats,
) -> Optional[dict]:
    if caps.supports_image:
        return msg

    content = msg.get("content")
    if not isinstance(content, list):
        return msg

    filtered_parts: list = []
    removed_any_image = False
    for part in content:
        if isinstance(part, dict):
            part_type = str(part.get("type", "")).lower()
            if part_type in {"image_url", "image"}:
                removed_any_image = True
                stats.removed_image_blocks += 1
                continue
        filtered_parts.append(part)

    if not removed_any_image:
        return msg

    if not filtered_parts or self._is_only_image_placeholder(filtered_parts):
        return None

    new_msg = dict(msg)
    new_msg["content"] = filtered_parts
    return new_msg
```

#### 清理空的 assistant 消息

```python
def _drop_empty_assistant_message(self, msg: dict) -> Optional[dict]:
    role = msg.get("role")
    if role != "assistant":
        return msg

    content = msg.get("content")
    has_tool_calls = bool(msg.get("tool_calls") or msg.get("function_call"))
    if has_tool_calls:
        return msg

    if content is None:
        return None
    if isinstance(content, str) and not content.strip():
        return None
    if isinstance(content, list) and len(content) == 0:
        return None

    return msg
```

### 5. 简化 `_sanitize_context_by_modalities` 的主循环

在上述辅助函数的基础上,核心逻辑可以变成对每条消息的一条简单流水线:

```python
def _sanitize_context_by_modalities(
    self,
    provider: Provider,
    req: ProviderRequest,
) -> None:
    if not self.sanitize_context_by_modalities:
        return
    if not isinstance(req.contexts, list) or not req.contexts:
        return

    caps = self._get_supported_capabilities(provider)
    if caps.supports_image and caps.supports_tool_use:
        return

    stats = SanitizeStats()
    sanitized_contexts: list[dict] = []

    for msg in req.contexts:
        if not isinstance(msg, dict):
            continue

        current = msg
        current = self._sanitize_tool_use_in_message(current, caps, stats)
        if current is None:
            continue

        current = self._sanitize_images_in_message(current, caps, stats)
        if current is None:
            continue

        current = self._drop_empty_assistant_message(current)
        if current is None:
            continue

        sanitized_contexts.append(current)

    if stats.removed_image_blocks or stats.removed_tool_messages or stats.removed_tool_calls:
        logger.debug(
            "sanitize_context_by_modalities applied: "
            f"removed_image_blocks={stats.removed_image_blocks}, "
            f"removed_tool_messages={stats.removed_tool_messages}, "
            f"removed_tool_calls={stats.removed_tool_calls}"
        )

    req.contexts = sanitized_contexts
```

这样在保持所有现有行为的前提下:

- 去除了主循环中的嵌套分支。
- 将关注点拆分开(能力检测、tool 清洗、图片清洗、空 assistant 清理、统计)。
- 让每个辅助函数都可以独立地进行简单的单元测试。
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的 review 有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的 review。
Original comment in English

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:286-287` </location>
<code_context>
+                    "tool_calls" in new_msg or "function_call" in new_msg
+                ):
+                    new_msg = dict(new_msg)
+                    if "tool_calls" in new_msg:
+                        removed_tool_calls += 1
+                    if "function_call" in new_msg:
+                        removed_tool_calls += 1
</code_context>

<issue_to_address>
**nitpick (bug_risk):** Tool-call removal logging undercounts when there are multiple tool calls in a single message.

Here `removed_tool_calls` increments once per key (`tool_calls` / `function_call`), not per actual tool call (e.g., multiple entries in `tool_calls`). This can make the metric misleading if used for monitoring. Consider incrementing by the number of tool calls (e.g., `len(new_msg["tool_calls"])` when it’s a list), or renaming the variable to clarify it counts messages-with-tool-calls, not individual tool calls.
</issue_to_address>

### Comment 2
<location> `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:209` </location>
<code_context>
                 )
                 req.func_tool = None

+    def _sanitize_context_by_modalities(
+        self,
+        provider: Provider,
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring `_sanitize_context_by_modalities` into smaller helper functions and simple data classes so the main loop becomes a straightforward, readable pipeline.

You can reduce the cognitive complexity of `_sanitize_context_by_modalities` substantially by extracting a few small, single‑purpose helpers and a tiny stats struct, without changing behavior.

### 1. Extract capability detection

Move the modalities parsing + capability flags into a helper. This removes early branching noise from the main function:

```python
@dataclass
class ProviderCapabilities:
    supports_image: bool
    supports_tool_use: bool

def _get_supported_capabilities(self, provider: Provider) -> ProviderCapabilities:
    modalities = provider.provider_config.get("modalities")
    if not isinstance(modalities, list):
        return ProviderCapabilities(supports_image=True, supports_tool_use=True)

    normalized = {
        str(m).lower() for m in modalities if isinstance(m, str) and m
    }
    supports_image = bool({"image", "image_url", "vision"} & normalized)
    supports_tool_use = bool(
        {"tool_use", "tools", "tool", "function_call", "function"} & normalized
    )
    return ProviderCapabilities(
        supports_image=supports_image,
        supports_tool_use=supports_tool_use,
    )
```

Then at the top of `_sanitize_context_by_modalities`:

```python
caps = self._get_supported_capabilities(provider)
if caps.supports_image and caps.supports_tool_use:
    return
```

### 2. Move `is_only_image_placeholder` out

Make it a reusable helper instead of an inner function:

```python
PLACEHOLDER_TEXTS = {"[图片]", "[image]", "[Image]", "[IMAGE]"}

def _is_only_image_placeholder(self, parts: list) -> bool:
    if not parts:
        return False
    for part in parts:
        if isinstance(part, dict):
            if str(part.get("type", "")).lower() != "text":
                return False
            text = part.get("text", "")
            if not isinstance(text, str) or text.strip() not in PLACEHOLDER_TEXTS:
                return False
        elif isinstance(part, str):
            if part.strip() not in PLACEHOLDER_TEXTS:
                return False
        else:
            return False
    return True
```

### 3. Use a tiny stats object

Track counters in a dedicated struct that helpers update, instead of interleaving counter manipulation with logic:

```python
@dataclass
class SanitizeStats:
    removed_image_blocks: int = 0
    removed_tool_messages: int = 0
    removed_tool_calls: int = 0
```

### 4. Split per‑message sanitization into helpers

Each helper takes a message + capabilities + stats and either returns a (possibly modified) message or `None` to drop it. This flattens your main loop and removes nested branching.

#### Tool-use sanitization

```python
def _sanitize_tool_use_in_message(
    self,
    msg: dict,
    caps: ProviderCapabilities,
    stats: SanitizeStats,
) -> Optional[dict]:
    if caps.supports_tool_use:
        return msg

    role = msg.get("role")
    if role == "tool":
        stats.removed_tool_messages += 1
        return None

    if role == "assistant" and ("tool_calls" in msg or "function_call" in msg):
        new_msg = dict(msg)
        if "tool_calls" in new_msg:
            stats.removed_tool_calls += 1
        if "function_call" in new_msg:
            stats.removed_tool_calls += 1
        new_msg.pop("tool_calls", None)
        new_msg.pop("function_call", None)
        new_msg.pop("tool_call_id", None)
        return new_msg

    return msg
```

#### Image sanitization

```python
def _sanitize_images_in_message(
    self,
    msg: dict,
    caps: ProviderCapabilities,
    stats: SanitizeStats,
) -> Optional[dict]:
    if caps.supports_image:
        return msg

    content = msg.get("content")
    if not isinstance(content, list):
        return msg

    filtered_parts: list = []
    removed_any_image = False
    for part in content:
        if isinstance(part, dict):
            part_type = str(part.get("type", "")).lower()
            if part_type in {"image_url", "image"}:
                removed_any_image = True
                stats.removed_image_blocks += 1
                continue
        filtered_parts.append(part)

    if not removed_any_image:
        return msg

    if not filtered_parts or self._is_only_image_placeholder(filtered_parts):
        return None

    new_msg = dict(msg)
    new_msg["content"] = filtered_parts
    return new_msg
```

#### Empty assistant message pruning

```python
def _drop_empty_assistant_message(self, msg: dict) -> Optional[dict]:
    role = msg.get("role")
    if role != "assistant":
        return msg

    content = msg.get("content")
    has_tool_calls = bool(msg.get("tool_calls") or msg.get("function_call"))
    if has_tool_calls:
        return msg

    if content is None:
        return None
    if isinstance(content, str) and not content.strip():
        return None
    if isinstance(content, list) and len(content) == 0:
        return None

    return msg
```

### 5. Simplify `_sanitize_context_by_modalities` main loop

With the above helpers, the core routine becomes a simple pipeline per message:

```python
def _sanitize_context_by_modalities(
    self,
    provider: Provider,
    req: ProviderRequest,
) -> None:
    if not self.sanitize_context_by_modalities:
        return
    if not isinstance(req.contexts, list) or not req.contexts:
        return

    caps = self._get_supported_capabilities(provider)
    if caps.supports_image and caps.supports_tool_use:
        return

    stats = SanitizeStats()
    sanitized_contexts: list[dict] = []

    for msg in req.contexts:
        if not isinstance(msg, dict):
            continue

        current = msg
        current = self._sanitize_tool_use_in_message(current, caps, stats)
        if current is None:
            continue

        current = self._sanitize_images_in_message(current, caps, stats)
        if current is None:
            continue

        current = self._drop_empty_assistant_message(current)
        if current is None:
            continue

        sanitized_contexts.append(current)

    if stats.removed_image_blocks or stats.removed_tool_messages or stats.removed_tool_calls:
        logger.debug(
            "sanitize_context_by_modalities applied: "
            f"removed_image_blocks={stats.removed_image_blocks}, "
            f"removed_tool_messages={stats.removed_tool_messages}, "
            f"removed_tool_calls={stats.removed_tool_calls}"
        )

    req.contexts = sanitized_contexts
```

This preserves all existing behavior while:

- Removing nested branching from the main loop.
- Separating concerns (capabilities, tool sanitization, image sanitization, empty‑assistant pruning, stats).
- Making each helper trivially unit‑testable independently.
</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.

@Soulter
Copy link
Member

Soulter commented Jan 11, 2026

可以 ruff format 一下,ci 没通过

@Soulter Soulter merged commit 2e17280 into AstrBotDevs:master Jan 11, 2026
5 checks passed
@Soulter Soulter changed the title feat(context): 添加按模型能力清理历史上下文 feat(context): sanitize llm context by modalities Jan 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

2 participants