Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"Stop calling tools, and based on the information you have gathered, "
"summarize your task and findings, and reply to the user directly."
)
# Class-level default so the read-only property is safe before reset().
_reached_max_steps: bool = False
SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
"You have decided to call tool(s): {tool_names}. Now call the tool(s) "
"with required arguments using the tool schema, and follow the existing "
Expand Down Expand Up @@ -233,6 +235,7 @@ async def reset(
) -> None:
self.req = request
self.streaming = streaming
self._reached_max_steps = False
self.enforce_max_turns = enforce_max_turns
self.llm_compress_instruction = llm_compress_instruction
self.llm_compress_keep_recent_ratio = llm_compress_keep_recent_ratio
Expand Down Expand Up @@ -1068,6 +1071,16 @@ async def step(self):

self.req.append_tool_calls_result(tool_calls_result)

@property
def reached_max_steps(self) -> bool:
"""Whether this run ended via the forced final response at max steps.

Only meaningful after ``step_until_done`` has run; a forced wrap-up
removes all tools, so any final text the model produced there could
not be delivered through ``send_message_to_user``.
"""
return self._reached_max_steps

async def step_until_done(
self, max_step: int
) -> T.AsyncGenerator[AgentResponse, None]:
Expand All @@ -1083,6 +1096,7 @@ async def step_until_done(
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
self._reached_max_steps = True
# 拔掉所有工具
if self.req:
self.req.func_tool = None
Expand Down
27 changes: 27 additions & 0 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,9 @@ async def _wake_main_agent_for_background_result(
_get_session_conv,
build_main_agent,
)
from astrbot.core.tools.message_tools import (
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY,
)

event = run_context.context.event
ctx = run_context.context.context
Expand Down Expand Up @@ -639,6 +642,30 @@ async def _wake_main_agent_for_background_result(
logger.warning("background task agent got no response")
return

final_text = (llm_resp.completion_text or "").strip()
if (
getattr(runner, "reached_max_steps", False)
and llm_resp.role == "assistant"
and final_text
):
# Same delivery gap as the cron path (#9980), and same narrowing:
# only the forced wrap-up at max steps removed every tool, so only
# there the final text could not have been sent via
# send_message_to_user. On normal completion the model may
# intentionally stay silent. Skip when this exact text was already
# delivered earlier in the same run.
already_sent = cron_event.get_extra(
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY, []
)
if final_text not in already_sent:
try:
await cron_event.send(MessageChain().message(final_text))
except Exception as e: # noqa: BLE001
logger.warning(
f"Failed to deliver background task final response: {e}",
exc_info=True,
)

@classmethod
async def _execute_local(
cls,
Expand Down
40 changes: 39 additions & 1 deletion astrbot/core/cron/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import CronJob
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entites import ProviderRequest
Expand Down Expand Up @@ -412,7 +413,10 @@ async def _woke_main_agent(
from astrbot.core.astr_main_agent_resources import (
PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT,
)
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.message_tools import (
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY,
SendMessageToUserTool,
)

try:
session = (
Expand Down Expand Up @@ -539,5 +543,39 @@ async def _woke_main_agent(
logger.warning("Cron job agent got no response")
return

final_text = (llm_resp.completion_text or "").strip()
if (
getattr(runner, "reached_max_steps", False)
and llm_resp.role == "assistant"
and final_text
and delivery_session_str
):
# Deliver ONLY on the forced wrap-up at max steps: there all tools
# were removed, so the model had no channel to reach the user even
# if it wanted to (#9980). On normal completion the model still has
# send_message_to_user available and may intentionally stay silent
# (e.g. conditional notify-only jobs), so the framework must not
# decide for it. Skip when the model already delivered this exact
# text earlier in the same run.
already_sent = cron_event.get_extra(
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY, []
)
if final_text not in already_sent:
try:
sent = await self.ctx.send_message(
delivery_session_str,
MessageChain().message(final_text),
)
if not sent:
logger.warning(
"Failed to deliver cron agent final response: "
f"no platform found for session {delivery_session_str}"
)
except Exception as e: # noqa: BLE001
logger.warning(
f"Failed to deliver cron agent final response: {e}",
exc_info=True,
)


__all__ = ["CronJobManager"]
13 changes: 11 additions & 2 deletions astrbot/core/tools/message_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool:
return any(path == root or path.is_relative_to(root) for root in roots)


# Extra key on the event recording plain texts already delivered to the current
# session via send_message_to_user during this agent run. Proactive callers
# (cron jobs, background task wakeups) read it to avoid re-sending the final
# assistant text the model already delivered through the tool.
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY = (
"_send_message_to_user_current_session_plain_texts"
)


def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool:
if not is_local_runtime(context):
return False
Expand Down Expand Up @@ -349,14 +358,14 @@ async def call(
sent_plain_text = message_chain.get_plain_text().strip()
if sent_plain_text:
sent_plain_texts = context.context.event.get_extra(
"_send_message_to_user_current_session_plain_texts",
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY,
[],
)
if not isinstance(sent_plain_texts, list):
sent_plain_texts = []
sent_plain_texts.append(sent_plain_text)
context.context.event.set_extra(
"_send_message_to_user_current_session_plain_texts",
SENT_TO_CURRENT_SESSION_PLAIN_TEXTS_EXTRA_KEY,
sent_plain_texts,
)
return f"Message sent to session {target_session}"
Expand Down
46 changes: 46 additions & 0 deletions tests/test_tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,52 @@ async def test_normal_completion_without_max_step(
assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用"


@pytest.mark.asyncio
@pytest.mark.parametrize(
("max_calls_before_normal_response", "max_steps", "expected_reached"),
[
pytest.param(100, 3, True, id="forced_wrap_up_sets_flag"),
pytest.param(2, 10, False, id="normal_completion_keeps_flag_false"),
],
)
async def test_reached_max_steps_flag(
runner,
mock_provider,
provider_request,
mock_tool_executor,
mock_hooks,
max_calls_before_normal_response,
max_steps,
expected_reached,
):
"""reached_max_steps 只在强制收尾分支置位,正常完成保持 False。

下游(cron / 后台唤醒)依赖该标记决定是否补投最终文本:正常结束时
模型仍有 send_message_to_user 可用,可能有意保持静默。
"""
assert runner.reached_max_steps is False

mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = max_calls_before_normal_response

await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)

async for _ in runner.step_until_done(max_steps):
pass

assert runner.done()
assert runner.reached_max_steps is expected_reached
# 标记与"工具被拔掉"这一强制收尾特征严格一致
assert (runner.req.func_tool is None) is expected_reached


@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_separate_latest_context_from_cumulative_usage(
Expand Down
Loading
Loading