Skip to content

feat: 主动消息支持配图、链式工具调用、模型回退与人格自选#80

Open
Ayleovelle wants to merge 7 commits into
DBJD-CR:mainfrom
Ayleovelle:feat/issue-56-proactive-agent
Open

feat: 主动消息支持配图、链式工具调用、模型回退与人格自选#80
Ayleovelle wants to merge 7 commits into
DBJD-CR:mainfrom
Ayleovelle:feat/issue-56-proactive-agent

Conversation

@Ayleovelle
Copy link
Copy Markdown
Contributor

@Ayleovelle Ayleovelle commented Jun 8, 2026

📝 描述 / Description

这个 PR 把主动消息的几项能力一起补上了(之前配图那个 PR 我关掉了,这次合到一起提):

底层都是基于本体的 tool_loop_agent 和同一个合成事件实现的,所以顺手把配图和正文这两套工具循环的合成事件统一成了一个,去掉了重复。

所有新功能默认都是关的,不开的话行为和旧版完全一样。

Closes #56
Closes #73

🛠️ 改动点 / Modifications

新增 core/agent_runner.py

  • ProactiveAgentEvent:统一的合成事件,既能捕获生图工具发出的图片,又会吞掉其余发送(正文只取模型最终输出)。配图模块也改用它,删掉了原来 image_generator.py 里那个几乎一模一样的 _ImageCaptureEvent
  • 链式工具调用:按 tool_mode(off / whitelist / all)选工具交给工具循环,max_steps=0 时按工具数自动推导上限。
  • 模型回退:解析备选 provider 列表传给本体的 fallback_providers,本地留空时回退读全局 fallback_chat_models,跟本体行为对齐。

core/llm_adapter.py

  • 人格解析改成三级优先——配置指定(select/custom)严格覆盖 → 会话人格 → 默认人格。
  • _generate_llm_response 在开启工具/回退时改走工具循环 Agent,失败则降级回原来的单次生成。

_conf_schema.json:私聊/群聊各加了 agent_settings(工具调用 + 回退)和 persona_settings(人格),用的都是本体原生控件(下拉、滑块、select_providers 多选、select_personacondition 联动隐藏)。

main.py:接入新的 mixin。

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

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

离线单测 16 项全过(工具选择、回退解析、max_steps、人格解析等纯逻辑),ruff check + ruff format + JSON 校验都过。

另外在真实 AstrBot 4.25.1 + 真实模型下做了一轮端到端测试,核心 8/8 通过。关键日志摘录:

链式工具调用真触发(whitelist 选中 uapi_weather,模型据工具结果生成):

[INFO] [runners.tool_loop_agent_runner]: Agent 使用工具: ['uapi_weather']
[INFO] [runners.tool_loop_agent_runner]: 使用工具:uapi_weather,参数:{'city': '北京'}
[PASS] G2_链式工具端到端 :: '抱歉,目前天气查询服务暂时不可用……建议您打开手机天气 App 查看最新情况'

模型回退真触发(坏 key 首选失败 → 本体接管 → 切备选成功):

当前会话 provider_id: bad/primary
[WARN] [runners.tool_loop_agent_runner:555]: Chat Model bad/primary request error
[PASS] F_回退真触发(坏首选→deepseek) :: '你好!有什么可以帮你的吗?'

配图捕获(统一合成事件捕获生图工具发出的图片):

[INFO] [runners.tool_loop_agent_runner]: Agent 使用工具: ['draw_image']
[INFO] [fake_drawer.main]: [FakeDrawer] draw_image 被调用,发送内联图片。
[PASS] H_配图捕获Image :: 捕获图片数=1

完整 8 项:插件加载无报错、纯 Agent 生成出文本、模型回退、链式工具调用、配图捕获、人格自选解析、max_steps 推导,全部 ✅。

回退用临时构造的坏 key provider 作首选验证;链式工具用 UApiPro 工具箱的 uapi_weather 验证;配图用一个只注册生图工具的本地测试插件验证“工具 → 合成事件捕获图片”这条链路。

✅ 检查清单 / 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 "Screenshots" or "Test Logs" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txt 文件中。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to requirements.txt.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

❤️ CONTRIBUTING

  • 🥳 我已阅读并同意遵守该项目的 贡献指南 / I have read and agree to abide by the CONTRIBUTING of this project.

Summary by Sourcery

为基于工具循环(tool-loop)的文本生成、模型回退、人格(persona)选择和自动图像生成添加主动消息支持,同时在默认情况下保持行为不变。

新功能:

  • 使主动消息在配置后可以使用 AstrBot 的工具循环代理(tool loop agent)进行链式工具调用,并在需要时自动进行模型回退。
  • 允许主动消息使用可配置的人格,并提供显式的覆盖模式,包括自定义提示词(custom prompts)。
  • 为主动消息添加通过图像工具进行的主动图像生成支持,包括自动/始终模式,以及基于关键词的工具选择,并支持按会话进行配置。

增强:

  • 引入统一的合成事件,用于主动工具循环运行,能够捕获图像并抑制中间发送,供文本和图像流程共享。
  • 对可用图像工具的发现过程进行缓存和预热,以降低开销并避免重复的失败扫描。

测试:

  • 利用现有的遥测和配置管道,使工具循环和回退的使用情况可以被单独跟踪,而无需更改默认行为。
Original summary in English

Summary by Sourcery

Add proactive message support for tool-loop based text generation, model fallback, persona selection, and automatic image generation while keeping behavior unchanged by default.

New Features:

  • Enable proactive messages to use AstrBot's tool loop agent for chained tool calls and automatic model fallback when configured.
  • Allow proactive messages to use configurable personas with explicit override modes including custom prompts.
  • Add proactive image generation for proactive messages via image tools, including auto/always modes and tool selection based on keywords with per-session configuration.

Enhancements:

  • Introduce a unified synthetic event for proactive tool-loop runs that captures images and suppresses intermediate sends, shared by text and image flows.
  • Cache and prewarm discovery of available image tools to reduce overhead and avoid repeated failed scans.

Tests:

  • Leverage existing telemetry and configuration plumbing so tool-loop and fallback usage can be tracked separately without changing default behavior.

