feat: 主动消息支持配图、链式工具调用、模型回退与人格自选#80
Conversation
实现 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>
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
使用 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
文件级改动
与关联 Issue 的对照评估
可能相关的 Issue
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
Original review guide in EnglishReviewer's GuideImplements 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_agentsequenceDiagram
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
Sequence diagram for proactive image generation with ProactiveAgentEventsequenceDiagram
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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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.
| f"[主动消息] 链式工具调用已启用,共暴露 {len(tool_set.func_list)} 个工具喵。" | ||
| ) | ||
| else: |
|
|
||
| # max_steps:0(或缺省/非法)表示自动——按本次暴露的工具数推导一个上限; | ||
| # 填正数则视为用户指定的固定上限。 | ||
| tool_count = len(tool_set.func_list) if tool_set is not None else 0 |
| configured_persona = ( | ||
| self.context.persona_manager.get_persona_v3_by_id( | ||
| configured_persona_id | ||
| ) | ||
| ) |
There was a problem hiding this comment.
self.context.persona_manager.get_persona_v3_by_id 是一个异步方法(类似于 get_persona 和 get_default_persona_v3),需要使用 await 关键字。如果不加 await,它将返回一个协程对象(coroutine),在后续通过 configured_persona["prompt"] 访问时会抛出 TypeError: 'coroutine' object is not subscriptable 异常。
| 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 | |
| ) | |
| ) |
|
|
||
| # extra 里指向但上面没收进来的(理论上已收,双保险按名字补一次) | ||
| for name in extra: | ||
| if tool_set.get_tool(name) is None and name not in exclude: |
There was a problem hiding this comment.
| raw_global = ( | ||
| provider_settings.get("fallback_chat_models", []) | ||
| if isinstance(provider_settings, dict) | ||
| else [] | ||
| ) |
There was a problem hiding this comment.
如果 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", [])There was a problem hiding this comment.
Hey - 我发现了两个问题,并给出了一些整体性的反馈:
- 用于解析名称列表的两个辅助函数(
ImageMixin._parse_name_list和AgentRunnerMixin._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>帮助我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
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_listandAgentRunnerMixin._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_responseis 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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 [] |
There was a problem hiding this comment.
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)
# 没选到:累计计数,达到上限则永久回退。- 如果在类的其他位置直接假设
_image_tools_cache是list[str],需要相应改成按cache_key访问或只在_ensure_image_tool_names这一处读取该属性,以避免类型不一致的问题。 - 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在
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_toolson each call when building theToolSet.
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)
# 没选到:累计计数,达到上限则永久回退。- 如果在类的其他位置直接假设
_image_tools_cache是list[str],需要相应改成按cache_key访问或只在_ensure_image_tool_names这一处读取该属性,以避免类型不一致的问题。 - 如果后续需要把更多会话级别字段(例如模型名、能力开关等)纳入缓存维度,可以在
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>
|
已按 review 调整,逐条说明: 已改(d5057fe): 以下经 AstrBot 4.25.1 源码核实后未改:
|
📝 描述 / 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时按工具数自动推导上限。fallback_providers,本地留空时回退读全局fallback_chat_models,跟本体行为对齐。core/llm_adapter.py:_generate_llm_response在开启工具/回退时改走工具循环 Agent,失败则降级回原来的单次生成。_conf_schema.json:私聊/群聊各加了agent_settings(工具调用 + 回退)和persona_settings(人格),用的都是本体原生控件(下拉、滑块、select_providers多选、select_persona、condition联动隐藏)。main.py:接入新的 mixin。📸 运行截图或测试结果 / Screenshots or Test Results
离线单测 16 项全过(工具选择、回退解析、max_steps、人格解析等纯逻辑),
ruff check+ruff format+ JSON 校验都过。另外在真实 AstrBot 4.25.1 + 真实模型下做了一轮端到端测试,核心 8/8 通过。关键日志摘录:
链式工具调用真触发(whitelist 选中
uapi_weather,模型据工具结果生成):模型回退真触发(坏 key 首选失败 → 本体接管 → 切备选成功):
配图捕获(统一合成事件捕获生图工具发出的图片):
完整 8 项:插件加载无报错、纯 Agent 生成出文本、模型回退、链式工具调用、配图捕获、人格自选解析、max_steps 推导,全部 ✅。
回退用临时构造的坏 key provider 作首选验证;链式工具用 UApiPro 工具箱的
uapi_weather验证;配图用一个只注册生图工具的本地测试插件验证“工具 → 合成事件捕获图片”这条链路。✅ 检查清单 / Checklist
requirements.txt文件中。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added torequirements.txt.❤️ CONTRIBUTING
Summary by Sourcery
为基于工具循环(tool-loop)的文本生成、模型回退、人格(persona)选择和自动图像生成添加主动消息支持,同时在默认情况下保持行为不变。
新功能:
增强:
测试:
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:
Enhancements:
Tests: