Repository navigation
OnLLMResponseEvent is not dispatched when the response also requests a tool call (but the text is still delivered) #10770
Description
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_contentextra used by result-decorate (:25-29).- Delivery is not skipped:
astrbot/core/astr_agent_run_util.py:246-251takes thellm_result
chain and:274-283yields it to the respond stage unless buffering is enabled
(_should_buffer_llm_result():90-99; defaultbuffer_intermediate_messages = Falseat
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)
- 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))- Enable one tool the model will call (e.g. a web search or any
@filter.llm_tool). - Send a prompt whose answer needs the tool, so the first assistant response contains
prose + a tool call. - Observe: the intermediate prose is delivered to the chat, but
[probe] on_llm_response firedis
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_contentis 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
- on_decorating_result / on_llm_response 钩子未触发,无法拦截 LLM 输出消息 #7899 (v4.23.5) reports
on_decorating_result/on_llm_responsehandlers "never fire" and asks
about handler signatures. That thread has no code-level root cause. - This issue provides a narrow, verifiable subset on 4.29.1: for responses carrying tool calls the
response hook is provably skipped by control flow (:924-925vs:937-948), independent of
signature/registration problems. - Suggested handling: keep on_decorating_result / on_llm_response 钩子未触发,无法拦截 LLM 输出消息 #7899 open for the general "hooks not firing" investigation and use this
report as a reproducible data point + a concrete fix candidate for itson_llm_responsehalf.
Activity
LIghtJUNction commentedon Oct 26, 2026
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:
A small, compatibility-preserving direction would be:
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:
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.