Skip to content

OnLLMResponseEvent is not dispatched when the response also requests a tool call (but the text is still delivered) #10770

Open

Description

@JosephTian876

Version: AstrBot 4.29.1 (verified against the installed tree; paths below are repo-relative)
Type: framework defect (silent hook skip), no crash
Impact: every plugin that sanitises/rewrites outbound text in @filter.on_llm_response() — the hook
silently never runs for tool-call steps, while the same text is delivered to the chat.

Related: #7899 reports on_decorating_result / on_llm_response "never fire" on 4.23.5. This issue is
not a duplicate: it pins one concrete, code-level cause (not a registration/signature problem) with
exact lines, a minimal repro and a fix that changes no delivery behaviour. Please treat it as
corroborating evidence for #7899.


Summary

In the native tool loop, a response that requests tool calls never reaches
_complete_with_assistant_response(), which is the only place that calls
agent_hooks.on_agent_done() — and MainAgentHooks.on_agent_done() is what dispatches
OnLLMResponseEvent. The very same response's text is still yielded as llm_result and delivered.

# astrbot/core/agent/runners/tool_loop_agent_runner.py
924:        if not llm_resp.tools_call_name:
925:            await self._complete_with_assistant_response(llm_resp)
...
937:        if llm_resp.result_chain:
938:            yield AgentResponse(
939:                type="llm_result",
940:                data=AgentResponseData(chain=llm_resp.result_chain),
941:            )
942:        elif llm_resp.completion_text:
943:            yield AgentResponse(
944:                type="llm_result",
945:                data=AgentResponseData(
946:                    chain=MessageChain().message(llm_resp.completion_text),
947:                ),
948:            )
  • _complete_with_assistant_response() — tool_loop_agent_runner.py:183-207 — is the only caller of
    self.agent_hooks.on_agent_done(...) in the normal path (:204; the interrupted path is :1594).
  • MainAgentHooks.on_agent_done() — astrbot/core/astr_agent_hooks.py:23-41 — dispatches
    EventType.OnLLMResponseEvent (:31-35) and also sets the
    _llm_reasoning_content extra used by result-decorate (:25-29).
  • Delivery is not skipped: astrbot/core/astr_agent_run_util.py:246-251 takes the llm_result
    chain and :274-283 yields it to the respond stage unless buffering is enabled
    (_should_buffer_llm_result() :90-99; default buffer_intermediate_messages = False at
    astrbot/core/config/default.py:163).

So for "text + tool call in one response": text goes out, OnLLMResponseEvent never fires.

Minimal reproduction (derived from the code path, not executed against a stock 4.29.1)

  1. Install a throwaway plugin:
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star, register

@register("llm_response_probe", "probe", "probe", "0.0.1")
class Probe(Star):
    def __init__(self, context: Context):
        super().__init__(context)

    @filter.on_llm_response()
    async def on_llm_resp(self, event: AstrMessageEvent, resp):
        self.context.logger.info("[probe] on_llm_response fired: %s", getattr(resp, "completion_text", None))
  1. Enable one tool the model will call (e.g. a web search or any @filter.llm_tool).
  2. Send a prompt whose answer needs the tool, so the first assistant response contains
    prose + a tool call.
  3. Observe: the intermediate prose is delivered to the chat, but [probe] on_llm_response fired is
    not logged for that response (it only fires for the final, tool-free reply).

Real-world evidence from a consumer plugin (private-companion 6.7.0, AstrBot 4.29.1) — the
tool-call step and the leaked outbound text belong to the same turn; the intermediate text was
delivered while no response hook ran:

[02:06:25.996] [Core] [INFO] [runners.tool_loop_agent_runner:1185]: Agent 使用工具: ['send_payment_qr']
[02:06:25.997] [Core] [INFO] [runners.tool_loop_agent_runner:1241]: 使用工具:send_payment_qr,参数:{}
[02:06:26.952] [plugin] [INFO]: [TTS增强] TTS 分块后台补发完成: …扫吧<pc_reaction_expression>{"purpose":"轻吐槽",…
[02:06:28.304] [Core] [INFO] [runners.tool_loop_agent_runner:1438]: Tool `send_payment_qr` Result: …

The repro steps above are derived from the verified code path; I did not run the throwaway probe
against a stock 4.29.1 instance (the production instance cannot be restarted for a probe). I can
capture a trace with the probe on request.

Expected behaviour

Dispatch OnLLMResponseEvent for every LLM response, including the ones that also request tools,
and keep delivery semantics byte-for-byte identical (no buffering change, no extra sends, no ordering
change). Minimal, surgical sketch — dispatch right before the llm_result yields:

        # tool_loop_agent_runner.py, before "if llm_resp.reasoning_content:" (~:927)
        await call_event_hook(
            self.run_context.context.event,
            EventType.OnLLMResponseEvent,
            llm_resp,
        )

Notes for the maintainer:

  • on_agent_done() is not the right place to call here — it also finalises the step
    (final_llm_resp, AgentState.DONE, assistant message append, follow-up resolution), so reusing it
    would change run state. Dispatching the event directly (as sketched) leaves state untouched.
  • This does change what plugins observe (they will start seeing intermediate responses). If that is
    considered too large a semantic change for a patch release, a new event type or a config switch
    (e.g. agent.dispatch_llm_response_for_tool_calls) would work equally well for the plugins that
    need outbound sanitisation.
  • A second, independently observable symptom of the same skip: _llm_reasoning_content is not set for
    tool-call turns (astr_agent_hooks.py:25-29), so result-decorate cannot inject reasoning for them.

Impact assessment

Audience Effect
Plugins sanitising outbound text in on_llm_response intermediate text is delivered unsanitised — this is how a user-visible internal-tag leak happened (private-companion 6.7.0)
Plugins rewriting/enriching text (LaTeX→PDF etc.) tool-call steps bypass the rewrite
Plugins injecting reasoning _llm_reasoning_content missing for those turns
Core-only users none (the text is delivered either way)

Workarounds available to plugin authors today: wrap event.send / event.send_streaming at the last
mile (what the affected plugin now does, in final_response_persistence.py), and clean again inside any
private re-send path that bypasses those two — the TTS background re-send and proactive messages go
through the plugin's own _send_chain_components(), which now removes the same markup before delivery.
Both are strictly workarounds for a hook that silently does not run.

Relationship to #7899

Activity

  1. LIghtJUNction commented on Oct 26, 2026

    @LIghtJUNction
    Contributor

    I checked the current master@832fc6d implementation and PR #10772. The reported gap is real: ToolLoopAgentRunner._complete_with_assistant_response() is the only normal path that calls agent_hooks.on_agent_done(); MainAgentHooks.on_agent_done() then dispatches OnLLMResponseEvent. In ToolLoopAgentRunner.step(), a response with tools_call_name skips that finalization but still yields its reasoning/text as llm_result before executing the tool.

    There are two contracts worth separating before merging the fix:

    1. Intermediate-vs-final semantics. Dispatching the existing OnLLMResponseEvent for every tool-call response changes it from effectively “once for the final assistant response” to “once per provider round”. A plugin that persists, translates, counts, or sends text from this hook can now run multiple times in one agent turn. OnAgentDoneEvent remains final-only, but existing plugins may not have chosen it because this distinction was not previously observable.
    2. Streaming mutation semantics. The current head of fix: dispatch response hooks for tool-call steps #10772 makes MainAgentHooks.should_buffer_streaming_response() true whenever any OnLLMResponseEvent handler is registered. step() computes that before provider iteration, so all chunks—including ordinary final responses with no tool call—are withheld and replayed only after completion. Thus an observer-only plugin registering this hook disables progressive output and time-to-first-visible-token for every streamed response handled by that plugin set.

    A small, compatibility-preserving direction would be:

    • define the contract explicitly: either introduce an intermediate-response hook, or add metadata such as is_final / has_tool_calls and make intermediate delivery opt-in;
    • keep ordinary streaming unchanged for observer hooks;
    • if a hook must rewrite content before users see it, require an explicit “mutates response / buffer stream” capability instead of inferring it from handler presence. Otherwise document that a completed-response hook can change the later llm_result/history but cannot retract chunks already sent.

    That would keep the requested intermediate notification without silently turning every response hook into a buffering hook.

    Suggested regression coverage in tests/test_tool_loop_agent_runner.py and tests/test_astr_agent_run_util.py:

    • one tool round: one intermediate callback plus one final callback, with the final/intermediate flag asserted;
    • multiple tool rounds: exact callback count and order;
    • observer-only hook + streaming: first chunk is yielded before the provider completes;
    • mutating opt-in hook + streaming: no pre-hook chunk leaks, and emitted text matches the rewritten response;
    • tool call with empty text, hook exception, and stream_to_general / non-streaming paths;
    • ensure no duplicate user-visible output from streaming_delta and llm_result.

    With that contract, users retain normal streaming latency unless they deliberately enable a rewriting hook, while plugins can reliably distinguish an intermediate tool narration from the final answer.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions