refactor: embed agent runner configuration in profiles (#9821)

* refactor: embed agent runner configuration in profiles

* fix: limit personas to local agent runner

* style(dashboard): refine unsaved config notice

* refactor: refine embedded local runner configuration

* refactor: centralize agent runner migrations
This commit is contained in:
Soulter
2026-08-29 22:40:46 +08:00
committed by GitHub
parent d2d7e5aefd
commit fe3d77568b
42 changed files with 2060 additions and 847 deletions
@@ -5,7 +5,6 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import logger
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
DEERFLOW_THREAD_ID_KEY,
)
@@ -39,32 +38,21 @@ async def _cleanup_deerflow_thread_if_present(
return
cfg = context.get_config(umo=umo)
provider_id = cfg["provider_settings"].get(
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
"",
)
if not provider_id:
agent_runner = cfg.get("agent_runner", {})
if agent_runner.get("runner_type") != DEERFLOW_PROVIDER_TYPE:
return
merged_provider_config = context.provider_manager.get_provider_config_by_id(
provider_id,
merged=True,
)
if not merged_provider_config:
logger.warning(
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
provider_id,
)
runner_config = agent_runner.get("config", {})
if not isinstance(runner_config, dict):
return
client = DeerFlowAPIClient(
api_base=merged_provider_config.get(
api_base=runner_config.get(
"deerflow_api_base",
"http://127.0.0.1:2026",
),
api_key=merged_provider_config.get("deerflow_api_key", ""),
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
proxy=merged_provider_config.get("proxy", ""),
api_key=runner_config.get("deerflow_api_key", ""),
auth_header=runner_config.get("deerflow_auth_header", ""),
proxy=runner_config.get("proxy", ""),
)
try:
await client.delete_thread(thread_id)
@@ -148,7 +136,7 @@ class ConversationCommands:
)
return
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
agent_runner_type = cfg["agent_runner"]["runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await _clear_third_party_agent_runner_state(
@@ -196,7 +184,7 @@ class ConversationCommands:
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话正在运行的 Agent"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
agent_runner_type = cfg["agent_runner"]["runner_type"]
umo = message.unified_msg_origin
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
@@ -222,7 +210,7 @@ class ConversationCommands:
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
agent_runner_type = cfg["agent_runner"]["runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await _clear_third_party_agent_runner_state(
@@ -1,4 +1,3 @@
DEERFLOW_PROVIDER_TYPE = "deerflow"
DEERFLOW_THREAD_ID_KEY = "deerflow_thread_id"
DEERFLOW_SESSION_PREFIX = "deerflow-ephemeral"
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY = "deerflow_agent_runner_provider_id"
+13 -4
View File
@@ -358,8 +358,14 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
except Exception:
continue
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
agent_max_step = int(prov_settings.get("max_agent_step", 30))
config = ctx.get_config(umo=umo)
prov_settings: dict = config.get("provider_settings", {})
agent_max_step = int(
config.get("agent_runner", {})
.get("config", {})
.get("misc", {})
.get("max_steps", 30)
)
stream = prov_settings.get("streaming_response", False)
llm_resp = await ctx.tool_loop_agent(
event=event,
@@ -551,10 +557,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cfg = ctx.get_config(umo=event.unified_msg_origin) or {}
provider_settings = cfg.get("provider_settings") or {}
agent_max_step = coerce_int_config(
provider_settings.get("max_agent_step", 30),
cfg.get("agent_runner", {})
.get("config", {})
.get("misc", {})
.get("max_steps", 30),
default=30,
min_value=1,
field_name="provider_settings.max_agent_step",
field_name="agent_runner.config.misc.max_steps",
)
config = MainAgentBuildConfig(
tool_call_timeout=run_context.tool_call_timeout,
+6 -5
View File
@@ -209,6 +209,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)
fallback_provider_ids: list[str] = field(default_factory=list)
request_max_retries: int = 5
subagent_orchestrator: dict = field(default_factory=dict)
timezone: str | None = None
max_quoted_fallback_images: int = 20
@@ -1341,12 +1343,11 @@ async def _get_compress_provider(
def _get_fallback_chat_providers(
provider: Provider, plugin_context: Context, provider_settings: dict
provider: Provider, plugin_context: Context, fallback_ids: list[str]
) -> list[Provider]:
fallback_ids = provider_settings.get("fallback_chat_models", [])
if not isinstance(fallback_ids, list):
logger.warning(
"fallback_chat_models setting is not a list, skip fallback providers."
"Agent Runner fallback_provider_ids is not a list, skip fallback providers."
)
return []
@@ -1657,7 +1658,7 @@ async def build_main_agent(
)
fallback_providers = _get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
provider, plugin_context, config.fallback_provider_ids
)
selected_provider = _select_image_chat_provider(provider, req, fallback_providers)
if selected_provider is not provider:
@@ -1732,7 +1733,7 @@ async def build_main_agent(
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
fallback_providers=fallback_providers,
request_max_retries=config.provider_settings.get("request_max_retries", 5),
request_max_retries=config.request_max_retries,
tool_result_overflow_dir=(
get_astrbot_system_tmp_path()
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import copy
from typing import Any
AGENT_RUNNER_TYPES = ("local", "dify", "coze", "dashscope", "deerflow")
THIRD_PARTY_AGENT_RUNNER_TYPES = AGENT_RUNNER_TYPES[1:]
AGENT_RUNNER_CONFIG_DEFAULTS: dict[str, dict[str, Any]] = {
"local": {
"model": {
"provider_id": "",
"fallback_provider_ids": [],
"request_max_retries": 5,
},
"persona": {
"persona_id": "default",
"safety_mode": True,
"safety_mode_strategy": "system_prompt",
},
"compression": {
"max_turns": -1,
"trim_turns": 1,
"overflow_strategy": "llm_compress",
"instruction": "",
"keep_recent_ratio": 0.15,
"provider_id": "",
"fallback_max_tokens": 128000,
},
"misc": {
"max_steps": 30,
"tool_schema_mode": "full",
"tool_call_timeout": 120,
"sanitize_context_by_modalities": False,
},
},
"dify": {
"dify_api_type": "chat",
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "astrbot_wf_output",
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"proxy": "",
},
"coze": {
"coze_api_key": "",
"bot_id": "",
"coze_api_base": "https://api.coze.cn",
"auto_save_history": True,
"timeout": 60,
"proxy": "",
},
"dashscope": {
"dashscope_app_type": "agent",
"dashscope_api_key": "",
"dashscope_app_id": "",
"rag_options": {
"pipeline_ids": [],
"file_ids": [],
"output_reference": False,
},
"variables": {},
"timeout": 60,
"proxy": "",
},
"deerflow": {
"deerflow_api_base": "http://127.0.0.1:2026",
"deerflow_api_key": "",
"deerflow_auth_header": "",
"deerflow_assistant_id": "lead_agent",
"deerflow_model_name": "",
"deerflow_thinking_enabled": False,
"deerflow_plan_mode": False,
"deerflow_subagent_enabled": False,
"deerflow_max_concurrent_subagents": 3,
"deerflow_recursion_limit": 1000,
"timeout": 300,
"proxy": "",
},
}
def get_agent_runner_config_default(runner_type: str) -> dict[str, Any]:
"""Return an isolated default configuration for an Agent Runner type.
Args:
runner_type: Short runner type name.
Returns:
A deep copy of the runner configuration defaults.
Raises:
ValueError: If the runner type is unsupported.
"""
if runner_type not in AGENT_RUNNER_CONFIG_DEFAULTS:
raise ValueError(f"Unsupported Agent Runner type: {runner_type}")
return copy.deepcopy(AGENT_RUNNER_CONFIG_DEFAULTS[runner_type])
def _normalize_value(value: Any, default: Any) -> Any:
if isinstance(default, dict):
if not isinstance(value, dict):
return copy.deepcopy(default)
if not default:
return copy.deepcopy(value)
return {
key: _normalize_value(value.get(key), child_default)
for key, child_default in default.items()
}
if isinstance(default, list):
return (
copy.deepcopy(value) if isinstance(value, list) else copy.deepcopy(default)
)
if isinstance(default, bool):
return value if isinstance(value, bool) else default
if isinstance(default, int):
if isinstance(value, bool):
return default
try:
return int(value)
except (TypeError, ValueError):
return default
if isinstance(default, float):
if isinstance(value, bool):
return default
try:
return float(value)
except (TypeError, ValueError):
return default
if isinstance(default, str):
return value if isinstance(value, str) else default
return copy.deepcopy(value) if value is not None else copy.deepcopy(default)
def normalize_agent_runner(agent_runner: object) -> dict[str, Any]:
"""Validate and normalize a complete Agent Runner configuration.
Args:
agent_runner: Untrusted root Agent Runner configuration.
Returns:
A normalized configuration containing only fields for the selected runner.
Raises:
ValueError: If the root value or runner type is invalid.
"""
if not isinstance(agent_runner, dict):
raise ValueError("agent_runner must be an object")
runner_type = agent_runner.get("runner_type")
if runner_type not in AGENT_RUNNER_TYPES:
raise ValueError(f"Unsupported Agent Runner type: {runner_type}")
config = agent_runner.get("config", {})
default = AGENT_RUNNER_CONFIG_DEFAULTS[runner_type]
normalized = _normalize_value(config, default)
if runner_type == "local":
ratio = normalized["compression"]["keep_recent_ratio"]
normalized["compression"]["keep_recent_ratio"] = min(0.3, max(0.0, ratio))
if normalized["model"]["request_max_retries"] < 1:
normalized["model"]["request_max_retries"] = 1
if normalized["misc"]["max_steps"] < 1:
normalized["misc"]["max_steps"] = 1
if normalized["compression"]["trim_turns"] < 1:
normalized["compression"]["trim_turns"] = 1
return {"runner_type": runner_type, "config": normalized}
+9
View File
@@ -6,6 +6,7 @@ import logging
import os
import tempfile
import threading
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.auth_password import (
@@ -14,6 +15,7 @@ from astrbot.core.utils.auth_password import (
hash_md5_dashboard_password,
validate_dashboard_password,
)
from astrbot.core.utils.migra_helper import migrate_config_on_load
from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
@@ -84,8 +86,12 @@ class AstrBotConfig(dict):
"_dashboard_password_change_required_from_config",
True,
)
config_migrated = False
if default_config is DEFAULT_CONFIG:
config_migrated = migrate_config_on_load(conf, Path(config_path))
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
has_new |= config_migrated
reset_dashboard_password = self._consume_reset_dashboard_password_flag()
if reset_dashboard_password and "dashboard" in conf:
self._reset_generated_dashboard_password(conf)
@@ -188,6 +194,9 @@ class AstrBotConfig(dict):
# 类型不匹配,使用默认值
new_conf[key] = value
has_new = True
elif (path + "." + key if path else key) == "agent_runner.config":
# Runner config is normalized according to runner_type when saved.
new_conf[key] = conf[key]
else:
# 递归检查并同步顺序
child_has_new = self.check_config_integrity(
+280 -236
View File
@@ -6,6 +6,8 @@ from astrbot import __version__
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .agent_runner import get_agent_runner_config_default
VERSION = __version__
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
@@ -61,7 +63,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
# 默认配置
DEFAULT_CONFIG = {
"config_version": 2,
"config_version": 3,
"platform_settings": {
"unique_session": False,
"rate_limit": {
@@ -108,9 +110,6 @@ DEFAULT_CONFIG = {
"provider": [], # models from provider_sources
"provider_settings": {
"enable": True,
"default_provider_id": "",
"fallback_chat_models": [],
"request_max_retries": 5,
"default_image_caption_provider_id": "",
"image_caption_prompt": "Please describe the image using Chinese.",
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
@@ -128,28 +127,12 @@ DEFAULT_CONFIG = {
"identifier": False,
"group_name_display": False,
"datetime_system_prompt": True,
"default_personality": "default",
"persona_pool": ["*"],
"prompt_prefix": "{{prompt}}",
"context_limit_reached_strategy": "llm_compress", # or truncate_by_turns
"llm_compress_instruction": (
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
"The primary goal of this summary is to enable seamless continuation of the work that follows.\n"
"1. Systematically cover all core topics discussed and the final conclusion/outcome for each; clearly highlight the latest primary focus.\n"
"2. If any tools were used, summarize tool usage (total call count) and extract the most valuable insights from tool outputs.\n"
"3. If any materials (files, documents, code, references) were read during the conversation that may be helpful for subsequent work, list each one with its scope and path.\n"
"4. If there was an initial user goal, state it first and describe the current progress/status.\n"
"5. Write the summary in the user's language.\n"
),
"llm_compress_keep_recent_ratio": 0.15,
"llm_compress_provider_id": "",
"max_context_length": -1, # 默认不限制
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"show_tool_call_result": False,
"buffer_intermediate_messages": False,
"sanitize_context_by_modalities": False,
"max_quoted_fallback_images": 20,
"quoted_message_parser": {
"max_component_chain_depth": 4,
@@ -157,18 +140,8 @@ DEFAULT_CONFIG = {
"max_forward_fetch": 32,
"warn_on_action_failure": False,
},
"agent_runner_type": "local",
"dify_agent_runner_provider_id": "",
"coze_agent_runner_provider_id": "",
"dashscope_agent_runner_provider_id": "",
"deerflow_agent_runner_provider_id": "",
"unsupported_streaming_strategy": "realtime_segmenting",
"reachability_check": False,
"max_agent_step": 30,
"tool_call_timeout": 120,
"tool_schema_mode": "full",
"llm_safety_mode": True,
"safety_mode_strategy": "system_prompt", # TODO: llm judge
"file_extract": {
"enable": False,
"provider": "moonshotai",
@@ -202,6 +175,10 @@ DEFAULT_CONFIG = {
"quality": 95,
},
},
"agent_runner": {
"runner_type": "local",
"config": get_agent_runner_config_default("local"),
},
# SubAgent orchestrator mode:
# - main_enable = False: disabled; main LLM mounts tools normally (persona selection).
# - main_enable = True: enabled; main LLM keeps its own tools and includes handoff
@@ -1680,71 +1657,6 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"Dify": {
"id": "dify_app_default",
"provider": "dify",
"type": "dify",
"provider_type": "agent_runner",
"enable": True,
"dify_api_type": "chat",
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "astrbot_wf_output",
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"proxy": "",
},
"Coze": {
"id": "coze",
"provider": "coze",
"provider_type": "agent_runner",
"type": "coze",
"enable": True,
"coze_api_key": "",
"bot_id": "",
"coze_api_base": "https://api.coze.cn",
"timeout": 60,
"proxy": "",
# "auto_save_history": True,
},
"阿里云百炼应用": {
"id": "dashscope",
"provider": "dashscope",
"type": "dashscope",
"provider_type": "agent_runner",
"enable": True,
"dashscope_app_type": "agent",
"dashscope_api_key": "",
"dashscope_app_id": "",
"rag_options": {
"pipeline_ids": [],
"file_ids": [],
"output_reference": False,
},
"variables": {},
"timeout": 60,
"proxy": "",
},
"DeerFlow": {
"id": "deerflow",
"provider": "deerflow",
"type": "deerflow",
"provider_type": "agent_runner",
"enable": True,
"deerflow_api_base": "http://127.0.0.1:2026",
"deerflow_api_key": "",
"deerflow_auth_header": "",
"deerflow_assistant_id": "lead_agent",
"deerflow_model_name": "",
"deerflow_thinking_enabled": False,
"deerflow_plan_mode": False,
"deerflow_subagent_enabled": False,
"deerflow_max_concurrent_subagents": 3,
"deerflow_recursion_limit": 1000,
"timeout": 300,
"proxy": "",
},
"FastGPT": {
"id": "fastgpt",
"provider": "fastgpt",
@@ -3068,16 +2980,6 @@ CONFIG_METADATA_2 = {
"enable": {
"type": "bool",
},
"default_provider_id": {
"type": "string",
},
"fallback_chat_models": {
"type": "list",
"items": {"type": "string"},
},
"request_max_retries": {
"type": "int",
},
"wake_prefix": {
"type": "string",
},
@@ -3099,18 +3001,9 @@ CONFIG_METADATA_2 = {
"datetime_system_prompt": {
"type": "bool",
},
"default_personality": {
"type": "string",
},
"prompt_prefix": {
"type": "string",
},
"max_context_length": {
"type": "int",
},
"dequeue_context_length": {
"type": "int",
},
"streaming_response": {
"type": "bool",
},
@@ -3126,30 +3019,6 @@ CONFIG_METADATA_2 = {
"unsupported_streaming_strategy": {
"type": "string",
},
"agent_runner_type": {
"type": "string",
},
"dify_agent_runner_provider_id": {
"type": "string",
},
"coze_agent_runner_provider_id": {
"type": "string",
},
"dashscope_agent_runner_provider_id": {
"type": "string",
},
"deerflow_agent_runner_provider_id": {
"type": "string",
},
"max_agent_step": {
"type": "int",
},
"tool_call_timeout": {
"type": "int",
},
"tool_schema_mode": {
"type": "string",
},
"file_extract": {
"type": "object",
"items": {
@@ -3175,6 +3044,13 @@ CONFIG_METADATA_2 = {
},
},
},
"agent_runner": {
"type": "object",
"items": {
"runner_type": {"type": "string"},
"config": {"type": "dict"},
},
},
"provider_stt_settings": {
"type": "object",
"items": {
@@ -3365,7 +3241,7 @@ CONFIG_METADATA_3 = {
"metadata": {
"agent_runner": {
"description": "Agent 执行方式",
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify、Coze、DeerFlow 等第三方 Agent 执行器,不需要修改此节",
"hint": "选择 AI 对话的执行器。切换执行器会使用新类型的默认配置,不保留上一类型的参数",
"type": "object",
"items": {
"provider_settings.enable": {
@@ -3373,7 +3249,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "AI 对话总开关",
},
"provider_settings.agent_runner_type": {
"agent_runner.runner_type": {
"description": "执行器",
"type": "string",
"options": ["local", "dify", "coze", "dashscope", "deerflow"],
@@ -3384,70 +3260,238 @@ CONFIG_METADATA_3 = {
"阿里云百炼应用",
"DeerFlow",
],
"condition": {
"provider_settings.enable": True,
"_special": "agent_runner_type",
"runner_defaults": {
runner_type: get_agent_runner_config_default(runner_type)
for runner_type in (
"local",
"dify",
"coze",
"dashscope",
"deerflow",
)
},
},
"provider_settings.coze_agent_runner_provider_id": {
"description": "Coze Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:coze",
"condition": {
"provider_settings.agent_runner_type": "coze",
"provider_settings.enable": True,
},
},
"provider_settings.dify_agent_runner_provider_id": {
"description": "Dify Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dify",
"condition": {
"provider_settings.agent_runner_type": "dify",
"provider_settings.enable": True,
},
},
"provider_settings.dashscope_agent_runner_provider_id": {
"description": "阿里云百炼应用 Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dashscope",
"condition": {
"provider_settings.agent_runner_type": "dashscope",
"provider_settings.enable": True,
},
},
"provider_settings.deerflow_agent_runner_provider_id": {
"description": "DeerFlow Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:deerflow",
"condition": {
"provider_settings.agent_runner_type": "deerflow",
"provider_settings.enable": True,
},
},
},
},
"dify_runner": {
"description": "Dify 配置",
"type": "object",
"condition": {
"provider_settings.enable": True,
"agent_runner.runner_type": "dify",
},
"items": {
"agent_runner.config.dify_api_type": {
"description": "应用类型",
"type": "string",
"options": ["chat", "chatflow", "agent", "workflow"],
},
"agent_runner.config.dify_api_key": {
"description": "API Key",
"type": "string",
},
"agent_runner.config.dify_api_base": {
"description": "API Base URL",
"type": "string",
},
"agent_runner.config.dify_workflow_output_key": {
"description": "Workflow 输出变量名",
"type": "string",
},
"agent_runner.config.dify_query_input_key": {
"description": "Prompt 输入变量名",
"type": "string",
},
"agent_runner.config.variables": {
"description": "变量",
"type": "dict",
},
"agent_runner.config.timeout": {
"description": "超时时间(秒)",
"type": "int",
},
"agent_runner.config.proxy": {
"description": "代理地址",
"type": "string",
},
},
},
"coze_runner": {
"description": "Coze 配置",
"type": "object",
"condition": {
"provider_settings.enable": True,
"agent_runner.runner_type": "coze",
},
"items": {
"agent_runner.config.coze_api_key": {
"description": "API Key",
"type": "string",
},
"agent_runner.config.bot_id": {
"description": "Bot ID",
"type": "string",
},
"agent_runner.config.coze_api_base": {
"description": "API Base URL",
"type": "string",
},
"agent_runner.config.auto_save_history": {
"description": "由 Coze 管理对话记录",
"type": "bool",
},
"agent_runner.config.timeout": {
"description": "超时时间(秒)",
"type": "int",
},
"agent_runner.config.proxy": {
"description": "代理地址",
"type": "string",
},
},
},
"dashscope_runner": {
"description": "阿里云百炼应用配置",
"type": "object",
"condition": {
"provider_settings.enable": True,
"agent_runner.runner_type": "dashscope",
},
"items": {
"agent_runner.config.dashscope_app_type": {
"description": "应用类型",
"type": "string",
"options": ["agent", "workflow"],
},
"agent_runner.config.dashscope_api_key": {
"description": "API Key",
"type": "string",
},
"agent_runner.config.dashscope_app_id": {
"description": "应用 ID",
"type": "string",
},
"agent_runner.config.rag_options.pipeline_ids": {
"description": "知识库 Pipeline ID",
"type": "list",
"items": {"type": "string"},
},
"agent_runner.config.rag_options.file_ids": {
"description": "文件 ID",
"type": "list",
"items": {"type": "string"},
},
"agent_runner.config.rag_options.output_reference": {
"description": "输出引用",
"type": "bool",
},
"agent_runner.config.variables": {
"description": "变量",
"type": "dict",
},
"agent_runner.config.timeout": {
"description": "超时时间(秒)",
"type": "int",
},
"agent_runner.config.proxy": {
"description": "代理地址",
"type": "string",
},
},
},
"deerflow_runner": {
"description": "DeerFlow 配置",
"type": "object",
"condition": {
"provider_settings.enable": True,
"agent_runner.runner_type": "deerflow",
},
"items": {
"agent_runner.config.deerflow_api_base": {
"description": "API Base URL",
"type": "string",
},
"agent_runner.config.deerflow_api_key": {
"description": "API Key",
"type": "string",
},
"agent_runner.config.deerflow_auth_header": {
"description": "Authorization Header",
"type": "string",
},
"agent_runner.config.deerflow_assistant_id": {
"description": "Assistant ID",
"type": "string",
},
"agent_runner.config.deerflow_model_name": {
"description": "模型名称覆盖",
"type": "string",
},
"agent_runner.config.deerflow_thinking_enabled": {
"description": "启用思考模式",
"type": "bool",
},
"agent_runner.config.deerflow_plan_mode": {
"description": "启用计划模式",
"type": "bool",
},
"agent_runner.config.deerflow_subagent_enabled": {
"description": "启用子智能体",
"type": "bool",
},
"agent_runner.config.deerflow_max_concurrent_subagents": {
"description": "子智能体最大并发数",
"type": "int",
},
"agent_runner.config.deerflow_recursion_limit": {
"description": "递归深度上限",
"type": "int",
},
"agent_runner.config.timeout": {
"description": "超时时间(秒)",
"type": "int",
},
"agent_runner.config.proxy": {
"description": "代理地址",
"type": "string",
},
},
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力",
"hint": "配置内置 Agent 使用的对话模型,以及通用的图片转述、语音模型",
"type": "object",
"items": {
"provider_settings.default_provider_id": {
"agent_runner.config.model.provider_id": {
"description": "默认对话模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时使用第一个模型",
"condition": {
"agent_runner.runner_type": "local",
},
},
"provider_settings.fallback_chat_models": {
"agent_runner.config.model.fallback_provider_ids": {
"description": "回退对话模型列表",
"type": "list",
"items": {"type": "string"},
"_special": "select_providers",
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
"condition": {
"agent_runner.runner_type": "local",
},
},
"provider_settings.request_max_retries": {
"agent_runner.config.model.request_max_retries": {
"description": "请求最大重试次数",
"type": "int",
"hint": "单次模型请求遇到可重试错误时的最大尝试次数。",
"condition": {
"agent_runner.runner_type": "local",
},
},
"provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型",
@@ -3504,14 +3548,28 @@ CONFIG_METADATA_3 = {
"hint": "",
"type": "object",
"items": {
"provider_settings.default_personality": {
"agent_runner.config.persona.persona_id": {
"description": "默认采用的人格",
"type": "string",
"_special": "select_persona",
},
"agent_runner.config.persona.safety_mode": {
"description": "健康模式",
"type": "bool",
"hint": "引导模型输出健康、安全的内容,避免有害或敏感话题。",
},
"agent_runner.config.persona.safety_mode_strategy": {
"description": "健康模式策略",
"type": "string",
"options": ["system_prompt"],
"hint": "选择健康模式的实现策略。",
"condition": {
"agent_runner.config.persona.safety_mode": True,
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3544,7 +3602,7 @@ CONFIG_METADATA_3 = {
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3646,7 +3704,7 @@ CONFIG_METADATA_3 = {
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3812,7 +3870,7 @@ CONFIG_METADATA_3 = {
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3842,7 +3900,7 @@ CONFIG_METADATA_3 = {
# },
# },
# "condition": {
# "provider_settings.agent_runner_type": "local",
# "agent_runner.runner_type": "local",
# "provider_settings.enable": True,
# },
# },
@@ -3858,7 +3916,7 @@ CONFIG_METADATA_3 = {
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3867,72 +3925,72 @@ CONFIG_METADATA_3 = {
"description": "上下文管理策略",
"type": "object",
"items": {
"provider_settings.max_context_length": {
"agent_runner.config.compression.max_turns": {
"description": "压缩前最多保留对话轮数",
"type": "int",
"hint": "普通会话历史超过该轮数后,才会按下方策略进行持久化截断或 LLM 压缩;请求发送前也会先按该值约束上下文。-1 表示不按轮数限制。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.dequeue_context_length": {
"agent_runner.config.compression.trim_turns": {
"description": "轮次超限时一次丢弃轮数",
"type": "int",
"hint": "当超过“压缩前最多保留对话轮数”且无法使用 LLM 压缩时,一次丢弃多少轮旧对话;请求期截断也会复用该值。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.context_limit_reached_strategy": {
"agent_runner.config.compression.overflow_strategy": {
"description": "历史超限或上下文接近上限时的处理方式",
"type": "string",
"options": ["truncate_by_turns", "llm_compress"],
"labels": ["按对话轮数截断", "由 LLM 压缩上下文"],
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"hint": "普通会话历史仅在超过“压缩前最多保留对话轮数”后执行该策略;请求发送前也会在上下文 token 接近模型窗口时使用同一策略保护本次请求。",
},
"provider_settings.llm_compress_instruction": {
"agent_runner.config.compression.instruction": {
"description": "上下文压缩提示词",
"type": "text",
"hint": "如果为空则使用默认提示词。",
"condition": {
"provider_settings.context_limit_reached_strategy": "llm_compress",
"provider_settings.agent_runner_type": "local",
"agent_runner.config.compression.overflow_strategy": "llm_compress",
"agent_runner.runner_type": "local",
},
},
"provider_settings.llm_compress_keep_recent_ratio": {
"agent_runner.config.compression.keep_recent_ratio": {
"description": "压缩时保留最近上下文比例",
"type": "float",
"slider": {"min": 0, "max": 0.3, "step": 0.01},
"hint": "按当前上下文 token 数保留最近内容,范围 0-0.3。0.15 表示保留 15%;比例大于 0 时至少保留最后一轮。",
"condition": {
"provider_settings.context_limit_reached_strategy": "llm_compress",
"provider_settings.agent_runner_type": "local",
"agent_runner.config.compression.overflow_strategy": "llm_compress",
"agent_runner.runner_type": "local",
},
},
"provider_settings.llm_compress_provider_id": {
"agent_runner.config.compression.provider_id": {
"description": "用于上下文压缩的模型提供商 ID",
"type": "string",
"_special": "select_provider",
"hint": "留空时使用当前聊天模型进行压缩;如果模型不可用或压缩失败,将回退为“按对话轮数截断”的策略。",
"condition": {
"provider_settings.context_limit_reached_strategy": "llm_compress",
"provider_settings.agent_runner_type": "local",
"agent_runner.config.compression.overflow_strategy": "llm_compress",
"agent_runner.runner_type": "local",
},
},
"provider_settings.fallback_max_context_tokens": {
"agent_runner.config.compression.fallback_max_tokens": {
"description": "上下文窗口兜底值",
"type": "int",
"hint": "当 max_context_tokens 为 0 且模型不在内置元数据中时,使用此值作为上下文窗口大小。默认 128000。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.enable": True,
},
},
@@ -3944,7 +4002,7 @@ CONFIG_METADATA_3 = {
"description": "显示思考内容",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.streaming_response": {
@@ -3961,20 +4019,6 @@ CONFIG_METADATA_3 = {
"provider_settings.streaming_response": True,
},
},
"provider_settings.llm_safety_mode": {
"description": "健康模式",
"type": "bool",
"hint": "引导模型输出健康、安全的内容,避免有害或敏感话题。",
},
"provider_settings.safety_mode_strategy": {
"description": "健康模式策略",
"type": "string",
"options": ["system_prompt"],
"hint": "选择健康模式的实现策略。",
"condition": {
"provider_settings.llm_safety_mode": True,
},
},
"provider_settings.identifier": {
"description": "用户识别",
"type": "bool",
@@ -3990,14 +4034,14 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "启用后,会在系统提示词中附带当前时间信息。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.show_tool_use_status": {
"description": "输出函数调用状态",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.show_tool_call_result": {
@@ -4005,7 +4049,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.show_tool_use_status": True,
},
},
@@ -4014,40 +4058,40 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
"provider_settings.streaming_response": False,
},
},
"provider_settings.sanitize_context_by_modalities": {
"agent_runner.config.misc.sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"type": "bool",
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.max_agent_step": {
"agent_runner.config.misc.max_steps": {
"description": "工具调用轮数上限",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.tool_call_timeout": {
"agent_runner.config.misc.tool_call_timeout": {
"description": "工具调用超时时间(秒)",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.tool_schema_mode": {
"agent_runner.config.misc.tool_schema_mode": {
"description": "工具调用模式",
"type": "string",
"options": ["skills_like", "full"],
"labels": ["Skills-like(两阶段)", "Full(完整参数)"],
"hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
},
"provider_settings.wake_prefix": {
@@ -4100,7 +4144,7 @@ CONFIG_METADATA_3 = {
"type": "int",
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"collapsed": True,
},
@@ -4109,7 +4153,7 @@ CONFIG_METADATA_3 = {
"type": "int",
"hint": "解析 Reply 组件链时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"collapsed": True,
},
@@ -4118,7 +4162,7 @@ CONFIG_METADATA_3 = {
"type": "int",
"hint": "解析合并转发节点时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"collapsed": True,
},
@@ -4127,7 +4171,7 @@ CONFIG_METADATA_3 = {
"type": "int",
"hint": "递归拉取 get_forward_msg 的最大次数。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"collapsed": True,
},
@@ -4136,7 +4180,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
"condition": {
"provider_settings.agent_runner_type": "local",
"agent_runner.runner_type": "local",
},
"collapsed": True,
},
+3 -4
View File
@@ -126,8 +126,7 @@ class AstrBotCoreLifecycle:
if len(providers) == 0:
return
provider_settings = getattr(pm, "provider_settings", None) or {}
default_id = provider_settings.get("default_provider_id")
default_id = getattr(pm, "default_chat_provider_id", "")
fallback = pm.curr_provider_inst or providers[0]
fallback_id = fallback.provider_config.get("id") or "unknown"
@@ -136,7 +135,7 @@ class AstrBotCoreLifecycle:
return
self._default_chat_provider_warning_emitted = True
logger.warning(
"Detected %d enabled chat providers but `provider_settings.default_provider_id` is empty. "
"Detected %d enabled chat providers but `agent_runner.config.model.provider_id` is empty. "
"AstrBot will use `%s` as the startup fallback chat provider. "
"Set a default chat model in the WebUI configuration page to avoid unexpected provider switching.",
len(providers),
@@ -148,7 +147,7 @@ class AstrBotCoreLifecycle:
if not found:
self._default_chat_provider_warning_emitted = True
logger.warning(
"Configured `default_provider_id` is `%s` but no enabled provider matches that ID. "
"Configured Agent Runner model provider ID `%s` does not match an enabled provider. "
"AstrBot will use `%s` as the fallback chat provider. "
"Please check the WebUI configuration page.",
default_id,
+11 -3
View File
@@ -442,12 +442,20 @@ class CronJobManager:
cron_event.role = "admin"
provider_settings = cfg.get("provider_settings", {}) or {}
tool_call_timeout = provider_settings.get("tool_call_timeout", 120)
tool_call_timeout = (
cfg.get("agent_runner", {})
.get("config", {})
.get("misc", {})
.get("tool_call_timeout", 120)
)
agent_max_step = coerce_int_config(
provider_settings.get("max_agent_step", 30),
cfg.get("agent_runner", {})
.get("config", {})
.get("misc", {})
.get("max_steps", 30),
default=30,
min_value=1,
field_name="provider_settings.max_agent_step",
field_name="agent_runner.config.misc.max_steps",
)
config = MainAgentBuildConfig(
tool_call_timeout=tool_call_timeout,
+21 -6
View File
@@ -23,8 +23,13 @@ class PersonaManager:
def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager) -> None:
self.db = db_helper
self.acm = acm
default_ps = acm.default_conf.get("provider_settings", {})
self.default_persona: str = default_ps.get("default_personality", "default")
default_runner = acm.default_conf.get("agent_runner", {})
default_runner_config = default_runner.get("config", {})
self.default_persona: str = (
default_runner_config.get("persona", {}).get("persona_id", "default")
if default_runner.get("runner_type") == "local"
else default_runner_config.get("persona_id", "default")
)
self.personas: list[Persona] = []
self.selected_default_persona: Persona | None = None
@@ -66,9 +71,12 @@ class PersonaManager:
) -> Personality:
"""获取默认 persona"""
cfg = self.acm.get_conf(umo)
default_persona_id = cfg.get("provider_settings", {}).get(
"default_personality",
"default",
agent_runner = cfg.get("agent_runner", {})
runner_config = agent_runner.get("config", {})
default_persona_id = (
runner_config.get("persona", {}).get("persona_id", "default")
if agent_runner.get("runner_type") == "local"
else runner_config.get("persona_id", "default")
)
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
@@ -107,7 +115,14 @@ class PersonaManager:
if persona_id == "[%None]":
pass
elif persona_id is None:
persona_id = (provider_settings or {}).get("default_personality")
cfg = self.acm.get_conf(umo)
agent_runner = cfg.get("agent_runner", {})
runner_config = agent_runner.get("config", {})
persona_id = (
runner_config.get("persona", {}).get("persona_id", "default")
if agent_runner.get("runner_type") == "local"
else runner_config.get("persona_id", "default")
)
persona = next(
(item for item in self.personas_v3 if item["name"] == persona_id),
@@ -1,6 +1,7 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.config.agent_runner import normalize_agent_runner
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.session_llm_manager import SessionServiceManager
@@ -26,7 +27,9 @@ class AgentRequestSubStage(Stage):
)
self.prov_wake_prefix = self.prov_wake_prefix[len(bwp) :]
agent_runner_type = self.config["provider_settings"]["agent_runner_type"]
agent_runner = normalize_agent_runner(self.config.get("agent_runner"))
self.config["agent_runner"] = agent_runner
agent_runner_type = agent_runner["runner_type"]
if agent_runner_type == "local":
self.agent_sub_stage = InternalAgentSubStage()
else:
@@ -55,13 +55,18 @@ class InternalAgentSubStage(Stage):
self.ctx = ctx
conf = ctx.astrbot_config
settings = conf["provider_settings"]
runner_config = conf["agent_runner"]["config"]
model_config = runner_config["model"]
persona_config = runner_config["persona"]
compression_config = runner_config["compression"]
misc_config = runner_config["misc"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
"unsupported_streaming_strategy"
]
self.max_step: int = settings.get("max_agent_step", 30)
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
self.max_step: int = misc_config.get("max_steps", 30)
self.tool_call_timeout: int = misc_config.get("tool_call_timeout", 120)
self.tool_schema_mode: str = misc_config.get("tool_schema_mode", "full")
if self.tool_schema_mode not in ("skills_like", "full"):
logger.warning(
"Unsupported tool_schema_mode: %s, fallback to skills_like",
@@ -77,7 +82,7 @@ class InternalAgentSubStage(Stage):
False,
)
self.show_reasoning = settings.get("display_reasoning_text", False)
self.sanitize_context_by_modalities: bool = settings.get(
self.sanitize_context_by_modalities: bool = misc_config.get(
"sanitize_context_by_modalities",
False,
)
@@ -91,31 +96,29 @@ class InternalAgentSubStage(Stage):
)
# 上下文管理相关
self.context_limit_reached_strategy: str = settings.get(
"context_limit_reached_strategy", "truncate_by_turns"
self.context_limit_reached_strategy: str = compression_config.get(
"overflow_strategy", "truncate_by_turns"
)
self.llm_compress_instruction: str = settings.get(
"llm_compress_instruction", ""
self.llm_compress_instruction: str = compression_config.get("instruction", "")
self.llm_compress_keep_recent_ratio: float = compression_config.get(
"keep_recent_ratio", 0.15
)
self.llm_compress_keep_recent_ratio: float = settings.get(
"llm_compress_keep_recent_ratio", 0.15
)
self.llm_compress_provider_id: str = settings.get(
"llm_compress_provider_id", ""
)
self.max_context_length = settings["max_context_length"] # int
self.llm_compress_provider_id: str = compression_config.get("provider_id", "")
self.max_context_length = compression_config.get("max_turns", -1)
self.dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
self.max_context_length - 1,
max(1, compression_config.get("trim_turns", 1)),
self.max_context_length - 1
if self.max_context_length > 0
else compression_config.get("trim_turns", 1),
)
if self.dequeue_context_length <= 0:
self.dequeue_context_length = 1
self.fallback_max_context_tokens: int = settings.get(
"fallback_max_context_tokens", 128000
self.fallback_max_context_tokens: int = compression_config.get(
"fallback_max_tokens", 128000
)
self.llm_safety_mode = settings.get("llm_safety_mode", True)
self.safety_mode_strategy = settings.get(
self.llm_safety_mode = persona_config.get("safety_mode", True)
self.safety_mode_strategy = persona_config.get(
"safety_mode_strategy", "system_prompt"
)
@@ -148,7 +151,12 @@ class InternalAgentSubStage(Stage):
computer_use_runtime=self.computer_use_runtime,
sandbox_cfg=self.sandbox_cfg,
add_cron_tools=self.add_cron_tools,
provider_settings=settings,
provider_settings={
**settings,
"default_personality": persona_config.get("persona_id", "default"),
},
fallback_provider_ids=model_config.get("fallback_provider_ids", []),
request_max_retries=model_config.get("request_max_retries", 5),
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
@@ -3,15 +3,12 @@ import inspect
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import TYPE_CHECKING
from astrbot.core import astrbot_config, logger
from astrbot.core import logger
from astrbot.core.agent.runners.coze.coze_agent_runner import CozeAgentRunner
from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
DashscopeAgentRunner,
)
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
)
from astrbot.core.agent.runners.deerflow.constants import DEERFLOW_PROVIDER_TYPE
from astrbot.core.agent.runners.deerflow.deerflow_agent_runner import (
DeerFlowAgentRunner,
)
@@ -44,12 +41,6 @@ from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
from ....context import PipelineContext, call_event_hook
AGENT_RUNNER_TYPE_KEY = {
"dify": "dify_agent_runner_provider_id",
"coze": "coze_agent_runner_provider_id",
"dashscope": "dashscope_agent_runner_provider_id",
DEERFLOW_PROVIDER_TYPE: DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
}
THIRD_PARTY_RUNNER_ERROR_EXTRA_KEY = "_third_party_runner_error"
STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC = 30
RUNNER_NO_RESULT_FALLBACK_MESSAGE = "Agent Runner did not return any result."
@@ -165,11 +156,9 @@ class ThirdPartyAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.conf = ctx.astrbot_config
self.runner_type = self.conf["provider_settings"]["agent_runner_type"]
self.prov_id = self.conf["provider_settings"].get(
AGENT_RUNNER_TYPE_KEY.get(self.runner_type, ""),
"",
)
agent_runner = self.conf["agent_runner"]
self.runner_type = agent_runner["runner_type"]
self.runner_config = agent_runner["config"]
settings = ctx.astrbot_config["provider_settings"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
@@ -197,7 +186,7 @@ class ThirdPartyAgentSubStage(Stage):
return await resolve_persona_custom_error_message(
event=event,
persona_manager=self.ctx.plugin_manager.context.persona_manager,
provider_settings=self.conf["provider_settings"],
provider_settings={"default_personality": "default"},
conversation_persona_id=conversation_persona_id,
)
except Exception as e:
@@ -296,23 +285,6 @@ class ThirdPartyAgentSubStage(Stage):
):
return
self.prov_cfg: dict = next(
(p for p in astrbot_config["provider"] if p["id"] == self.prov_id),
{},
)
if not self.prov_id:
logger.error(
"No Agent Runner provider ID is configured. Configure one on the "
"settings page."
)
return
if not self.prov_cfg:
logger.error(
f"Configuration for Agent Runner provider {self.prov_id} does not "
"exist. Update it on the settings page."
)
return
# make provider request
req = ProviderRequest()
req.session_id = event.unified_msg_origin
@@ -388,7 +360,7 @@ class ThirdPartyAgentSubStage(Stage):
tool_call_timeout=120,
),
agent_hooks=MAIN_AGENT_HOOKS,
provider_config=self.prov_cfg,
provider_config=self.runner_config,
streaming=streaming_response,
)
+15 -6
View File
@@ -45,6 +45,13 @@ class ProviderManager:
self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"]
agent_runner = config.get("agent_runner", {})
agent_runner_config = agent_runner.get("config", {})
self.default_chat_provider_id = (
agent_runner_config.get("model", {}).get("provider_id", "")
if agent_runner.get("runner_type") == "local"
else ""
)
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -240,7 +247,12 @@ class ProviderManager:
# default setting
config = self.acm.get_conf(umo)
if provider_type == ProviderType.CHAT_COMPLETION:
provider_id = config["provider_settings"].get("default_provider_id")
agent_runner = config.get("agent_runner", {})
provider_id = (
agent_runner.get("config", {}).get("model", {}).get("provider_id")
if agent_runner.get("runner_type") == "local"
else None
)
provider = self.inst_map.get(provider_id)
if not provider:
provider = self.provider_insts[0] if self.provider_insts else None
@@ -337,7 +349,7 @@ class ProviderManager:
selected_provider_id = await sp.get_async(
key="curr_provider",
default=self.provider_settings.get("default_provider_id"),
default=self.default_chat_provider_id,
scope="global",
scope_id="global",
)
@@ -760,10 +772,7 @@ class ProviderManager:
await inst.initialize()
self.provider_insts.append(inst)
if (
self.provider_settings.get("default_provider_id")
== provider_config["id"]
):
if self.default_chat_provider_id == provider_config["id"]:
self.curr_provider_inst = inst
logger.info(
f"Selected {provider_config['type']}({provider_config['id']}) as default chat model provider",
+398 -106
View File
@@ -1,60 +1,375 @@
from __future__ import annotations
import copy
import json
import logging
import traceback
from pathlib import Path
from typing import Any
from astrbot.core import astrbot_config, logger
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
from astrbot.core.config.agent_runner import (
AGENT_RUNNER_TYPES,
THIRD_PARTY_AGENT_RUNNER_TYPES,
get_agent_runner_config_default,
normalize_agent_runner,
)
from astrbot.core.astrbot_config_mgr import AstrBotConfig, AstrBotConfigManager
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_token_usage import migrate_token_usage
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
)
logger = logging.getLogger("astrbot")
_LEGACY_AGENT_RUNNER_PROVIDER_ID_KEYS = {
"dify": "dify_agent_runner_provider_id",
"coze": "coze_agent_runner_provider_id",
"dashscope": "dashscope_agent_runner_provider_id",
"deerflow": "deerflow_agent_runner_provider_id",
}
_LEGACY_AGENT_RUNNER_SETTING_KEYS = (
"agent_runner_type",
*_LEGACY_AGENT_RUNNER_PROVIDER_ID_KEYS.values(),
"default_provider_id",
"fallback_chat_models",
"request_max_retries",
"default_personality",
"llm_safety_mode",
"safety_mode_strategy",
"max_agent_step",
"tool_schema_mode",
"tool_call_timeout",
"sanitize_context_by_modalities",
"context_limit_reached_strategy",
"llm_compress_instruction",
"llm_compress_keep_recent_ratio",
"llm_compress_provider_id",
"max_context_length",
"dequeue_context_length",
"fallback_max_context_tokens",
)
_LEGACY_PROVIDER_IDENTITY_FIELDS = {
"id",
"type",
"provider",
"provider_type",
"enable",
"provider_source_id",
"model_config",
}
def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
def _get_effective_provider_map(config: object) -> dict[str, dict[str, Any]]:
"""Build providers with their Provider Source fields merged in.
Args:
config: Configuration containing provider and provider_sources lists.
Returns:
Effective providers indexed by provider ID.
"""
Migra agent runner configs from provider configs.
"""
try:
default_prov_id = conf["provider_settings"]["default_provider_id"]
if default_prov_id in ids_map:
conf["provider_settings"]["default_provider_id"] = ""
p = ids_map[default_prov_id]
if p["type"] == "dify":
conf["provider_settings"]["dify_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "dify"
elif p["type"] == "coze":
conf["provider_settings"]["coze_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "coze"
elif p["type"] == "dashscope":
conf["provider_settings"]["dashscope_agent_runner_provider_id"] = p[
"id"
]
conf["provider_settings"]["agent_runner_type"] = "dashscope"
elif p["type"] == DEERFLOW_PROVIDER_TYPE:
conf["provider_settings"][DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY] = p[
"id"
]
conf["provider_settings"]["agent_runner_type"] = DEERFLOW_PROVIDER_TYPE
conf.save_config()
except Exception as e:
logger.error(f"Migration for third party agent runner configs failed: {e!s}")
logger.error(traceback.format_exc())
if not isinstance(config, dict):
return {}
provider_sources = config.get("provider_sources", [])
source_map = {
source.get("id"): source
for source in provider_sources
if isinstance(source, dict) and source.get("id")
}
provider_map: dict[str, dict[str, Any]] = {}
for provider in config.get("provider", []):
if not isinstance(provider, dict) or not provider.get("id"):
continue
effective_provider = copy.deepcopy(
source_map.get(provider.get("provider_source_id"), {})
)
effective_provider.update(copy.deepcopy(provider))
provider_map[provider["id"]] = effective_provider
return provider_map
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
def _get_provider_runner_type(provider: object) -> str | None:
"""Return the third-party runner type represented by a provider.
Args:
provider: Effective provider configuration.
Returns:
Runner type when the provider is a known Agent Runner, otherwise None.
"""
Migrate old provider structure to new provider-source separation.
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
All other fields move to provider_sources.
if not isinstance(provider, dict):
return None
provider_type = provider.get("provider_type")
runner_type = provider.get("type") or provider.get("provider")
if (
provider_type == "agent_runner"
and runner_type in THIRD_PARTY_AGENT_RUNNER_TYPES
):
return runner_type
expected_field = {
"dify": "dify_api_key",
"coze": "coze_api_key",
"dashscope": "dashscope_app_id",
"deerflow": "deerflow_api_base",
}
if (
runner_type in THIRD_PARTY_AGENT_RUNNER_TYPES
and expected_field[runner_type] in provider
):
return runner_type
return None
def _copy_provider_config(
runner_type: str,
provider: dict[str, Any],
) -> dict[str, Any]:
"""Copy an effective legacy provider into an inline runner configuration.
Args:
runner_type: Destination Agent Runner type.
provider: Effective provider configuration.
Returns:
Normalized inline runner configuration.
"""
runner_config = {
key: copy.deepcopy(value)
for key, value in provider.items()
if key not in _LEGACY_PROVIDER_IDENTITY_FIELDS
}
return normalize_agent_runner(
{"runner_type": runner_type, "config": runner_config}
)["config"]
def _migrate_agent_runner_config(
config: dict[str, Any],
fallback_config: dict[str, Any] | None = None,
) -> bool:
"""Migrate legacy Agent Runner fields in one core configuration.
Args:
config: Mutable AstrBot configuration loaded from disk.
fallback_config: Default configuration used to resolve shared providers.
Returns:
Whether the configuration changed.
"""
changed = False
provider_settings = config.get("provider_settings")
if not isinstance(provider_settings, dict):
provider_settings = {}
config["provider_settings"] = provider_settings
changed = True
existing_agent_runner = config.get("agent_runner")
config_version = config.get("config_version")
legacy_version = not isinstance(config_version, int) or config_version < 3
default_local_agent_runner = {
"runner_type": "local",
"config": get_agent_runner_config_default("local"),
}
default_root_inserted_before_migration = (
legacy_version
and existing_agent_runner == default_local_agent_runner
and any(key in provider_settings for key in _LEGACY_AGENT_RUNNER_SETTING_KEYS)
)
if isinstance(existing_agent_runner, dict) and not (
default_root_inserted_before_migration
):
for key in _LEGACY_AGENT_RUNNER_SETTING_KEYS:
if key in provider_settings:
provider_settings.pop(key)
changed = True
else:
provider_map = _get_effective_provider_map(fallback_config)
provider_map.update(_get_effective_provider_map(config))
runner_type = provider_settings.get("agent_runner_type", "local")
if runner_type not in AGENT_RUNNER_TYPES:
runner_type = "local"
default_provider_id = provider_settings.get("default_provider_id", "")
if not isinstance(default_provider_id, str):
default_provider_id = ""
default_provider = provider_map.get(default_provider_id)
default_provider_runner_type = _get_provider_runner_type(default_provider)
if runner_type == "local" and default_provider_runner_type:
runner_type = default_provider_runner_type
if runner_type == "local":
persona_id = provider_settings.get("default_personality", "default")
if not isinstance(persona_id, str) or not persona_id:
persona_id = "default"
runner_config = get_agent_runner_config_default("local")
runner_config["model"] = {
"provider_id": default_provider_id,
"fallback_provider_ids": copy.deepcopy(
provider_settings.get("fallback_chat_models", [])
),
"request_max_retries": provider_settings.get("request_max_retries", 5),
}
runner_config["persona"] = {
"persona_id": persona_id,
"safety_mode": provider_settings.get("llm_safety_mode", True),
"safety_mode_strategy": provider_settings.get(
"safety_mode_strategy", "system_prompt"
),
}
runner_config["compression"] = {
"max_turns": provider_settings.get("max_context_length", -1),
"trim_turns": provider_settings.get("dequeue_context_length", 1),
"overflow_strategy": provider_settings.get(
"context_limit_reached_strategy", "llm_compress"
),
"instruction": provider_settings.get("llm_compress_instruction", ""),
"keep_recent_ratio": provider_settings.get(
"llm_compress_keep_recent_ratio", 0.15
),
"provider_id": provider_settings.get("llm_compress_provider_id", ""),
"fallback_max_tokens": provider_settings.get(
"fallback_max_context_tokens", 128000
),
}
runner_config["misc"] = {
"max_steps": provider_settings.get("max_agent_step", 30),
"tool_schema_mode": provider_settings.get("tool_schema_mode", "full"),
"tool_call_timeout": provider_settings.get("tool_call_timeout", 120),
"sanitize_context_by_modalities": provider_settings.get(
"sanitize_context_by_modalities", False
),
}
runner_config = normalize_agent_runner(
{"runner_type": "local", "config": runner_config}
)["config"]
available_model_provider_ids = {
provider_id
for provider_id, provider in provider_map.items()
if provider.get("provider_type") != "agent_runner"
and _get_provider_runner_type(provider) is None
}
if (
runner_config["model"]["provider_id"]
not in available_model_provider_ids
):
runner_config["model"]["provider_id"] = ""
runner_config["model"]["fallback_provider_ids"] = [
provider_id
for provider_id in runner_config["model"]["fallback_provider_ids"]
if provider_id in available_model_provider_ids
]
if (
runner_config["compression"]["provider_id"]
not in available_model_provider_ids
):
runner_config["compression"]["provider_id"] = ""
else:
provider_id = provider_settings.get(
_LEGACY_AGENT_RUNNER_PROVIDER_ID_KEYS[runner_type], ""
)
if not provider_id and default_provider_runner_type == runner_type:
provider_id = default_provider_id
provider = provider_map.get(provider_id)
if provider and _get_provider_runner_type(provider) == runner_type:
runner_config = _copy_provider_config(runner_type, provider)
else:
runner_config = get_agent_runner_config_default(runner_type)
config["agent_runner"] = {
"runner_type": runner_type,
"config": runner_config,
}
for key in _LEGACY_AGENT_RUNNER_SETTING_KEYS:
provider_settings.pop(key, None)
changed = True
if config.get("config_version") != 3:
config["config_version"] = 3
changed = True
return changed
def migrate_config_on_load(config: dict[str, Any], config_path: Path) -> bool:
"""Run core configuration migrations before integrity cleanup.
Profile configurations can reference providers stored in the default
configuration, which has already been loaded and persisted at this point.
Args:
config: Mutable AstrBot configuration loaded from disk.
config_path: Path of the configuration being loaded.
Returns:
Whether the configuration changed.
"""
fallback_config = None
resolved_path = config_path.resolve()
profile_root = Path(get_astrbot_config_path()).resolve()
if resolved_path.is_relative_to(profile_root):
default_path = Path(get_astrbot_data_path()) / "cmd_config.json"
try:
with default_path.open(encoding="utf-8-sig") as default_file:
loaded_default = json.load(default_file)
if isinstance(loaded_default, dict):
fallback_config = loaded_default
except (OSError, json.JSONDecodeError) as exc:
logger.warning(
"Failed to load default configuration while migrating %s: %s",
resolved_path,
exc,
)
return _migrate_agent_runner_config(config, fallback_config)
def finalize_config_migrations(configs: list[dict[str, Any]]) -> bool:
"""Clean legacy shared data after every profile has been migrated.
Args:
configs: Loaded configurations with the default configuration first.
Returns:
Whether the default configuration changed.
"""
if not configs:
return False
default_config = configs[0]
providers = default_config.get("provider", [])
if not isinstance(providers, list):
return False
effective_provider_map = _get_effective_provider_map(default_config)
filtered_providers = [
provider
for provider in providers
if not (
isinstance(provider, dict)
and (
provider.get("provider_type") == "agent_runner"
or effective_provider_map.get(provider.get("id"), {}).get(
"provider_type"
)
== "agent_runner"
or _get_provider_runner_type(
effective_provider_map.get(provider.get("id"), provider)
)
is not None
)
)
]
if len(filtered_providers) == len(providers):
return False
default_config["provider"] = filtered_providers
return True
def _migra_provider_to_source_structure(conf: Any) -> None:
"""Migrate old providers to the provider-source structure.
Args:
conf: Mutable default configuration with a save_config method.
"""
providers = conf.get("provider", [])
provider_sources = conf.get("provider_sources", [])
# Track if any migration happened
migrated = False
# Provider-only fields that should stay in provider
provider_only_fields = {
"id",
"provider_source_id",
@@ -63,63 +378,44 @@ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
"custom_extra_body",
"enable",
}
# Fields that should not go to source
source_exclude_fields = provider_only_fields | {"model_config"}
for provider in providers:
# Skip if already has provider_source_id
if provider.get("provider_source_id"):
continue
# Skip non-chat-completion types (they don't need source separation)
provider_type = provider.get("provider_type", "")
if provider_type != "chat_completion":
# For old types without provider_type, check type field
old_type = provider.get("type", "")
if "chat_completion" not in old_type:
continue
migrated = True
logger.info(f"Migrating provider {provider.get('id')} to new structure")
# Extract source fields from provider
source_fields = {}
for key, value in list(provider.items()):
if key not in source_exclude_fields:
source_fields[key] = value
# Create new provider_source
logger.info("Migrating provider %s to new structure", provider.get("id"))
source_fields = {
key: value
for key, value in list(provider.items())
if key not in source_exclude_fields
}
source_id = provider.get("id", "") + "_source"
new_source = {"id": source_id, **source_fields}
# Update provider to only keep necessary fields
provider["provider_source_id"] = source_id
# Extract model from model_config if exists
if "model_config" in provider and isinstance(provider["model_config"], dict):
model_config = provider["model_config"]
provider["model"] = model_config.get("model", "")
# Put other model_config fields into custom_extra_body
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
if extra_body_fields:
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
provider["custom_extra_body"].update(extra_body_fields)
# Initialize new fields if not present
if "modalities" not in provider:
provider["modalities"] = []
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
# Remove fields that should be in source
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
keys_to_remove = [key for key in provider if key not in provider_only_fields]
for key in keys_to_remove:
del provider[key]
# Add source to provider_sources
provider_sources.append(new_source)
if migrated:
@@ -129,55 +425,51 @@ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
db: Any, astrbot_config_mgr: Any, umop_config_router: Any, acm: Any
) -> None:
"""Run migrations that require initialized configuration or database state.
Args:
db: Initialized AstrBot database.
astrbot_config_mgr: Configuration manager used by legacy migrations.
umop_config_router: Initialized UMOP configuration router.
acm: Initialized AstrBot configuration manager.
"""
Stores the migration logic here.
btw, i really don't like migration :(
"""
# 4.5 to 4.6 migration for umop_config_router
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_token_usage import migrate_token_usage
from astrbot.core.db.migration.migra_webchat_session import (
migrate_webchat_session,
)
try:
await migrate_45_to_46(astrbot_config_mgr, umop_config_router)
except Exception as e:
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
except Exception as exc:
logger.error("Migration from version 4.5 to 4.6 failed: %s", exc)
logger.error(traceback.format_exc())
# migration for webchat session
try:
await migrate_webchat_session(db)
except Exception as e:
logger.error(f"Migration for webchat session failed: {e!s}")
except Exception as exc:
logger.error("Migration for webchat session failed: %s", exc)
logger.error(traceback.format_exc())
# migration for token_usage column
try:
await migrate_token_usage(db)
except Exception as e:
logger.error(f"Migration for token_usage column failed: {e!s}")
except Exception as exc:
logger.error("Migration for token_usage column failed: %s", exc)
logger.error(traceback.format_exc())
# migra third party agent runner configs
_c = False
providers = astrbot_config["provider"]
ids_map = {}
for prov in providers:
type_ = prov.get("type")
if type_ in ["dify", "coze", "dashscope", DEERFLOW_PROVIDER_TYPE]:
prov["provider_type"] = "agent_runner"
ids_map[prov["id"]] = {
"type": type_,
"id": prov["id"],
}
_c = True
if _c:
astrbot_config.save_config()
for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map)
# Migrate providers to new structure: extract source fields to provider_sources
configs = list(acm.confs.values())
try:
_migra_provider_to_source_structure(astrbot_config)
except Exception as e:
logger.error(f"Migration for provider-source structure failed: {e!s}")
if finalize_config_migrations(configs):
configs[0].save_config()
logger.info("Agent Runner configuration migration completed")
except Exception as exc:
logger.error("Agent Runner configuration migration failed: %s", exc)
logger.error(traceback.format_exc())
try:
_migra_provider_to_source_structure(acm.default_conf)
except Exception as exc:
logger.error("Migration for provider-source structure failed: %s", exc)
logger.error(traceback.format_exc())
-1
View File
@@ -527,7 +527,6 @@ class ProviderConfigRequest(OpenModel):
if self.capability and "provider_type" not in config:
capability_map = {
"chat": "chat_completion",
"agent": "agent_runner",
"stt": "speech_to_text",
"tts": "text_to_speech",
"embedding": "embedding",
+17 -3
View File
@@ -11,6 +11,7 @@ from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from astrbot.core import file_token_service, logger
from astrbot.core.config.agent_runner import normalize_agent_runner
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import (
CONFIG_METADATA_2,
@@ -427,6 +428,9 @@ def save_config(
) -> None:
if is_core:
_log_computer_config_changes(dict(config), post_config)
post_config["agent_runner"] = normalize_agent_runner(
post_config.get("agent_runner")
)
try:
if is_core:
@@ -532,9 +536,14 @@ class ConfigProfileService:
"config:edit_admin scope is required to change admins_id",
status_code=403,
)
profile_config = copy.deepcopy(config or DEFAULT_CONFIG)
if "agent_runner" in profile_config:
profile_config["agent_runner"] = normalize_agent_runner(
profile_config["agent_runner"]
)
conf_id = await self.acm.create_conf(
name=name,
config=config or DEFAULT_CONFIG,
config=profile_config,
)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return {"conf_id": conf_id}
@@ -1346,7 +1355,6 @@ class BotConfigService:
class ProviderConfigService:
CAPABILITY_TO_PROVIDER_TYPE = {
"chat": "chat_completion",
"agent": "agent_runner",
"stt": "speech_to_text",
"tts": "text_to_speech",
"embedding": "embedding",
@@ -1391,7 +1399,11 @@ class ProviderConfigService:
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
providers = copy.deepcopy(self.config.get("provider", []))
providers = [
copy.deepcopy(provider)
for provider in self.config.get("provider", [])
if provider.get("provider_type") != "agent_runner"
]
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_metadata = {}
@@ -1657,6 +1669,8 @@ class ProviderConfigService:
for source in self.provider_manager.provider_sources_config
}
for provider in self.provider_manager.providers_config:
if provider.get("provider_type") == "agent_runner":
continue
if source_id and provider.get("provider_source_id") != source_id:
continue
if enabled is not None and bool(provider.get("enable", False)) != enabled:
+1 -2
View File
@@ -151,7 +151,7 @@ export interface BotListParams {
}
export interface ProviderListParams {
capability?: 'chat' | 'agent' | 'stt' | 'tts' | 'embedding' | 'rerank';
capability?: 'chat' | 'stt' | 'tts' | 'embedding' | 'rerank';
source_id?: string;
enabled?: boolean;
}
@@ -199,7 +199,6 @@ type ProviderCapability = NonNullable<ProviderListParams['capability']>;
const PROVIDER_TYPE_TO_CAPABILITY: Record<string, ProviderCapability> = {
chat_completion: 'chat',
agent_runner: 'agent',
speech_to_text: 'stt',
text_to_speech: 'tts',
embedding: 'embedding',
@@ -220,7 +220,7 @@ async function getAgentRunnerType(confId: string): Promise<string> {
try {
const res = await configProfileApi.get(confId);
const config = ((res.data.data as any).config || {}) as any;
const type = config?.provider_settings?.agent_runner_type || 'local';
const type = config?.agent_runner?.runner_type || 'local';
configCache.value[confId] = type;
return type;
} catch (error) {
@@ -336,7 +336,7 @@ function getSpecialSubtype(value) {
</v-row>
<v-row
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona' && itemKey === 'provider_settings.default_personality'"
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona'"
class="persona-preview-row"
>
<v-col cols="12" class="persona-preview-display">
@@ -423,7 +423,7 @@ function getSpecialSubtype(value) {
</v-row>
<v-row
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona' && itemKey === 'provider_settings.default_personality'"
v-if="!itemMeta?.invisible && itemMeta?._special === 'select_persona'"
class="persona-preview-row"
>
<v-col cols="12" class="persona-preview-display">
@@ -18,14 +18,6 @@
:multiple="true"
/>
</template>
<template v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
<ProviderSelector
:model-value="modelValue"
@update:model-value="emitUpdate"
:provider-type="'agent_runner'"
:provider-subtype="getSpecialSubtype(itemMeta?._special)"
/>
</template>
<template v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'chat_completion'"
:button-text="t('core.shared.providerSelector.selectProviderPool')" />
@@ -308,6 +300,15 @@ const { getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
function emitUpdate(val) {
if (
props.itemMeta?._special === 'agent_runner_type'
&& props.configRoot?.agent_runner
&& props.itemMeta?.runner_defaults?.[val]
) {
props.configRoot.agent_runner.config = JSON.parse(
JSON.stringify(props.itemMeta.runner_defaults[val])
)
}
emit('update:modelValue', val)
}
@@ -371,24 +372,6 @@ function getSelectItems(itemMeta) {
return itemMeta.options || []
}
function parseSpecialValue(value) {
if (!value || typeof value !== 'string') {
return { name: '', subtype: '' }
}
const [name, ...rest] = value.split(':')
return {
name,
subtype: rest.join(':') || ''
}
}
function getSpecialName(value) {
return parseSpecialValue(value).name
}
function getSpecialSubtype(value) {
return parseSpecialValue(value).subtype
}
</script>
<style scoped>
@@ -254,10 +254,6 @@ const props = defineProps({
type: String,
default: 'chat_completion'
},
providerSubtype: {
type: String,
default: ''
},
buttonText: {
type: String,
default: ''
@@ -290,9 +286,6 @@ const hasSelection = computed(() => {
})
const defaultTab = computed(() => {
if (props.providerType === 'agent_runner' && props.providerSubtype) {
return `select_agent_runner_provider:${props.providerSubtype}`
}
return props.providerType || 'chat_completion'
})
@@ -331,10 +324,7 @@ async function loadProviders() {
const response = await providerApi.listByProviderType(props.providerType)
if (response.data.status === 'ok') {
modelMetadata.value = response.data.model_metadata || {}
const providers = response.data.data || []
providerList.value = props.providerSubtype
? providers.filter((provider) => matchesProviderSubtype(provider, props.providerSubtype))
: providers
providerList.value = response.data.data || []
}
} catch (error) {
console.error('加载提供商列表失败:', error)
@@ -344,17 +334,6 @@ async function loadProviders() {
}
}
function matchesProviderSubtype(provider, subtype) {
if (!subtype) {
return true
}
const normalized = String(subtype).toLowerCase()
const candidates = [provider.type, provider.provider, provider.id]
.filter(Boolean)
.map((value) => String(value).toLowerCase())
return candidates.includes(normalized)
}
function selectProvider(provider) {
if (props.multiple) {
if (!provider.id) {
@@ -24,10 +24,6 @@ interface ProviderIconSource {
export function resolveDefaultTab(value?: string) {
const normalized = (value || '').toLowerCase()
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
return 'agent_runner'
}
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
return 'speech_to_text'
}
@@ -83,7 +79,6 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
const providerTypes = computed(() => [
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
@@ -361,8 +356,6 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
anthropic_chat_completion: 'chat_completion',
googlegenai_chat_completion: 'chat_completion',
zhipu_chat_completion: 'chat_completion',
dify: 'agent_runner',
coze: 'agent_runner',
dashscope: 'chat_completion',
openai_whisper_api: 'speech_to_text',
mimo_stt_api: 'speech_to_text',
@@ -3,13 +3,15 @@
"name": "AI",
"agent_runner": {
"description": "Agent Runner",
"hint": "Select the runner for AI conversations. Defaults to AstrBot's built-in Agent runner, which supports knowledge base, persona, and tool calling features. You don't need to modify this section unless you plan to integrate third-party Agent runners like Dify, Coze, or DeerFlow.",
"hint": "Select the runner for AI conversations. Switching runners resets the configuration to the new type's defaults.",
"provider_settings": {
"enable": {
"description": "Enable",
"hint": "Master switch for AI conversations"
},
"agent_runner_type": {
}
},
"agent_runner": {
"runner_type": {
"description": "Runner",
"labels": [
"Built-in Agent",
@@ -18,37 +20,84 @@
"Alibaba Cloud Bailian Application",
"DeerFlow"
]
},
"coze_agent_runner_provider_id": {
"description": "Coze Agent Runner Provider ID"
},
"dify_agent_runner_provider_id": {
"description": "Dify Agent Runner Provider ID"
},
"dashscope_agent_runner_provider_id": {
"description": "Alibaba Cloud Bailian Application Agent Runner Provider ID"
},
"deerflow_agent_runner_provider_id": {
"description": "DeerFlow Agent Runner Provider ID"
}
}
},
"dify_runner": {
"description": "Dify Configuration",
"agent_runner": { "config": {
"dify_api_type": { "description": "Application Type" },
"dify_api_key": { "description": "API Key" },
"dify_api_base": { "description": "API Base URL" },
"dify_workflow_output_key": { "description": "Workflow Output Variable" },
"dify_query_input_key": { "description": "Prompt Input Variable" },
"variables": { "description": "Variables" },
"timeout": { "description": "Timeout (seconds)" },
"proxy": { "description": "Proxy URL" }
} }
},
"coze_runner": {
"description": "Coze Configuration",
"agent_runner": { "config": {
"coze_api_key": { "description": "API Key" },
"bot_id": { "description": "Bot ID" },
"coze_api_base": { "description": "API Base URL" },
"auto_save_history": { "description": "Let Coze Manage Conversation History" },
"timeout": { "description": "Timeout (seconds)" },
"proxy": { "description": "Proxy URL" }
} }
},
"dashscope_runner": {
"description": "Alibaba Cloud Bailian Application Configuration",
"agent_runner": { "config": {
"dashscope_app_type": { "description": "Application Type" },
"dashscope_api_key": { "description": "API Key" },
"dashscope_app_id": { "description": "Application ID" },
"rag_options": {
"pipeline_ids": { "description": "Knowledge Base Pipeline IDs" },
"file_ids": { "description": "File IDs" },
"output_reference": { "description": "Include References" }
},
"variables": { "description": "Variables" },
"timeout": { "description": "Timeout (seconds)" },
"proxy": { "description": "Proxy URL" }
} }
},
"deerflow_runner": {
"description": "DeerFlow Configuration",
"agent_runner": { "config": {
"deerflow_api_base": { "description": "API Base URL" },
"deerflow_api_key": { "description": "API Key" },
"deerflow_auth_header": { "description": "Authorization Header" },
"deerflow_assistant_id": { "description": "Assistant ID" },
"deerflow_model_name": { "description": "Model Name Override" },
"deerflow_thinking_enabled": { "description": "Enable Thinking Mode" },
"deerflow_plan_mode": { "description": "Enable Plan Mode" },
"deerflow_subagent_enabled": { "description": "Enable Subagents" },
"deerflow_max_concurrent_subagents": { "description": "Maximum Concurrent Subagents" },
"deerflow_recursion_limit": { "description": "Recursion Limit" },
"timeout": { "description": "Timeout (seconds)" },
"proxy": { "description": "Proxy URL" }
} }
},
"ai": {
"description": "Model",
"hint": "When using non-built-in Agent runners, the default chat model and default image caption model may not take effect, but some plugins rely on these settings to invoke AI capabilities.",
"provider_settings": {
"default_provider_id": {
"hint": "Configure the built-in Agent's chat models and shared image caption and speech models.",
"agent_runner": { "config": { "model": {
"provider_id": {
"description": "Default Chat Model",
"hint": "Uses the first model when left empty"
},
"fallback_chat_models": {
"description": "Fallback chat model IDs",
"hint": "When the primary chat model request fails, fallback to these chat models in order."
"fallback_provider_ids": {
"description": "Fallback Chat Model IDs",
"hint": "Try these chat models in order when the primary model request fails."
},
"request_max_retries": {
"description": "Request Max Retries",
"hint": "Maximum attempts for a single model request when retryable errors occur."
},
}
} } },
"provider_settings": {
"default_image_caption_provider_id": {
"description": "Default Image Caption Model",
"hint": "Leave empty to disable; useful for non-multimodal models"
@@ -83,11 +132,17 @@
"persona": {
"description": "Persona",
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
"agent_runner": { "config": { "persona": {
"persona_id": { "description": "Default Persona" },
"safety_mode": {
"description": "Safety Mode",
"hint": "Guide the model toward safe content and away from harmful or sensitive topics."
},
"safety_mode_strategy": {
"description": "Safety Mode Strategy",
"hint": "Select how safety mode is applied."
}
}
} } }
},
"knowledgebase": {
"description": "Knowledge Base",
@@ -252,16 +307,16 @@
"truncate_and_compress": {
"hint": "[Context Management](https://docs.astrbot.app/en/use/context-compress.html)",
"description": "Context Management Strategy",
"provider_settings": {
"max_context_length": {
"agent_runner": { "config": { "compression": {
"max_turns": {
"description": "Max Turns Before Compression",
"hint": "Persistent conversation history is truncated or LLM-compressed by the strategy below only after it exceeds this many turns. Request-time contexts are also constrained by this value before sending. -1 means no turn-based limit."
},
"dequeue_context_length": {
"trim_turns": {
"description": "Turns to Discard When Limit Exceeded",
"hint": "When history exceeds 'Max Turns Before Compression' and LLM compression is unavailable, discard this many oldest turns at once. Request-time truncation also reuses this value."
},
"context_limit_reached_strategy": {
"overflow_strategy": {
"description": "Handling for History Limits or Context Window Pressure",
"labels": [
"Truncate by Turns",
@@ -269,38 +324,45 @@
],
"hint": "Persistent conversation history uses this strategy only after exceeding 'Max Turns Before Compression'. Before each request, the same strategy may also protect the in-flight context when tokens approach the model window."
},
"llm_compress_instruction": {
"instruction": {
"description": "Context Compression Instruction",
"hint": "If empty, the default prompt will be used."
},
"llm_compress_keep_recent_ratio": {
"keep_recent_ratio": {
"description": "Recent Context Token Ratio to Keep",
"hint": "Keep recent exact context by current context token ratio, from 0-0.3. 0.15 means keeping 15%; values above 0 keep at least the latest round."
},
"llm_compress_provider_id": {
"provider_id": {
"description": "Model Provider ID for Context Compression",
"hint": "When left empty, the current chat model will be used for compression. If the model is unavailable or compression fails, AstrBot falls back to the 'Truncate by Turns' strategy."
},
"fallback_max_context_tokens": {
"fallback_max_tokens": {
"description": "Fallback context window size",
"hint": "When max_context_tokens is 0 and the model is not in built-in metadata, use this value as the context window size. Default: 128000."
}
}
} } }
},
"others": {
"description": "Other Settings",
"agent_runner": { "config": { "misc": {
"max_steps": { "description": "Maximum Tool Call Rounds" },
"tool_schema_mode": {
"description": "Tool Schema Mode",
"hint": "Skills-like sends name/description first and re-queries for parameters; Full sends the complete schema in one step.",
"labels": ["Skills-like (two-stage)", "Full schema"]
},
"tool_call_timeout": {
"description": "Tool Call Timeout (seconds)"
},
"sanitize_context_by_modalities": {
"description": "Sanitize History by Modalities",
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
}
} } },
"provider_settings": {
"display_reasoning_text": {
"description": "Display Reasoning Content"
},
"llm_safety_mode": {
"description": "Healthy Mode",
"hint": "Add safety guardrails to model replies."
},
"safety_mode_strategy": {
"description": "Healthy Mode Strategy",
"hint": "How to apply healthy mode."
},
"identifier": {
"description": "User Identification",
"hint": "When enabled, user ID information will be included in the prompt."
@@ -324,10 +386,6 @@
"description": "Merge Agent Intermediate Messages",
"hint": "When enabled, intermediate text generated during multi-step tool calls in non-streaming mode will be buffered and sent as a single merged reply after the Agent finishes."
},
"sanitize_context_by_modalities": {
"description": "Sanitize History by Modalities",
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
},
"max_quoted_fallback_images": {
"description": "Forwarded Image Fetch Limit",
"hint": "Maximum number of images injected from forwarded-message parsing; extra images are truncated."
@@ -350,20 +408,6 @@
"hint": "When enabled, log warnings when all get_msg/get_forward_msg attempts fail."
}
},
"max_agent_step": {
"description": "Maximum Tool Call Rounds"
},
"tool_call_timeout": {
"description": "Tool Call Timeout (seconds)"
},
"tool_schema_mode": {
"description": "Tool Schema Mode",
"hint": "Skills-like sends name/description first and re-queries for parameters; Full sends the complete schema in one step.",
"labels": [
"Skills-like (two-stage)",
"Full schema"
]
},
"streaming_response": {
"description": "Streaming Output"
},
@@ -28,7 +28,7 @@
"messages": {
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
"configApplyError": "Configuration not applied, JSON format error.",
"unsavedChangesNotice": "You have unsaved configuration changes. Click the save button in the bottom-right corner to apply them.",
"unsavedChangesNotice": "Unsaved. Click the button in the bottom-right corner to save.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration",
@@ -11,7 +11,6 @@
"tabs": {
"all": "All",
"chatCompletion": "Chat Completion",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
@@ -48,7 +47,6 @@
"title": "Service Provider",
"tabs": {
"basic": "Basic",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
@@ -252,43 +252,71 @@
"truncate_and_compress": {
"hint": "[Управление контекстом](https://docs.astrbot.app/en/use/context-compress.html)",
"description": "Стратегия управления контекстом",
"provider_settings": {
"max_context_length": {
"description": "Макс. раундов перед сжатием",
"hint": "Постоянная история диалога обрезается или сжимается LLM по стратегии ниже только после превышения этого числа раундов. Контекст перед запросом также ограничивается этим значением. -1 означает без ограничений по раундам."
},
"dequeue_context_length": {
"description": "Раундов для удаления при превышении лимита",
"hint": "Когда история превышает лимит раундов и LLM-сжатие недоступно, за один раз удаляется это число самых старых раундов. Обрезка перед запросом также использует это значение."
},
"context_limit_reached_strategy": {
"description": "Действие при лимите истории или давлении окна контекста",
"labels": [
"Обрезать по раундам",
"Сжать с помощью LLM"
],
"hint": "Постоянная история диалога использует эту стратегию только после превышения лимита раундов. Перед каждым запросом та же стратегия может защищать текущий контекст, когда токены приближаются к окну модели."
},
"llm_compress_instruction": {
"description": "Инструкция для сжатия контекста",
"hint": "Если пусто, используется промпт по умолчанию."
},
"llm_compress_keep_recent_ratio": {
"description": "Доля последних токенов контекста при сжатии",
"hint": "Сохраняет последние сообщения по доле текущих токенов контекста, от 0 до 0.3. 0.15 означает 15%; значение выше 0 сохраняет как минимум последний раунд."
},
"llm_compress_provider_id": {
"description": "Модель для сжатия контекста",
"hint": "Если не выбрано, для сжатия используется текущая модель чата. Если модель недоступна или сжатие завершается ошибкой, AstrBot откатывается к обрезке по раундам."
},
"fallback_max_context_tokens": {
"description": "Запасной размер окна контекста",
"hint": "Если max_context_tokens равен 0 и модель отсутствует во встроенных метаданных, используется это значение. По умолчанию: 128000."
"agent_runner": {
"config": {
"compression": {
"max_turns": {
"description": "Макс. раундов перед сжатием",
"hint": "Постоянная история диалога обрезается или сжимается LLM по стратегии ниже только после превышения этого числа раундов. Контекст перед запросом также ограничивается этим значением. -1 означает без ограничений по раундам."
},
"trim_turns": {
"description": "Раундов для удаления при превышении лимита",
"hint": "Когда история превышает лимит раундов и LLM-сжатие недоступно, за один раз удаляется это число самых старых раундов. Обрезка перед запросом также использует это значение."
},
"overflow_strategy": {
"description": "Действие при лимите истории или давлении окна контекста",
"labels": [
"Обрезать по раундам",
"Сжать с помощью LLM"
],
"hint": "Постоянная история диалога использует эту стратегию только после превышения лимита раундов. Перед каждым запросом та же стратегия может защищать текущий контекст, когда токены приближаются к окну модели."
},
"instruction": {
"description": "Инструкция для сжатия контекста",
"hint": "Если пусто, используется промпт по умолчанию."
},
"keep_recent_ratio": {
"description": "Доля последних токенов контекста при сжатии",
"hint": "Сохраняет последние сообщения по доле текущих токенов контекста, от 0 до 0.3. 0.15 означает 15%; значение выше 0 сохраняет как минимум последний раунд."
},
"provider_id": {
"description": "Модель для сжатия контекста",
"hint": "Если не выбрано, для сжатия используется текущая модель чата. Если модель недоступна или сжатие завершается ошибкой, AstrBot откатывается к обрезке по раундам."
},
"fallback_max_tokens": {
"description": "Запасной размер окна контекста",
"hint": "Если max_context_tokens равен 0 и модель отсутствует во встроенных метаданных, используется это значение. По умолчанию: 128000."
}
}
}
}
},
"others": {
"description": "Прочие настройки",
"agent_runner": {
"config": {
"misc": {
"max_steps": {
"description": "Макс. количество раундов вызова инструментов"
},
"tool_call_timeout": {
"description": "Таймаут вызова инструмента (сек)"
},
"tool_schema_mode": {
"description": "Режим схемы инструментов",
"hint": "Skills-like сначала отправляет имя/описание и дозапрашивает параметры; Full отправляет полную схему сразу.",
"labels": [
"Skills-like (двухэтапный)",
"Полная схема (Full)"
]
},
"sanitize_context_by_modalities": {
"description": "Очистка истории по модальностям",
"hint": "Если включено, очищает контекст перед запросом, удаляя блоки (например, изображения), которые не поддерживаются выбранным провайдером."
}
}
}
},
"provider_settings": {
"display_reasoning_text": {
"description": "Отображать процесс рассуждения (Reasoning)"
@@ -324,10 +352,6 @@
"description": "Объединять промежуточные сообщения Agent",
"hint": "Если включено, промежуточный текст, созданный во время многошаговых вызовов инструментов в непотоковом режиме, будет буферизован и отправлен одним объединенным ответом после завершения Agent."
},
"sanitize_context_by_modalities": {
"description": "Очистка истории по модальностям",
"hint": "Если включено, очищает контекст перед запросом, удаляя блоки (например, изображения), которые не поддерживаются выбранным провайдером."
},
"max_quoted_fallback_images": {
"description": "Лимит загрузки изображений из пересланных сообщений",
"hint": "Максимальное количество изображений при парсинге цитируемых сообщений."
@@ -350,20 +374,6 @@
"hint": "Если включено, логирует предупреждения при неудачных попытках получения сообщений."
}
},
"max_agent_step": {
"description": "Макс. количество раундов вызова инструментов"
},
"tool_call_timeout": {
"description": "Таймаут вызова инструмента (сек)"
},
"tool_schema_mode": {
"description": "Режим схемы инструментов",
"hint": "Skills-like сначала отправляет имя/описание и дозапрашивает параметры; Full отправляет полную схему сразу.",
"labels": [
"Skills-like (двухэтапный)",
"Полная схема (Full)"
]
},
"streaming_response": {
"description": "Потоковый вывод (Streaming)"
},
@@ -28,7 +28,7 @@
"messages": {
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
"configApplyError": "Ошибка применения: некорректный формат JSON.",
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
"unsavedChangesNotice": "Не сохранено. Нажмите кнопку в правом нижнем углу, чтобы сохранить.",
"saveSuccess": "Настройки успешно сохранены",
"saveError": "Ошибка при сохранении",
"loadError": "Ошибка при загрузке настроек",
@@ -11,7 +11,6 @@
"tabs": {
"all": "Все",
"chatCompletion": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "STT (Речь -> Текст)",
"textToSpeech": "TTS (Текст -> Речь)",
"embedding": "Эмбеддинги",
@@ -49,7 +48,6 @@
"title": "Новый провайдер",
"tabs": {
"basic": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "Преобразование текста в речь",
"textToSpeech": "Переранжирование",
"embedding": "Эмбеддинги",
@@ -3,13 +3,15 @@
"name": "AI 配置",
"agent_runner": {
"description": "Agent 执行方式",
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify、Coze、DeerFlow 等第三方 Agent 执行器,不需要修改此节。",
"hint": "选择 AI 对话的执行器。切换执行器会使用新类型的默认配置,不保留上一类型的参数。",
"provider_settings": {
"enable": {
"description": "启用",
"hint": "AI 对话总开关"
},
"agent_runner_type": {
}
},
"agent_runner": {
"runner_type": {
"description": "执行器",
"labels": [
"内置 Agent",
@@ -18,37 +20,96 @@
"阿里云百炼应用",
"DeerFlow"
]
},
"coze_agent_runner_provider_id": {
"description": "Coze Agent 执行器提供商 ID"
},
"dify_agent_runner_provider_id": {
"description": "Dify Agent 执行器提供商 ID"
},
"dashscope_agent_runner_provider_id": {
"description": "阿里云百炼应用 Agent 执行器提供商 ID"
},
"deerflow_agent_runner_provider_id": {
"description": "DeerFlow Agent 执行器提供商 ID"
}
}
},
"dify_runner": {
"description": "Dify 配置",
"agent_runner": {
"config": {
"dify_api_type": { "description": "应用类型" },
"dify_api_key": { "description": "API Key" },
"dify_api_base": { "description": "API Base URL" },
"dify_workflow_output_key": { "description": "Workflow 输出变量名" },
"dify_query_input_key": { "description": "Prompt 输入变量名" },
"variables": { "description": "变量" },
"timeout": { "description": "超时时间(秒)" },
"proxy": { "description": "代理地址" }
}
}
},
"coze_runner": {
"description": "Coze 配置",
"agent_runner": {
"config": {
"coze_api_key": { "description": "API Key" },
"bot_id": { "description": "Bot ID" },
"coze_api_base": { "description": "API Base URL" },
"auto_save_history": { "description": "由 Coze 管理对话记录" },
"timeout": { "description": "超时时间(秒)" },
"proxy": { "description": "代理地址" }
}
}
},
"dashscope_runner": {
"description": "阿里云百炼应用配置",
"agent_runner": {
"config": {
"dashscope_app_type": { "description": "应用类型" },
"dashscope_api_key": { "description": "API Key" },
"dashscope_app_id": { "description": "应用 ID" },
"rag_options": {
"pipeline_ids": { "description": "知识库 Pipeline ID" },
"file_ids": { "description": "文件 ID" },
"output_reference": { "description": "输出引用" }
},
"variables": { "description": "变量" },
"timeout": { "description": "超时时间(秒)" },
"proxy": { "description": "代理地址" }
}
}
},
"deerflow_runner": {
"description": "DeerFlow 配置",
"agent_runner": {
"config": {
"deerflow_api_base": { "description": "API Base URL" },
"deerflow_api_key": { "description": "API Key" },
"deerflow_auth_header": { "description": "Authorization Header" },
"deerflow_assistant_id": { "description": "Assistant ID" },
"deerflow_model_name": { "description": "模型名称覆盖" },
"deerflow_thinking_enabled": { "description": "启用思考模式" },
"deerflow_plan_mode": { "description": "启用计划模式" },
"deerflow_subagent_enabled": { "description": "启用子智能体" },
"deerflow_max_concurrent_subagents": { "description": "子智能体最大并发数" },
"deerflow_recursion_limit": { "description": "递归深度上限" },
"timeout": { "description": "超时时间(秒)" },
"proxy": { "description": "代理地址" }
}
}
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"hint": "配置内置 Agent 使用的对话模型,以及通用的图片转述、语音模型。",
"agent_runner": {
"config": {
"model": {
"provider_id": {
"description": "默认对话模型",
"hint": "留空时使用第一个模型"
},
"fallback_provider_ids": {
"description": "回退对话模型列表",
"hint": "主对话模型请求失败时,按顺序切换到这些对话模型。"
},
"request_max_retries": {
"description": "请求最大重试次数",
"hint": "单次模型请求遇到可重试错误时的最大尝试次数。"
}
}
}
},
"provider_settings": {
"default_provider_id": {
"description": "默认对话模型",
"hint": "留空时使用第一个模型"
},
"fallback_chat_models": {
"description": "回退对话模型列表",
"hint": "主对话模型请求失败时,按顺序切换到这些对话模型。"
},
"request_max_retries": {
"description": "请求最大重试次数",
"hint": "单次模型请求遇到可重试错误时的最大尝试次数。"
},
"default_image_caption_provider_id": {
"description": "默认图片转述模型",
"hint": "留空代表不使用,可用于非多模态模型"
@@ -83,9 +144,19 @@
"persona": {
"description": "人格",
"hint": "赋予 AstrBot 人格。",
"provider_settings": {
"default_personality": {
"description": "默认采用的人格"
"agent_runner": {
"config": {
"persona": {
"persona_id": { "description": "默认采用的人格" },
"safety_mode": {
"description": "健康模式",
"hint": "引导模型输出健康、安全的内容,避免有害或敏感话题。"
},
"safety_mode_strategy": {
"description": "健康模式策略",
"hint": "选择健康模式的实现策略。"
}
}
}
}
},
@@ -254,55 +325,67 @@
"truncate_and_compress": {
"hint": "AstrBot 如何管理工作记忆。详见: [上下文管理策略](https://docs.astrbot.app/use/context-compress.html)。",
"description": "上下文管理策略",
"provider_settings": {
"max_context_length": {
"description": "压缩前最多保留对话轮数",
"hint": "普通会话历史超过该轮数后,才会按下方策略进行持久化截断或 LLM 压缩;请求发送前也会先按该值约束上下文。-1 表示不按轮数限制。"
},
"dequeue_context_length": {
"description": "轮次超限时一次丢弃轮数",
"hint": "当超过\"压缩前最多保留对话轮数\"且无法使用 LLM 压缩时,一次丢弃多少轮旧对话;请求期截断也会复用该值。"
},
"context_limit_reached_strategy": {
"description": "历史超限或上下文接近上限时的处理方式",
"labels": [
"按对话轮数截断",
"由 LLM 压缩上下文"
],
"hint": "普通会话历史仅在超过\"压缩前最多保留对话轮数\"后执行该策略;请求发送前也会在上下文 token 接近模型窗口时使用同一策略保护本次请求。"
},
"llm_compress_instruction": {
"description": "上下文压缩提示词",
"hint": "如果为空则使用默认提示词。"
},
"llm_compress_keep_recent_ratio": {
"description": "压缩时保留最近上下文比例",
"hint": "按当前上下文 token 数保留最近内容,范围 0-0.3。0.15 表示保留 15%;比例大于 0 时至少保留最后一轮。"
},
"llm_compress_provider_id": {
"description": "用于上下文压缩的模型提供商 ID",
"hint": "留空时使用当前聊天模型进行压缩;如果模型不可用或压缩失败,将回退为\"按对话轮数截断\"的策略。"
},
"fallback_max_context_tokens": {
"description": "上下文窗口兜底值",
"hint": "当 max_context_tokens 为 0 且模型不在内置元数据中时,使用此值作为上下文窗口大小。默认 128000。"
"agent_runner": {
"config": {
"compression": {
"max_turns": {
"description": "压缩前最多保留对话轮数",
"hint": "普通会话历史超过该轮数后,才会按下方策略进行持久化截断或 LLM 压缩;请求发送前也会先按该值约束上下文。-1 表示不按轮数限制。"
},
"trim_turns": {
"description": "轮次超限时一次丢弃轮数",
"hint": "当超过\"压缩前最多保留对话轮数\"且无法使用 LLM 压缩时,一次丢弃多少轮旧对话;请求期截断也会复用该值。"
},
"overflow_strategy": {
"description": "历史超限或上下文接近上限时的处理方式",
"labels": ["按对话轮数截断", "由 LLM 压缩上下文"],
"hint": "普通会话历史仅在超过\"压缩前最多保留对话轮数\"后执行该策略;请求发送前也会在上下文 token 接近模型窗口时使用同一策略保护本次请求。"
},
"instruction": {
"description": "上下文压缩提示词",
"hint": "如果为空则使用默认提示词"
},
"keep_recent_ratio": {
"description": "压缩时保留最近上下文比例",
"hint": "按当前上下文 token 数保留最近内容,范围 0-0.3。0.15 表示保留 15%;比例大于 0 时至少保留最后一轮。"
},
"provider_id": {
"description": "用于上下文压缩的模型提供商 ID",
"hint": "留空时使用当前聊天模型进行压缩;如果模型不可用或压缩失败,将回退为\"按对话轮数截断\"的策略。"
},
"fallback_max_tokens": {
"description": "上下文窗口兜底值",
"hint": "当 max_context_tokens 为 0 且模型不在内置元数据中时,使用此值作为上下文窗口大小。默认 128000。"
}
}
}
}
},
"others": {
"description": "其他配置",
"agent_runner": {
"config": {
"misc": {
"max_steps": { "description": "工具调用轮数上限" },
"tool_schema_mode": {
"description": "工具调用模式",
"hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
"labels": ["Skills-like(两阶段)", "Full(完整参数)"]
},
"tool_call_timeout": {
"description": "工具调用超时时间(秒)"
},
"sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
}
}
}
},
"provider_settings": {
"display_reasoning_text": {
"description": "显示思考内容"
},
"llm_safety_mode": {
"description": "健康模式",
"hint": "引导模型输出健康、安全、积极的内容,避免有害或敏感话题。"
},
"safety_mode_strategy": {
"description": "健康模式策略",
"hint": "选择健康模式的实现方式。"
},
"identifier": {
"description": "用户识别",
"hint": "启用后,会在提示词前包含用户 ID 信息。"
@@ -326,10 +409,6 @@
"description": "合并 Agent 中间消息",
"hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。"
},
"sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
},
"max_quoted_fallback_images": {
"description": "转发消息中图片获取上限",
"hint": "转发消息解析到的图片最多注入数量,超出部分会截断。"
@@ -352,20 +431,6 @@
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。"
}
},
"max_agent_step": {
"description": "工具调用轮数上限"
},
"tool_call_timeout": {
"description": "工具调用超时时间(秒)"
},
"tool_schema_mode": {
"description": "工具调用模式",
"hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
"labels": [
"Skills-like(两阶段)",
"Full(完整参数)"
]
},
"streaming_response": {
"description": "流式输出"
},
@@ -28,7 +28,7 @@
"messages": {
"configApplied": "配置成功应用。如要保存,需再点击右下角保存按钮。",
"configApplyError": "配置未应用,Json 格式错误。",
"unsavedChangesNotice": "当前配置有未保存修改。请点击右下角保存按钮以生效。",
"unsavedChangesNotice": "尚未保存,点击右下角按钮以保存。",
"saveSuccess": "配置保存成功",
"saveError": "配置保存失败",
"loadError": "配置加载失败",
@@ -11,7 +11,6 @@
"tabs": {
"all": "全部",
"chatCompletion": "对话",
"agentRunner": "Agent 执行器",
"speechToText": "语音转文字",
"textToSpeech": "文字转语音",
"embedding": "嵌入",
@@ -49,7 +48,6 @@
"title": "模型提供商",
"tabs": {
"basic": "对话",
"agentRunner": "Agent 执行器",
"speechToText": "语音转文字",
"textToSpeech": "文字转语音",
"embedding": "嵌入",
+39 -29
View File
@@ -28,17 +28,6 @@
</div>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<v-slide-y-transition mode="out-in">
@@ -83,6 +72,18 @@
</div>
</div>
<v-slide-y-reverse-transition>
<div
v-if="fetched && hasUnsavedChanges"
class="unsaved-changes-pill"
role="status"
aria-live="polite"
>
<v-icon size="18">mdi-alert-circle-outline</v-icon>
<span>{{ tm('messages.unsavedChangesNotice') }}</span>
</div>
</v-slide-y-reverse-transition>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="codeEditorDialog" fullscreen transition="dialog-bottom-transition" scrollable>
@@ -965,24 +966,25 @@ export default {
text-transform: none !important;
}
.unsaved-changes-banner {
border-radius: 8px;
}
.v-theme--light .unsaved-changes-banner {
background-color: #f1f4f9 !important;
}
.v-theme--dark .unsaved-changes-banner {
background-color: #2d2d2d !important;
}
.unsaved-changes-banner-wrap {
position: sticky;
top: calc(var(--v-layout-top, 64px));
z-index: 20;
width: 100%;
margin-bottom: 6px;
.unsaved-changes-pill {
position: fixed;
left: calc(var(--v-layout-left, 0px) + 32px);
bottom: 52px;
z-index: 1005;
display: flex;
align-items: center;
gap: 8px;
max-width: min(440px, calc(100vw - var(--v-layout-left, 0px) - 160px));
padding: 9px 14px 9px 12px;
border-radius: 999px;
background: rgba(var(--v-theme-surface), 0.94);
color: rgba(var(--v-theme-on-surface), 0.82);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
backdrop-filter: blur(16px);
font-size: 0.8125rem;
line-height: 1.25rem;
white-space: nowrap;
pointer-events: none;
}
/* 按钮切换样式优化 */
@@ -1047,6 +1049,14 @@ export default {
width: 100%;
min-width: 0 !important;
}
.unsaved-changes-pill {
right: 16px;
bottom: 16px;
left: 16px;
max-width: none;
white-space: normal;
}
}
/* 测试聊天抽屉样式 */
-38
View File
@@ -293,34 +293,11 @@
{{ snackbar.message }}
</v-snackbar>
<v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent>
<v-card>
<v-card-title class="text-h3 pa-4 pb-0 pl-6 d-flex align-center">
<v-icon start class="me-2">mdi-information</v-icon>
请前往配置文件页测试 Agent 执行器
</v-card-title>
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
Agent 执行器的测试请在配置文件页进行
<ol class="ml-4 mt-4 mb-4">
<li>找到对应的配置文件并打开</li>
<li>找到 Agent 执行方式部分修改执行器后点击保存</li>
<li>点击右下角的 💬 聊天按钮进行测试</li>
</ol>
要让机器人应用这个 Agent 执行器你也需要前往修改 Agent 执行器
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showAgentRunnerDialog = false">好的</v-btn>
<v-btn color="primary" variant="tonal" @click="goToConfigPage">点击前往</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { providerApi } from '@/api/v1'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
@@ -337,7 +314,6 @@ const props = defineProps({
})
const { tm } = useModuleI18n('features/provider')
const router = useRouter()
const snackbar = ref({
show: false,
@@ -405,7 +381,6 @@ const newProviderOriginalId = ref('')
const updatingMode = ref(false)
const loading = ref(false)
const isLegacyProviderModified = ref(false)
const showAgentRunnerDialog = ref(false)
const showManualModelDialog = ref(false)
let suppressLegacyProviderWatch = false
@@ -663,21 +638,8 @@ async function testSingleProvider(provider) {
showMessage('该提供商未被用户启用', 'error')
return
}
if (
provider.provider_type === 'agent_runner' ||
selectedProviderType.value === 'agent_runner'
) {
showAgentRunnerDialog.value = true
return
}
await testProvider(provider)
}
function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false
}
</script>
<style scoped>
+6 -4
View File
@@ -380,13 +380,15 @@ async function syncDefaultConfigProviderIfNeeded() {
if (!targetProviderId) return;
const configData = await fetchDefaultConfig();
if (!configData.provider_settings) {
configData.provider_settings = {};
if (configData?.agent_runner?.runner_type !== 'local') {
return;
}
const modelConfig = configData.agent_runner.config?.model;
if (!modelConfig) return;
if (configData.provider_settings.default_provider_id === targetProviderId) return;
if (modelConfig.provider_id === targetProviderId) return;
configData.provider_settings.default_provider_id = targetProviderId;
modelConfig.provider_id = targetProviderId;
const updateRes = await configProfileApi.update('default', configData);
if (updateRes.data.status !== 'ok') {
+18 -39
View File
@@ -32,23 +32,16 @@ async def test_clear_third_party_agent_runner_state_deletes_deerflow_thread_befo
context = SimpleNamespace(
get_config=lambda **kwargs: {
"provider_settings": {
"deerflow_agent_runner_provider_id": "deerflow-runner"
}
},
provider_manager=SimpleNamespace(
get_provider_config_by_id=lambda provider_id, merged=False: (
{
"id": provider_id,
"agent_runner": {
"runner_type": "deerflow",
"config": {
"deerflow_api_base": "http://127.0.0.1:2026",
"deerflow_api_key": "token",
"deerflow_auth_header": "",
"proxy": "",
}
if merged
else {"id": provider_id}
),
),
},
}
},
)
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
@@ -99,23 +92,16 @@ async def test_clear_third_party_agent_runner_state_removes_local_state_when_dee
context = SimpleNamespace(
get_config=lambda **kwargs: {
"provider_settings": {
"deerflow_agent_runner_provider_id": "deerflow-runner"
}
},
provider_manager=SimpleNamespace(
get_provider_config_by_id=lambda provider_id, merged=False: (
{
"id": provider_id,
"agent_runner": {
"runner_type": "deerflow",
"config": {
"deerflow_api_base": "http://127.0.0.1:2026",
"deerflow_api_key": "",
"deerflow_auth_header": "",
"proxy": "",
}
if merged
else {"id": provider_id}
),
),
},
}
},
)
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
@@ -156,23 +142,16 @@ async def test_clear_third_party_agent_runner_state_removes_local_state_when_dee
context = SimpleNamespace(
get_config=lambda **kwargs: {
"provider_settings": {
"deerflow_agent_runner_provider_id": "deerflow-runner"
}
},
provider_manager=SimpleNamespace(
get_provider_config_by_id=lambda provider_id, merged=False: (
{
"id": provider_id,
"agent_runner": {
"runner_type": "deerflow",
"config": {
"deerflow_api_base": "http://127.0.0.1:2026",
"deerflow_api_key": "",
"deerflow_auth_header": "",
"proxy": "",
}
if merged
else {"id": provider_id}
),
),
},
}
},
)
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
+547
View File
@@ -0,0 +1,547 @@
import copy
import json
from types import SimpleNamespace
import pytest
from astrbot.core.config.agent_runner import (
AGENT_RUNNER_CONFIG_DEFAULTS,
get_agent_runner_config_default,
normalize_agent_runner,
)
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.pipeline.process_stage.stage import AgentRequestSubStage
from astrbot.core.utils.migra_helper import (
_migrate_agent_runner_config,
finalize_config_migrations,
)
@pytest.mark.parametrize(
"runner_type", ["local", "dify", "coze", "dashscope", "deerflow"]
)
def test_agent_runner_defaults_are_isolated_and_normalized(runner_type: str):
first = get_agent_runner_config_default(runner_type)
second = get_agent_runner_config_default(runner_type)
first["test_mutation"] = True
assert second == AGENT_RUNNER_CONFIG_DEFAULTS[runner_type]
assert normalize_agent_runner({"runner_type": runner_type, "config": second}) == {
"runner_type": runner_type,
"config": second,
}
if runner_type != "local":
assert "persona_id" not in second
def test_switching_runner_type_discards_previous_runner_fields():
normalized = normalize_agent_runner(
{
"runner_type": "dify",
"config": {
"provider_id": "legacy-provider",
"persona_id": "legacy-persona",
"model": {"provider_id": "chat-model"},
"dify_api_key": "secret",
"unexpected": True,
},
}
)
assert normalized["config"] == {
**get_agent_runner_config_default("dify"),
"dify_api_key": "secret",
}
assert "provider_id" not in normalized["config"]
assert "persona_id" not in normalized["config"]
assert "model" not in normalized["config"]
@pytest.mark.asyncio
async def test_agent_request_normalizes_incomplete_runner_config():
config = {
"wake_prefix": [],
"provider_settings": {
"wake_prefix": "",
"streaming_response": True,
"unsupported_streaming_strategy": "aggregate",
},
"agent_runner": {
"runner_type": "dify",
"config": {"dify_api_key": "saved-key"},
},
}
stage = AgentRequestSubStage()
await stage.initialize(SimpleNamespace(astrbot_config=config))
assert stage.agent_sub_stage.runner_config == {
**get_agent_runner_config_default("dify"),
"dify_api_key": "saved-key",
}
@pytest.mark.parametrize(
"runner_type", ["local", "dify", "coze", "dashscope", "deerflow"]
)
def test_each_runner_configuration_round_trips(tmp_path, runner_type: str):
config = copy.deepcopy(DEFAULT_CONFIG)
expected = {
"runner_type": runner_type,
"config": get_agent_runner_config_default(runner_type),
}
config["agent_runner"] = expected
config_path = tmp_path / f"{runner_type}.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
loaded = AstrBotConfig(config_path=str(config_path))
loaded.save_config()
reloaded = AstrBotConfig(config_path=str(config_path))
assert reloaded["agent_runner"] == expected
def test_local_legacy_fields_are_fully_migrated():
config = {
"config_version": 2,
"provider": [],
"provider_settings": {
"agent_runner_type": "local",
"default_provider_id": "chat-main",
"fallback_chat_models": ["chat-backup"],
"request_max_retries": 7,
"default_personality": "developer",
"llm_safety_mode": False,
"safety_mode_strategy": "system_prompt",
"max_agent_step": 42,
"tool_schema_mode": "skills_like",
"tool_call_timeout": 88,
"sanitize_context_by_modalities": True,
"context_limit_reached_strategy": "truncate_by_turns",
"llm_compress_instruction": "Summarize",
"llm_compress_keep_recent_ratio": 0.2,
"llm_compress_provider_id": "compressor",
"max_context_length": 20,
"dequeue_context_length": 3,
"fallback_max_context_tokens": 64000,
},
}
default_config = {
"provider": [
{"id": "chat-main", "provider_type": "chat_completion"},
{"id": "chat-backup", "provider_type": "chat_completion"},
{"id": "compressor", "provider_type": "chat_completion"},
]
}
assert _migrate_agent_runner_config(config, default_config)
assert config["config_version"] == 3
assert config["agent_runner"] == {
"runner_type": "local",
"config": {
"model": {
"provider_id": "chat-main",
"fallback_provider_ids": ["chat-backup"],
"request_max_retries": 7,
},
"persona": {
"persona_id": "developer",
"safety_mode": False,
"safety_mode_strategy": "system_prompt",
},
"compression": {
"max_turns": 20,
"trim_turns": 3,
"overflow_strategy": "truncate_by_turns",
"instruction": "Summarize",
"keep_recent_ratio": 0.2,
"provider_id": "compressor",
"fallback_max_tokens": 64000,
},
"misc": {
"max_steps": 42,
"tool_schema_mode": "skills_like",
"tool_call_timeout": 88,
"sanitize_context_by_modalities": True,
},
},
}
assert not {
"agent_runner_type",
"default_provider_id",
"fallback_chat_models",
"request_max_retries",
"default_personality",
"max_agent_step",
"tool_call_timeout",
"sanitize_context_by_modalities",
}.intersection(config["provider_settings"])
def test_local_migration_replaces_default_root_inserted_before_version_bump():
config = {
"config_version": 2,
"provider": [
{"id": "chat-main", "provider_type": "chat_completion"},
{"id": "compressor", "provider_type": "chat_completion"},
],
"provider_settings": {
"agent_runner_type": "local",
"default_provider_id": "chat-main",
"fallback_chat_models": ["chat-backup"],
"request_max_retries": 8,
"default_personality": "developer",
"llm_safety_mode": False,
"max_agent_step": 48,
"tool_call_timeout": 96,
"sanitize_context_by_modalities": True,
"context_limit_reached_strategy": "llm_compress",
"llm_compress_instruction": "Keep decisions",
"llm_compress_provider_id": "compressor",
"max_context_length": 24,
"dequeue_context_length": 4,
},
"agent_runner": {
"runner_type": "local",
"config": get_agent_runner_config_default("local"),
},
}
default_config = {
"provider": [
{"id": "chat-main", "provider_type": "chat_completion"},
{"id": "chat-backup", "provider_type": "chat_completion"},
{"id": "compressor", "provider_type": "chat_completion"},
]
}
assert _migrate_agent_runner_config(config, default_config)
assert config["agent_runner"] == {
"runner_type": "local",
"config": {
"model": {
"provider_id": "chat-main",
"fallback_provider_ids": ["chat-backup"],
"request_max_retries": 8,
},
"persona": {
"persona_id": "developer",
"safety_mode": False,
"safety_mode_strategy": "system_prompt",
},
"compression": {
"max_turns": 24,
"trim_turns": 4,
"overflow_strategy": "llm_compress",
"instruction": "Keep decisions",
"keep_recent_ratio": 0.15,
"provider_id": "compressor",
"fallback_max_tokens": 128000,
},
"misc": {
"max_steps": 48,
"tool_schema_mode": "full",
"tool_call_timeout": 96,
"sanitize_context_by_modalities": True,
},
},
}
assert not set(config["provider_settings"]).intersection(
{
"agent_runner_type",
"default_provider_id",
"fallback_chat_models",
"default_personality",
"llm_compress_provider_id",
"tool_call_timeout",
"sanitize_context_by_modalities",
}
)
def test_missing_local_provider_references_are_removed():
config = {
"provider": [],
"provider_settings": {
"agent_runner_type": "local",
"default_provider_id": "missing-main",
"fallback_chat_models": ["available", "missing-fallback"],
"llm_compress_provider_id": "missing-compressor",
},
}
global_config = {
"provider": [{"id": "available", "provider_type": "chat_completion"}]
}
_migrate_agent_runner_config(config, global_config)
runner_config = config["agent_runner"]["config"]
assert runner_config["model"]["provider_id"] == ""
assert runner_config["model"]["fallback_provider_ids"] == ["available"]
assert runner_config["compression"]["provider_id"] == ""
@pytest.mark.parametrize(
("runner_type", "provider_config", "expected_key"),
[
(
"dify",
{"dify_api_key": "dify-key", "dify_api_type": "workflow"},
"dify_api_key",
),
(
"coze",
{"coze_api_key": "coze-key", "bot_id": "bot"},
"coze_api_key",
),
(
"dashscope",
{"dashscope_api_key": "dash-key", "dashscope_app_id": "app"},
"dashscope_api_key",
),
(
"deerflow",
{"deerflow_api_key": "deer-key", "deerflow_plan_mode": True},
"deerflow_api_key",
),
],
)
def test_third_party_provider_config_is_copied_inline(
runner_type: str,
provider_config: dict,
expected_key: str,
):
provider_id = f"{runner_type}-provider"
config = {
"config_version": 2,
"provider": [],
"provider_settings": {
"agent_runner_type": runner_type,
f"{runner_type}_agent_runner_provider_id": provider_id,
"default_personality": "operator",
},
}
global_config = {
"provider": [
{
"id": provider_id,
"type": runner_type,
"provider": runner_type,
"provider_type": "agent_runner",
"enable": True,
**provider_config,
}
]
}
assert _migrate_agent_runner_config(config, global_config)
assert finalize_config_migrations([global_config, config])
runner_config = config["agent_runner"]["config"]
assert config["agent_runner"]["runner_type"] == runner_type
assert runner_config[expected_key] == provider_config[expected_key]
assert not {
"id",
"type",
"provider",
"provider_type",
"enable",
"persona_id",
}.intersection(runner_config)
assert global_config["provider"] == []
def test_profile_migration_merges_runner_provider_source(
tmp_path, monkeypatch: pytest.MonkeyPatch
):
data_path = tmp_path / "data"
profile_path = data_path / "config" / "abconf_profile.json"
profile_path.parent.mkdir(parents=True)
default_config = {
"provider_sources": [
{
"id": "dify-source",
"type": "dify",
"provider_type": "agent_runner",
"dify_api_key": "source-key",
"dify_api_base": "https://example.test/v1",
}
],
"provider": [
{
"id": "dify-provider",
"provider_source_id": "dify-source",
"enable": True,
}
],
}
(data_path / "cmd_config.json").write_text(
json.dumps(default_config), encoding="utf-8"
)
profile_config = {
"config_version": 2,
"provider": [],
"provider_settings": {
"agent_runner_type": "dify",
"dify_agent_runner_provider_id": "dify-provider",
},
}
profile_path.write_text(json.dumps(profile_config), encoding="utf-8")
monkeypatch.setattr(
"astrbot.core.utils.migra_helper.get_astrbot_config_path",
lambda: str(profile_path.parent),
)
monkeypatch.setattr(
"astrbot.core.utils.migra_helper.get_astrbot_data_path",
lambda: str(data_path),
)
loaded = AstrBotConfig(config_path=str(profile_path))
assert loaded["agent_runner"]["runner_type"] == "dify"
assert loaded["agent_runner"]["config"]["dify_api_key"] == "source-key"
assert (
loaded["agent_runner"]["config"]["dify_api_base"] == "https://example.test/v1"
)
assert "provider_source_id" not in loaded["agent_runner"]["config"]
assert finalize_config_migrations([default_config, loaded])
assert default_config["provider"] == []
def test_missing_third_party_provider_uses_runner_defaults():
config = {
"provider": [],
"provider_settings": {
"agent_runner_type": "coze",
"coze_agent_runner_provider_id": "missing",
"default_personality": "operator",
},
}
_migrate_agent_runner_config(config)
expected = get_agent_runner_config_default("coze")
assert config["agent_runner"] == {
"runner_type": "coze",
"config": expected,
}
def test_legacy_default_provider_only_selects_actual_third_party_runner():
chat_config = {
"provider": [],
"provider_settings": {
"agent_runner_type": "local",
"default_provider_id": "chat-model",
},
}
runner_config = {
"provider": [],
"provider_settings": {
"agent_runner_type": "local",
"default_provider_id": "dify-provider",
},
}
global_config = {
"provider": [
{
"id": "chat-model",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
},
{
"id": "dify-provider",
"type": "dify",
"provider_type": "agent_runner",
"dify_api_key": "secret",
},
]
}
_migrate_agent_runner_config(chat_config, global_config)
_migrate_agent_runner_config(runner_config, global_config)
assert chat_config["agent_runner"]["runner_type"] == "local"
assert chat_config["agent_runner"]["config"]["model"]["provider_id"] == "chat-model"
assert runner_config["agent_runner"]["runner_type"] == "dify"
assert runner_config["agent_runner"]["config"]["dify_api_key"] == "secret"
def test_multiple_profiles_can_copy_one_provider_and_migration_is_idempotent():
global_config = {
"provider": [
{
"id": "shared-deerflow",
"type": "deerflow",
"provider_type": "agent_runner",
"deerflow_api_key": "shared-key",
},
{
"id": "unused-coze",
"type": "coze",
"provider_type": "agent_runner",
"coze_api_key": "unused",
},
{
"id": "unused-custom-runner",
"type": "custom-runner",
"provider_type": "agent_runner",
},
],
"provider_settings": {},
"agent_runner": {
"runner_type": "local",
"config": get_agent_runner_config_default("local"),
},
}
profiles = []
for persona_id in ("one", "two"):
profile = {
"provider": [],
"provider_settings": {
"agent_runner_type": "deerflow",
"deerflow_agent_runner_provider_id": "shared-deerflow",
"default_personality": persona_id,
},
}
_migrate_agent_runner_config(profile, global_config)
profiles.append(profile)
finalize_config_migrations([global_config, *profiles])
first_result = copy.deepcopy([global_config, *profiles])
assert [
profile["agent_runner"]["config"]["deerflow_api_key"] for profile in profiles
] == ["shared-key", "shared-key"]
assert all(
"persona_id" not in profile["agent_runner"]["config"] for profile in profiles
)
assert global_config["provider"] == []
assert not finalize_config_migrations([global_config, *profiles])
assert [global_config, *profiles] == first_result
def test_new_agent_runner_config_is_authoritative_and_opaque_on_reload(tmp_path):
config = copy.deepcopy(DEFAULT_CONFIG)
config["config_version"] = 2
config["provider_settings"]["agent_runner_type"] = "coze"
config["agent_runner"] = {
"runner_type": "dify",
"config": {
**get_agent_runner_config_default("dify"),
"dify_api_key": "saved-key",
"variables": {"nested": {"value": 1}},
},
}
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
loaded = AstrBotConfig(config_path=str(config_path))
assert loaded["agent_runner"]["runner_type"] == "dify"
assert loaded["agent_runner"]["config"]["dify_api_key"] == "saved-key"
assert loaded["agent_runner"]["config"]["variables"] == {"nested": {"value": 1}}
assert "agent_runner_type" not in loaded["provider_settings"]
+9 -1
View File
@@ -495,7 +495,15 @@ async def test_background_wakeup_applies_max_agent_step(
parameters={"type": "object", "properties": {}},
)
context = SimpleNamespace(
get_config=lambda **_kwargs: {"provider_settings": dict(provider_settings)},
get_config=lambda **_kwargs: {
"provider_settings": {},
"agent_runner": {
"runner_type": "local",
"config": {
"misc": {"max_steps": provider_settings.get("max_agent_step", 30)}
},
},
},
get_llm_tool_manager=lambda: SimpleNamespace(
get_builtin_tool=lambda _tool_cls: send_tool
),
+1 -3
View File
@@ -1874,9 +1874,7 @@ class TestBuildMainAgent:
llm_safety_mode=False,
computer_use_runtime="none",
add_cron_tools=False,
provider_settings={
"fallback_chat_models": ["image-provider"],
},
fallback_provider_ids=["image-provider"],
),
provider=text_provider,
req=req,
+6 -6
View File
@@ -278,7 +278,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
provider_a = self._make_provider("openai_source/model-a")
provider_b = self._make_provider("openai_source/model-b")
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
default_chat_provider_id="",
provider_insts=[provider_a, provider_b],
curr_provider_inst=provider_b,
)
@@ -293,7 +293,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
def test_warns_only_once_per_lifecycle(self, mock_log_broker, mock_db):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
default_chat_provider_id="",
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
@@ -312,7 +312,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
default_chat_provider_id="",
provider_insts=[self._make_provider("openai_source/model-a")],
curr_provider_inst=self._make_provider("openai_source/model-a"),
)
@@ -327,7 +327,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": "openai_source/model-a"},
default_chat_provider_id="openai_source/model-a",
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
@@ -347,7 +347,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
provider_a = self._make_provider("openai_source/model-a")
provider_b = self._make_provider("openai_source/model-b")
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": ""},
default_chat_provider_id="",
provider_insts=[provider_a, provider_b],
curr_provider_inst=None,
)
@@ -364,7 +364,7 @@ class TestAstrBotCoreLifecycleDefaultChatProviderWarning:
):
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
lifecycle.provider_manager = MagicMock(
provider_settings={"default_provider_id": "non-existent-id"},
default_chat_provider_id="non-existent-id",
provider_insts=[
self._make_provider("openai_source/model-a"),
self._make_provider("openai_source/model-b"),
+11 -2
View File
@@ -591,13 +591,16 @@ class TestRunActiveAgentJob:
):
"""Test active cron agent keeps structured history and provider settings."""
provider_settings = {
"tool_call_timeout": 77,
"fallback_chat_models": ["fallback-provider"],
}
ctx = MagicMock()
ctx.get_config.return_value = {
"admins_id": [],
"provider_settings": provider_settings,
"agent_runner": {
"runner_type": "local",
"config": {"misc": {"tool_call_timeout": 77}},
},
}
cron_manager.ctx = ctx
@@ -695,7 +698,13 @@ class TestRunActiveAgentJob:
ctx = MagicMock()
ctx.get_config.return_value = {
"admins_id": [],
"provider_settings": dict(provider_settings),
"provider_settings": {},
"agent_runner": {
"runner_type": "local",
"config": {
"misc": {"max_steps": provider_settings.get("max_agent_step", 30)}
},
},
}
cron_manager.ctx = ctx
@@ -0,0 +1,96 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.pipeline.process_stage.method.agent_sub_stages import third_party
@pytest.mark.asyncio
@pytest.mark.parametrize(
("runner_type", "runner_class_name", "config_key"),
[
("dify", "DifyAgentRunner", "dify_api_key"),
("coze", "CozeAgentRunner", "coze_api_key"),
("dashscope", "DashscopeAgentRunner", "dashscope_api_key"),
("deerflow", "DeerFlowAgentRunner", "deerflow_api_key"),
],
)
async def test_third_party_runner_receives_inline_profile_config(
monkeypatch: pytest.MonkeyPatch,
runner_type: str,
runner_class_name: str,
config_key: str,
):
inline_config = {config_key: "inline-secret"}
runner = MagicMock()
runner.reset = AsyncMock()
runner.get_final_llm_resp.return_value = LLMResponse(
role="assistant",
result_chain=MessageChain().message("done"),
)
runner.close = AsyncMock()
async def step_until_done(max_step: int = 30):
_ = max_step
if False:
yield None
runner.step_until_done = step_until_done
runner_factory_calls = []
class RunnerFactory:
@classmethod
def __class_getitem__(cls, item):
_ = item
return cls
def __new__(cls):
runner_factory_calls.append(True)
return runner
monkeypatch.setattr(third_party, runner_class_name, RunnerFactory)
monkeypatch.setattr(
third_party, "AstrAgentContext", MagicMock(return_value=object())
)
monkeypatch.setattr(
third_party, "AgentContextWrapper", MagicMock(return_value=object())
)
monkeypatch.setattr(third_party, "call_event_hook", AsyncMock(return_value=False))
monkeypatch.setattr(third_party.Metric, "upload", AsyncMock(return_value=None))
config = {
"agent_runner": {"runner_type": runner_type, "config": inline_config},
"provider_settings": {
"streaming_response": False,
"unsupported_streaming_strategy": "turn_off",
"third_party_stream_consumption_close_timeout_sec": 30,
},
}
stage = third_party.ThirdPartyAgentSubStage()
await stage.initialize(
SimpleNamespace(
astrbot_config=config,
plugin_manager=SimpleNamespace(
context=SimpleNamespace(
conversation_manager=MagicMock(),
persona_manager=MagicMock(),
)
),
)
)
stage._resolve_persona_custom_error_message = AsyncMock(return_value=None)
event = MagicMock()
event.message_str = "hello"
event.unified_msg_origin = "webchat:FriendMessage:test"
event.message_obj.message = []
event.platform_meta.support_streaming_message = True
event.get_extra.return_value = None
results = [item async for item in stage.process(event, "")]
assert results == [None]
assert runner.reset.await_args.kwargs["provider_config"] is inline_config
assert runner_factory_calls == [True]