diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 39e1d2a875..3a1ace1423 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -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) @@ -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: @@ -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): diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 76e7848718..ec85de5fc3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -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": { @@ -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", diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index d3d258c44d..cc8fe618f8 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -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, diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index d1ee999b84..4f72d8b32a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1190,6 +1190,10 @@ "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": { @@ -1197,6 +1201,12 @@ "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." + } } } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 4a42cbf145..8f20540d75 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1184,6 +1184,10 @@ "max_concurrent": { "description": "工作任务执行并发", "hint": "同时执行的工作任务数,默认 2。此值不是等待队列的长度限制。" + }, + "provider_id": { + "description": "工作循环模型", + "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" } }, "work_session": { @@ -1191,6 +1195,12 @@ "description": "终态工作会话保留秒数", "hint": "已完成、失败或取消的工作会话保留时间,默认 3600 秒。" } + }, + "conversation_loop": { + "provider_id": { + "description": "对话循环模型", + "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" + } } } } diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 67d25eaf77..6d5cbae096 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -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. diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 622bb5c1ea..37cd171dc2 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -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。 diff --git a/tests/unit/test_agent_internal_process.py b/tests/unit/test_agent_internal_process.py index 72aec84272..6a418aa717 100644 --- a/tests/unit/test_agent_internal_process.py +++ b/tests/unit/test_agent_internal_process.py @@ -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, diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index f81d47780e..5084cc7be4 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -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 diff --git a/tests/unit/test_config_metadata_i18n.py b/tests/unit/test_config_metadata_i18n.py index fd4d89bf08..bd26ca5194 100644 --- a/tests/unit/test_config_metadata_i18n.py +++ b/tests/unit/test_config_metadata_i18n.py @@ -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 ( @@ -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"]