Ayleovelle and others added 6 commits June 7, 2026 04:30
实现 issue DBJD-CR#73:主动消息可根据文本内容生成配图,提供「不生图 / Bot 自行
判断 / 直接生图」三种模式,群聊与私聊各自独立配置。

实现要点:
- provider 无关:通过工具循环 Agent + get_full_tool_set 暴露的全部已注册
  LLM 工具,由主模型自行判断并调用任意已安装的生图插件,不绑定 aiimg 等
  特定插件;未安装任何生图插件时自动降级为纯文本。
- 图片回流自管:新增「捕获型」合成事件(继承 AstrMessageEvent,重写 send
  拦截图片到缓冲区),使生图插件产出的图片不直接发往平台,而是交还本插件,
  统一走既有分段 / 装饰钩子 / 平台历史持久化的发送流程。
- 不改动 AstrBot 源码:仅继承公开基类、调用 context 公开 API。
- 失败静默降级:生图任何环节出错只记录日志并回退纯文本,绝不把错误信息塞进
  发送给用户的消息内容。

新增 core/image_generator.py;_conf_schema.json 为 friend/group 各增 image_settings;
message_sender 在文本发送后接入配图发送;main.py 注册 ImageMixin。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
在 issue DBJD-CR#73 配图功能基础上的改进:

1. 先图后文:把生图与发图移到文本发送之前,修复“文本先发、图片掉队”的问题,
   改为生图完成后先发图再发文本。

2. 自动识别生图工具(不再把全部已注册工具暴露给模型):按关键词双线匹配工具名
   与描述自动挑出生图类工具;命中“识别/视频”等负向词时,需名字或描述含强生图
   信号才保留,避免误收 read_image_file、视频生成等工具。用户可用 extra_tools
   手动补充名字不含关键词的工具、用 exclude_tools 排除误判。

3. 预选与缓存:插件加载后延迟预热探测一次并缓存结果;发送时直接复用缓存,不再
   每次扫描。连续 3 次未找到任何生图工具则本次运行内永久回退为纯文本。日志精简
   为“已找到/暂未找到/已回退”,不再逐个列出工具名。

涉及 core/image_generator.py、core/message_sender.py、core/plugin_lifecycle.py、
main.py、_conf_schema.json(image_settings 增加 extra_tools / exclude_tools)。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
此前生图的画面描述由主 LLM 在工具循环里临场决定,插件无法掌控。改为两步:

1. 插件先调用一次 LLM,把主动消息文本转成一段明确的画面描述(生图提示词)。
   always 模式总是生成;auto 模式先让模型判断是否适合配图,不适合则返回
   NO_IMAGE 跳过;extra_prompt 注入到这一步用于画风偏好;任何失败均返回空、
   回退为纯文本。
2. 把这段由插件掌控的画面描述作为明确指令交给工具循环 Agent,要求原样用作
   生图工具的绘画提示词、不改写不自行发挥。

仍不绑定特定生图插件(由 Agent 选择具体工具)。涉及 core/image_generator.py。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
- 对 image_generator.py、plugin_lifecycle.py 应用 ruff format,修复 CI 的
  ruff-format 检查。
- 修正 image_generator.py 模块 docstring 中关于工具选择的过时描述(已由
  get_full_tool_set 全量暴露改为关键词识别 + 提示词插件端生成)。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
采纳 PR DBJD-CR#79 review 中确属问题的几条:
- _ImageCaptureEvent.send 兼容 MessageChain / list / 单个组件,避免第三方生图
  插件以不同形式调用 event.send 时漏接图片。
- _run_image_agent 移除重复构造的 capture_event(第一次从未使用)。
- _run_image_agent 对 get_llm_tool_manager() 返回 None 做判空,避免 AttributeError。
- initialize 中预热任务用 _track_task 登记引用,防止后台任务在 sleep 期间被 GC。

未采纳:llm_generate 返回 str 的兜底(宿主签名为 LLMResponse,不会返回 str)、
缓存标志加锁(主动消息由 apscheduler 串行触发,非并发场景)。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
为主动消息正文生成接入 AstrBot 工具循环 Agent,实现 issue DBJD-CR#56 的两项需求并
顺带补充人格自选,默认全部关闭、向后兼容:

- 链式工具调用:tool_mode=whitelist/all 时由模型在一次主动消息里按需多次调用
  已注册工具,再据此生成最终文本。
- 模型自动回退:复用本体 tool_loop_agent 的 fallback_providers,首选模型不可用
  时顺延切换备选模型;本地未配置时回退读取全局 fallback_chat_models。
- 人格自选:persona_settings 支持选择已有人格或自定义人设,严格覆盖会话/默认人格。
- max_steps=0 时按暴露的工具数自动推导上限。

同时统一配图与正文工具循环的合成事件:将 image_generator 的 _ImageCaptureEvent
收敛到 agent_runner 的 ProactiveAgentEvent(既捕获图片、又吞掉其余发送),消除两套
几乎一致的合成事件,配图“插件主导画面描述 + auto 判断”的行为保持不变。

配置项采用 AstrBot 原生渲染:options/labels、slider、select_providers、
select_persona、condition 联动隐藏。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@dosubot dosubot Bot added size:XL 修改了 500-999 行代码 (忽略生成文件) type/feat ✨ 新功能 / New Feature labels Jun 8, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Jun 8, 2026

Reviewer's Guide

实现主动消息增强:为 tool-loop agents 引入统一的 ProactiveAgentEvent,添加 AgentRunnerMixin 和 ImageMixin 以支持串联工具调用、模型回退以及按消息选择 persona,将它们接入主插件,并更新配置 schema 和 LLM 适配器,在启用时将主动生成路由到 tool-loop agent,同时在禁用时保持完全向后兼容的行为。

通过 tool_loop_agent 进行主动文本生成的时序图

