Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ class MainAgentBuildConfig:
add_cron_tools: bool = True
"""This will add cron job management tools to the main agent for proactive cron job execution."""
provider_settings: dict = field(default_factory=dict)
provider_id_override: str = ""
"""Optional request-scoped chat provider override."""
fallback_provider_ids: list[str] = field(default_factory=list)
request_max_retries: int = 5
subagent_orchestrator: dict = field(default_factory=dict)
Expand Down Expand Up @@ -317,10 +319,12 @@ def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None:


def _select_provider(
event: AstrMessageEvent, plugin_context: CoreExecutionContext
event: AstrMessageEvent,
plugin_context: CoreExecutionContext,
provider_id_override: str = "",
) -> ChatModel | None:
"""Select chat provider for the event."""
sel_provider = event.get_extra("selected_provider")
sel_provider = provider_id_override or event.get_extra("selected_provider")
if sel_provider and isinstance(sel_provider, str):
provider = plugin_context.get_provider_by_id(sel_provider)
if provider is None:
Expand Down Expand Up @@ -1952,7 +1956,9 @@ async def build_main_agent(

If apply_reset is False, will not call reset on the agent runner.
"""
provider = provider or _select_provider(event, plugin_context)
provider = provider or _select_provider(
event, plugin_context, config.provider_id_override
)
if provider is None:
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
if not event.get_extra(LLM_ERROR_MESSAGE_EXTRA_KEY):
Expand Down
17 changes: 16 additions & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@
},
"btw": {
"enabled": False,
"work_loop": {"enabled": False, "max_concurrent": 2},
"conversation_loop": {"provider_id": ""},
"work_loop": {"enabled": False, "provider_id": "", "max_concurrent": 2},
"work_session": {"max_age_seconds": 3600},
},
"provider_stt_settings": {
Expand Down Expand Up @@ -4699,12 +4700,26 @@
"type": "bool",
"hint": "实验功能,默认关闭。开启后,普通 AI 请求通过对话循环进入现有 Agent。",
},
"btw.conversation_loop.provider_id": {
"description": "对话循环模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。",
"condition": {"btw.enabled": True},
},
"btw.work_loop.enabled": {
"description": "启用工作循环",
"type": "bool",
"hint": "默认关闭;允许显式工作请求使用工作执行器。",
"condition": {"btw.enabled": True},
},
"btw.work_loop.provider_id": {
"description": "工作循环模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。",
"condition": {"btw.enabled": True},
},
"btw.work_loop.max_concurrent": {
"description": "工作任务执行并发",
"type": "int",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,19 @@ async def _build_checked_agent_runner(
streaming_response: bool,
) -> MainAgentBuildResult | None:
"""Build a runner and reject configured provider endpoints unsafe for use."""
btw = self._profile_config(event).get("btw", {})
provider_id_override = ""
if isinstance(btw, dict) and btw.get("enabled", False):
loop = "work" if event.get_extra("btw_loop") == "work" else "conversation"
loop_config = btw.get(f"{loop}_loop", {})
if isinstance(loop_config, dict):
provider_id = loop_config.get("provider_id", "")
if isinstance(provider_id, str):
provider_id_override = provider_id.strip()
build_cfg = replace(
self.main_agent_cfg,
streaming_response=streaming_response,
provider_id_override=provider_id_override,
)
build_result = await build_main_agent(
event=event,
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -1190,13 +1190,23 @@
"max_concurrent": {
"description": "Concurrent work execution",
"hint": "Active execution limit, default 2. This is not a waiting-queue length limit."
},
"provider_id": {
"description": "Work loop model",
"hint": "Leave empty to keep the current session model selection. When set, this model takes priority."
}
},
"work_session": {
"max_age_seconds": {
"description": "Terminal work retention (seconds)",
"hint": "Keep completed, failed or cancelled records for 3600 seconds by default."
}
},
"conversation_loop": {
"provider_id": {
"description": "Conversation loop model",
"hint": "Leave empty to keep the current session model selection. When set, this model takes priority."
}
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -1184,13 +1184,23 @@
"max_concurrent": {
"description": "工作任务执行并发",
"hint": "同时执行的工作任务数,默认 2。此值不是等待队列的长度限制。"
},
"provider_id": {
"description": "工作循环模型",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。"
}
},
"work_session": {
"max_age_seconds": {
"description": "终态工作会话保留秒数",
"hint": "已完成、失败或取消的工作会话保留时间,默认 3600 秒。"
}
},
"conversation_loop": {
"provider_id": {
"description": "对话循环模型",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。"
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions docs/en/dev/astrbot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ Local mode operates directly on the AstrBot host and belongs only in a trusted e

`image_compress_enabled` and `image_compress_options.max_size/quality` control image handling in the request-preparation choke point `prepare_provider_request`. The main-agent chat path, SDK `llm_generate`, and `tool_loop_agent` share that step. Provider-bound images are converted to JPEG there; the long edge is downscaled only and never upscaled. Animated GIF/WebP sources are dhash-sampled, at most 8 frames. Disabling compression still converts to JPEG without resizing. The main agent only materializes adapter refs to local paths and does not pre-encode chat attachments to JPEG. `max_quoted_fallback_images` and `quoted_message_parser` limit quoted and forwarded-message expansion to prevent unbounded fetching. For `quoted_message_parser`, `0` is a valid boundary: depth limits keep the root level but stop child recursion, and `max_forward_fetch=0` disables recursive `get_forward_msg` calls. Negative or invalid values fall back to defaults; this setting does not globally disable a direct quoted-message `get_msg` fallback.

## BTW model selection

When `btw.enabled` is enabled for a local Agent profile, `btw.conversation_loop.provider_id` and `btw.work_loop.provider_id` select the chat model for each loop. A configured loop model takes priority over the event/session model selection. An empty field preserves the current selection, including the profile default. Messages without an explicit work-loop marker use the conversation model. Disabling BTW ignores both overrides.

The selected provider must still be a configured chat model. An unavailable or incompatible loop provider fails through the existing model-selection error path; it does not silently switch to the other loop's model. Existing model fallback and retry settings continue to apply to the selected primary provider.

## SubAgents, speech, and knowledge base

- `subagent_orchestrator.main_enable` enables handoffs.
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/dev/astrbot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志

`image_compress_enabled` 和 `image_compress_options.max_size/quality` 控制请求准备卡口 `prepare_provider_request` 中的图片处理,主智能体聊天路径、SDK `llm_generate` 与 `tool_loop_agent` 共用该卡口。送给模型的图片在此处转为 JPEG,最长边只缩小、从不放大;动画 GIF/WebP 会按 dhash 抽帧,最多 8 帧。关闭压缩时仍会转 JPEG,但不缩放。主智能体只把适配器引用物化为本地路径,不在组装附件时预编码 JPEG。`max_quoted_fallback_images` 与 `quoted_message_parser` 限制引用消息和转发消息展开深度,避免无限抓取。对 `quoted_message_parser` 而言,`0` 是有效边界:深度限制会保留根层并停止子层递归,`max_forward_fetch=0` 会禁止递归调用 `get_forward_msg`。负数或无效值会回退为默认值;该设置不会全局禁止引用消息回退路径中的直接 `get_msg` 调用。

## BTW 模型选择

本地 Agent 配置启用 `btw.enabled` 后,`btw.conversation_loop.provider_id` 与 `btw.work_loop.provider_id` 分别选择两个循环的对话模型。已配置的循环模型优先于事件或会话的模型选择;留空则沿用当前选择,包括配置档默认模型。没有显式工作循环标记的消息使用对话循环模型。关闭 BTW 后不应用这两个覆盖项。

所选提供商仍须是已配置的对话模型。不存在或类型不适用的循环提供商沿用现有模型选择错误路径,不会静默改用另一个循环的模型。已有模型回退和重试设置继续作用于所选主模型。

## 子代理、语音与知识库

- `subagent_orchestrator.main_enable`:启用 handoff。
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/test_agent_internal_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,57 @@

import pytest

from astrbot.core.astr_main_agent import MainAgentBuildConfig
from astrbot.core.message.components import Json
from tests.unit.agent_sub_stage_support import * # noqa: F403


@pytest.mark.asyncio
@pytest.mark.parametrize(
("enabled", "loop", "conversation_provider", "work_provider", "expected"),
[
(True, None, "conversation-model", "work-model", "conversation-model"),
(
True,
"conversation",
"conversation-model",
"work-model",
"conversation-model",
),
(True, "work", "conversation-model", "work-model", "work-model"),
(True, "invalid", "conversation-model", "work-model", "conversation-model"),
(True, "work", "conversation-model", "", ""),
(True, None, None, "work-model", ""),
(False, "work", "conversation-model", "work-model", ""),
],
)
async def test_btw_loop_provider_selection_is_request_scoped(
monkeypatch, enabled, loop, conversation_provider, work_provider, expected
):
stage = internal.InternalAgentSubStage.__new__(internal.InternalAgentSubStage)
stage.ctx = _pipeline_context(_internal_plugin_context())
stage.ctx.astrbot_config = {
"btw": {
"enabled": enabled,
"conversation_loop": {"provider_id": conversation_provider},
"work_loop": {"provider_id": work_provider},
}
}
stage.main_agent_cfg = MainAgentBuildConfig(tool_call_timeout=60)
result = SimpleNamespace(provider=SimpleNamespace(provider_config={}))
build = AsyncMock(return_value=result)
monkeypatch.setattr(internal, "build_main_agent", build)
event = FakeEvent(extras={"btw_loop": loop, "selected_provider": "session-model"})

assert await stage._build_checked_agent_runner(event, False) is result

config = build.await_args.kwargs["config"]
assert config.provider_id_override == expected
assert config.streaming_response is False
assert stage.main_agent_cfg.provider_id_override == ""
assert event.get_extra("selected_provider") == "session-model"


@pytest.mark.asyncio
async def test_internal_process_skips_empty_messages_without_provider_request(
monkeypatch,
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,31 @@ def test_config_with_custom_values(self):
class TestSelectProvider:
"""Tests for _select_provider function."""

def test_loop_override_takes_priority_without_changing_event(
self, mock_event, mock_context, mock_provider
):
mock_event.set_extra("selected_provider", "session-model")
mock_context.get_provider_by_id.return_value = mock_provider

assert (
ama._select_provider(mock_event, mock_context, "loop-model")
is mock_provider
)
mock_context.get_provider_by_id.assert_called_once_with("loop-model")
mock_context.get_using_provider.assert_not_called()
assert mock_event.get_extra("selected_provider") == "session-model"

@pytest.mark.parametrize("provider", [None, "not-a-chat-provider"])
def test_invalid_loop_override_does_not_fall_back(
self, mock_event, mock_context, provider
):
mock_event.set_extra("selected_provider", "session-model")
mock_context.get_provider_by_id.return_value = provider

assert ama._select_provider(mock_event, mock_context, "loop-model") is None
assert mock_event.get_extra(ama.LLM_ERROR_MESSAGE_EXTRA_KEY)
mock_context.get_using_provider.assert_not_called()

def test_select_provider_by_id(self, mock_event, mock_context, mock_provider):
"""Test selecting provider by ID from event extra."""
module = ama
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_config_metadata_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CONFIG_METADATA_2,
CONFIG_METADATA_3,
CONFIG_METADATA_3_SYSTEM,
DEFAULT_CONFIG,
)
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.platform.sources.line.line_adapter import (
Expand Down Expand Up @@ -134,6 +135,13 @@ def test_config_metadata_locale_trees_match() -> None:
assert sorted(en_keys - zh_keys) == []


def test_every_btw_profile_field_reaches_dashboard_controls() -> None:
converted = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
items = converted["plugin_group"]["metadata"]["btw"]["items"]
expected_fields = {f"btw.{field}" for field in _flatten(DEFAULT_CONFIG["btw"])}
assert set(items) == expected_fields


def test_btw_controls_survive_dashboard_metadata_conversion() -> None:
converted = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
section = converted["plugin_group"]["metadata"]["btw"]
Expand Down
Loading