From d19945009f9bf4c2e619641c57c90561bb9520aa Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 14 Nov 2025 19:17:24 +0800 Subject: [PATCH 1/8] refactor: decople the agent impl part and introduce some helper context method to call llm --- astrbot/core/agent/runners/base.py | 7 + .../agent/runners/tool_loop_agent_runner.py | 10 + astrbot/core/astr_agent_context.py | 8 +- astrbot/core/astr_agent_hooks.py | 36 ++ astrbot/core/astr_agent_run_util.py | 77 ++++ astrbot/core/astr_agent_tool_exec.py | 301 +++++++++++++++ astrbot/core/exceptions.py | 9 + astrbot/core/pipeline/context.py | 3 +- astrbot/core/pipeline/context_utils.py | 65 ---- .../process_stage/method/llm_request.py | 348 +----------------- astrbot/core/provider/entities.py | 4 +- astrbot/core/star/context.py | 144 +++++++- 12 files changed, 604 insertions(+), 408 deletions(-) create mode 100644 astrbot/core/astr_agent_hooks.py create mode 100644 astrbot/core/astr_agent_run_util.py create mode 100644 astrbot/core/astr_agent_tool_exec.py create mode 100644 astrbot/core/exceptions.py diff --git a/astrbot/core/agent/runners/base.py b/astrbot/core/agent/runners/base.py index c7cd36d96..f7e0913b4 100644 --- a/astrbot/core/agent/runners/base.py +++ b/astrbot/core/agent/runners/base.py @@ -40,6 +40,13 @@ class BaseAgentRunner(T.Generic[TContext]): """Process a single step of the agent.""" ... + @abc.abstractmethod + async def step_until_done( + self, max_step: int + ) -> T.AsyncGenerator[AgentResponse, None]: + """Process steps until the agent is done.""" + ... + @abc.abstractmethod def done(self) -> bool: """Check if the agent has completed its task. diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 23071d446..f6e613679 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -177,6 +177,16 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) self.req.append_tool_calls_result(tool_calls_result) + async def step_until_done( + self, max_step: int + ) -> T.AsyncGenerator[AgentResponse, None]: + """Process steps until the agent is done.""" + step_count = 0 + while not self.done() and step_count < max_step: + step_count += 1 + async for resp in self.step(): + yield resp + async def _handle_function_tools( self, req: ProviderRequest, diff --git a/astrbot/core/astr_agent_context.py b/astrbot/core/astr_agent_context.py index 28b242253..ffe8a199b 100644 --- a/astrbot/core/astr_agent_context.py +++ b/astrbot/core/astr_agent_context.py @@ -1,14 +1,14 @@ from dataclasses import dataclass +from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider import Provider -from astrbot.core.provider.entities import ProviderRequest @dataclass class AstrAgentContext: provider: Provider - first_provider_request: ProviderRequest - curr_provider_request: ProviderRequest - streaming: bool event: AstrMessageEvent + + +AgentContextWrapper = ContextWrapper[AstrAgentContext] diff --git a/astrbot/core/astr_agent_hooks.py b/astrbot/core/astr_agent_hooks.py new file mode 100644 index 000000000..f394fc947 --- /dev/null +++ b/astrbot/core/astr_agent_hooks.py @@ -0,0 +1,36 @@ +from typing import Any + +from mcp.types import CallToolResult + +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool +from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.pipeline.context_utils import call_event_hook +from astrbot.core.star.star_handler import EventType + + +class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]): + async def on_agent_done(self, run_context, llm_response): + # 执行事件钩子 + await call_event_hook( + run_context.context.event, + EventType.OnLLMResponseEvent, + llm_response, + ) + + async def on_tool_end( + self, + run_context: ContextWrapper[AstrAgentContext], + tool: FunctionTool[Any], + tool_args: dict | None, + tool_result: CallToolResult | None, + ): + run_context.context.event.clear_result() + + +class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]): + pass + + +MAIN_AGENT_HOOKS = MainAgentHooks() diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py new file mode 100644 index 000000000..ed8c0028d --- /dev/null +++ b/astrbot/core/astr_agent_run_util.py @@ -0,0 +1,77 @@ +import traceback +from collections.abc import AsyncGenerator + +from astrbot.core import logger +from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner +from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.message.message_event_result import ( + MessageChain, + MessageEventResult, + ResultContentType, +) + +AgentRunner = ToolLoopAgentRunner[AstrAgentContext] + + +async def run_agent( + agent_runner: AgentRunner, + max_step: int = 30, + show_tool_use: bool = True, + stream_to_general: bool = False, +) -> AsyncGenerator[MessageChain | None, None]: + step_idx = 0 + astr_event = agent_runner.run_context.context.event + while step_idx < max_step: + step_idx += 1 + try: + async for resp in agent_runner.step(): + if astr_event.is_stopped(): + return + if resp.type == "tool_call_result": + msg_chain = resp.data["chain"] + if msg_chain.type == "tool_direct_result": + # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 + resp.data["chain"].type = "tool_call_result" + await astr_event.send(resp.data["chain"]) + continue + # 对于其他情况,暂时先不处理 + continue + elif resp.type == "tool_call": + if agent_runner.streaming: + # 用来标记流式响应需要分节 + yield MessageChain(chain=[], type="break") + if show_tool_use or astr_event.get_platform_name() == "webchat": + resp.data["chain"].type = "tool_call" + await astr_event.send(resp.data["chain"]) + continue + + if stream_to_general and resp.type == "streaming_delta": + continue + + if stream_to_general or not agent_runner.streaming: + content_typ = ( + ResultContentType.LLM_RESULT + if resp.type == "llm_result" + else ResultContentType.GENERAL_RESULT + ) + astr_event.set_result( + MessageEventResult( + chain=resp.data["chain"].chain, + result_content_type=content_typ, + ), + ) + yield + astr_event.clear_result() + elif resp.type == "streaming_delta": + yield resp.data["chain"] # MessageChain + if agent_runner.done(): + break + + except Exception as e: + logger.error(traceback.format_exc()) + err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n" + if agent_runner.streaming: + yield MessageChain().message(err_msg) + else: + astr_event.set_result(MessageEventResult().message(err_msg)) + return diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py new file mode 100644 index 000000000..f7425b0b5 --- /dev/null +++ b/astrbot/core/astr_agent_tool_exec.py @@ -0,0 +1,301 @@ +import asyncio +import inspect +import traceback +import typing as T + +import mcp + +from astrbot import logger +from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.mcp_client import MCPTool +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor +from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.message.message_event_result import ( + CommandResult, + MessageChain, + MessageEventResult, +) +from astrbot.core.provider.entities import ProviderRequest +from astrbot.core.provider.register import llm_tools + +from .astr_agent_context import AgentContextWrapper +from .astr_agent_run_util import AgentRunner, run_agent + + +class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): + @classmethod + async def execute(cls, tool, run_context, **tool_args): + """执行函数调用。 + + Args: + event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。 + **kwargs: 函数调用的参数。 + + Returns: + AsyncGenerator[None | mcp.types.CallToolResult, None] + + """ + if isinstance(tool, HandoffTool): + async for r in cls._execute_handoff(tool, run_context, **tool_args): + yield r + return + + elif isinstance(tool, MCPTool): + async for r in cls._execute_mcp(tool, run_context, **tool_args): + yield r + return + + else: + async for r in cls._execute_local(tool, run_context, **tool_args): + yield r + return + + @classmethod + async def _execute_handoff( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + input_ = tool_args.get("input", "agent") + agent_runner = AgentRunner() + + # make toolset for the agent + tools = tool.agent.tools + if tools: + toolset = ToolSet() + for t in tools: + if isinstance(t, str): + _t = llm_tools.get_func(t) + if _t: + toolset.add_tool(_t) + elif isinstance(t, FunctionTool): + toolset.add_tool(t) + else: + toolset = None + + request = ProviderRequest( + prompt=input_, + system_prompt=tool.description or "", + image_urls=[], # 暂时不传递原始 agent 的上下文 + contexts=[], # 暂时不传递原始 agent 的上下文 + func_tool=toolset, + ) + astr_agent_ctx = AstrAgentContext( + provider=run_context.context.provider, + event=run_context.context.event, + ) + + event = run_context.context.event + + logger.debug(f"正在将任务委托给 Agent: {tool.agent.name}, input: {input_}") + await event.send( + MessageChain().message("✨ 正在将任务委托给 Agent: " + tool.agent.name), + ) + + await agent_runner.reset( + provider=run_context.context.provider, + request=request, + run_context=AgentContextWrapper( + context=astr_agent_ctx, + tool_call_timeout=run_context.tool_call_timeout, + ), + tool_executor=FunctionToolExecutor(), + agent_hooks=tool.agent.run_hooks or BaseAgentRunHooks[AstrAgentContext](), + ) + + async for _ in run_agent(agent_runner, 15, True): + pass + + if agent_runner.done(): + llm_response = agent_runner.get_final_llm_resp() + + if not llm_response: + text_content = mcp.types.TextContent( + type="text", + text=f"error when deligate task to {tool.agent.name}", + ) + yield mcp.types.CallToolResult(content=[text_content]) + return + + logger.debug( + f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}", + ) + + result = ( + f"Agent {tool.agent.name} respond with: {llm_response.completion_text}\n\n" + "Note: If the result is error or need user provide more information, please provide more information to the agent(you can ask user for more information first)." + ) + + text_content = mcp.types.TextContent( + type="text", + text=result, + ) + yield mcp.types.CallToolResult(content=[text_content]) + else: + text_content = mcp.types.TextContent( + type="text", + text=f"error when deligate task to {tool.agent.name}", + ) + yield mcp.types.CallToolResult(content=[text_content]) + return + + @classmethod + async def _execute_local( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + event = run_context.context.event + if not event: + raise ValueError("Event must be provided for local function tools.") + + is_override_call = False + for ty in type(tool).mro(): + if "call" in ty.__dict__ and ty.__dict__["call"] is not FunctionTool.call: + is_override_call = True + break + + # 检查 tool 下有没有 run 方法 + if not tool.handler and not hasattr(tool, "run") and not is_override_call: + raise ValueError("Tool must have a valid handler or override 'run' method.") + + awaitable = None + method_name = "" + if tool.handler: + awaitable = tool.handler + method_name = "decorator_handler" + elif is_override_call: + awaitable = tool.call + method_name = "call" + elif hasattr(tool, "run"): + awaitable = getattr(tool, "run") + method_name = "run" + if awaitable is None: + raise ValueError("Tool must have a valid handler or override 'run' method.") + + wrapper = call_local_llm_tool( + context=run_context, + handler=awaitable, + method_name=method_name, + **tool_args, + ) + while True: + try: + resp = await asyncio.wait_for( + anext(wrapper), + timeout=run_context.tool_call_timeout, + ) + if resp is not None: + if isinstance(resp, mcp.types.CallToolResult): + yield resp + else: + text_content = mcp.types.TextContent( + type="text", + text=str(resp), + ) + yield mcp.types.CallToolResult(content=[text_content]) + else: + # NOTE: Tool 在这里直接请求发送消息给用户 + # TODO: 是否需要判断 event.get_result() 是否为空? + # 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容" + if res := run_context.context.event.get_result(): + if res.chain: + try: + await event.send( + MessageChain( + chain=res.chain, + type="tool_direct_result", + ) + ) + except Exception as e: + logger.error( + f"Tool 直接发送消息失败: {e}", + exc_info=True, + ) + yield None + except asyncio.TimeoutError: + raise Exception( + f"tool {tool.name} execution timeout after {run_context.tool_call_timeout} seconds.", + ) + except StopAsyncIteration: + break + + @classmethod + async def _execute_mcp( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + res = await tool.call(run_context, **tool_args) + if not res: + return + yield res + + +async def call_local_llm_tool( + context: ContextWrapper[AstrAgentContext], + handler: T.Callable[..., T.Awaitable[T.Any]], + method_name: str, + *args, + **kwargs, +) -> T.AsyncGenerator[T.Any, None]: + """执行本地 LLM 工具的处理函数并处理其返回结果""" + ready_to_call = None # 一个协程或者异步生成器 + + trace_ = None + + event = context.context.event + + try: + if method_name == "run" or method_name == "decorator_handler": + ready_to_call = handler(event, *args, **kwargs) + elif method_name == "call": + ready_to_call = handler(context, *args, **kwargs) + else: + raise ValueError(f"未知的方法名: {method_name}") + except ValueError as e: + logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True) + except TypeError: + logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True) + except Exception as e: + trace_ = traceback.format_exc() + logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}") + + if not ready_to_call: + return + + if inspect.isasyncgen(ready_to_call): + _has_yielded = False + try: + async for ret in ready_to_call: + # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 + # 返回值只能是 MessageEventResult 或者 None(无返回值) + _has_yielded = True + if isinstance(ret, (MessageEventResult, CommandResult)): + # 如果返回值是 MessageEventResult, 设置结果并继续 + event.set_result(ret) + yield + else: + # 如果返回值是 None, 则不设置结果并继续 + # 继续执行后续阶段 + yield ret + if not _has_yielded: + # 如果这个异步生成器没有执行到 yield 分支 + yield + except Exception as e: + logger.error(f"Previous Error: {trace_}") + raise e + elif inspect.iscoroutine(ready_to_call): + # 如果只是一个协程, 直接执行 + ret = await ready_to_call + if isinstance(ret, (MessageEventResult, CommandResult)): + event.set_result(ret) + yield + else: + yield ret diff --git a/astrbot/core/exceptions.py b/astrbot/core/exceptions.py new file mode 100644 index 000000000..e637d4930 --- /dev/null +++ b/astrbot/core/exceptions.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class AstrBotError(Exception): + """Base exception for all AstrBot errors.""" + + +class ProviderNotFoundError(AstrBotError): + """Raised when a specified provider is not found.""" diff --git a/astrbot/core/pipeline/context.py b/astrbot/core/pipeline/context.py index 44186764e..a6cd567e0 100644 --- a/astrbot/core/pipeline/context.py +++ b/astrbot/core/pipeline/context.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from astrbot.core.config import AstrBotConfig from astrbot.core.star import PluginManager -from .context_utils import call_event_hook, call_handler, call_local_llm_tool +from .context_utils import call_event_hook, call_handler @dataclass @@ -15,4 +15,3 @@ class PipelineContext: astrbot_config_id: str call_handler = call_handler call_event_hook = call_event_hook - call_local_llm_tool = call_local_llm_tool diff --git a/astrbot/core/pipeline/context_utils.py b/astrbot/core/pipeline/context_utils.py index 371816b6e..73d28c5d1 100644 --- a/astrbot/core/pipeline/context_utils.py +++ b/astrbot/core/pipeline/context_utils.py @@ -3,8 +3,6 @@ import traceback import typing as T from astrbot import logger -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.message.message_event_result import CommandResult, MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star import star_map @@ -107,66 +105,3 @@ async def call_event_hook( return True return event.is_stopped() - - -async def call_local_llm_tool( - context: ContextWrapper[AstrAgentContext], - handler: T.Callable[..., T.Awaitable[T.Any]], - method_name: str, - *args, - **kwargs, -) -> T.AsyncGenerator[T.Any, None]: - """执行本地 LLM 工具的处理函数并处理其返回结果""" - ready_to_call = None # 一个协程或者异步生成器 - - trace_ = None - - event = context.context.event - - try: - if method_name == "run" or method_name == "decorator_handler": - ready_to_call = handler(event, *args, **kwargs) - elif method_name == "call": - ready_to_call = handler(context, *args, **kwargs) - else: - raise ValueError(f"未知的方法名: {method_name}") - except ValueError as e: - logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True) - except TypeError: - logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True) - except Exception as e: - trace_ = traceback.format_exc() - logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}") - - if not ready_to_call: - return - - if inspect.isasyncgen(ready_to_call): - _has_yielded = False - try: - async for ret in ready_to_call: - # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 - # 返回值只能是 MessageEventResult 或者 None(无返回值) - _has_yielded = True - if isinstance(ret, (MessageEventResult, CommandResult)): - # 如果返回值是 MessageEventResult, 设置结果并继续 - event.set_result(ret) - yield - else: - # 如果返回值是 None, 则不设置结果并继续 - # 继续执行后续阶段 - yield ret - if not _has_yielded: - # 如果这个异步生成器没有执行到 yield 分支 - yield - except Exception as e: - logger.error(f"Previous Error: {trace_}") - raise e - elif inspect.iscoroutine(ready_to_call): - # 如果只是一个协程, 直接执行 - ret = await ready_to_call - if isinstance(ret, (MessageEventResult, CommandResult)): - event.set_result(ret) - yield - else: - yield ret diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 69bf31a55..eef9d69e2 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -3,20 +3,10 @@ import asyncio import copy import json -import traceback from collections.abc import AsyncGenerator -from typing import Any - -from mcp.types import CallToolResult from astrbot.core import logger -from astrbot.core.agent.handoff import HandoffTool -from astrbot.core.agent.hooks import BaseAgentRunHooks -from astrbot.core.agent.mcp_client import MCPTool -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner -from astrbot.core.agent.tool import FunctionTool, ToolSet -from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor +from astrbot.core.agent.tool import ToolSet from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.conversation_mgr import Conversation from astrbot.core.message.components import Image @@ -31,328 +21,19 @@ from astrbot.core.provider.entities import ( LLMResponse, ProviderRequest, ) -from astrbot.core.provider.register import llm_tools from astrbot.core.star.session_llm_manager import SessionServiceManager from astrbot.core.star.star_handler import EventType, star_map from astrbot.core.utils.metrics import Metric from astrbot.core.utils.session_lock import session_lock_manager -from ...context import PipelineContext, call_event_hook, call_local_llm_tool +from ....astr_agent_context import AgentContextWrapper +from ....astr_agent_hooks import MAIN_AGENT_HOOKS +from ....astr_agent_run_util import AgentRunner, run_agent +from ....astr_agent_tool_exec import FunctionToolExecutor +from ...context import PipelineContext, call_event_hook from ..stage import Stage from ..utils import inject_kb_context -try: - import mcp -except (ModuleNotFoundError, ImportError): - logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。") - - -AgentContextWrapper = ContextWrapper[AstrAgentContext] -AgentRunner = ToolLoopAgentRunner[AstrAgentContext] - - -class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): - @classmethod - async def execute(cls, tool, run_context, **tool_args): - """执行函数调用。 - - Args: - event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。 - **kwargs: 函数调用的参数。 - - Returns: - AsyncGenerator[None | mcp.types.CallToolResult, None] - - """ - if isinstance(tool, HandoffTool): - async for r in cls._execute_handoff(tool, run_context, **tool_args): - yield r - return - - elif isinstance(tool, MCPTool): - async for r in cls._execute_mcp(tool, run_context, **tool_args): - yield r - return - - else: - async for r in cls._execute_local(tool, run_context, **tool_args): - yield r - return - - @classmethod - async def _execute_handoff( - cls, - tool: HandoffTool, - run_context: ContextWrapper[AstrAgentContext], - **tool_args, - ): - input_ = tool_args.get("input", "agent") - agent_runner = AgentRunner() - - # make toolset for the agent - tools = tool.agent.tools - if tools: - toolset = ToolSet() - for t in tools: - if isinstance(t, str): - _t = llm_tools.get_func(t) - if _t: - toolset.add_tool(_t) - elif isinstance(t, FunctionTool): - toolset.add_tool(t) - else: - toolset = None - - request = ProviderRequest( - prompt=input_, - system_prompt=tool.description or "", - image_urls=[], # 暂时不传递原始 agent 的上下文 - contexts=[], # 暂时不传递原始 agent 的上下文 - func_tool=toolset, - ) - astr_agent_ctx = AstrAgentContext( - provider=run_context.context.provider, - first_provider_request=run_context.context.first_provider_request, - curr_provider_request=request, - streaming=run_context.context.streaming, - event=run_context.context.event, - ) - - event = run_context.context.event - - logger.debug(f"正在将任务委托给 Agent: {tool.agent.name}, input: {input_}") - await event.send( - MessageChain().message("✨ 正在将任务委托给 Agent: " + tool.agent.name), - ) - - await agent_runner.reset( - provider=run_context.context.provider, - request=request, - run_context=AgentContextWrapper( - context=astr_agent_ctx, - tool_call_timeout=run_context.tool_call_timeout, - ), - tool_executor=FunctionToolExecutor(), - agent_hooks=tool.agent.run_hooks or BaseAgentRunHooks[AstrAgentContext](), - streaming=run_context.context.streaming, - ) - - async for _ in run_agent(agent_runner, 15, True): - pass - - if agent_runner.done(): - llm_response = agent_runner.get_final_llm_resp() - - if not llm_response: - text_content = mcp.types.TextContent( - type="text", - text=f"error when deligate task to {tool.agent.name}", - ) - yield mcp.types.CallToolResult(content=[text_content]) - return - - logger.debug( - f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}", - ) - - result = ( - f"Agent {tool.agent.name} respond with: {llm_response.completion_text}\n\n" - "Note: If the result is error or need user provide more information, please provide more information to the agent(you can ask user for more information first)." - ) - - text_content = mcp.types.TextContent( - type="text", - text=result, - ) - yield mcp.types.CallToolResult(content=[text_content]) - else: - text_content = mcp.types.TextContent( - type="text", - text=f"error when deligate task to {tool.agent.name}", - ) - yield mcp.types.CallToolResult(content=[text_content]) - return - - @classmethod - async def _execute_local( - cls, - tool: FunctionTool, - run_context: ContextWrapper[AstrAgentContext], - **tool_args, - ): - event = run_context.context.event - if not event: - raise ValueError("Event must be provided for local function tools.") - - is_override_call = False - for ty in type(tool).mro(): - if "call" in ty.__dict__ and ty.__dict__["call"] is not FunctionTool.call: - is_override_call = True - break - - # 检查 tool 下有没有 run 方法 - if not tool.handler and not hasattr(tool, "run") and not is_override_call: - raise ValueError("Tool must have a valid handler or override 'run' method.") - - awaitable = None - method_name = "" - if tool.handler: - awaitable = tool.handler - method_name = "decorator_handler" - elif is_override_call: - awaitable = tool.call - method_name = "call" - elif hasattr(tool, "run"): - awaitable = getattr(tool, "run") - method_name = "run" - if awaitable is None: - raise ValueError("Tool must have a valid handler or override 'run' method.") - - wrapper = call_local_llm_tool( - context=run_context, - handler=awaitable, - method_name=method_name, - **tool_args, - ) - while True: - try: - resp = await asyncio.wait_for( - anext(wrapper), - timeout=run_context.tool_call_timeout, - ) - if resp is not None: - if isinstance(resp, mcp.types.CallToolResult): - yield resp - else: - text_content = mcp.types.TextContent( - type="text", - text=str(resp), - ) - yield mcp.types.CallToolResult(content=[text_content]) - else: - # NOTE: Tool 在这里直接请求发送消息给用户 - # TODO: 是否需要判断 event.get_result() 是否为空? - # 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容" - if res := run_context.context.event.get_result(): - if res.chain: - try: - await event.send( - MessageChain( - chain=res.chain, - type="tool_direct_result", - ) - ) - except Exception as e: - logger.error( - f"Tool 直接发送消息失败: {e}", - exc_info=True, - ) - yield None - except asyncio.TimeoutError: - raise Exception( - f"tool {tool.name} execution timeout after {run_context.tool_call_timeout} seconds.", - ) - except StopAsyncIteration: - break - - @classmethod - async def _execute_mcp( - cls, - tool: FunctionTool, - run_context: ContextWrapper[AstrAgentContext], - **tool_args, - ): - res = await tool.call(run_context, **tool_args) - if not res: - return - yield res - - -class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]): - async def on_agent_done(self, run_context, llm_response): - # 执行事件钩子 - await call_event_hook( - run_context.context.event, - EventType.OnLLMResponseEvent, - llm_response, - ) - - async def on_tool_end( - self, - run_context: ContextWrapper[AstrAgentContext], - tool: FunctionTool[Any], - tool_args: dict | None, - tool_result: CallToolResult | None, - ): - run_context.context.event.clear_result() - - -MAIN_AGENT_HOOKS = MainAgentHooks() - - -async def run_agent( - agent_runner: AgentRunner, - max_step: int = 30, - show_tool_use: bool = True, - stream_to_general: bool = False, -) -> AsyncGenerator[MessageChain, None]: - step_idx = 0 - astr_event = agent_runner.run_context.context.event - while step_idx < max_step: - step_idx += 1 - try: - async for resp in agent_runner.step(): - if astr_event.is_stopped(): - return - if resp.type == "tool_call_result": - msg_chain = resp.data["chain"] - if msg_chain.type == "tool_direct_result": - # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 - resp.data["chain"].type = "tool_call_result" - await astr_event.send(resp.data["chain"]) - continue - # 对于其他情况,暂时先不处理 - continue - elif resp.type == "tool_call": - if agent_runner.streaming: - # 用来标记流式响应需要分节 - yield MessageChain(chain=[], type="break") - if show_tool_use or astr_event.get_platform_name() == "webchat": - resp.data["chain"].type = "tool_call" - await astr_event.send(resp.data["chain"]) - continue - - if stream_to_general and resp.type == "streaming_delta": - continue - - if stream_to_general or not agent_runner.streaming: - content_typ = ( - ResultContentType.LLM_RESULT - if resp.type == "llm_result" - else ResultContentType.GENERAL_RESULT - ) - astr_event.set_result( - MessageEventResult( - chain=resp.data["chain"].chain, - result_content_type=content_typ, - ), - ) - yield - astr_event.clear_result() - elif resp.type == "streaming_delta": - yield resp.data["chain"] # MessageChain - if agent_runner.done(): - break - - except Exception as e: - logger.error(traceback.format_exc()) - err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n" - if agent_runner.streaming: - yield MessageChain().message(err_msg) - else: - astr_event.set_result(MessageEventResult().message(err_msg)) - return - class LLMRequestSubStage(Stage): async def initialize(self, ctx: PipelineContext) -> None: @@ -569,6 +250,9 @@ class LLMRequestSubStage(Stage): logger.debug("LLM 响应为空,不保存记录。") return + if req.contexts is None: + req.contexts = [] + # 历史上下文 messages = copy.deepcopy(req.contexts) # 这一轮对话请求的用户输入 @@ -644,7 +328,9 @@ class LLMRequestSubStage(Stage): req.contexts = json.loads(req.conversation.history) else: - req = ProviderRequest(prompt="", image_urls=[]) + req = ProviderRequest() + req.prompt = "" + req.image_urls = [] if sel_model := event.get_extra("selected_model"): req.model = sel_model if self.provider_wake_prefix and not event.message_str.startswith( @@ -681,15 +367,14 @@ class LLMRequestSubStage(Stage): req.contexts = json.loads(req.contexts) # truncate contexts to fit max length - req.contexts = self._truncate_contexts(req.contexts) + if req.contexts: + req.contexts = self._truncate_contexts(req.contexts) + self._fix_messages(req.contexts) # session_id if not req.session_id: req.session_id = event.unified_msg_origin - # fix messages - req.contexts = self._fix_messages(req.contexts) - # check provider modalities, if provider does not support image/tool_use, clear them in request. self._modalities_fix(provider, req) @@ -710,9 +395,6 @@ class LLMRequestSubStage(Stage): ) astr_agent_ctx = AstrAgentContext( provider=provider, - first_provider_request=req, - curr_provider_request=req, - streaming=streaming_response, event=event, ) await agent_runner.reset( diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 6852f9cd6..e26f3ea50 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -66,9 +66,9 @@ class ToolCallsResult: @dataclass class ProviderRequest: - prompt: str + prompt: str | None = None """提示词""" - session_id: str = "" + session_id: str | None = "" """会话 ID""" image_urls: list[str] = field(default_factory=list) """图片 URL 列表""" diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 1a5bc53d9..7e238b8a0 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -5,6 +5,12 @@ from typing import Any from deprecated import deprecated +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.message import Message +from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner +from astrbot.core.agent.tool import ToolSet +from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext +from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.conversation_mgr import ConversationManager @@ -13,10 +19,10 @@ from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.message.message_event_result import MessageChain from astrbot.core.persona_mgr import PersonaManager from astrbot.core.platform import Platform -from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion from astrbot.core.platform.manager import PlatformManager from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager -from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager from astrbot.core.provider.manager import ProviderManager from astrbot.core.provider.provider import ( @@ -31,6 +37,7 @@ from astrbot.core.star.filter.platform_adapter_type import ( PlatformAdapterType, ) +from ..exceptions import ProviderNotFoundError from .filter.command import CommandFilter from .filter.regex import RegexFilter from .star import StarMetadata, star_map, star_registry @@ -75,6 +82,139 @@ class Context: self.astrbot_config_mgr = astrbot_config_mgr self.kb_manager = knowledge_base_manager + async def llm_generate( + self, + *, + chat_provider_id: str, + prompt: str | None = None, + image_urls: list[str] | None = None, + tools: ToolSet | None = None, + system_prompt: str | None = None, + contexts: list[Message] | list[dict] | None = None, + **kwargs: Any, + ) -> LLMResponse: + """Call the LLM to generate a response. The method will not automatically execute tool calls. If you want to use tool calls, please use `tool_loop_agent()`. + + .. versionadded:: 4.5.7 (sdk) + + Args: + chat_provider_id: The chat provider ID to use. + prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message + image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message + tools: ToolSet of tools available to the LLM + system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context + contexts: context messages for the LLM + **kwargs: Additional keyword arguments for LLM generation, OpenAI compatible + + Raises: + ChatProviderNotFoundError: If the specified chat provider ID is not found + Exception: For other errors during LLM generation + """ + prov = await self.provider_manager.get_provider_by_id(chat_provider_id) + if not prov or not isinstance(prov, Provider): + raise ProviderNotFoundError(f"Provider {chat_provider_id} not found") + llm_resp = await prov.text_chat( + prompt=prompt, + image_urls=image_urls, + func_tool=tools, + contexts=contexts, + system_prompt=system_prompt, + **kwargs, + ) + return llm_resp + + async def tool_loop_agent( + self, + *, + event: AstrMessageEvent, + chat_provider_id: str, + prompt: str | None = None, + image_urls: list[str] | None = None, + tools: ToolSet | None = None, + system_prompt: str | None = None, + contexts: list[Message] | list[dict] | None = None, + max_steps: int = 30, + tool_call_timeout: int = 60, + **kwargs: Any, + ) -> LLMResponse: + """Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced. + + Args: + chat_provider_id: The chat provider ID to use. + prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message + image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message + tools: ToolSet of tools available to the LLM + system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context + contexts: context messages for the LLM + max_steps: Maximum number of tool calls before stopping the loop + **kwargs: Additional keyword arguments for LLM generation, OpenAI compatible + + Returns: + The final LLMResponse after tool calls are completed. + + Raises: + ChatProviderNotFoundError: If the specified chat provider ID is not found + Exception: For other errors during LLM generation + """ + prov = await self.provider_manager.get_provider_by_id(chat_provider_id) + if not prov or not isinstance(prov, Provider): + raise ProviderNotFoundError(f"Provider {chat_provider_id} not found") + + context_ = [] + for msg in contexts or []: + if isinstance(msg, Message): + context_.append(msg.model_dump()) + else: + context_.append(msg) + + request = ProviderRequest( + prompt=prompt, + image_urls=image_urls, + func_tool=tools, + contexts=context_, + system_prompt=system_prompt, + ) + astr_agent_ctx = AstrAgentContext( + provider=prov, + event=event, + ) + agent_runner = ToolLoopAgentRunner() + tool_executor = FunctionToolExecutor() + await agent_runner.reset( + provider=prov, + request=request, + run_context=AgentContextWrapper( + context=astr_agent_ctx, + tool_call_timeout=tool_call_timeout, + ), + tool_executor=tool_executor, + agent_hooks=kwargs.get( + "agent_hooks", BaseAgentRunHooks[AstrAgentContext]() + ), + streaming=kwargs.get("stream", False), + ) + async for _ in agent_runner.step_until_done(max_steps): + pass + llm_resp = agent_runner.get_final_llm_resp() + if not llm_resp: + raise Exception("Agent did not produce a final LLM response") + return llm_resp + + async def get_current_chat_provider_id(self, umo: str) -> str: + """Get the ID of the currently used chat provider. + + Args: + umo(str): unified_message_origin value, if provided and user has enabled provider session isolation, the provider preferred by that session will be used. + + Raises: + ProviderNotFoundError: If the specified chat provider is not found + + """ + prov = self.get_using_provider(umo) + if not prov: + raise ProviderNotFoundError("Provider not found") + return prov.meta().id + def get_registered_star(self, star_name: str) -> StarMetadata | None: """根据插件名获取插件的 Metadata""" for star in star_registry: From 89e79863f635baf557c26fb9ef4166c298ab2572 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 14 Nov 2025 22:45:55 +0800 Subject: [PATCH 2/8] fix: ensure image_urls and system_prompt default to empty values in ProviderRequest --- astrbot/core/star/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 7e238b8a0..638dd435b 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -169,10 +169,10 @@ class Context: request = ProviderRequest( prompt=prompt, - image_urls=image_urls, + image_urls=image_urls or [], func_tool=tools, contexts=context_, - system_prompt=system_prompt, + system_prompt=system_prompt or "", ) astr_agent_ctx = AstrAgentContext( provider=prov, From 17422ba9c3e9722002409349a84f4d512b8372e2 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sat, 15 Nov 2025 21:15:20 +0800 Subject: [PATCH 3/8] feat: introduce messages field in agent RunContext --- astrbot/core/agent/run_context.py | 9 ++- .../agent/runners/tool_loop_agent_runner.py | 28 ++++++- astrbot/core/agent/tool.py | 5 +- astrbot/core/astr_agent_context.py | 13 ++- astrbot/core/astr_agent_tool_exec.py | 81 +++---------------- .../process_stage/method/llm_request.py | 6 +- astrbot/core/provider/entities.py | 8 ++ astrbot/core/star/context.py | 38 ++++++--- 8 files changed, 95 insertions(+), 93 deletions(-) diff --git a/astrbot/core/agent/run_context.py b/astrbot/core/agent/run_context.py index 395817679..07e435895 100644 --- a/astrbot/core/agent/run_context.py +++ b/astrbot/core/agent/run_context.py @@ -1,16 +1,21 @@ -from dataclasses import dataclass from typing import Any, Generic +from pydantic import Field +from pydantic.dataclasses import dataclass from typing_extensions import TypeVar +from .message import Message + TContext = TypeVar("TContext", default=Any) -@dataclass +@dataclass(config={"arbitrary_types_allowed": True}) class ContextWrapper(Generic[TContext]): """A context for running an agent, which can be used to pass additional data or state.""" context: TContext + messages: list[Message] = Field(default_factory=list) + """This field stores the llm message context for the agent run, agent runners will maintain this field automatically.""" tool_call_timeout: int = 60 # Default tool call timeout in seconds diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index f6e613679..744030bbc 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -23,7 +23,7 @@ from astrbot.core.provider.entities import ( from astrbot.core.provider.provider import Provider from ..hooks import BaseAgentRunHooks -from ..message import AssistantMessageSegment, ToolCallMessageSegment +from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment from ..response import AgentResponseData from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor @@ -55,6 +55,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.agent_hooks = agent_hooks self.run_context = run_context + messages = [] + # append existing messages in the run context + for msg in request.contexts: + messages.append(Message.model_validate(msg)) + if request.prompt is not None: + m = await request.assemble_context() + messages.append(Message.model_validate(m)) + if request.system_prompt: + messages.insert( + 0, + Message(role="system", content=request.system_prompt), + ) + self.run_context.messages = messages + def _transition_state(self, new_state: AgentState) -> None: """转换 Agent 状态""" if self._state != new_state: @@ -130,6 +144,13 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): # 如果没有工具调用,转换到完成状态 self.final_llm_resp = llm_resp self._transition_state(AgentState.DONE) + # record the final assistant message + self.run_context.messages.append( + Message( + role="assistant", + content=llm_resp.completion_text or "", + ), + ) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) except Exception as e: @@ -175,6 +196,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ), tool_calls_result=tool_call_result_blocks, ) + # record the assistant message with tool calls + self.run_context.messages.extend( + tool_calls_result.to_openai_messages_model() + ) + self.req.append_tool_calls_result(tool_calls_result) async def step_until_done( diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index ae240d2e0..45226991c 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -10,6 +10,7 @@ from pydantic.dataclasses import dataclass from .run_context import ContextWrapper, TContext ParametersType = dict[str, Any] +ToolExecResult = str | mcp.types.CallToolResult @dataclass @@ -55,9 +56,7 @@ class FunctionTool(ToolSchema, Generic[TContext]): def __repr__(self): return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})" - async def call( - self, context: ContextWrapper[TContext], **kwargs - ) -> str | mcp.types.CallToolResult: + async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult: """Run the tool with the given arguments. The handler field has priority.""" raise NotImplementedError( "FunctionTool.call() must be implemented by subclasses or set a handler." diff --git a/astrbot/core/astr_agent_context.py b/astrbot/core/astr_agent_context.py index ffe8a199b..5eed5de8f 100644 --- a/astrbot/core/astr_agent_context.py +++ b/astrbot/core/astr_agent_context.py @@ -1,14 +1,19 @@ -from dataclasses import dataclass +from pydantic import Field +from pydantic.dataclasses import dataclass from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.provider import Provider +from astrbot.core.star.context import Context -@dataclass +@dataclass(config={"arbitrary_types_allowed": True}) class AstrAgentContext: - provider: Provider + context: Context + """The star context instance""" event: AstrMessageEvent + """The message event associated with the agent context.""" + extra: dict[str, str] = Field(default_factory=dict) + """Customized extra data.""" AgentContextWrapper = ContextWrapper[AstrAgentContext] diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index f7425b0b5..25a6a06e5 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -7,7 +7,6 @@ import mcp from astrbot import logger from astrbot.core.agent.handoff import HandoffTool -from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolSet @@ -18,12 +17,8 @@ from astrbot.core.message.message_event_result import ( MessageChain, MessageEventResult, ) -from astrbot.core.provider.entities import ProviderRequest from astrbot.core.provider.register import llm_tools -from .astr_agent_context import AgentContextWrapper -from .astr_agent_run_util import AgentRunner, run_agent - class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): @classmethod @@ -60,8 +55,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): run_context: ContextWrapper[AstrAgentContext], **tool_args, ): - input_ = tool_args.get("input", "agent") - agent_runner = AgentRunner() + input_ = tool_args.get("input") # make toolset for the agent tools = tool.agent.tools @@ -77,72 +71,21 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): else: toolset = None - request = ProviderRequest( - prompt=input_, - system_prompt=tool.description or "", - image_urls=[], # 暂时不传递原始 agent 的上下文 - contexts=[], # 暂时不传递原始 agent 的上下文 - func_tool=toolset, - ) - astr_agent_ctx = AstrAgentContext( - provider=run_context.context.provider, - event=run_context.context.event, - ) - + ctx = run_context.context.context event = run_context.context.event - - logger.debug(f"正在将任务委托给 Agent: {tool.agent.name}, input: {input_}") - await event.send( - MessageChain().message("✨ 正在将任务委托给 Agent: " + tool.agent.name), + umo = event.unified_msg_origin + prov_id = await ctx.get_current_chat_provider_id(umo) + llm_resp = await ctx.tool_loop_agent( + event=event, + chat_provider_id=prov_id, + prompt=input_, + tools=toolset, + max_steps=30, ) - - await agent_runner.reset( - provider=run_context.context.provider, - request=request, - run_context=AgentContextWrapper( - context=astr_agent_ctx, - tool_call_timeout=run_context.tool_call_timeout, - ), - tool_executor=FunctionToolExecutor(), - agent_hooks=tool.agent.run_hooks or BaseAgentRunHooks[AstrAgentContext](), + yield mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] ) - async for _ in run_agent(agent_runner, 15, True): - pass - - if agent_runner.done(): - llm_response = agent_runner.get_final_llm_resp() - - if not llm_response: - text_content = mcp.types.TextContent( - type="text", - text=f"error when deligate task to {tool.agent.name}", - ) - yield mcp.types.CallToolResult(content=[text_content]) - return - - logger.debug( - f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}", - ) - - result = ( - f"Agent {tool.agent.name} respond with: {llm_response.completion_text}\n\n" - "Note: If the result is error or need user provide more information, please provide more information to the agent(you can ask user for more information first)." - ) - - text_content = mcp.types.TextContent( - type="text", - text=result, - ) - yield mcp.types.CallToolResult(content=[text_content]) - else: - text_content = mcp.types.TextContent( - type="text", - text=f"error when deligate task to {tool.agent.name}", - ) - yield mcp.types.CallToolResult(content=[text_content]) - return - @classmethod async def _execute_local( cls, diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index eef9d69e2..76481ce56 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -6,6 +6,7 @@ import json from collections.abc import AsyncGenerator from astrbot.core import logger +from astrbot.core.agent.message import Message from astrbot.core.agent.tool import ToolSet from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.conversation_mgr import Conversation @@ -393,8 +394,11 @@ class LLMRequestSubStage(Stage): logger.debug( f"handle provider[id: {provider.provider_config['id']}] request: {req}", ) + context_model: list[Message] = [] + for msg in req.contexts: + context_model.append(Message.model_validate(msg)) astr_agent_ctx = AstrAgentContext( - provider=provider, + context=self.ctx.plugin_manager.context, event=event, ) await agent_runner.reset( diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index e26f3ea50..0a0b8d405 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -63,6 +63,14 @@ class ToolCallsResult: ] return ret + def to_openai_messages_model( + self, + ) -> list[AssistantMessageSegment | ToolCallMessageSegment]: + return [ + self.tool_calls_info, + *self.tool_calls_result, + ] + @dataclass class ProviderRequest: diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 638dd435b..5918b2029 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -9,8 +9,6 @@ from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.message import Message from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.tool import ToolSet -from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext -from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.conversation_mgr import ConversationManager @@ -90,7 +88,7 @@ class Context: image_urls: list[str] | None = None, tools: ToolSet | None = None, system_prompt: str | None = None, - contexts: list[Message] | list[dict] | None = None, + contexts: list[Message] | None = None, **kwargs: Any, ) -> LLMResponse: """Call the LLM to generate a response. The method will not automatically execute tool calls. If you want to use tool calls, please use `tool_loop_agent()`. @@ -132,12 +130,15 @@ class Context: image_urls: list[str] | None = None, tools: ToolSet | None = None, system_prompt: str | None = None, - contexts: list[Message] | list[dict] | None = None, + contexts: list[Message] | None = None, max_steps: int = 30, tool_call_timeout: int = 60, **kwargs: Any, ) -> LLMResponse: """Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced. + If you do not pass the agent_context parameter, the method will recreate a new agent context. + + .. versionadded:: 4.5.7 (sdk) Args: chat_provider_id: The chat provider ID to use. @@ -147,7 +148,9 @@ class Context: system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context contexts: context messages for the LLM max_steps: Maximum number of tool calls before stopping the loop - **kwargs: Additional keyword arguments for LLM generation, OpenAI compatible + **kwargs: Additional keyword arguments. The kwargs will not be passed to the LLM directly for now, but can include: + agent_hooks: BaseAgentRunHooks[AstrAgentContext] - hooks to run during agent execution + agent_context: AstrAgentContext - context to use for the agent Returns: The final LLMResponse after tool calls are completed. @@ -156,10 +159,20 @@ class Context: ChatProviderNotFoundError: If the specified chat provider ID is not found Exception: For other errors during LLM generation """ + # Import here to avoid circular imports + from astrbot.core.astr_agent_context import ( + AgentContextWrapper, + AstrAgentContext, + ) + from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor + prov = await self.provider_manager.get_provider_by_id(chat_provider_id) if not prov or not isinstance(prov, Provider): raise ProviderNotFoundError(f"Provider {chat_provider_id} not found") + agent_hooks = kwargs.get("agent_hooks") or BaseAgentRunHooks[AstrAgentContext]() + agent_context = kwargs.get("agent_context") + context_ = [] for msg in contexts or []: if isinstance(msg, Message): @@ -174,23 +187,22 @@ class Context: contexts=context_, system_prompt=system_prompt or "", ) - astr_agent_ctx = AstrAgentContext( - provider=prov, - event=event, - ) + if agent_context is None: + agent_context = AstrAgentContext( + context=self, + event=event, + ) agent_runner = ToolLoopAgentRunner() tool_executor = FunctionToolExecutor() await agent_runner.reset( provider=prov, request=request, run_context=AgentContextWrapper( - context=astr_agent_ctx, + context=agent_context, tool_call_timeout=tool_call_timeout, ), tool_executor=tool_executor, - agent_hooks=kwargs.get( - "agent_hooks", BaseAgentRunHooks[AstrAgentContext]() - ), + agent_hooks=agent_hooks, streaming=kwargs.get("stream", False), ) async for _ in agent_runner.step_until_done(max_steps): From b062e83d540481a535cae6eacd45b9ecd99c22a4 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:39:49 +0800 Subject: [PATCH 4/8] refactor: remove redundant session lock management from message sending logic in RespondStage (#3645) fixes: #3644 Co-authored-by: Dt8333 --- astrbot/core/pipeline/respond/stage.py | 30 ++++++++++++-------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 86946d023..760649563 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -10,7 +10,6 @@ from astrbot.core.message.message_event_result import MessageChain, ResultConten from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star_handler import EventType from astrbot.core.utils.path_util import path_Mapping -from astrbot.core.utils.session_lock import session_lock_manager from ..context import PipelineContext, call_event_hook from ..stage import Stage, register_stage @@ -221,21 +220,20 @@ class RespondStage(Stage): f"实际消息链为空, 跳过发送阶段。header_chain: {header_comps}, actual_chain: {result.chain}", ) return - async with session_lock_manager.acquire_lock(event.unified_msg_origin): - for comp in result.chain: - i = await self._calc_comp_interval(comp) - await asyncio.sleep(i) - try: - if comp.type in need_separately: - await event.send(MessageChain([comp])) - else: - await event.send(MessageChain([*header_comps, comp])) - header_comps.clear() - except Exception as e: - logger.error( - f"发送消息链失败: chain = {MessageChain([comp])}, error = {e}", - exc_info=True, - ) + for comp in result.chain: + i = await self._calc_comp_interval(comp) + await asyncio.sleep(i) + try: + if comp.type in need_separately: + await event.send(MessageChain([comp])) + else: + await event.send(MessageChain([*header_comps, comp])) + header_comps.clear() + except Exception as e: + logger.error( + f"发送消息链失败: chain = {MessageChain([comp])}, error = {e}", + exc_info=True, + ) else: if all( comp.type in {ComponentType.Reply, ComponentType.At} From 08ec787491ae44d5c217299ea79eb05ba69b5a84 Mon Sep 17 00:00:00 2001 From: Dt8333 <25431943+Dt8333@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:31:03 +0800 Subject: [PATCH 5/8] fix(core.platform): make DingTalk user-ID compliant with UMO (#3634) --- .../sources/dingtalk/dingtalk_adapter.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 3ffb71493..18be49a1b 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -76,6 +76,14 @@ class DingtalkPlatformAdapter(Platform): ) self.client_ = client # 用于 websockets 的 client + def _id_to_sid(self, dingtalk_id: str | None) -> str | None: + if not dingtalk_id: + return dingtalk_id + prefix = "$:LWCP_v1:$" + if dingtalk_id.startswith(prefix): + return dingtalk_id[len(prefix) :] + return dingtalk_id + async def send_by_session( self, session: MessageSesion, @@ -105,10 +113,10 @@ class DingtalkPlatformAdapter(Platform): else MessageType.FRIEND_MESSAGE ) abm.sender = MessageMember( - user_id=message.sender_id, + user_id=self._id_to_sid(message.sender_id), nickname=message.sender_nick, ) - abm.self_id = message.chatbot_user_id + abm.self_id = self._id_to_sid(message.chatbot_user_id) abm.message_id = message.message_id abm.raw_message = message @@ -116,8 +124,8 @@ class DingtalkPlatformAdapter(Platform): # 处理所有被 @ 的用户(包括机器人自己,因 at_users 已包含) if message.at_users: for user in message.at_users: - if user.dingtalk_id: - abm.message.append(At(qq=user.dingtalk_id)) + if id := self._id_to_sid(user.dingtalk_id): + abm.message.append(At(qq=id)) abm.group_id = message.conversation_id if self.unique_session: abm.session_id = abm.sender.user_id From 824af5eeead63fafaff9e74e96ef873a47fbb8db Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:01:51 +0800 Subject: [PATCH 6/8] fix: Provider.meta() error (#3647) fixes: #3643 --- astrbot/core/provider/entities.py | 28 +++++++++++++++++++--------- astrbot/core/provider/provider.py | 12 +++++++++--- astrbot/core/provider/register.py | 1 + 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 0a0b8d405..811d94f75 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -30,21 +30,31 @@ class ProviderType(enum.Enum): @dataclass -class ProviderMetaData: +class ProviderMeta: + """The basic metadata of a provider instance.""" + id: str - """提供商适配器 ID""" + """the unique id of the provider instance that user configured""" + model: str | None + """the model name of the provider instance currently used""" type: str - """提供商适配器名称,如 openai, ollama""" - desc: str = "" - """提供商适配器描述""" + """the name of the provider adapter, such as openai, ollama""" provider_type: ProviderType = ProviderType.CHAT_COMPLETION - """提供商类型""" + """the capability type of the provider adapter""" + + +@dataclass +class ProviderMetaData(ProviderMeta): + """The metadata of a provider adapter for registration.""" + + desc: str = "" + """the short description of the provider adapter""" cls_type: Any = None - """提供商适配器类类型""" + """the class type of the provider adapter""" default_config_tmpl: dict | None = None - """平台的默认配置模板""" + """the default configuration template of the provider adapter""" provider_display_name: str | None = None - """显示在 WebUI 配置页中的提供商名称,如空则是 type""" + """the display name of the provider shown in the WebUI configuration page; if empty, the type is used""" @dataclass diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index cd1781fb6..00ac0f14c 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -7,7 +7,7 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.db.po import Personality from astrbot.core.provider.entities import ( LLMResponse, - ProviderMetaData, + ProviderMeta, RerankResult, ToolCallsResult, ) @@ -30,13 +30,19 @@ class AbstractProvider(abc.ABC): """Get the current model name""" return self.model_name - def meta(self) -> ProviderMetaData: + def meta(self) -> ProviderMeta: """Get the provider metadata""" provider_type_name = self.provider_config["type"] meta_data = provider_cls_map.get(provider_type_name) if not meta_data: raise ValueError(f"Provider type {provider_type_name} not registered") - return meta_data + meta = ProviderMeta( + id=self.provider_config.get("id", "default"), + model=self.get_model(), + type=provider_type_name, + provider_type=meta_data.provider_type, + ) + return meta class Provider(AbstractProvider): diff --git a/astrbot/core/provider/register.py b/astrbot/core/provider/register.py index af9870dd7..3ad83784e 100644 --- a/astrbot/core/provider/register.py +++ b/astrbot/core/provider/register.py @@ -37,6 +37,7 @@ def register_provider_adapter( pm = ProviderMetaData( id="default", # will be replaced when instantiated + model=None, type=provider_type_name, desc=desc, provider_type=provider_type, From 5a11d8f0ee0db1c7c5d8756aa83c4fb7abd74147 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:59:17 +0800 Subject: [PATCH 7/8] refactor: LLM response handling with reasoning content (#3632) * refactor: LLM response handling with reasoning content - Added a `show_reasoning` parameter to `run_agent` to control the display of reasoning content. - Updated `LLMResponse` to include a `reasoning_content` field for storing reasoning text. - Modified `WebChatMessageEvent` to handle and send reasoning content in streaming responses. - Implemented reasoning extraction in various provider sources (e.g., OpenAI, Gemini). - Updated the chat interface to display reasoning content in a collapsible format. - Removed the deprecated `thinking_filter` package and its associated logic. - Updated localization files to include new reasoning-related strings. * feat: add Groq chat completion provider and associated configurations * Update astrbot/core/provider/sources/gemini_source.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- astrbot/api/all.py | 3 +- astrbot/api/provider/__init__.py | 3 +- .../agent/runners/tool_loop_agent_runner.py | 16 +- astrbot/core/astr_agent_run_util.py | 9 +- astrbot/core/config/default.py | 17 ++ .../process_stage/method/llm_request.py | 14 +- .../platform/sources/webchat/webchat_event.py | 9 +- astrbot/core/provider/__init__.py | 4 +- astrbot/core/provider/entities.py | 19 +- astrbot/core/provider/manager.py | 3 +- astrbot/core/provider/provider.py | 6 - .../core/provider/sources/anthropic_source.py | 2 - astrbot/core/provider/sources/coze_source.py | 2 - .../core/provider/sources/dashscope_source.py | 4 +- astrbot/core/provider/sources/dify_source.py | 2 - .../core/provider/sources/gemini_source.py | 34 ++- astrbot/core/provider/sources/groq_source.py | 15 ++ .../core/provider/sources/openai_source.py | 78 +++++-- astrbot/core/provider/sources/zhipu_source.py | 7 +- astrbot/dashboard/routes/chat.py | 2 + dashboard/src/components/chat/Chat.vue | 28 +-- dashboard/src/components/chat/MessageList.vue | 97 +++++++- .../src/i18n/locales/en-US/features/chat.json | 3 + .../src/i18n/locales/zh-CN/features/chat.json | 3 + dashboard/src/utils/providerUtils.js | 1 + packages/astrbot/main.py | 13 +- packages/thinking_filter/main.py | 208 ------------------ packages/thinking_filter/metadata.yaml | 5 - 28 files changed, 307 insertions(+), 300 deletions(-) create mode 100644 astrbot/core/provider/sources/groq_source.py delete mode 100644 packages/thinking_filter/main.py delete mode 100644 packages/thinking_filter/metadata.yaml diff --git a/astrbot/api/all.py b/astrbot/api/all.py index 2463dbc2b..df3e1170f 100644 --- a/astrbot/api/all.py +++ b/astrbot/api/all.py @@ -36,7 +36,8 @@ from astrbot.core.star.config import * # provider -from astrbot.core.provider import Provider, Personality, ProviderMetaData +from astrbot.core.provider import Provider, ProviderMetaData +from astrbot.core.db.po import Personality # platform from astrbot.core.platform import ( diff --git a/astrbot/api/provider/__init__.py b/astrbot/api/provider/__init__.py index 2008c7bcf..f62b340f8 100644 --- a/astrbot/api/provider/__init__.py +++ b/astrbot/api/provider/__init__.py @@ -1,4 +1,5 @@ -from astrbot.core.provider import Personality, Provider, STTProvider +from astrbot.core.db.po import Personality +from astrbot.core.provider import Provider, STTProvider from astrbot.core.provider.entities import ( LLMResponse, ProviderMetaData, diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 744030bbc..d74a45982 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -110,13 +110,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): type="streaming_delta", data=AgentResponseData(chain=llm_response.result_chain), ) - else: + elif llm_response.completion_text: yield AgentResponse( type="streaming_delta", data=AgentResponseData( chain=MessageChain().message(llm_response.completion_text), ), ) + elif llm_response.reasoning_content: + yield AgentResponse( + type="streaming_delta", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_response.reasoning_content, + ), + ), + ) continue llm_resp_result = llm_response break # got final response @@ -177,13 +186,16 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): yield AgentResponse( type="tool_call", data=AgentResponseData( - chain=MessageChain().message(f"🔨 调用工具: {tool_call_name}"), + chain=MessageChain(type="tool_call").message( + f"🔨 调用工具: {tool_call_name}" + ), ), ) async for result in self._handle_function_tools(self.req, llm_resp): if isinstance(result, list): tool_call_result_blocks = result elif isinstance(result, MessageChain): + result.type = "tool_call_result" yield AgentResponse( type="tool_call_result", data=AgentResponseData(chain=result), diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index ed8c0028d..5deb5af4e 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -18,6 +18,7 @@ async def run_agent( max_step: int = 30, show_tool_use: bool = True, stream_to_general: bool = False, + show_reasoning: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: step_idx = 0 astr_event = agent_runner.run_context.context.event @@ -31,7 +32,6 @@ async def run_agent( msg_chain = resp.data["chain"] if msg_chain.type == "tool_direct_result": # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 - resp.data["chain"].type = "tool_call_result" await astr_event.send(resp.data["chain"]) continue # 对于其他情况,暂时先不处理 @@ -40,8 +40,7 @@ async def run_agent( if agent_runner.streaming: # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") - if show_tool_use or astr_event.get_platform_name() == "webchat": - resp.data["chain"].type = "tool_call" + if show_tool_use: await astr_event.send(resp.data["chain"]) continue @@ -63,6 +62,10 @@ async def run_agent( yield astr_event.clear_result() elif resp.type == "streaming_delta": + chain = resp.data["chain"] + if chain.type == "reasoning" and not show_reasoning: + # display the reasoning content only when configured + continue yield resp.data["chain"] # MessageChain if agent_runner.done(): break diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7cd024e70..e9b5613f4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -880,6 +880,23 @@ CONFIG_METADATA_2 = { "custom_extra_body": {}, "modalities": ["text", "tool_use"], }, + "Groq": { + "id": "groq_default", + "provider": "groq", + "type": "groq_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://api.groq.com/openai/v1", + "timeout": 120, + "model_config": { + "model": "openai/gpt-oss-20b", + "temperature": 0.4, + }, + "custom_headers": {}, + "custom_extra_body": {}, + "modalities": ["text", "tool_use"], + }, "302.AI": { "id": "302ai", "provider": "302ai", diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 76481ce56..2e7f43baf 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -57,6 +57,7 @@ class LLMRequestSubStage(Stage): if isinstance(self.max_step, bool): # workaround: #2622 self.max_step = 30 self.show_tool_use: bool = settings.get("show_tool_use_status", True) + self.show_reasoning = settings.get("display_reasoning_text", False) for bwp in self.bot_wake_prefixs: if self.provider_wake_prefix.startswith(bwp): @@ -419,7 +420,12 @@ class LLMRequestSubStage(Stage): MessageEventResult() .set_result_content_type(ResultContentType.STREAMING_RESULT) .set_async_stream( - run_agent(agent_runner, self.max_step, self.show_tool_use), + run_agent( + agent_runner, + self.max_step, + self.show_tool_use, + show_reasoning=self.show_reasoning, + ), ), ) yield @@ -443,7 +449,11 @@ class LLMRequestSubStage(Stage): ) else: async for _ in run_agent( - agent_runner, self.max_step, self.show_tool_use, stream_to_general + agent_runner, + self.max_step, + self.show_tool_use, + stream_to_general, + show_reasoning=self.show_reasoning, ): yield diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 4d4d3b59e..4ced79b19 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -109,6 +109,7 @@ class WebChatMessageEvent(AstrMessageEvent): async def send_streaming(self, generator, use_fallback: bool = False): final_data = "" + reasoning_content = "" cid = self.session_id.split("!")[-1] web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) async for chain in generator: @@ -124,16 +125,22 @@ class WebChatMessageEvent(AstrMessageEvent): ) final_data = "" continue - final_data += await WebChatMessageEvent._send( + + r = await WebChatMessageEvent._send( chain, session_id=self.session_id, streaming=True, ) + if chain.type == "reasoning": + reasoning_content += chain.get_plain_text() + else: + final_data += r await web_chat_back_queue.put( { "type": "complete", # complete means we return the final result "data": final_data, + "reasoning": reasoning_content, "streaming": True, "cid": cid, }, diff --git a/astrbot/core/provider/__init__.py b/astrbot/core/provider/__init__.py index abbe08234..812e02171 100644 --- a/astrbot/core/provider/__init__.py +++ b/astrbot/core/provider/__init__.py @@ -1,4 +1,4 @@ from .entities import ProviderMetaData -from .provider import Personality, Provider, STTProvider +from .provider import Provider, STTProvider -__all__ = ["Personality", "Provider", "ProviderMetaData", "STTProvider"] +__all__ = ["Provider", "ProviderMetaData", "STTProvider"] diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 811d94f75..c6978e7b9 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -202,25 +202,28 @@ class ProviderRequest: @dataclass class LLMResponse: role: str - """角色, assistant, tool, err""" + """The role of the message, e.g., assistant, tool, err""" result_chain: MessageChain | None = None - """返回的消息链""" + """A chain of message components representing the text completion from LLM.""" tools_call_args: list[dict[str, Any]] = field(default_factory=list) - """工具调用参数""" + """Tool call arguments.""" tools_call_name: list[str] = field(default_factory=list) - """工具调用名称""" + """Tool call names.""" tools_call_ids: list[str] = field(default_factory=list) - """工具调用 ID""" + """Tool call IDs.""" + reasoning_content: str = "" + """The reasoning content extracted from the LLM, if any.""" raw_completion: ( ChatCompletion | GenerateContentResponse | AnthropicMessage | None ) = None - _new_record: dict[str, Any] | None = None + """The raw completion response from the LLM provider.""" _completion_text: str = "" + """The plain text of the completion.""" is_chunk: bool = False - """是否是流式输出的单个 Chunk""" + """Indicates if the response is a chunked response.""" def __init__( self, @@ -234,7 +237,6 @@ class LLMResponse: | GenerateContentResponse | AnthropicMessage | None = None, - _new_record: dict[str, Any] | None = None, is_chunk: bool = False, ): """初始化 LLMResponse @@ -262,7 +264,6 @@ class LLMResponse: self.tools_call_name = tools_call_name self.tools_call_ids = tools_call_ids self.raw_completion = raw_completion - self._new_record = _new_record self.is_chunk = is_chunk @property diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 0984138ae..320c98d4e 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -241,6 +241,8 @@ class ProviderManager: ) case "zhipu_chat_completion": from .sources.zhipu_source import ProviderZhipu as ProviderZhipu + case "groq_chat_completion": + from .sources.groq_source import ProviderGroq as ProviderGroq case "anthropic_chat_completion": from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, @@ -396,7 +398,6 @@ class ProviderManager: inst = cls_type( provider_config, self.provider_settings, - self.selected_default_persona, ) if getattr(inst, "initialize", None): diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 00ac0f14c..3ae5f2bd9 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -4,7 +4,6 @@ from collections.abc import AsyncGenerator from astrbot.core.agent.message import Message from astrbot.core.agent.tool import ToolSet -from astrbot.core.db.po import Personality from astrbot.core.provider.entities import ( LLMResponse, ProviderMeta, @@ -52,15 +51,10 @@ class Provider(AbstractProvider): self, provider_config: dict, provider_settings: dict, - default_persona: Personality | None = None, ) -> None: super().__init__(provider_config) - self.provider_settings = provider_settings - self.curr_personality = default_persona - """维护了当前的使用的 persona,即人格。可能为 None""" - @abc.abstractmethod def get_current_key(self) -> str: raise NotImplementedError diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 77c85cef4..f05d205c7 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -25,12 +25,10 @@ class ProviderAnthropic(Provider): self, provider_config, provider_settings, - default_persona=None, ) -> None: super().__init__( provider_config, provider_settings, - default_persona, ) self.chosen_api_key: str = "" diff --git a/astrbot/core/provider/sources/coze_source.py b/astrbot/core/provider/sources/coze_source.py index 23a8b3b76..6f1355bf7 100644 --- a/astrbot/core/provider/sources/coze_source.py +++ b/astrbot/core/provider/sources/coze_source.py @@ -20,12 +20,10 @@ class ProviderCoze(Provider): self, provider_config, provider_settings, - default_persona=None, ) -> None: super().__init__( provider_config, provider_settings, - default_persona, ) self.api_key = provider_config.get("coze_api_key", "") if not self.api_key: diff --git a/astrbot/core/provider/sources/dashscope_source.py b/astrbot/core/provider/sources/dashscope_source.py index 9b262c001..7c690e048 100644 --- a/astrbot/core/provider/sources/dashscope_source.py +++ b/astrbot/core/provider/sources/dashscope_source.py @@ -8,7 +8,7 @@ from dashscope.app.application_response import ApplicationResponse from astrbot.core import logger, sp from astrbot.core.message.message_event_result import MessageChain -from .. import Personality, Provider +from .. import Provider from ..entities import LLMResponse from ..register import register_provider_adapter from .openai_source import ProviderOpenAIOfficial @@ -20,13 +20,11 @@ class ProviderDashscope(ProviderOpenAIOfficial): self, provider_config: dict, provider_settings: dict, - default_persona: Personality | None = None, ) -> None: Provider.__init__( self, provider_config, provider_settings, - default_persona, ) self.api_key = provider_config.get("dashscope_api_key", "") if not self.api_key: diff --git a/astrbot/core/provider/sources/dify_source.py b/astrbot/core/provider/sources/dify_source.py index 9f9f146aa..7850a982c 100644 --- a/astrbot/core/provider/sources/dify_source.py +++ b/astrbot/core/provider/sources/dify_source.py @@ -18,12 +18,10 @@ class ProviderDify(Provider): self, provider_config, provider_settings, - default_persona=None, ) -> None: super().__init__( provider_config, provider_settings, - default_persona, ) self.api_key = provider_config.get("dify_api_key", "") if not self.api_key: diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index c3c9253a5..b9159eec9 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -53,12 +53,10 @@ class ProviderGoogleGenAI(Provider): self, provider_config, provider_settings, - default_persona=None, ) -> None: super().__init__( provider_config, provider_settings, - default_persona, ) self.api_keys: list = super().get_keys() self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else "" @@ -326,8 +324,18 @@ class ProviderGoogleGenAI(Provider): return gemini_contents - @staticmethod + def _extract_reasoning_content(self, candidate: types.Candidate) -> str: + """Extract reasoning content from candidate parts""" + if not candidate.content or not candidate.content.parts: + return "" + + thought_buf: list[str] = [ + (p.text or "") for p in candidate.content.parts if p.thought + ] + return "".join(thought_buf).strip() + def _process_content_parts( + self, candidate: types.Candidate, llm_response: LLMResponse, ) -> MessageChain: @@ -358,6 +366,11 @@ class ProviderGoogleGenAI(Provider): logger.warning(f"收到的 candidate.content.parts 为空: {candidate}") raise Exception("API 返回的 candidate.content.parts 为空。") + # 提取 reasoning content + reasoning = self._extract_reasoning_content(candidate) + if reasoning: + llm_response.reasoning_content = reasoning + chain = [] part: types.Part @@ -515,6 +528,7 @@ class ProviderGoogleGenAI(Provider): # Accumulate the complete response text for the final response accumulated_text = "" + accumulated_reasoning = "" final_response = None async for chunk in result: @@ -539,9 +553,19 @@ class ProviderGoogleGenAI(Provider): yield llm_response return + _f = False + + # 提取 reasoning content + reasoning = self._extract_reasoning_content(chunk.candidates[0]) + if reasoning: + _f = True + accumulated_reasoning += reasoning + llm_response.reasoning_content = reasoning if chunk.text: + _f = True accumulated_text += chunk.text llm_response.result_chain = MessageChain(chain=[Comp.Plain(chunk.text)]) + if _f: yield llm_response if chunk.candidates[0].finish_reason: @@ -559,6 +583,10 @@ class ProviderGoogleGenAI(Provider): if not final_response: final_response = LLMResponse("assistant", is_chunk=False) + # Set the complete accumulated reasoning in the final response + if accumulated_reasoning: + final_response.reasoning_content = accumulated_reasoning + # Set the complete accumulated text in the final response if accumulated_text: final_response.result_chain = MessageChain( diff --git a/astrbot/core/provider/sources/groq_source.py b/astrbot/core/provider/sources/groq_source.py new file mode 100644 index 000000000..fcc8f238f --- /dev/null +++ b/astrbot/core/provider/sources/groq_source.py @@ -0,0 +1,15 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "groq_chat_completion", "Groq Chat Completion Provider Adapter" +) +class ProviderGroq(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.reasoning_key = "reasoning" diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 823287b6f..87e2eeaab 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -4,12 +4,14 @@ import inspect import json import os import random +import re from collections.abc import AsyncGenerator from openai import AsyncAzureOpenAI, AsyncOpenAI from openai._exceptions import NotFoundError, UnprocessableEntityError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk import astrbot.core.message.components as Comp from astrbot import logger @@ -28,17 +30,8 @@ from ..register import register_provider_adapter "OpenAI API Chat Completion 提供商适配器", ) class ProviderOpenAIOfficial(Provider): - def __init__( - self, - provider_config, - provider_settings, - default_persona=None, - ) -> None: - super().__init__( - provider_config, - provider_settings, - default_persona, - ) + def __init__(self, provider_config, provider_settings) -> None: + super().__init__(provider_config, provider_settings) self.chosen_api_key = None self.api_keys: list = super().get_keys() self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None @@ -53,9 +46,8 @@ class ProviderOpenAIOfficial(Provider): for key in self.custom_headers: self.custom_headers[key] = str(self.custom_headers[key]) - # 适配 azure openai #332 if "api_version" in provider_config: - # 使用 azure api + # Using Azure OpenAI API self.client = AsyncAzureOpenAI( api_key=self.chosen_api_key, api_version=provider_config.get("api_version", None), @@ -64,7 +56,7 @@ class ProviderOpenAIOfficial(Provider): timeout=self.timeout, ) else: - # 使用 openai api + # Using OpenAI Official API self.client = AsyncOpenAI( api_key=self.chosen_api_key, base_url=provider_config.get("api_base", None), @@ -80,6 +72,8 @@ class ProviderOpenAIOfficial(Provider): model = model_config.get("model", "unknown") self.set_model(model) + self.reasoning_key = "reasoning_content" + def _maybe_inject_xai_search(self, payloads: dict, **kwargs): """当开启 xAI 原生搜索时,向请求体注入 Live Search 参数。 @@ -157,7 +151,7 @@ class ProviderOpenAIOfficial(Provider): logger.debug(f"completion: {completion}") - llm_response = await self.parse_openai_completion(completion, tools) + llm_response = await self._parse_openai_completion(completion, tools) return llm_response @@ -210,36 +204,78 @@ class ProviderOpenAIOfficial(Provider): if len(chunk.choices) == 0: continue delta = chunk.choices[0].delta - # 处理文本内容 + logger.debug(f"chunk delta: {delta}") + # handle the content delta + reasoning = self._extract_reasoning_content(chunk) + _y = False + if reasoning: + llm_response.reasoning_content = reasoning + _y = True if delta.content: completion_text = delta.content llm_response.result_chain = MessageChain( chain=[Comp.Plain(completion_text)], ) + _y = True + if _y: yield llm_response final_completion = state.get_final_completion() - llm_response = await self.parse_openai_completion(final_completion, tools) + llm_response = await self._parse_openai_completion(final_completion, tools) yield llm_response - async def parse_openai_completion( + def _extract_reasoning_content( + self, + completion: ChatCompletion | ChatCompletionChunk, + ) -> str: + """Extract reasoning content from OpenAI ChatCompletion if available.""" + reasoning_text = "" + if len(completion.choices) == 0: + return reasoning_text + if isinstance(completion, ChatCompletion): + choice = completion.choices[0] + reasoning_attr = getattr(choice.message, self.reasoning_key, None) + if reasoning_attr: + reasoning_text = str(reasoning_attr) + elif isinstance(completion, ChatCompletionChunk): + delta = completion.choices[0].delta + reasoning_attr = getattr(delta, self.reasoning_key, None) + if reasoning_attr: + reasoning_text = str(reasoning_attr) + return reasoning_text + + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: - """解析 OpenAI 的 ChatCompletion 响应""" + """Parse OpenAI ChatCompletion into LLMResponse""" llm_response = LLMResponse("assistant") if len(completion.choices) == 0: raise Exception("API 返回的 completion 为空。") choice = completion.choices[0] + # parse the text completion if choice.message.content is not None: # text completion completion_text = str(choice.message.content).strip() + # specially, some providers may set tags around reasoning content in the completion text, + # we use regex to remove them, and store then in reasoning_content field + reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) + matches = reasoning_pattern.findall(completion_text) + if matches: + llm_response.reasoning_content = "\n".join( + [match.strip() for match in matches], + ) + completion_text = reasoning_pattern.sub("", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) + # parse the reasoning content if any + # the priority is higher than the tag extraction + llm_response.reasoning_content = self._extract_reasoning_content(completion) + + # parse tool calls if any if choice.message.tool_calls and tools is not None: - # tools call (function calling) args_ls = [] func_name_ls = [] tool_call_ids = [] @@ -265,11 +301,11 @@ class ProviderOpenAIOfficial(Provider): llm_response.tools_call_name = func_name_ls llm_response.tools_call_ids = tool_call_ids + # specially handle finish reason if choice.finish_reason == "content_filter": raise Exception( "API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。", ) - if llm_response.completion_text is None and not llm_response.tools_call_args: logger.error(f"API 返回的 completion 无法解析:{completion}。") raise Exception(f"API 返回的 completion 无法解析:{completion}。") diff --git a/astrbot/core/provider/sources/zhipu_source.py b/astrbot/core/provider/sources/zhipu_source.py index e7b6ee4f4..ed4bc0bf8 100644 --- a/astrbot/core/provider/sources/zhipu_source.py +++ b/astrbot/core/provider/sources/zhipu_source.py @@ -12,10 +12,5 @@ class ProviderZhipu(ProviderOpenAIOfficial): self, provider_config: dict, provider_settings: dict, - default_persona=None, ) -> None: - super().__init__( - provider_config, - provider_settings, - default_persona, - ) + super().__init__(provider_config, provider_settings) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index d7afcbc17..56946550a 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -204,6 +204,8 @@ class ChatRoute(Route): ): # 追加机器人消息 new_his = {"type": "bot", "message": result_text} + if "reasoning" in result: + new_his["reasoning"] = result["reasoning"] await self.platform_history_mgr.insert( platform_id="webchat", user_id=webchat_conv_id, diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 26c7df563..d671b15b7 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -146,21 +146,6 @@ Hello, I'm AstrBot ⭐ -
- {{ t('core.common.type') }} - help - {{ tm('shortcuts.help') }} 😊 -
-
- {{ t('core.common.longPress') }} - Ctrl + B - {{ tm('shortcuts.voiceRecord') }} 🎤 -
-
- {{ t('core.common.press') }} - Ctrl + V - {{ tm('shortcuts.pasteImage') }} 🏞️ -
@@ -1031,17 +1016,26 @@ export default { "content": bot_resp }); } else if (chunk_json.type === 'plain') { + const chain_type = chunk_json.chain_type || 'normal'; + if (!in_streaming) { message_obj = { type: 'bot', - message: this.ref(chunk_json.data), + message: this.ref(chain_type === 'reasoning' ? '' : chunk_json.data), + reasoning: this.ref(chain_type === 'reasoning' ? chunk_json.data : ''), } this.messages.push({ "content": message_obj }); in_streaming = true; } else { - message_obj.message.value += chunk_json.data; + if (chain_type === 'reasoning') { + // Append to reasoning content + message_obj.reasoning.value += chunk_json.data; + } else { + // Append to normal message + message_obj.message.value += chunk_json.data; + } } } else if (chunk_json.type === 'update_title') { // 更新对话标题 diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 7ab592497..9cd69241f 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -37,6 +37,19 @@
+ +
+
+ + {{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }} + + {{ tm('reasoning.thinking') }} +
+
+
+
+
+
@@ -125,7 +138,8 @@ export default { copiedMessages: new Set(), isUserNearBottom: true, scrollThreshold: 1, - scrollTimer: null + scrollTimer: null, + expandedReasoning: new Set(), // Track which reasoning blocks are expanded }; }, mounted() { @@ -142,6 +156,22 @@ export default { } }, methods: { + // Toggle reasoning expansion state + toggleReasoning(messageIndex) { + if (this.expandedReasoning.has(messageIndex)) { + this.expandedReasoning.delete(messageIndex); + } else { + this.expandedReasoning.add(messageIndex); + } + // Force reactivity + this.expandedReasoning = new Set(this.expandedReasoning); + }, + + // Check if reasoning is expanded + isReasoningExpanded(messageIndex) { + return this.expandedReasoning.has(messageIndex); + }, + // 复制代码到剪贴板 copyCodeToClipboard(code) { navigator.clipboard.writeText(code).then(() => { @@ -348,7 +378,7 @@ export default { @keyframes fadeIn { from { opacity: 0; - transform: translateY(10px); + transform: translateY(0); } to { @@ -539,6 +569,69 @@ export default { .fade-in { animation: fadeIn 0.3s ease-in-out; } + +/* Reasoning 区块样式 */ +.reasoning-container { + margin-bottom: 12px; + margin-top: 6px; + border: 1px solid var(--v-theme-border); + border-radius: 8px; + overflow: hidden; + width: fit-content; +} + +.v-theme--dark .reasoning-container { + background-color: rgba(103, 58, 183, 0.08); +} + +.reasoning-header { + display: inline-flex; + align-items: center; + padding: 8px 8px; + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; + border-radius: 8px; +} + +.reasoning-header:hover { + background-color: rgba(103, 58, 183, 0.08); +} + +.v-theme--dark .reasoning-header:hover { + background-color: rgba(103, 58, 183, 0.15); +} + +.reasoning-icon { + margin-right: 6px; + color: var(--v-theme-secondary); + transition: transform 0.2s ease; +} + +.reasoning-label { + font-size: 13px; + font-weight: 500; + color: var(--v-theme-secondary); + letter-spacing: 0.3px; +} + +.reasoning-content { + padding: 0px 12px; + border-top: 1px solid var(--v-theme-border); + color: gray; + animation: fadeIn 0.2s ease-in-out; + font-style: italic; +} + +.reasoning-text { + font-size: 14px; + line-height: 1.6; + color: var(--v-theme-secondaryText); +} + +.v-theme--dark .reasoning-text { + opacity: 0.85; +}