sequenceDiagram
    participant LlmAdapter
    participant AgentRunnerMixin
    participant Context
    participant ToolLoopAgent as tool_loop_agent
    participant ProactiveAgentEvent

    LlmAdapter->>LlmAdapter: _generate_llm_response(session_id, history_messages, system_prompt)
    LlmAdapter->>AgentRunnerMixin: _run_proactive_agent(session_id, final_user_simulation_prompt, history_for_agent, system_prompt, agent_conf)

    AgentRunnerMixin->>Context: get_current_chat_provider_id(session_id)
    AgentRunnerMixin->>Context: get_llm_tool_manager()
    AgentRunnerMixin->>AgentRunnerMixin: _select_agent_tools(tool_manager, agent_conf)
    AgentRunnerMixin->>AgentRunnerMixin: _resolve_fallback_providers(session_id, provider_id, agent_conf)
    AgentRunnerMixin->>AgentRunnerMixin: _compute_max_steps(max_steps, tool_count)
    AgentRunnerMixin->>ProactiveAgentEvent: ProactiveAgentEvent(context, session, prompt, message_type)

    AgentRunnerMixin->>Context: tool_loop_agent(event=ProactiveAgentEvent, chat_provider_id, prompt, contexts, system_prompt, tools?, fallback_providers?, max_steps)
    Context-->>AgentRunnerMixin: resp
    AgentRunnerMixin->>AgentRunnerMixin: extract completion_text

    alt agent produced text
        AgentRunnerMixin-->>LlmAdapter: agent_text
        LlmAdapter->>LlmAdapter: use agent_text as proactive message
    else agent failed or empty
        AgentRunnerMixin-->>LlmAdapter: None
        LlmAdapter->>LlmAdapter: fallback to single LLM generate path
    end
Loading

使用 ProactiveAgentEvent 进行主动图像生成的时序图

sequenceDiagram
    participant SenderMixin as MessageSender
    participant ImageMixin
    participant Context
    participant ToolLoopAgent as tool_loop_agent
    participant ProactiveAgentEvent

    SenderMixin->>SenderMixin: _send_proactive_message(session_id, text)
    SenderMixin->>ImageMixin: _maybe_generate_proactive_images(session_id, text, session_config)

    ImageMixin->>ImageMixin: inspect image_settings.mode
    alt mode is auto/always and _IMAGE_AGENT_AVAILABLE
        ImageMixin->>ImageMixin: _run_image_agent(session_id, text, image_conf, mode)
        ImageMixin->>Context: get_current_chat_provider_id(session_id)
        ImageMixin->>Context: get_llm_tool_manager()
        ImageMixin->>ImageMixin: _ensure_image_tool_names(tool_manager, image_conf)
        ImageMixin->>ImageMixin: _generate_image_prompt(provider_id, text, image_conf, mode)

        ImageMixin->>ProactiveAgentEvent: ProactiveAgentEvent(context, session, text, message_type)
        ImageMixin->>Context: tool_loop_agent(event=ProactiveAgentEvent, chat_provider_id, prompt=image_prompt, tools=ToolSet, system_prompt, max_steps=6)
        Context-->>ProactiveAgentEvent: intermediate tool calls
        ProactiveAgentEvent->>ProactiveAgentEvent: send(message) captures Image components
        Context-->>ImageMixin: tool_loop_agent result
        ImageMixin->>ImageMixin: images = list(capture_event.captured_images)
        ImageMixin-->>SenderMixin: images
    else mode off or unsupported
        ImageMixin-->>SenderMixin: []
    end

    loop for each image
        SenderMixin->>SenderMixin: _send_chain_with_hooks(session_id, [image])
    end
    SenderMixin->>SenderMixin: send text content afterward
Loading

文件级改动

Change Details Files
Add AgentRunnerMixin and unified ProactiveAgentEvent to drive proactive message generation via tool_loop_agent with optional chained tools and model fallback.
  • 引入 ProactiveAgentEvent 这种“合成事件”,用于拦截 tool-loop 的发送行为,捕获 Image 组件,同时吞掉其他内容,从而只使用 completion_text 作为主动输出。
  • 实现 AgentRunnerMixin,提供工具/回退列表的解析辅助方法、动态 max_steps 推导、按模式(off/whitelist/all)进行工具选择,以及与 AstrBot 核心行为保持一致的回退 Provider 解析。
  • 提供 _run_proactive_agent 来执行 context.tool_loop_agent,并可选传入 ToolSet 和 fallback_providers,返回最终文本或 None,以便优雅地回退到传统的单次 LLM 调用路径。
core/agent_runner.py
core/llm_adapter.py
core/plugin_lifecycle.py
main.py
Add ImageMixin and integrate proactive image generation before sending text using the same ProactiveAgentEvent capture mechanism.
  • 引入 ImageMixin,包含针对图像工具的选择启发式(基于关键词并支持 include/exclude 覆盖)、图像工具名称的缓存与预热,以及两阶段图像生成流程(先通过 LLM 生成图像 prompt,再执行 tool_loop_agent)。
  • 将 _maybe_generate_proactive_images 接入 _send_proactive_message,使图像在发送文本前同步生成并发送(复用现有 hooks 和历史),并在所有失败场景中吞掉错误,避免影响文本发送。
  • 在插件初始化时异步预热图像工具,以避免首次发送时扫描工具,并在重复选择失败后实现软禁用机制。
core/image_generator.py
core/message_sender.py
core/plugin_lifecycle.py
main.py
Extend LLM adapter to support per-session persona overrides and to route proactive generations through the agent runner when tools or fallback are enabled.
  • 新增 _parse_persona_settings 并更新 _prepare_llm_request,实现三层 persona 优先级:配置 persona(select/custom)具有严格最高优先级,其次是会话 persona,最后是默认 persona;同时在配置 persona 缺失时进行日志记录和错误处理。
  • 更新 _generate_llm_response,从会话配置中读取 agent_settings,根据 tool_mode 和 model_fallback_enable 决定何时使用 _run_proactive_agent,为 agent 使用清洗历史消息,处理意外的 “[object Object]” 输出,并在 agent 路径成功时以 provider_mode 为 “tool_loop_agent” 记录遥测信息。
core/llm_adapter.py
_conf_schema.json
Wire new mixins and state into the main plugin and extend configuration schema for agent and persona settings.
  • 在 ProactiveChatPlugin 的基类列表中注册 AgentRunnerMixin 和 ImageMixin,并在 init 中初始化图像工具缓存和禁用标志等内部状态。
  • 扩展配置 schema,为私聊和群聊上下文增加 agent_settings(tool_mode、工具 include/exclude、回退开关及模型、max_steps)和 persona_settings(persona_mode、persona_id、custom_persona),并使用原生 AstrBot UI 控件和条件可见性。
main.py
_conf_schema.json

与关联 Issue 的对照评估

Issue Objective Addressed Explanation
#56 为主动消息增加串联工具调用,使 Agent 能在单条主动消息中执行多个工具后再进行回复。
#56 为主动消息增加自动模型回退机制,当主模型不可用时自动切换到已配置的备用模型,并与 AstrBot 核心回退行为保持一致。
#73 使主动(主动)消息在发送时可以包含由图像模型生成的图片。
#73 为主动消息图片提供配置选项,包含三种模式:不生成图片、总是生成图片、由机器人根据文本自动决定。

可能相关的 Issue


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the
    pull request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Original review guide in English

Reviewer's Guide

Implements proactive message enhancements: unified ProactiveAgentEvent for tool-loop agents, adds AgentRunnerMixin and ImageMixin to support chained tool calls, model fallback, and per-message persona selection, wires them into the main plugin, and updates config schema and LLM adapter to route proactive generations through the tool-loop agent when enabled while keeping behavior fully backward compatible when disabled.

Sequence diagram for proactive text generation via tool_loop_agent

sequenceDiagram
    participant LlmAdapter
    participant AgentRunnerMixin
    participant Context
    participant ToolLoopAgent as tool_loop_agent
    participant ProactiveAgentEvent

    LlmAdapter->>LlmAdapter: _generate_llm_response(session_id, history_messages, system_prompt)
    LlmAdapter->>AgentRunnerMixin: _run_proactive_agent(session_id, final_user_simulation_prompt, history_for_agent, system_prompt, agent_conf)

    AgentRunnerMixin->>Context: get_current_chat_provider_id(session_id)
    AgentRunnerMixin->>Context: get_llm_tool_manager()
    AgentRunnerMixin->>AgentRunnerMixin: _select_agent_tools(tool_manager, agent_conf)
    AgentRunnerMixin->>AgentRunnerMixin: _resolve_fallback_providers(session_id, provider_id, agent_conf)
    AgentRunnerMixin->>AgentRunnerMixin: _compute_max_steps(max_steps, tool_count)
    AgentRunnerMixin->>ProactiveAgentEvent: ProactiveAgentEvent(context, session, prompt, message_type)

    AgentRunnerMixin->>Context: tool_loop_agent(event=ProactiveAgentEvent, chat_provider_id, prompt, contexts, system_prompt, tools?, fallback_providers?, max_steps)
    Context-->>AgentRunnerMixin: resp
    AgentRunnerMixin->>AgentRunnerMixin: extract completion_text

    alt agent produced text
        AgentRunnerMixin-->>LlmAdapter: agent_text
        LlmAdapter->>LlmAdapter: use agent_text as proactive message
    else agent failed or empty
        AgentRunnerMixin-->>LlmAdapter: None
        LlmAdapter->>LlmAdapter: fallback to single LLM generate path
    end
Loading

Sequence diagram for proactive image generation with ProactiveAgentEvent

sequenceDiagram
    participant SenderMixin as MessageSender
    participant ImageMixin
    participant Context
    participant ToolLoopAgent as tool_loop_agent
    participant ProactiveAgentEvent

    SenderMixin->>SenderMixin: _send_proactive_message(session_id, text)
    SenderMixin->>ImageMixin: _maybe_generate_proactive_images(session_id, text, session_config)

    ImageMixin->>ImageMixin: inspect image_settings.mode
    alt mode is auto/always and _IMAGE_AGENT_AVAILABLE
        ImageMixin->>ImageMixin: _run_image_agent(session_id, text, image_conf, mode)
        ImageMixin->>Context: get_current_chat_provider_id(session_id)
        ImageMixin->>Context: get_llm_tool_manager()
        ImageMixin->>ImageMixin: _ensure_image_tool_names(tool_manager, image_conf)
        ImageMixin->>ImageMixin: _generate_image_prompt(provider_id, text, image_conf, mode)

        ImageMixin->>ProactiveAgentEvent: ProactiveAgentEvent(context, session, text, message_type)
        ImageMixin->>Context: tool_loop_agent(event=ProactiveAgentEvent, chat_provider_id, prompt=image_prompt, tools=ToolSet, system_prompt, max_steps=6)
        Context-->>ProactiveAgentEvent: intermediate tool calls
        ProactiveAgentEvent->>ProactiveAgentEvent: send(message) captures Image components
        Context-->>ImageMixin: tool_loop_agent result
        ImageMixin->>ImageMixin: images = list(capture_event.captured_images)
        ImageMixin-->>SenderMixin: images
    else mode off or unsupported
        ImageMixin-->>SenderMixin: []
    end

    loop for each image
        SenderMixin->>SenderMixin: _send_chain_with_hooks(session_id, [image])
    end
    SenderMixin->>SenderMixin: send text content afterward
Loading

File-Level Changes

Change Details Files
Add AgentRunnerMixin and unified ProactiveAgentEvent to drive proactive message generation via tool_loop_agent with optional chained tools and model fallback.
  • Introduce ProactiveAgentEvent synthetic event that intercepts tool-loop sends, capturing Image components while swallowing other content so only completion_text is used for proactive outputs.
  • Implement AgentRunnerMixin with parsing helpers for tool/fallback lists, dynamic max_steps derivation, tool selection by mode (off/whitelist/all), and resolution of fallback Providers aligned with core AstrBot behavior.
  • Provide _run_proactive_agent to execute context.tool_loop_agent with optional ToolSet and fallback_providers, returning final text or None for graceful fallback to the legacy single-shot LLM path.
core/agent_runner.py
core/llm_adapter.py
core/plugin_lifecycle.py
main.py
Add ImageMixin and integrate proactive image generation before sending text using the same ProactiveAgentEvent capture mechanism.
  • Introduce ImageMixin with tool selection heuristics for image tools (keyword-based plus include/exclude overrides), caching and prewarming of image tool names, and a two-phase image generation flow (LLM-derived image prompt then tool_loop_agent execution).
  • Wire _maybe_generate_proactive_images into _send_proactive_message so images are synchronously generated and sent (via existing hooks and history) before text, with all failures swallowed to avoid impacting text delivery.
  • Prewarm image tools asynchronously during plugin initialization to avoid scanning tools on first send and implement a soft-disable mechanism after repeated selection failures.
core/image_generator.py
core/message_sender.py
core/plugin_lifecycle.py
main.py
Extend LLM adapter to support per-session persona overrides and to route proactive generations through the agent runner when tools or fallback are enabled.
  • Add _parse_persona_settings and update _prepare_llm_request to implement three-level persona priority: config persona (select/custom) strictly overrides, then conversation persona, then default persona, including logging and error handling when configured personas are missing.
  • Update _generate_llm_response to read agent_settings from session config, decide when to use _run_proactive_agent based on tool_mode and model_fallback_enable, sanitize history for agent use, handle unexpected '[object Object]' outputs, and track telemetry with provider_mode 'tool_loop_agent' when the agent path succeeds.
core/llm_adapter.py
_conf_schema.json
Wire new mixins and state into the main plugin and extend configuration schema for agent and persona settings.
  • Register AgentRunnerMixin and ImageMixin in the ProactiveChatPlugin base class list and initialize internal state for image tool caching and disablement flags in init.
  • Extend configuration schema with agent_settings (tool_mode, tool include/exclude, fallback enable and models, max_steps) and persona_settings (persona_mode, persona_id, custom_persona) for both private and group chat contexts, using native AstrBot UI controls and conditional visibility.
main.py
_conf_schema.json

Assessment against linked issues

Issue Objective Addressed Explanation
#56 Add chained tool invocation for proactive messages, allowing the Agent to execute multiple tools within a single proactive message before responding.
#56 Add an automatic model fallback mechanism for proactive messages, switching from the primary model to configured backup models when the primary is unavailable, aligned with AstrBot’s core fallback behavior.
#73 Enable proactive (主动) messages to include images generated by an image model when sending.
#73 Provide a configuration option for proactive message images with three modes: no image, always generate image, and bot automatically decides based on the text.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

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

This pull request introduces proactive message chain tool calling, model automatic fallback, and proactive image generation capabilities to the plugin. Key additions include schema configuration updates, an agent runner mixin for handling tool loops and fallbacks, and an image generator mixin to drive image generation plugins. The code review identified several critical issues: ToolSet lacks a func_list attribute in core/agent_runner.py which will cause AttributeErrors, get_persona_v3_by_id in core/llm_adapter.py is an async method that must be awaited to prevent a TypeError, ToolSet may not have a get_tool method in core/image_generator.py, and provider_settings in core/agent_runner.py should support both dictionary and attribute access for better compatibility.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread core/agent_runner.py Outdated
Comment on lines +312 to +314
f"[主动消息] 链式工具调用已启用,共暴露 {len(tool_set.func_list)} 个工具喵。"
)
else:
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.

high

ToolSet 对象没有 func_list 属性(func_listtool_manager 的属性)。在 core/image_generator.py 中,使用的是 tool_set.tools。因此,这里应该使用 tool_set.tools 来获取工具列表,否则在启用链式工具调用时会抛出 AttributeError 导致生成失败。

                    logger.info(
                        f"[主动消息] 链式工具调用已启用,共暴露 {len(tool_set.tools)} 个工具喵。"
                    )

Comment thread core/agent_runner.py Outdated

# max_steps:0(或缺省/非法)表示自动——按本次暴露的工具数推导一个上限;
# 填正数则视为用户指定的固定上限。
tool_count = len(tool_set.func_list) if tool_set is not None else 0
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.

high

同上,ToolSet 对象没有 func_list 属性,应该使用 tool_set.tools 来获取工具数量,否则在计算最大步数时会抛出 AttributeError

Suggested change
tool_count = len(tool_set.func_list) if tool_set is not None else 0
tool_count = len(tool_set.tools) if tool_set is not None else 0

Comment thread core/llm_adapter.py
Comment on lines +653 to +657
configured_persona = (
self.context.persona_manager.get_persona_v3_by_id(
configured_persona_id
)
)
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.

high

self.context.persona_manager.get_persona_v3_by_id 是一个异步方法(类似于 get_personaget_default_persona_v3),需要使用 await 关键字。如果不加 await,它将返回一个协程对象(coroutine),在后续通过 configured_persona["prompt"] 访问时会抛出 TypeError: 'coroutine' object is not subscriptable 异常。

Suggested change
configured_persona = (
self.context.persona_manager.get_persona_v3_by_id(
configured_persona_id
)
)
configured_persona = (
await self.context.persona_manager.get_persona_v3_by_id(
configured_persona_id
)
)

Comment thread core/image_generator.py

# extra 里指向但上面没收进来的(理论上已收,双保险按名字补一次)
for name in extra:
if tool_set.get_tool(name) is None and name not in exclude:
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.

medium

ToolSet 可能没有 get_tool 方法。为了确保代码的健壮性,建议直接遍历 tool_set.tools 来检查工具是否已存在,这与第 221 行中对 tool_set.tools 的使用方式保持一致。

Suggested change
if tool_set.get_tool(name) is None and name not in exclude:
if not any(getattr(t, "name", "") == name for t in tool_set.tools) and name not in exclude:

Comment thread core/agent_runner.py
Comment on lines +238 to +242
raw_global = (
provider_settings.get("fallback_chat_models", [])
if isinstance(provider_settings, dict)
else []
)
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.

medium

如果 provider_settings 是一个配置对象(而非 dict),isinstance(provider_settings, dict) 将为 False,导致无法读取 fallback_chat_models。建议像处理 global_conf 一样,同时支持 dict 和对象属性获取,以提高代码的兼容性和健壮性。

            if isinstance(provider_settings, dict):
                raw_global = provider_settings.get("fallback_chat_models", [])
            else:
                raw_global = getattr(provider_settings, "fallback_chat_models", [])

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 - 我发现了两个问题,并给出了一些整体性的反馈:

  • 用于解析名称列表的两个辅助函数(ImageMixin._parse_name_listAgentRunnerMixin._parse_tool_name_list)几乎完全相同;建议抽取为一个通用工具函数,以避免它们的行为随时间产生差异。
  • _generate_llm_response 中对 agent_text == "[object Object]" 的特殊处理比较脆弱;可能更稳妥的方式是在更早阶段检测非字符串/结构化响应(例如在 agent 调用之后立即校验返回值的类型/结构),或从源头修复该值的产生逻辑。
给 AI Agent 的提示词
Please address the comments from this code review:

## Overall Comments
- 用于解析名称列表的两个辅助函数(`ImageMixin._parse_name_list``AgentRunnerMixin._parse_tool_name_list`)几乎完全相同;建议抽取为一个通用工具函数,以避免它们的行为随时间产生差异。
- `_generate_llm_response` 中对 `agent_text == "[object Object]"` 的特殊处理比较脆弱;可能更稳妥的方式是在更早阶段检测非字符串/结构化响应(例如在 agent 调用之后立即校验返回值的类型/结构),或从源头修复该值的产生逻辑。

## Individual Comments

### Comment 1
<location path="core/agent_runner.py" line_range="311-320" />
<code_context>
+                        f"[主动消息] 链式工具调用已启用,共暴露 {len(tool_set.func_list)} 个工具喵。"
+                    )
+                else:
+                    logger.info(
+                        "[主动消息] 未选出可用工具,本次仅按普通对话方式生成喵。"
+                    )
+
+        # 解析回退 Provider 列表(开启回退时)。
+        fallback_providers = None
+        if (agent_conf or {}).get("model_fallback_enable", False):
+            resolved = self._resolve_fallback_providers(
+                session_id, provider_id, agent_conf
+            )
+            if resolved:
+                fallback_providers = resolved
+                logger.info(
+                    f"[主动消息] 模型自动回退已启用,备选模型 {len(resolved)} 个喵。"
+                )
+
+        # max_steps:0(或缺省/非法)表示自动——按本次暴露的工具数推导一个上限;
+        # 填正数则视为用户指定的固定上限。
+        tool_count = len(tool_set.func_list) if tool_set is not None else 0
+        max_steps = self._compute_max_steps(
+            (agent_conf or {}).get("max_steps", 0), tool_count
</code_context>
<issue_to_address>
**issue (bug_risk):** 通过 `func_list` 访问 ToolSet 时,该属性可能不存在;建议使用与 `ImageMixin` 一致的 API(例如 `tools`)以避免运行时属性错误。

在 `_run_proactive_agent` 中,你假设 `ToolSet` 存在 `func_list`,但在其他地方(例如 `ImageMixin`)同一类型却通过 `tool_set.tools` 访问。为了避免 `AttributeError` 并保持 API 一致性,建议在这里也使用 `tool_set.tools`(例如在日志和 `tool_count` 中都使用 `len(tool_set.tools)`);如果需要兼容多个 `ToolSet` 版本,可以通过 `getattr(tool_set, "tools", [])` 做回退处理。
</issue_to_address>

### Comment 2
<location path="core/image_generator.py" line_range="209-47" />
<code_context>
+    # 选不到生图工具的最大累计次数,超过则本次运行内永久回退为纯文本。
+    _IMAGE_TOOLS_MAX_ATTEMPTS = 3
+
+    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
+        """返回已选定并缓存的生图工具名;未选定则尝试选一次。
+
+        - 一旦成功选定就缓存,后续直接复用,不再扫描。
+        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
+        """
+        if getattr(self, "_image_tools_disabled", False):
+            return []
+        if getattr(self, "_image_tools_selected", False):
+            return list(self._image_tools_cache)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 在实例级别缓存生图工具名称,会导致第一次选择成功之后,每个会话的 `extra_tools` / `exclude_tools` 变更都被忽略。

由于 `_image_tools_selected` 会在首次选择成功后直接短路后续的 `_ensure_image_tool_names` 调用,之后即使传入不同的 `image_conf.extra_tools` / `exclude_tools`,也不会影响已选工具集合。如果希望会话级配置生效,可以考虑:
- 使用 `image_conf`(或其相关字段的哈希)作为缓存 key,或
- 仅缓存自动检测出的工具集合,并在每次构建 `ToolSet` 时重新应用 `extra_tools` / `exclude_tools`。
如果当前不打算支持会话级覆盖,建议在文档中明确说明:整个进程生命周期内只会以第一次成功选择为准。

Suggested implementation:

```python
    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
        """返回已选定并缓存的生图工具名;未选定则尝试选一次。

        - 一旦成功选定就缓存,后续直接复用,不再扫描。
        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
        - 缓存会按会话配置(extra_tools / exclude_tools)区分,不同配置会单独选择并缓存。
        """
        if getattr(self, "_image_tools_disabled", False):
            return []

        # 根据会话配置构造缓存 key,保证不同 extra/exclude 组合互不影响。
        extra_tools = image_conf.get("extra_tools") or ()
        exclude_tools = image_conf.get("exclude_tools") or ()
        cache_key = (
            tuple(sorted(extra_tools)),
            tuple(sorted(exclude_tools)),
        )

        cache: dict | None = getattr(self, "_image_tools_cache", None)
        if isinstance(cache, dict) and cache_key in cache:
            # 已按当前配置选过工具,直接复用。
            return list(cache[cache_key])

        tool_set = self._select_image_tools(tool_manager, image_conf)
        names = [t.name for t in tool_set.tools] if tool_set else []
        if names:
            # 统一维护为 {cache_key: [tool_name, ...]} 结构。
            new_cache: dict = {}
            if isinstance(cache, dict):
                new_cache.update(cache)
            new_cache[cache_key] = names
            self._image_tools_cache = new_cache

            # 保持原有语义:一旦成功选过至少一组工具就置位。
            self._image_tools_selected = True

            logger.info("[主动消息] 已找到可用的生图工具喵。")
            return list(names)

        # 没选到:累计计数,达到上限则永久回退。

```

1. 如果在类的其他位置直接假设 `_image_tools_cache``list[str]`,需要相应改成按 `cache_key` 访问或只在 `_ensure_image_tool_names` 这一处读取该属性,以避免类型不一致的问题。
2. 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在 `cache_key` 中一并加入这些字段的归一化表示。
</issue_to_address>

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

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

  • There are two nearly identical helpers for parsing name lists (ImageMixin._parse_name_list and AgentRunnerMixin._parse_tool_name_list); consider consolidating them into a single utility to avoid divergence in behavior over time.
  • The special handling of agent_text == "[object Object]" in _generate_llm_response is quite brittle; it may be more robust to detect non-string/structured responses earlier (e.g., by validating type/shape right after the agent call) or fix the upstream source of this value.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There are two nearly identical helpers for parsing name lists (`ImageMixin._parse_name_list` and `AgentRunnerMixin._parse_tool_name_list`); consider consolidating them into a single utility to avoid divergence in behavior over time.
- The special handling of `agent_text == "[object Object]"` in `_generate_llm_response` is quite brittle; it may be more robust to detect non-string/structured responses earlier (e.g., by validating type/shape right after the agent call) or fix the upstream source of this value.

## Individual Comments

### Comment 1
<location path="core/agent_runner.py" line_range="311-320" />
<code_context>
+                        f"[主动消息] 链式工具调用已启用,共暴露 {len(tool_set.func_list)} 个工具喵。"
+                    )
+                else:
+                    logger.info(
+                        "[主动消息] 未选出可用工具,本次仅按普通对话方式生成喵。"
+                    )
+
+        # 解析回退 Provider 列表(开启回退时)。
+        fallback_providers = None
+        if (agent_conf or {}).get("model_fallback_enable", False):
+            resolved = self._resolve_fallback_providers(
+                session_id, provider_id, agent_conf
+            )
+            if resolved:
+                fallback_providers = resolved
+                logger.info(
+                    f"[主动消息] 模型自动回退已启用,备选模型 {len(resolved)} 个喵。"
+                )
+
+        # max_steps:0(或缺省/非法)表示自动——按本次暴露的工具数推导一个上限;
+        # 填正数则视为用户指定的固定上限。
+        tool_count = len(tool_set.func_list) if tool_set is not None else 0
+        max_steps = self._compute_max_steps(
+            (agent_conf or {}).get("max_steps", 0), tool_count
</code_context>
<issue_to_address>
**issue (bug_risk):** ToolSet is accessed via `func_list`, which may not exist; use the same API as in `ImageMixin` (e.g., `tools`) to avoid runtime attribute errors.

In `_run_proactive_agent`, you’re assuming `ToolSet` has `func_list`, but elsewhere (e.g. `ImageMixin`) the same type is accessed via `tool_set.tools`. To avoid `AttributeError` and keep the API consistent, consider using `tool_set.tools` here (e.g. `len(tool_set.tools)` for both the log and `tool_count`), or fall back via `getattr(tool_set, "tools", [])` if you need compatibility with multiple `ToolSet` versions.
</issue_to_address>

### Comment 2
<location path="core/image_generator.py" line_range="209-47" />
<code_context>
+    # 选不到生图工具的最大累计次数,超过则本次运行内永久回退为纯文本。
+    _IMAGE_TOOLS_MAX_ATTEMPTS = 3
+
+    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
+        """返回已选定并缓存的生图工具名;未选定则尝试选一次。
+
+        - 一旦成功选定就缓存,后续直接复用,不再扫描。
+        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
+        """
+        if getattr(self, "_image_tools_disabled", False):
+            return []
+        if getattr(self, "_image_tools_selected", False):
+            return list(self._image_tools_cache)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Caching image tool names at the instance level means per-session `extra_tools` / `exclude_tools` changes are ignored after the first successful selection.

Because `_image_tools_selected` short-circuits `_ensure_image_tool_names` after the first success, later calls with different `image_conf.extra_tools` / `exclude_tools` won’t change the selected tools. If per-session configuration should take effect, consider either:
- Keying the cache by `image_conf` (or a hash of its relevant fields), or
- Caching only the auto-detected tools and reapplying `extra_tools` / `exclude_tools` on each call when building the `ToolSet`.
If session-specific overrides are out of scope, it would help to document that only the first successful selection applies for the process lifetime.

Suggested implementation:

```python
    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
        """返回已选定并缓存的生图工具名;未选定则尝试选一次。

        - 一旦成功选定就缓存,后续直接复用,不再扫描。
        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
        - 缓存会按会话配置(extra_tools / exclude_tools)区分,不同配置会单独选择并缓存。
        """
        if getattr(self, "_image_tools_disabled", False):
            return []

        # 根据会话配置构造缓存 key,保证不同 extra/exclude 组合互不影响。
        extra_tools = image_conf.get("extra_tools") or ()
        exclude_tools = image_conf.get("exclude_tools") or ()
        cache_key = (
            tuple(sorted(extra_tools)),
            tuple(sorted(exclude_tools)),
        )

        cache: dict | None = getattr(self, "_image_tools_cache", None)
        if isinstance(cache, dict) and cache_key in cache:
            # 已按当前配置选过工具,直接复用。
            return list(cache[cache_key])

        tool_set = self._select_image_tools(tool_manager, image_conf)
        names = [t.name for t in tool_set.tools] if tool_set else []
        if names:
            # 统一维护为 {cache_key: [tool_name, ...]} 结构。
            new_cache: dict = {}
            if isinstance(cache, dict):
                new_cache.update(cache)
            new_cache[cache_key] = names
            self._image_tools_cache = new_cache

            # 保持原有语义:一旦成功选过至少一组工具就置位。
            self._image_tools_selected = True

            logger.info("[主动消息] 已找到可用的生图工具喵。")
            return list(names)

        # 没选到:累计计数,达到上限则永久回退。

```

1. 如果在类的其他位置直接假设 `_image_tools_cache``list[str]`,需要相应改成按 `cache_key` 访问或只在 `_ensure_image_tool_names` 这一处读取该属性,以避免类型不一致的问题。
2. 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在 `cache_key` 中一并加入这些字段的归一化表示。
</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 thread core/agent_runner.py
Comment thread core/image_generator.py
image_conf = (session_config or {}).get("image_settings", {})
mode = str(image_conf.get("mode", "off") or "off").strip().lower()
if mode not in ("auto", "always"):
return []
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): 在实例级别缓存生图工具名称,会导致第一次选择成功之后,每个会话的 extra_tools / exclude_tools 变更都被忽略。

由于 _image_tools_selected 会在首次选择成功后直接短路后续的 _ensure_image_tool_names 调用,之后即使传入不同的 image_conf.extra_tools / exclude_tools,也不会影响已选工具集合。如果希望会话级配置生效,可以考虑:

  • 使用 image_conf(或其相关字段的哈希)作为缓存 key,或
  • 仅缓存自动检测出的工具集合,并在每次构建 ToolSet 时重新应用 extra_tools / exclude_tools
    如果当前不打算支持会话级覆盖,建议在文档中明确说明:整个进程生命周期内只会以第一次成功选择为准。

Suggested implementation:

    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
        """返回已选定并缓存的生图工具名;未选定则尝试选一次。

        - 一旦成功选定就缓存,后续直接复用,不再扫描。
        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
        - 缓存会按会话配置(extra_tools / exclude_tools)区分,不同配置会单独选择并缓存。
        """
        if getattr(self, "_image_tools_disabled", False):
            return []

        # 根据会话配置构造缓存 key,保证不同 extra/exclude 组合互不影响。
        extra_tools = image_conf.get("extra_tools") or ()
        exclude_tools = image_conf.get("exclude_tools") or ()
        cache_key = (
            tuple(sorted(extra_tools)),
            tuple(sorted(exclude_tools)),
        )

        cache: dict | None = getattr(self, "_image_tools_cache", None)
        if isinstance(cache, dict) and cache_key in cache:
            # 已按当前配置选过工具,直接复用。
            return list(cache[cache_key])

        tool_set = self._select_image_tools(tool_manager, image_conf)
        names = [t.name for t in tool_set.tools] if tool_set else []
        if names:
            # 统一维护为 {cache_key: [tool_name, ...]} 结构。
            new_cache: dict = {}
            if isinstance(cache, dict):
                new_cache.update(cache)
            new_cache[cache_key] = names
            self._image_tools_cache = new_cache

            # 保持原有语义:一旦成功选过至少一组工具就置位。
            self._image_tools_selected = True

            logger.info("[主动消息] 已找到可用的生图工具喵。")
            return list(names)

        # 没选到:累计计数,达到上限则永久回退。
  1. 如果在类的其他位置直接假设 _image_tools_cachelist[str],需要相应改成按 cache_key 访问或只在 _ensure_image_tool_names 这一处读取该属性,以避免类型不一致的问题。
  2. 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在 cache_key 中一并加入这些字段的归一化表示。
Original comment in English

suggestion (bug_risk): Caching image tool names at the instance level means per-session extra_tools / exclude_tools changes are ignored after the first successful selection.

Because _image_tools_selected short-circuits _ensure_image_tool_names after the first success, later calls with different image_conf.extra_tools / exclude_tools won’t change the selected tools. If per-session configuration should take effect, consider either:

  • Keying the cache by image_conf (or a hash of its relevant fields), or
  • Caching only the auto-detected tools and reapplying extra_tools / exclude_tools on each call when building the ToolSet.
    If session-specific overrides are out of scope, it would help to document that only the first successful selection applies for the process lifetime.

Suggested implementation:

    def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]:
        """返回已选定并缓存的生图工具名;未选定则尝试选一次。

        - 一旦成功选定就缓存,后续直接复用,不再扫描。
        - 累计选不到达到上限后永久回退(本次运行内不再尝试)。
        - 缓存会按会话配置(extra_tools / exclude_tools)区分,不同配置会单独选择并缓存。
        """
        if getattr(self, "_image_tools_disabled", False):
            return []

        # 根据会话配置构造缓存 key,保证不同 extra/exclude 组合互不影响。
        extra_tools = image_conf.get("extra_tools") or ()
        exclude_tools = image_conf.get("exclude_tools") or ()
        cache_key = (
            tuple(sorted(extra_tools)),
            tuple(sorted(exclude_tools)),
        )

        cache: dict | None = getattr(self, "_image_tools_cache", None)
        if isinstance(cache, dict) and cache_key in cache:
            # 已按当前配置选过工具,直接复用。
            return list(cache[cache_key])

        tool_set = self._select_image_tools(tool_manager, image_conf)
        names = [t.name for t in tool_set.tools] if tool_set else []
        if names:
            # 统一维护为 {cache_key: [tool_name, ...]} 结构。
            new_cache: dict = {}
            if isinstance(cache, dict):
                new_cache.update(cache)
            new_cache[cache_key] = names
            self._image_tools_cache = new_cache

            # 保持原有语义:一旦成功选过至少一组工具就置位。
            self._image_tools_selected = True

            logger.info("[主动消息] 已找到可用的生图工具喵。")
            return list(names)

        # 没选到:累计计数,达到上限则永久回退。
  1. 如果在类的其他位置直接假设 _image_tools_cachelist[str],需要相应改成按 cache_key 访问或只在 _ensure_image_tool_names 这一处读取该属性,以避免类型不一致的问题。
  2. 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在 cache_key 中一并加入这些字段的归一化表示。

按 review 建议,_run_proactive_agent 中对 ToolSet 改用 .tools 字段(与
image_generator 一致),func_list 虽为其等价 property,但 .tools 是更基础
的字段、各版本一致。注意 tool_manager.func_list 是 FunctionToolManager 的
属性、并无 .tools,保持不变。

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@Ayleovelle
Copy link
Copy Markdown
Contributor Author

已按 review 调整,逐条说明:

已改(d5057fe):_run_proactive_agentToolSet 改用 .toolstool_manager.func_list 不动——FunctionToolManager 只有 func_list、无 .tools

以下经 AstrBot 4.25.1 源码核实后未改:

  • get_persona_v3_by_idpersona_mgr.py:47 是同步 def(非 async),加 await 会报错。
  • ToolSet.get_tooltool.py:114 存在该方法,image_generator 已在用。
  • provider_settings 对象属性兼容:AstrBotConfigdict 子类,isinstance(_, dict) 恒为 True,现有分支已正确。
  • _parse_name_list 重复:分属两个 mixin、其一为配图既有代码,跨文件抽取超出本 PR 范围,留待后续。
  • [object Object] 检测、配图工具缓存:均为既有配图代码,非本 PR 改动,保持现状。

cc @gemini-code-assist @sourcery-ai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 修改了 500-999 行代码 (忽略生成文件) type/feat ✨ 新功能 / New Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 发送主动消息时允许发送图片 [Feature] 希望增加链式工具调用和自动回退功能

1 participant