Skip to content

feat: supports to register agent runner in plugins#6224

Open
advent259141 wants to merge 12 commits intoAstrBotDevs:masterfrom
advent259141:feature/agent-runner-plugin-interface
Open

feat: supports to register agent runner in plugins#6224
advent259141 wants to merge 12 commits intoAstrBotDevs:masterfrom
advent259141:feature/agent-runner-plugin-interface

Conversation

@advent259141
Copy link
Copy Markdown
Member

@advent259141 advent259141 commented Mar 13, 2026

插件(如 MaiBot)需要注册自定义 Agent Runner,但目前 AstrBot 只支持内置的 runner 类型。此 PR 实现了一套动态的 Agent Runner 注册机制,允许插件在安装时自动注册 runner 类型、Provider 配置模板和相关的 WebUI 元数据,卸载时自动清理。同时引入配置白名单机制,解决插件注入的配置项在重启时被配置迁移删除的问题。

Modifications / 改动点

1. 动态 Agent Runner 注册表

  • [NEW] registry.py
    • 新增 AgentRunnerRegistry 单例 + AgentRunnerEntry 数据类
    • 插件通过 register() 注册自定义 runner 类型,自动注入 WebUI 配置元数据:
      • CONFIG_METADATA_3: agent_runner_type 下拉选项 + provider_id 选择器
      • CONFIG_METADATA_2: provider_settings schema + provider 配置模板
    • unregister() 时自动清理所有注入的元数据

2. 配置持久化白名单

  • **[MODIFY] astrbot_config.py **
    • 新增 AstrBotConfig._dynamic_config_keys 类属性
    • 新增 register_dynamic_key(path) / unregister_dynamic_key(path) 类方法
    • check_config_integrity() 在处理未知配置项时,先检查是否在白名单中:白名单中的保留,不在的照常删除
    • 解决了插件注入的配置项(如 provider_settings.maibot_agent_runner_provider_id)在重启时被配置迁移删除的问题

3. 插件 API 集成

4. 其他

  • [MODIFY] default.py — Runner 相关默认配置结构调整

使用方式

插件在 on_activated 中注册:

from astrbot.core.agent.runners.registry import AgentRunnerEntry

entry = AgentRunnerEntry(
    runner_type="maibot",
    display_name="MaiBot",
    provider_id_key="maibot_agent_runner_provider_id",
    provider_config_fields={
        "ws_url": {"type": "string", "description": "MaiBot WS 地址", "default": "ws://localhost:18000/ws"},
        "api_key": {"type": "string", "description": "API Key", "default": ""},
    },
    factory=create_maibot_runner,
)
context.register_agent_runner(entry)
  • This is NOT a breaking change. / 这不是一个破坏性变更。

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

image image

Checklist / 检查清单

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

Summary by Sourcery

Introduce a dynamic registry and plugin API for third‑party Agent Runners, including WebUI integration and config whitelisting so plugin‑defined runners and their settings can be registered and persisted at runtime.

New Features:

  • Add a global AgentRunnerRegistry and AgentRunnerEntry descriptor to allow plugins to register custom Agent Runners with associated WebUI metadata and provider config templates at runtime.
  • Expose register_agent_runner and unregister_agent_runner methods on the plugin context so plugins can add or remove third‑party Agent Runners programmatically.

Enhancements:

  • Extend the third‑party agent runner pipeline stage to resolve provider IDs, invoke optional initialization callbacks, and instantiate runners via the dynamic registry as a fallback to built‑in runners.
  • Introduce a dynamic config key whitelist in AstrBotConfig so plugin‑injected configuration paths are preserved during config migration instead of being deleted.
  • Update the dashboard config wrapper translation helper to gracefully fall back to raw keys, allowing dynamically injected field descriptions to display correctly in the WebUI.

@auto-assign auto-assign Bot requested review from Fridemn and LIghtJUNction March 13, 2026 15:41
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求旨在增强 AstrBot 的可扩展性,允许第三方插件动态注册自定义的 Agent 执行器,并确保这些动态配置项在系统重启或配置迁移时能够正确持久化。通过引入动态注册表和配置白名单机制,开发者可以更灵活地集成新的 Agent 服务,同时保持配置的稳定性和 WebUI 的自动更新能力,极大地提升了插件生态的健壮性和用户体验。

Highlights

  • 动态 Agent 执行器注册机制: 新增 AgentRunnerRegistry,允许插件在运行时注册自定义 Agent 执行器,并自动注入 WebUI 配置元数据,包括执行器类型下拉选项、Provider ID 选择器、Provider 配置模板和额外的配置字段,并在卸载时自动清理。
  • 配置持久化白名单: 在 AstrBotConfig 中引入动态配置项白名单机制,通过 register_dynamic_keyunregister_dynamic_key 方法管理,确保插件注入的配置项在配置迁移时不会被错误删除。
  • 插件 API 集成: 在 context.py 中暴露 register_agent_runner()unregister_agent_runner() API,供插件方便地注册和注销 Agent 执行器。同时,star_manager.py 也在插件卸载/禁用时自动调用注销逻辑。
  • Agent 执行器解析逻辑更新: third_party.py 中的 Agent 执行器解析逻辑已更新,优先检查内置类型,然后回退到动态注册表以支持插件提供的执行器,并支持调用注册执行器的 on_initialize 回调。
  • WebUI 国际化处理优化: 前端 AstrBotCoreConfigWrapper.vue 中的 tm 函数增加了对动态注入描述的兼容处理,当翻译键不存在时,直接返回键本身,以正确显示插件动态生成的配置描述。
Changelog
  • astrbot/core/agent/runners/registry.py
    • 新增 AgentRunnerRegistry 类,用于管理插件注册的 Agent 执行器。
    • 新增 AgentRunnerEntry 数据类,定义了 Agent 执行器的注册信息。
    • 实现了 register 方法,用于注册执行器并动态注入 WebUI 配置元数据。
    • 实现了 unregister 方法,用于注销执行器并清理 WebUI 配置元数据。
    • 实现了 _inject_config_metadata_remove_config_metadata 静态方法,处理 WebUI 配置的动态增删。
  • astrbot/core/config/astrbot_config.py
    • 新增 _dynamic_config_keys 类属性,用于存储动态配置项的路径白名单。
    • 新增 register_dynamic_key 类方法,允许将配置路径添加到白名单。
    • 新增 unregister_dynamic_key 类方法,允许从白名单中移除配置路径。
    • 修改 check_config_integrity 方法,在处理未知配置项时,如果配置项在动态白名单中则保留,否则删除。
  • astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py
    • 导入 agent_runner_registry 模块。
    • 更新了 AGENT_RUNNER_TYPE_KEY 的注释,明确插件注册的执行器使用注册表。
    • 修改 initialize 方法,在解析 prov_id 时,优先检查内置映射,然后查询 agent_runner_registry
    • 新增 _run_registry_on_initialize 异步方法,用于执行插件注册执行器的 on_initialize 回调。
    • 修改 process 方法,在无法匹配内置执行器类型时,回退到 agent_runner_registry 中查找并实例化插件注册的执行器。
  • astrbot/core/star/context.py
    • 新增 register_agent_runner 方法,提供给插件注册自定义 Agent 执行器。
    • 新增 unregister_agent_runner 方法,提供给插件注销已注册的 Agent 执行器。
  • dashboard/src/components/config/AstrBotCoreConfigWrapper.vue
    • 修改 tm 函数,增加了对空 key 的处理。
    • 优化了 tm 函数的逻辑,当 tmMetadatatmConfig 均无法找到翻译时,直接返回 key 本身,以支持动态注入的描述显示。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

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

  • The AgentRunnerEntry.conversation_id_key field is currently unused in the registry and third-party runner handling; consider either wiring it into the conversation state logic or removing it to avoid confusion about its purpose.
  • In AgentRunnerRegistry._inject_config_metadata / _remove_config_metadata, provider_config_fields are added and removed by plain field name, so two plugins using the same field name would conflict; you may want to enforce namespacing or guard against removing fields still used by other runners.
  • The log message in _run_registry_on_initialize mentions it "will retry on first message", but there is no actual retry path implemented; either add a concrete retry mechanism or adjust the wording to avoid implying behavior that doesn't exist.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `AgentRunnerEntry.conversation_id_key` field is currently unused in the registry and third-party runner handling; consider either wiring it into the conversation state logic or removing it to avoid confusion about its purpose.
- In `AgentRunnerRegistry._inject_config_metadata` / `_remove_config_metadata`, `provider_config_fields` are added and removed by plain field name, so two plugins using the same field name would conflict; you may want to enforce namespacing or guard against removing fields still used by other runners.
- The log message in `_run_registry_on_initialize` mentions it "will retry on first message", but there is no actual retry path implemented; either add a concrete retry mechanism or adjust the wording to avoid implying behavior that doesn't exist.

## Individual Comments

### Comment 1
<location path="astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py" line_range="199-208" />
<code_context>

+        # Invoke on_initialize callback for plugin-registered runners
+        registry_entry = agent_runner_registry.get(self.runner_type)
+        if registry_entry and registry_entry.on_initialize and self.prov_id:
+            asyncio.create_task(self._run_registry_on_initialize(registry_entry))
+
+    async def _run_registry_on_initialize(self, entry) -> None:
+        """Run the on_initialize callback for a plugin-registered runner."""
+        try:
+            await entry.on_initialize(self.ctx, self.prov_id)
+        except Exception as e:
+            logger.warning(
+                "[%s] on_initialize failed (will retry on first message): %s",
+                entry.runner_type,
+                e,
</code_context>
<issue_to_address>
**issue (bug_risk):** The warning message mentions a retry on first message, but no retry path is visible here.

The current implementation only runs `on_initialize` via `initialize()` using `create_task`, and `process()` never retries it if that task fails. If you do want lazy retry on the first user message, you’ll need to add that logic (e.g., track failure and call `on_initialize` on the first `process` when needed). Otherwise, please update the log text so it doesn’t promise a retry path that isn’t implemented.
</issue_to_address>

### Comment 2
<location path="astrbot/core/agent/runners/registry.py" line_range="114-118" />
<code_context>
+            from astrbot.core.config.astrbot_config import AstrBotConfig
+
+            # --- CONFIG_METADATA_3: agent_runner dropdown ---
+            agent_runner_section = (
+                CONFIG_METADATA_3
+                .get("ai_group", {})
+                .get("metadata", {})
+                .get("agent_runner", {})
+                .get("items", {})
+            )
</code_context>
<issue_to_address>
**issue (bug_risk):** Chained `.get(..., {})` calls may cause silent no-ops if any intermediate key is missing.

Because this section is built via chained `.get(..., {})` calls, if any of `"ai_group"`, `"metadata"`, or `"agent_runner"` is missing, `agent_runner_section` becomes a fresh `{}` and later mutations won’t affect `CONFIG_METADATA_3`. This causes silent loss of dynamic runner options when the config shape changes. Consider either explicitly validating each level and returning/logging if missing, or creating missing nested dicts in-place so updates always apply to `CONFIG_METADATA_3`.
</issue_to_address>

### Comment 3
<location path="astrbot/core/agent/runners/registry.py" line_range="158-167" />
<code_context>
+            if prov_settings_schema:
+                prov_settings_schema.pop(entry.provider_id_key, None)
+
+            provider_schema = (
+                CONFIG_METADATA_2
+                .get("provider_group", {})
+                .get("metadata", {})
+                .get("provider", {})
+                .get("items", {})
+            )
+            if provider_schema and entry.provider_config_fields:
+                for field_name in entry.provider_config_fields:
+                    provider_schema.pop(field_name, None)
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Unregistering a runner unconditionally removes shared provider fields, which may affect other runners.

In `_remove_config_metadata`, every `field_name` in `entry.provider_config_fields` is removed from `provider_schema`. If multiple runners share a field name (e.g. a common `callback_url`), unregistering one will remove the field for all, breaking the remaining runner’s schema. Likewise, `provider_config_template.pop(entry.display_name, None)` will remove shared display names. Please either namespace plugin-specific fields (e.g. prefix with `runner_type`) or track ownership so only fields/templates unique to the unregistered runner are removed.

Suggested implementation:

```python
            if prov_settings_schema:
                prov_settings_schema.pop(entry.provider_id_key, None)

            provider_schema = (
                CONFIG_METADATA_2
                .get("provider_group", {})
                .get("metadata", {})
                .get("provider", {})
                .get("items", {})
            )
            if provider_schema and entry.provider_config_fields:
                # Only remove provider-level fields that are not shared with any
                # other registered runner; shared fields must remain in the schema.
                other_entries = [
                    e for e in self._entries  # adjust if the registry uses a different collection
                    if e is not entry
                ]
                for field_name in entry.provider_config_fields:
                    still_used = any(
                        getattr(e, "provider_config_fields", None)
                        and field_name in e.provider_config_fields
                        for e in other_entries
                    )
                    if not still_used:
                        provider_schema.pop(field_name, None)


```

```python
        # Only remove the template entry keyed by display_name if this display_name
        # is not shared with any other registered runner.
        other_entries = [
            e for e in self._entries  # adjust if the registry uses a different collection
            if e is not entry
        ]
        shared_display_name = any(
            getattr(e, "display_name", None) == entry.display_name
            for e in other_entries
        )
        if not shared_display_name:
            provider_config_template.pop(entry.display_name, None)

```

1. Ensure that `self._entries` (or whatever collection holds all registered runners) is available within the `_remove_config_metadata` scope. If your registry uses a different attribute (e.g. `self._registry`, `self._runners`, or a module-level list), replace `self._entries` with the correct collection.
2. If `_remove_config_metadata` is a standalone function rather than a method, you will need to pass the registry (or the list of all entries) into the function and use that instead of `self._entries`.
3. If the code that removes `provider_config_template` is inside a different method or uses different indentation / context, adjust the indentation of the replacement block accordingly so it aligns with the surrounding code.
</issue_to_address>

### Comment 4
<location path="astrbot/core/agent/runners/registry.py" line_range="103" />
<code_context>
+    # WebUI config injection helpers
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    def _inject_config_metadata(entry: AgentRunnerEntry) -> None:
+        """Mutate CONFIG_METADATA_3 to add the runner option."""
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the config-metadata injection/removal logic into shared helper functions to avoid repeated dict-walking and duplicated symmetric code paths.

You can reduce the complexity without changing behavior by extracting the repeated dict-walking and making injection/removal use the same small helpers. That also lets you narrow the `try/except` around the minimum surface.

### 1. Centralize config-path access

The chained `.get(...).get(...).get(...)` blocks are repeated and mirrored in both methods. A tiny set of helpers makes this easier to read and maintain:

```python
# near the top of the file
def _get_agent_runner_section(CONFIG_METADATA_3: dict) -> dict:
    return (
        CONFIG_METADATA_3
        .get("ai_group", {})
        .get("metadata", {})
        .get("agent_runner", {})
        .get("items", {})
    )

def _get_provider_settings_schema(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider_settings", {})
        .get("items", {})
    )

def _get_provider_schema(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider", {})
        .get("items", {})
    )

def _get_provider_config_template(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider", {})
        .get("config_template", {})
    )
```

Then both `_inject_config_metadata` and `_remove_config_metadata` become visually simpler:

```python
agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
provider_schema = _get_provider_schema(CONFIG_METADATA_2)
provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)
```

### 2. Share symmetrical logic via small helpers

The inject/remove logic for each “concern” (runner options, provider_id field, extra provider fields, config_template, dynamic key) can be factored into paired helpers. This keeps behavior but removes duplication and reduces the chance of divergence.

Example for the dropdown options and provider_id field:

```python
def _add_runner_type_option(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    runner_type_field = agent_runner_section.get("provider_settings.agent_runner_type")
    if not runner_type_field:
        return

    options: list = runner_type_field.setdefault("options", [])
    labels: list = runner_type_field.setdefault("labels", [])
    if entry.runner_type not in options:
        options.append(entry.runner_type)
        labels.append(entry.display_name)

def _remove_runner_type_option(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    runner_type_field = agent_runner_section.get("provider_settings.agent_runner_type")
    if not runner_type_field:
        return

    options: list = runner_type_field.get("options", [])
    labels: list = runner_type_field.get("labels", [])
    if entry.runner_type in options:
        idx = options.index(entry.runner_type)
        options.pop(idx)
        if idx < len(labels):
            labels.pop(idx)

def _add_provider_id_field(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    prov_id_config_key = f"provider_settings.{entry.provider_id_key}"
    if prov_id_config_key not in agent_runner_section:
        agent_runner_section[prov_id_config_key] = {
            "description": f"{entry.display_name} Agent 执行器提供商 ID",
            "type": "string",
            "_special": f"select_agent_runner_provider:{entry.runner_type}",
            "condition": {
                "provider_settings.agent_runner_type": entry.runner_type,
                "provider_settings.enable": True,
            },
        }

def _remove_provider_id_field(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    prov_id_config_key = f"provider_settings.{entry.provider_id_key}"
    agent_runner_section.pop(prov_id_config_key, None)
```

Then `_inject_config_metadata` becomes a straightforward composition of these helpers:

```python
@staticmethod
def _inject_config_metadata(entry: AgentRunnerEntry) -> None:
    from astrbot.core.config.default import CONFIG_METADATA_2, CONFIG_METADATA_3
    from astrbot.core.config.astrbot_config import AstrBotConfig

    try:
        agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
        prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
        provider_schema = _get_provider_schema(CONFIG_METADATA_2)
        provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)

        _add_runner_type_option(agent_runner_section, entry)
        _add_provider_id_field(agent_runner_section, entry)

        if prov_settings_schema and entry.provider_id_key not in prov_settings_schema:
            prov_settings_schema[entry.provider_id_key] = {"type": "string"}

        if provider_schema and entry.provider_config_fields:
            for field_name, field_def in entry.provider_config_fields.items():
                provider_schema.setdefault(field_name, field_def)

        AstrBotConfig.register_dynamic_key(f"provider_settings.{entry.provider_id_key}")

        if entry.display_name not in provider_config_template:
            template: dict[str, Any] = {
                "id": entry.runner_type,
                "provider": entry.runner_type,
                "type": entry.runner_type,
                "provider_type": "agent_runner",
                "enable": True,
            }
            for field_name, field_def in entry.provider_config_fields.items():
                template[field_name] = field_def.get("default", "")
            provider_config_template[entry.display_name] = template

    except Exception:
        logger.warning(
            "Failed to inject config metadata for runner %s",
            entry.runner_type,
            exc_info=True,
        )
```

And `_remove_config_metadata` mirrors this using the same helpers:

```python
@staticmethod
def _remove_config_metadata(entry: AgentRunnerEntry) -> None:
    from astrbot.core.config.default import CONFIG_METADATA_2, CONFIG_METADATA_3
    from astrbot.core.config.astrbot_config import AstrBotConfig

    try:
        agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
        prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
        provider_schema = _get_provider_schema(CONFIG_METADATA_2)
        provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)

        _remove_runner_type_option(agent_runner_section, entry)
        _remove_provider_id_field(agent_runner_section, entry)

        if prov_settings_schema:
            prov_settings_schema.pop(entry.provider_id_key, None)

        if provider_schema and entry.provider_config_fields:
            for field_name in entry.provider_config_fields:
                provider_schema.pop(field_name, None)

        provider_config_template.pop(entry.display_name, None)
        AstrBotConfig.unregister_dynamic_key(f"provider_settings.{entry.provider_id_key}")

    except Exception:
        logger.warning(
            "Failed to remove config metadata for runner %s",
            entry.runner_type,
            exc_info=True,
        )
```

This keeps the current behavior (including the try/except and side effects) but:

- isolates config path knowledge to a few helpers,
- reduces duplication between inject/remove,
- makes it clearer what each step is doing and where to update when config structure changes.
</issue_to_address>

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

Comment on lines +199 to +208
if registry_entry and registry_entry.on_initialize and self.prov_id:
asyncio.create_task(self._run_registry_on_initialize(registry_entry))

async def _run_registry_on_initialize(self, entry) -> None:
"""Run the on_initialize callback for a plugin-registered runner."""
try:
await entry.on_initialize(self.ctx, self.prov_id)
except Exception as e:
logger.warning(
"[%s] on_initialize failed (will retry on first message): %s",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): The warning message mentions a retry on first message, but no retry path is visible here.

The current implementation only runs on_initialize via initialize() using create_task, and process() never retries it if that task fails. If you do want lazy retry on the first user message, you’ll need to add that logic (e.g., track failure and call on_initialize on the first process when needed). Otherwise, please update the log text so it doesn’t promise a retry path that isn’t implemented.

Comment on lines +114 to +118
agent_runner_section = (
CONFIG_METADATA_3
.get("ai_group", {})
.get("metadata", {})
.get("agent_runner", {})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Chained .get(..., {}) calls may cause silent no-ops if any intermediate key is missing.

Because this section is built via chained .get(..., {}) calls, if any of "ai_group", "metadata", or "agent_runner" is missing, agent_runner_section becomes a fresh {} and later mutations won’t affect CONFIG_METADATA_3. This causes silent loss of dynamic runner options when the config shape changes. Consider either explicitly validating each level and returning/logging if missing, or creating missing nested dicts in-place so updates always apply to CONFIG_METADATA_3.

Comment on lines +158 to +167
provider_schema = (
CONFIG_METADATA_2
.get("provider_group", {})
.get("metadata", {})
.get("provider", {})
.get("items", {})
)
if provider_schema and entry.provider_config_fields:
for field_name, field_def in entry.provider_config_fields.items():
if field_name not in provider_schema:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Unregistering a runner unconditionally removes shared provider fields, which may affect other runners.

In _remove_config_metadata, every field_name in entry.provider_config_fields is removed from provider_schema. If multiple runners share a field name (e.g. a common callback_url), unregistering one will remove the field for all, breaking the remaining runner’s schema. Likewise, provider_config_template.pop(entry.display_name, None) will remove shared display names. Please either namespace plugin-specific fields (e.g. prefix with runner_type) or track ownership so only fields/templates unique to the unregistered runner are removed.

Suggested implementation:

            if prov_settings_schema:
                prov_settings_schema.pop(entry.provider_id_key, None)

            provider_schema = (
                CONFIG_METADATA_2
                .get("provider_group", {})
                .get("metadata", {})
                .get("provider", {})
                .get("items", {})
            )
            if provider_schema and entry.provider_config_fields:
                # Only remove provider-level fields that are not shared with any
                # other registered runner; shared fields must remain in the schema.
                other_entries = [
                    e for e in self._entries  # adjust if the registry uses a different collection
                    if e is not entry
                ]
                for field_name in entry.provider_config_fields:
                    still_used = any(
                        getattr(e, "provider_config_fields", None)
                        and field_name in e.provider_config_fields
                        for e in other_entries
                    )
                    if not still_used:
                        provider_schema.pop(field_name, None)
        # Only remove the template entry keyed by display_name if this display_name
        # is not shared with any other registered runner.
        other_entries = [
            e for e in self._entries  # adjust if the registry uses a different collection
            if e is not entry
        ]
        shared_display_name = any(
            getattr(e, "display_name", None) == entry.display_name
            for e in other_entries
        )
        if not shared_display_name:
            provider_config_template.pop(entry.display_name, None)
  1. Ensure that self._entries (or whatever collection holds all registered runners) is available within the _remove_config_metadata scope. If your registry uses a different attribute (e.g. self._registry, self._runners, or a module-level list), replace self._entries with the correct collection.
  2. If _remove_config_metadata is a standalone function rather than a method, you will need to pass the registry (or the list of all entries) into the function and use that instead of self._entries.
  3. If the code that removes provider_config_template is inside a different method or uses different indentation / context, adjust the indentation of the replacement block accordingly so it aligns with the surrounding code.

# WebUI config injection helpers
# ------------------------------------------------------------------

@staticmethod
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider refactoring the config-metadata injection/removal logic into shared helper functions to avoid repeated dict-walking and duplicated symmetric code paths.

You can reduce the complexity without changing behavior by extracting the repeated dict-walking and making injection/removal use the same small helpers. That also lets you narrow the try/except around the minimum surface.

1. Centralize config-path access

The chained .get(...).get(...).get(...) blocks are repeated and mirrored in both methods. A tiny set of helpers makes this easier to read and maintain:

# near the top of the file
def _get_agent_runner_section(CONFIG_METADATA_3: dict) -> dict:
    return (
        CONFIG_METADATA_3
        .get("ai_group", {})
        .get("metadata", {})
        .get("agent_runner", {})
        .get("items", {})
    )

def _get_provider_settings_schema(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider_settings", {})
        .get("items", {})
    )

def _get_provider_schema(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider", {})
        .get("items", {})
    )

def _get_provider_config_template(CONFIG_METADATA_2: dict) -> dict:
    return (
        CONFIG_METADATA_2
        .get("provider_group", {})
        .get("metadata", {})
        .get("provider", {})
        .get("config_template", {})
    )

Then both _inject_config_metadata and _remove_config_metadata become visually simpler:

agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
provider_schema = _get_provider_schema(CONFIG_METADATA_2)
provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)

2. Share symmetrical logic via small helpers

The inject/remove logic for each “concern” (runner options, provider_id field, extra provider fields, config_template, dynamic key) can be factored into paired helpers. This keeps behavior but removes duplication and reduces the chance of divergence.

Example for the dropdown options and provider_id field:

def _add_runner_type_option(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    runner_type_field = agent_runner_section.get("provider_settings.agent_runner_type")
    if not runner_type_field:
        return

    options: list = runner_type_field.setdefault("options", [])
    labels: list = runner_type_field.setdefault("labels", [])
    if entry.runner_type not in options:
        options.append(entry.runner_type)
        labels.append(entry.display_name)

def _remove_runner_type_option(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    runner_type_field = agent_runner_section.get("provider_settings.agent_runner_type")
    if not runner_type_field:
        return

    options: list = runner_type_field.get("options", [])
    labels: list = runner_type_field.get("labels", [])
    if entry.runner_type in options:
        idx = options.index(entry.runner_type)
        options.pop(idx)
        if idx < len(labels):
            labels.pop(idx)

def _add_provider_id_field(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    prov_id_config_key = f"provider_settings.{entry.provider_id_key}"
    if prov_id_config_key not in agent_runner_section:
        agent_runner_section[prov_id_config_key] = {
            "description": f"{entry.display_name} Agent 执行器提供商 ID",
            "type": "string",
            "_special": f"select_agent_runner_provider:{entry.runner_type}",
            "condition": {
                "provider_settings.agent_runner_type": entry.runner_type,
                "provider_settings.enable": True,
            },
        }

def _remove_provider_id_field(agent_runner_section: dict, entry: AgentRunnerEntry) -> None:
    prov_id_config_key = f"provider_settings.{entry.provider_id_key}"
    agent_runner_section.pop(prov_id_config_key, None)

Then _inject_config_metadata becomes a straightforward composition of these helpers:

@staticmethod
def _inject_config_metadata(entry: AgentRunnerEntry) -> None:
    from astrbot.core.config.default import CONFIG_METADATA_2, CONFIG_METADATA_3
    from astrbot.core.config.astrbot_config import AstrBotConfig

    try:
        agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
        prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
        provider_schema = _get_provider_schema(CONFIG_METADATA_2)
        provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)

        _add_runner_type_option(agent_runner_section, entry)
        _add_provider_id_field(agent_runner_section, entry)

        if prov_settings_schema and entry.provider_id_key not in prov_settings_schema:
            prov_settings_schema[entry.provider_id_key] = {"type": "string"}

        if provider_schema and entry.provider_config_fields:
            for field_name, field_def in entry.provider_config_fields.items():
                provider_schema.setdefault(field_name, field_def)

        AstrBotConfig.register_dynamic_key(f"provider_settings.{entry.provider_id_key}")

        if entry.display_name not in provider_config_template:
            template: dict[str, Any] = {
                "id": entry.runner_type,
                "provider": entry.runner_type,
                "type": entry.runner_type,
                "provider_type": "agent_runner",
                "enable": True,
            }
            for field_name, field_def in entry.provider_config_fields.items():
                template[field_name] = field_def.get("default", "")
            provider_config_template[entry.display_name] = template

    except Exception:
        logger.warning(
            "Failed to inject config metadata for runner %s",
            entry.runner_type,
            exc_info=True,
        )

And _remove_config_metadata mirrors this using the same helpers:

@staticmethod
def _remove_config_metadata(entry: AgentRunnerEntry) -> None:
    from astrbot.core.config.default import CONFIG_METADATA_2, CONFIG_METADATA_3
    from astrbot.core.config.astrbot_config import AstrBotConfig

    try:
        agent_runner_section = _get_agent_runner_section(CONFIG_METADATA_3)
        prov_settings_schema = _get_provider_settings_schema(CONFIG_METADATA_2)
        provider_schema = _get_provider_schema(CONFIG_METADATA_2)
        provider_config_template = _get_provider_config_template(CONFIG_METADATA_2)

        _remove_runner_type_option(agent_runner_section, entry)
        _remove_provider_id_field(agent_runner_section, entry)

        if prov_settings_schema:
            prov_settings_schema.pop(entry.provider_id_key, None)

        if provider_schema and entry.provider_config_fields:
            for field_name in entry.provider_config_fields:
                provider_schema.pop(field_name, None)

        provider_config_template.pop(entry.display_name, None)
        AstrBotConfig.unregister_dynamic_key(f"provider_settings.{entry.provider_id_key}")

    except Exception:
        logger.warning(
            "Failed to remove config metadata for runner %s",
            entry.runner_type,
            exc_info=True,
        )

This keeps the current behavior (including the try/except and side effects) but:

  • isolates config path knowledge to a few helpers,
  • reduces duplication between inject/remove,
  • makes it clearer what each step is doing and where to update when config structure changes.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Code Review

这个 PR 实现了一套动态的 Agent Runner 注册机制,允许插件在运行时注册自定义的执行器,并与 WebUI 和配置系统集成,这是一个非常棒的功能增强。代码结构清晰,考虑了插件注册、卸载以及配置持久化等多个方面。

我有以下几点建议:

  1. registry.py 中,异常捕获的范围过大,建议使用更具体的异常类型,以便更好地定位问题。
  2. 同样在 registry.py 中,清理 WebUI 元数据时对 optionslabels 列表的操作存在一定的脆弱性,如果列表不同步可能会导致 UI 显示不一致。
  3. third_party.py 中,为辅助方法的参数添加类型提示,可以增强代码的可读性和可维护性。

整体而言,这是一个高质量的 PR,上述建议旨在提升代码的健壮性和可维护性。

I am having trouble creating individual review comments. Click here to see my feedback.

astrbot/core/agent/runners/registry.py (196-201)

medium

这里的 except Exception: 捕获范围过大,可能会掩盖一些意料之外的错误,不利于调试。建议捕获更具体的异常类型,例如 ImportError, AttributeError, IndexError 等,这样可以让错误处理更精确。此建议同样适用于第 271 行的异常捕获。

        except (ImportError, AttributeError, IndexError, ValueError) as e:
            logger.warning(
                "Failed to inject config metadata for runner %s",
                entry.runner_type,
                exc_info=e,
            )

astrbot/core/agent/runners/registry.py (226-230)

medium

这段代码在清理 optionslabels 列表时,依赖于 pop(idx) 操作,并假设两个列表是完全同步的。如果因为某些意外情况导致 len(labels) 小于 len(options)if idx < len(labels): 会阻止程序出错,但对应的 label 不会被删除,导致 optionslabels 失去同步,可能引发前端显示问题。建议在 idx >= len(labels) 的情况下增加一条警告日志,以便于快速发现和定位这类潜在问题。

                if entry.runner_type in options:
                    idx = options.index(entry.runner_type)
                    options.pop(idx)
                    if idx < len(labels):
                        labels.pop(idx)
                    else:
                        logger.warning(
                            "Mismatch between options and labels for runner %s. "
                            "Label could not be removed by index.",
                            entry.runner_type,
                        )

astrbot/core/agent/runners/registry.py (271-276)

medium

这里的 except Exception: 捕获范围过大,可能会掩盖一些意料之外的错误,不利于调试。建议捕获更具体的异常类型,例如 ImportError, AttributeError, IndexError 等,这样可以让错误处理更精确。

        except (ImportError, AttributeError, IndexError, ValueError) as e:
            logger.warning(
                "Failed to remove config metadata for runner %s",
                entry.runner_type,
                exc_info=e,
            )

astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py (202)

medium

为了提高代码的可读性和可维护性,建议为 _run_registry_on_initialize 方法中的 entry 参数添加类型提示。你可以使用字符串前向引用 "AgentRunnerEntry" 来避免循环导入问题。

    async def _run_registry_on_initialize(self, entry: "AgentRunnerEntry") -> None:

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:core The bug / feature is about astrbot's core, backend labels Mar 13, 2026
@dosubot
Copy link
Copy Markdown

dosubot Bot commented Mar 13, 2026

Related Documentation

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

AstrBotTeam's Space

pr4697的改动
View Suggested Changes
@@ -467,6 +467,119 @@
   - `on_task_failed`:任务失败时触发(包含错误类型和异常)
   - `on_task_canceled`:任务被取消时触发
   - `on_task_result_ignored`:任务结果被忽略时触发(如状态已变化)
+
+#### 插件注册自定义 Agent Runner(PR #6224)
+
+[PR #6224](https://github.com/AstrBotDevs/AstrBot/pull/6224) 新增了插件动态注册 Agent Runner 的功能,允许插件(如 MaiBot)扩展 AstrBot 的 Agent 执行能力,无需修改核心代码。
+
+**核心特性:**
+
+##### 1. 插件 API 方法
+
+`context.py` 模块新增两个公开方法供插件使用:
+
+- **`context.register_agent_runner(entry: AgentRunnerEntry)`**:注册自定义 Agent Runner,自动注入 WebUI 下拉选项和配置元数据
+  - 版本要求:SDK 4.6.0+
+  - 使用示例:
+    ```python
+    from astrbot.core.agent.runners.registry import AgentRunnerEntry
+
+    context.register_agent_runner(AgentRunnerEntry(
+        runner_type="maibot",
+        runner_cls=MaiBotAgentRunner,
+        provider_id_key="maibot_agent_runner_provider_id",
+        display_name="MaiBot",
+    ))
+    ```
+
+- **`context.unregister_agent_runner(runner_type: str)`**:移除已注册的 Agent Runner
+  - 版本要求:SDK 4.6.0+
+  - 插件卸载/禁用时自动调用
+
+##### 2. AgentRunnerRegistry 系统
+
+新增全局单例 `agent_runner_registry`(`astrbot/core/agent/runners/registry.py`),负责管理插件注册的 Agent Runner:
+
+- **注册表结构**:
+  - 内置 Runner(Dify、Coze、DashScope、DeerFlow)仍使用静态分发
+  - 插件 Runner 通过注册表动态查找,作为回退路径
+  - 自动注入 WebUI 元数据到 `CONFIG_METADATA_3`(下拉选项)和 `CONFIG_METADATA_2`(Provider 配置模板)
+
+- **生命周期管理**:
+  - `register(entry)` 时自动注入配置元数据和动态配置键白名单
+  - `unregister(runner_type)` 时自动清理所有注入的元数据
+
+##### 3. AgentRunnerEntry 数据类
+
+`AgentRunnerEntry` 定义了插件 Agent Runner 的注册信息,包含以下属性:
+
+- **`runner_type`** (str):唯一标识符,用于配置文件中的 `agent_runner_type` 字段(例如 `"maibot"`)
+- **`runner_cls`** (type[BaseAgentRunner]):具体的 Agent Runner 类,需继承 `BaseAgentRunner`
+- **`provider_id_key`** (str):配置键,用于存储 Provider ID(例如 `"maibot_agent_runner_provider_id"`)
+- **`display_name`** (str):WebUI 下拉菜单中显示的名称
+- **`on_initialize`** (Callable,可选):异步回调函数,在 Pipeline 阶段初始化时调用(用于预连接、工具同步等)
+- **`conversation_id_key`** (str,可选):用于存储会话/线程 ID 的配置键(若 Runner 管理自己的会话状态)
+- **`provider_config_fields`** (dict,可选):额外的 Provider 配置字段定义,自动注入到 `CONFIG_METADATA_2`
+  - 格式:`{"field_name": {"description": "...", "type": "string", "default": "..."}}`
+
+##### 4. 动态配置管理
+
+`astrbot_config.py` 新增配置白名单机制,防止插件注入的配置项在系统重启时被配置迁移删除:
+
+- **`AstrBotConfig.register_dynamic_key(path: str)`**:注册动态配置项路径(例如 `"provider_settings.maibot_agent_runner_provider_id"`),迁移时不会被删除
+- **`AstrBotConfig.unregister_dynamic_key(path: str)`**:移除白名单条目
+- **自动调用**:注册表在 `register()` 和 `unregister()` 时自动调用这些方法
+
+##### 5. WebUI 集成
+
+插件注册 Agent Runner 后,WebUI 自动更新:
+
+- **下拉选项**:`agent_runner_type` 字段自动添加新选项和显示名称
+- **Provider ID 选择器**:自动创建条件显示的 Provider ID 选择器
+- **Provider 配置模板**:自动注入 `config_template` 和额外配置字段
+- **清理机制**:`unregister()` 时自动移除所有注入的元数据
+
+##### 6. 运行时分发
+
+`third_party.py` 中的 Agent Runner 分发逻辑:
+
+- **优先级**:先检查内置 Runner(静态 if/elif 链),未找到时查询 `agent_runner_registry`
+- **on_initialize 回调**:若插件 Runner 定义了 `on_initialize` 回调,系统会在 Pipeline 初始化时异步调用
+- **错误处理**:回调失败会记录警告,但不阻塞系统启动
+
+##### 7. 使用示例
+
+插件在 `on_activated` 方法中注册自定义 Agent Runner:
+
+```python
+from astrbot.core.agent.runners.registry import AgentRunnerEntry
+
+entry = AgentRunnerEntry(
+    runner_type="maibot",
+    display_name="MaiBot",
+    provider_id_key="maibot_agent_runner_provider_id",
+    provider_config_fields={
+        "ws_url": {
+            "type": "string",
+            "description": "MaiBot WebSocket 地址",
+            "default": "ws://localhost:18000/ws"
+        },
+        "api_key": {
+            "type": "string",
+            "description": "API Key",
+            "default": ""
+        },
+    },
+    runner_cls=MaiBotAgentRunner,
+)
+context.register_agent_runner(entry)
+```
+
+**注意事项**:
+- 插件需确保 `runner_type` 唯一,避免与内置或其他插件冲突
+- `runner_cls` 必须实现 `BaseAgentRunner` 接口
+- `provider_id_key` 应遵循命名规范:`<runner_type>_agent_runner_provider_id`
+- 卸载插件时会自动清理注册的 Agent Runner 和相关配置元数据
 
 #### 注意事项
 - 工具需在对应 SubAgent/Persona 配置中声明

[Accept] [Decline]

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

How did I do? Any feedback?  Join Discord

@LIghtJUNction
Copy link
Copy Markdown
Member

提交到dev分支,将更快更容易被合并!

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Mar 24, 2026
Comment thread astrbot/core/agent/runners/registry.py Outdated
Comment thread astrbot/core/star/context.py Outdated
"""Optional async callback invoked once when the pipeline stage initialises
(for pre-connection, tool sync, etc.)."""

conversation_id_key: str | None = None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

这个的作用是?

Comment thread astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py Outdated
@Soulter Soulter changed the title Feat:为插件注册agent执行器提供入口 feat: supports to register agent runner in plugins Mar 28, 2026
@Soulter Soulter force-pushed the master branch 2 times, most recently from faf411f to 0068960 Compare April 19, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants