bumpy-florist-37019
12/22/2025, 12:51 AMI'm doing well, thanks. Let me check your account. <function=fetch_customer_profile>{"phone_number": "0000000000"}</function>
In these cases, the tool executes correctly, and its result is injected into the context, but the assistant doesn't continue the turn to complete the response. We assume this is because there’s no trailing text after the function tag to prompt further LLM generation.
❓Question:
What is the recommended way in LiveKit agents to automatically trigger LLM continuation after tool execution, especially when the assistant message ends in function markup?
Is there a built-in hook or preferred pattern (e.g., continue_turn()) that we should call at the end of the function execution flow to handle this case?
We want a universal solution that applies to any tool, not just specific ones, and we’d like to avoid re-invoking the LLM unnecessarily.tall-airline-16756
12/22/2025, 12:51 AMtall-airline-16756
12/22/2025, 12:51 AMawait context.session.generate_reply(...) at the end of the tool when you expect the assistant to continue.
• If you want this applied universally without changing every tool, implement detection in the agent’s LLM node (override llm_node) to detect function-only turns and trigger session.generate_reply() automatically.
Why
• When the assistant’s turn ends with a function call and no trailing text, the LLM won’t produce further tokens by itself. Explicitly invoking generate_reply() prompts the LLM to continue using the newly injected tool output.
• Overriding llm_node lets you detect “function-only” last messages and resume automatically for all tools, avoiding per-tool changes.
Recommended patterns
1) Per-tool (simple, explicit)
• At the end of a tool that should lead to a spoken follow-up, add:
await context.session.generate_reply(instructions="Continue the conversation naturally using the data just retrieved.")
• Use this only for tools where a follow-up is desired (mark terminal tools to skip).
2) Universal (automatic)
• Override llm_node to inspect the last assistant message after LLM generation; if it contains only a function call (no assistant text), call:
await self.session.generate_reply(instructions="Continue based on the tool result.")
• This applies to all tools but requires care to avoid unintended extra generations (add guards: check turn state, rate-limit, or allow tools to set a no-followup flag).
3) Helper wrapper (convenience)
• Wrap tool functions with a decorator that triggers generate_reply() after execution unless the tool opts out. This centralizes behavior without altering llm_node.
Notes / best practices
• Trigger generate_reply() only after tool execution (not before).
• Provide brief instructions to the LLM so it knows to continue naturally and not repeat the tool output verbatim.
• Add opt-outs (a _no_followup flag or similar) for terminal tools (e.g., hangups, purely logging).
• When using llm_node automation, add guards to prevent loops or duplicate replies.
If you’d like, I can:
• Show a complete example decorator you can apply to multiple tools, or
• Draft an llm_node snippet with guards you can drop into your agent. Which would be more helpful? Sources: Tool definition and use | LiveKit docs | Turns overview | LiveKit docs | Agents and handoffs | LiveKit docs | Agent speech and audio | LiveKit docs | Pipeline nodes and hooks | LiveKit docsbumpy-florist-37019
12/22/2025, 12:53 AMllm_node override snippet that includes:
Detection of function-only messages
Guard conditions to avoid duplicate generations or infinite loops
• The appropriate call to generate_reply() with brief instructions
That way, we can integrate it directly into our agent and test safely.
A helper decorator is also interesting long-term, but llm_node control is probably our immediate best path.tall-airline-16756
12/22/2025, 12:54 AMllm_node override that (1) detects function-only assistant messages, (2) triggers a single follow-up generation, and (3) includes guards to avoid duplicate or infinite loops. Below is a compact, safe example you can drop into your agent and adapt.
Notes:
• This example assumes your LLM stream exposes structured deltas with a tool_call or similar marker when a function is invoked. Adjust the detection logic to match your LLM plugin’s streaming shape.
• The follow-up is triggered once per turn and uses a short instruction so the LLM produces a verbal continuation rather than another silent function call.
import asyncio
from typing import AsyncIterable, Optional
from livekit.agents import Agent, ChatContext, FunctionTool, ModelSettings
from livekit.agents.llm import ChatChunk
class AutoContinueAgent(Agent):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# tune backoff / guard params as needed
self._follow_up_cooldown = 1.0 # seconds to avoid immediate retrigger
async def llm_node(
self,
chat_ctx: ChatContext,
tools: list[FunctionTool],
model_settings: Optional[ModelSettings] = None,
) -> AsyncIterable[ChatChunk]:
"""
Override that streams LLM output and, when it detects a function-only
assistant message, triggers a single follow-up generation via
session.generate_reply(...).
"""
# Per-turn flag to prevent duplicate follow-ups
follow_up_triggered = False
# Helper to schedule follow-up without blocking stream
def schedule_follow_up():
async def _do_follow_up():
await asyncio.sleep(0.05) # tiny delay to avoid race conditions
# Guard: ensure we don't re-trigger too fast
if getattr(self, "_last_follow_up_ts", 0) + self._follow_up_cooldown > asyncio.get_event_loop().time():
return
self._last_follow_up_ts = asyncio.get_event_loop().time()
await self.session.generate_reply(
instructions=(
"You just invoked a tool. Now provide a concise, natural follow-up "
"to the user that summarizes or uses the tool result."
),
allow_interruptions=True,
)
asyncio.create_task(_do_follow_up())
# Stream from default LLM behavior
async for chunk in super().llm_node(chat_ctx, tools, model_settings):
# Yield chunk to caller so normal streaming still works
yield chunk
# Detect a function-only assistant message once per turn
if not follow_up_triggered and self._is_function_call_only_chunk(chunk):
follow_up_triggered = True
schedule_follow_up()
# do not immediately return; allow remaining stream chunks to flush
# end of stream
def _is_function_call_only_chunk(self, chunk: ChatChunk) -> bool:
"""
Return True if the chunk represents a function/tool invocation and
contains no assistant text content in the chunk's delta.
Adjust this to your LLM plugin's stream structure.
"""
# If chunk is plain text string -> not a function-only call
if isinstance(chunk, str):
return False
# Safely inspect attributes commonly emitted by LLM streams
delta = getattr(chunk, "delta", None)
if not delta:
return False
# Example patterns to check:
# - structured field like delta.tool_call or delta.tool_calls
# - absence of content / text in delta.content or delta.message
has_tool_call = False
if hasattr(delta, "tool_call") and getattr(delta, "tool_call"):
has_tool_call = True
elif hasattr(delta, "tool_calls") and getattr(delta, "tool_calls"):
has_tool_call = True
elif hasattr(delta, "tool") and getattr(delta, "tool"):
has_tool_call = True
# check for presence of any verbal content in this chunk
content = getattr(delta, "content", None) or getattr(delta, "text", None) or getattr(delta, "message", None)
has_text = False
if content:
# treat empty/whitespace-only as no text
try:
text = "".join([c for c in content]) if isinstance(content, (list, tuple)) else str(content)
except Exception:
text = str(content)
if text.strip():
has_text = True
return has_tool_call and not has_text
If you want, I can:
• Adapt the detection to a specific LLM plugin (OpenAI, Anthropic, etc.) if you tell me the stream fields your plugin emits; or
• Provide a decorator-based wrapper as a follow-up for longer-term reuse. Which would you prefer? Sources: Pipeline nodes and hooks | LiveKit docs | Tool definition and use | LiveKit docs | Agent speech and audio | LiveKit docs | Large language models (LLM) | LiveKit Docsbumpy-florist-37019
12/22/2025, 12:55 AMgenerate_reply() trigger in llm_node, is it safe to rely on self.session.generate_reply(...) directly within the agent subclass? Or is there a preferred interface or pattern (e.g. dispatching via a handler or mediator) that’s more robust across session lifecycles and threading contexts?
Also, do you recommend placing the follow-up logic inside llm_node, or would a wrapper-based approach around tool functions be more maintainable in the long run from your perspective?
Thanks again — this is unlocking a much smoother agent UX for us.tall-airline-16756
12/22/2025, 12:56 AMself.session.generate_reply(...) from the agent, and which architecture I recommend (llm_node vs tool wrapper).
Short answers
• Yes — it’s safe to call self.session.generate_reply(...) directly inside an agent subclass (including from llm_node). The session API is designed to be used from agent code and enqueues reply generation asynchronously, so it won’t block the streaming pipeline when used correctly.
• Use an llm_node override for a universal, all-tools continuation behavior; use a wrapper/decorator on tools when only specific tools should produce a follow-up. Each approach has trade-offs described below.
Details and recommended patterns
1) Safety and concurrency
• Direct call is fine: call await self.session.generate_reply(...) or schedule it with asyncio.create_task(...) if you must avoid awaiting inside a streaming callback.
• Add small guards (timestamp/cooldown, per-turn flag) to prevent immediate retriggers or duplicate follow-ups.
• Use a short sleep (e.g., 0.05–0.2s) before calling generate_reply() to avoid race conditions between tool result injection and the new generation pass.
2) Where to place follow-up logic
• llm_node (recommended when you want automatic, consistent behavior across all tools)
◦ Pros: Centralized, no per-tool changes, easier to detect “function-only” streaming patterns and react globally.
◦ Cons: Slightly more complex to implement correctly (must inspect streaming deltas and add guards).
◦ Implementation tips:
▪︎ Detect function-only messages in the LLM stream.
▪︎ Use a per-turn flag and a small cooldown to avoid duplicates.
▪︎ Prefer scheduling follow-ups via asyncio.create_task() so streaming isn’t blocked.
▪︎ Provide concise instructions to generate_reply() so the LLM produces a verbal continuation (not another silent tool call).
• Tool wrapper/decorator (recommended when only specific tools need follow-up or when you want localized control)
◦ Pros: Clear intent next to tool code, simpler reasoning about which tools cause speech, easier to opt-out for terminal tools.
◦ Cons: Requires touching each tool (or applying decorator programmatically to groups of tools).
◦ Implementation tips:
▪︎ After tool action and context injection, call await context.session.generate_reply(...) (or schedule it) unless tool sets a _no_followup flag.
▪︎ Keep follow-up instructions short and explicit.
3) Practical pattern to combine safety and maintainability
• Use llm_node for global behavior but allow tools to opt out:
◦ llm_node detects function-only turns and triggers generate_reply().
◦ Tools can set a flag in the run context (e.g., context.meta["suppress_followup"] = True) to prevent follow-ups for terminal tools.
• Use a small cooldown on follow-ups and a per-turn boolean to avoid loops.
Suggested follow-up parameters
• Instruction text example:
"You just invoked a tool. Provide a concise, user-facing follow-up that summarizes or uses the tool result."
• Cooldown: 0.5–2 seconds depending on your throughput.
• Tiny delay before triggering: 0.05–0.2 seconds.
If you want, I can:
• Convert the earlier llm_node snippet to use await self.session.generate_reply(...) directly (or show the safe asyncio.create_task() pattern), and add an example of a tool opt-out flag and cooldown implementation. Which variation do you want to test first? Sources: Pipeline nodes and hooks | LiveKit docs | Agent speech and audio | LiveKit docs | Simple Content Filter | LiveKit docs | Tool definition and use | LiveKit docs | Agents and handoffs | LiveKit docsbumpy-florist-37019
12/22/2025, 1:04 AMllm_node override using await self.session.generate_reply(...) directly (with optional asyncio.create_task(...) if you recommend it),
1. Support for tools setting a flag like context.meta["suppress_followup"] = True to skip continuation, and
2. A safe cooldown implementation to avoid rapid retriggers.
We’re aiming for reliable follow-ups after tools without per-tool code changes — unless a tool explicitly opts out.
Thanks again — this is shaping up really nicely.tall-airline-16756
12/22/2025, 1:05 AMllm_node override that:
• Calls await self.session.generate_reply(...) (with safe optional usage of asyncio.create_task()),
• Respects a tool-level opt-out via context.meta["suppress_followup"] = True, and
• Implements a cooldown + per-turn guard to avoid duplicate or infinite follow-ups.
Paste this into your agent class and adapt small details (LLM stream fields or RunContext access) to match your environment.
import asyncio
from typing import AsyncIterable, Optional
from livekit.agents import Agent, ChatContext, FunctionTool, ModelSettings, RunContext
from livekit.agents.llm import ChatChunk
class UniversalFollowupAgent(Agent):
def __init__(self, *args, cooldown_seconds: float = 2.0, **kwargs):
super().__init__(*args, **kwargs)
self._cooldown_seconds = cooldown_seconds
self._last_followup_ts = 0.0
self._pending_followup_task: Optional[asyncio.Task] = None
async def llm_node(
self,
chat_ctx: ChatContext,
tools: list[FunctionTool],
model_settings: Optional[ModelSettings] = None,
) -> AsyncIterable[ChatChunk]:
"""
Streams the LLM. If the stream indicates a function-only assistant message
(tool call with no assistant text), schedule a follow-up generation unless
a tool set context.meta['suppress_followup'] = True or cooldown applies.
"""
followup_scheduled_for_turn = False
saw_tool_call_in_turn = False
# Stream default LLM output
async for chunk in super().llm_node(chat_ctx, tools, model_settings):
yield chunk
# Detect tool/function call in chunk (adjust to your LLM's stream shape):
if self._chunk_has_tool_call(chunk):
saw_tool_call_in_turn = True
# If chunk also contains assistant text, then not function-only for that chunk
# (we still keep scanning until stream ends)
# After stream completes for this LLM pass:
if not saw_tool_call_in_turn:
return # nothing to do
# Determine if a tool explicitly suppressed follow-ups.
# Tools can set during execution:
# context.meta["suppress_followup"] = True
suppress = self._check_suppress_followup_flag()
if suppress:
return
# Cooldown and pending-task checks
now = asyncio.get_event_loop().time()
if now - self._last_followup_ts < self._cooldown_seconds:
return
if self._pending_followup_task and not self._pending_followup_task.done():
return
# Update last-followup timestamp and schedule a follow-up.
self._last_followup_ts = now
# Option A: await directly (safe if you want to block until follow-up finishes)
# await self.session.generate_reply(instructions="You invoked a tool—now provide a concise follow-up to the user.")
#
# Option B (recommended inside streaming hooks): schedule non-blocking task
self._pending_followup_task = asyncio.create_task(self._do_generate_followup())
def _chunk_has_tool_call(self, chunk: ChatChunk) -> bool:
"""
Heuristic: return True if the chunk contains a tool/function call and no user-facing text.
Adjust checks to match your LLM plugin's streaming fields (e.g., delta.tool_call,
delta.tool_calls, delta.function_call, delta.content).
"""
if isinstance(chunk, str):
return False
delta = getattr(chunk, "delta", None)
if not delta:
return False
# Example checks; adapt to your plugin:
has_tool = False
if hasattr(delta, "tool_call") and getattr(delta, "tool_call"):
has_tool = True
elif hasattr(delta, "tool_calls") and getattr(delta, "tool_calls"):
has_tool = True
elif hasattr(delta, "function_call") and getattr(delta, "function_call"):
has_tool = True
# Check for any textual content in this chunk
content = getattr(delta, "content", None) or getattr(delta, "text", None) or getattr(delta, "message", None)
has_text = False
if content:
try:
text = "".join(content) if isinstance(content, (list, tuple)) else str(content)
except Exception:
text = str(content)
if text.strip():
has_text = True
# Consider it a tool call chunk only if there is a tool marker and no textual content.
return has_tool and not has_text
def _check_suppress_followup_flag(self) -> bool:
"""
Check RunContext or other context storage for suppress flag written by tools.
Tools should set:
context.meta['suppress_followup'] = True
or
context.userdata.setdefault('meta', {})['suppress_followup'] = True
depending on your RunContext usage.
"""
try:
ctx = RunContext.get_current()
if not ctx:
return False
# prefer context.meta if available
if hasattr(ctx, "meta") and isinstance(ctx.meta, dict):
return bool(ctx.meta.get("suppress_followup"))
# fallback to userdata.meta
if hasattr(ctx, "userdata") and isinstance(ctx.userdata, dict):
meta = ctx.userdata.get("meta") or {}
return bool(meta.get("suppress_followup"))
except Exception:
pass
return False
async def _do_generate_followup(self):
"""
The actual follow-up: call generate_reply() directly.
We include a tiny sleep to reduce race conditions between tool result injection and LLM start.
"""
try:
await asyncio.sleep(0.05)
await self.session.generate_reply(
instructions=(
"You previously invoked a tool. Provide a concise, user-facing follow-up "
"that summarizes or uses the tool result."
),
allow_interruptions=True,
)
except Exception:
# log/monitor as appropriate in your environment
pass
finally:
self._pending_followup_task = None
Quick integration notes
• Tools should set the opt-out flag in their RunContext during execution:
◦ Example inside a tool:
context.meta["suppress_followup"] = True
# or
context.userdata.setdefault("meta", {})["suppress_followup"] = True
• Adjust _chunk_has_tool_call() to match your LLM plugin’s actual streaming fields (OpenAI-style streams often use delta.function_call or delta.tool_call).
• Use the scheduling pattern (asyncio.create_task) to avoid blocking the node stream; if you prefer to block until the follow-up completes, replace the create_task call with await self.session.generate_reply(...).
• Tune _cooldown_seconds for your app (0.5–3s common); cooldown prevents repeated follow-ups from quick successive tool calls or retries.
If you’d like, I can:
• Adapt _chunk_has_tool_call() to a specific LLM plugin (OpenAI, Anthropic, etc.) if you tell me the stream delta shape you see; or
• Show an alternative that blocks on await generate_reply() instead of scheduling — helpful for deterministic tests. Which would you like to try first? Sources: LLM Output Replacement | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Tool definition and use | LiveKit docsbumpy-florist-37019
12/22/2025, 1:33 AMsession.generate_reply() method in our tool-calling agent. After a tool response, we’re seeing the assistant repeat parts of its earlier message (e.g., greetings like “I’m doing well…”). From our debugging, it seems this happens because generate_reply() reuses the full chat context (including the user’s last message), and there’s no parameter to inject a custom context.
Our current workaround is to pass a specific instructions string to generate_reply(), guiding the LLM to continue from where it left off and not repeat responses already rendered before the tool call.
Example:
instructions = "Continue from where the assistant left off. Don't repeat earlier responses like greetings or acknowledgments. Only add new info based on the tool output."
Can you confirm if this is the recommended approach, or if there’s a more robust method to manage mid-turn tool call continuations?tall-airline-16756
12/22/2025, 1:33 AMinstructions to session.generate_reply() telling the model to continue and not repeat earlier text) is the recommended and most reliable method to avoid repetition after a tool call.
Why
• generate_reply() reuses the full chat history; there is no built-in parameter to send a trimmed or alternate context. Clear, directive instructions are the intended mechanism to control what the LLM emits next.
• Well-crafted instructions tell the model (1) you want continuation, (2) which parts are already spoken, and (3) what to avoid repeating — and that reliably prevents the greeting/acknowledgement replay you observed.
Best-practice refinements
• Make the instruction explicit and brief, e.g.:
"Continue from where the assistant left off. Do not repeat prior greetings or acknowledgments. Incorporate only new information from the tool result."
• Add a small context hint if needed:
"Ignore any preceding assistant text marked as [SPOKEN]. Continue the response using the tool output." (You can prefix or annotate the last assistant message in chat context before calling generate_reply() if you want an extra signal.)
• Use tiny delay/cooldown when triggering follow-ups (0.05–0.5s) so tool output has been injected and race conditions are minimized.
• Avoid system-level instructions that encourage restating or greeting on every turn.
• If a tool should prevent follow-up, set a suppress flag (e.g., context.meta["suppress_followup"] = True) and skip calling generate_reply().
Optional advanced alternatives
• Temporarily annotate or mark the prior assistant message in agent.chat_ctx to indicate it was already spoken, then call generate_reply() with instructions to ignore marked messages. This is more invasive but adds an extra signal beyond instructions.
• For deterministic tests, you can block and await self.session.generate_reply(...) and craft stricter instructions; in production, scheduling via asyncio.create_task() with the same instructions is usually preferable.
If you’d like, I can:
• Draft an exact instruction template tuned to your agent’s wording, or
• Show a brief example that annotates the last assistant message then calls generate_reply() to demonstrate that approach. Which would help most? Sources: Agent speech and audio | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Tasks and task groups | LiveKit docsbumpy-florist-37019
12/22/2025, 1:42 AMsession.generate_reply(). We have a situation where the assistant:
Outputs a function call in its LLM response (e.g., “I’m doing well, thanks … <function=fetch_customer_profile>…”).
The function tool executes and is injected into chat context correctly.
1. The agent then triggers a continuation via session.generate_reply() with focused instructions (e.g., “do not repeat prior phrases…”).
However, in practice, the LLM still repeats portions of the original assistant text (e.g., “I’m doing well, thanks…”) in the continuation response.
We’ve confirmed the following:
session.generate_reply() always includes full chat history in the prompt (per docs) — there is no parameter to trim context. LiveKit docs
• Our continuation logic recognizes the function call and triggers a follow‑up, but the model still re‑answers the original user query because the user query and the assistant’s pre‑function answer are still in history.
Critical Question
LiveKit knows that session.generate_reply() uses the entire chat context. LiveKit docs
What is the official, supported way to tell the LiveKit LLM pipeline that a user query has already been answered by the assistant prior to a function/tool call, such that the continuation should not re‑answer the same user query in the follow‑up?
Specifically:
Should we be adding annotations or metadata on chat items (e.g., marking the assistant’s pre‑function message as “answered”) so the LLM sees that the user query has already been addressed?
If yes, what is the supported API for doing that in LiveKit (e.g., modifying ChatContext metadata, tool_call outputs, or another mechanism)?
1. If not, is there a LiveKit pattern or API for telling the generate_reply() call to ignore previous signals of already‑answered content — beyond human‑crafted continuation instructions — so that the LLM does not repeat earlier segments?
We want a reliable, non‑heuristic API‑supported pattern (not “try stronger instructions”), so that follow‑ups after a tool call continue the conversation without repeating or re‑answering already‑handled user intents.tall-airline-16756
12/22/2025, 1:43 AMgenerate_reply(), or built-in “answered” annotation) to tell the LLM that a user query has already been answered.
• The officially supported, reliable pattern is to (A) modify the chat context before calling generate_reply() and/or (B) pass focused instructions to generate_reply() to direct the model not to repeat prior content.
What to do (recommended, supported patterns)
1) Preferred: edit the ChatContext before continuation
• After the tool returns, update the agent’s chat context to mark or remove the prior assistant text you don’t want repeated (for example replace the assistant’s pre-function content with a short summary like “[Previously delivered]”).
• Call await agent.update_chat_ctx(modified_chat_ctx) (or the equivalent session.updateChatCtx) and then call await session.generate_reply(...).
• This is explicit, programmatic, and supported by the LiveKit API — it prevents the LLM from seeing the original full text you want suppressed.
2) Always use focused continuation instructions
• Provide a clear instruction to generate_reply() such as:
"Continue from where the assistant left off. Do not repeat prior greetings or acknowledgments. Use only the new tool output to continue."
• This is the standard, supported method and should be used even when you also modify context.
3) Combine both for robustness
• Editing chat context + strong instructions gives the most reliable results across models and avoids heuristic-only solutions.
What is not available
• There is no built-in message-level metadata field (e.g., answered: true) that the LLM pipeline will honor automatically.
• There is no generate_reply(..., ignorePrior=true) or similar parameter.
Practical tips
• If you edit messages, prefer annotating (e.g., replace content with “[PREVIOUSLY SPOKEN]”) rather than wholesale deletion so troubleshooting is easier.
• Keep continuation instructions short and explicit.
• Small delay (0.05–0.2s) between tool injection and generate_reply() reduces race conditions.
• Use the suppress opt-out flag pattern for tools that should be terminal: context.meta["suppress_followup"] = True.
If you want, I can:
• Provide a short code example showing how to safely replace the prior assistant message in ChatContext, call update_chat_ctx(), then generate_reply() with instructions; or
• Draft a small helper function you can reuse to mark/annotate answered messages before follow-ups. Which would you prefer? Sources: Agent speech and audio | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | examples/drive-thru | livekit/livekit_composite | DeepWiki | Agents and handoffs | LiveKit docs | External data and RAG | LiveKit docscreamy-actor-70792
12/22/2025, 3:41 AMupdate_chat_ctx(), then generate_reply() with instructions; ortall-airline-16756
12/22/2025, 3:42 AMupdate_chat_ctx(), then call generate_reply() with focused instructions to avoid repetition.
Notes:
• Adjust field names if your ChatChunk/ChatMessage representation differs.
• This uses await self.update_chat_ctx(...) (agent API) and then await self.session.generate_reply(...).
• Tools can set context.meta["suppress_followup"] = True to opt out; this example skips follow-up in that case.
async def continue_after_tool(self, tool_name: str):
"""
Call this after a tool has executed and been injected into chat context.
It annotates/replaces the prior assistant message to avoid repetition,
updates the chat context, and triggers a focused generate_reply().
"""
# 1) Quick guard: check if tool opted out
try:
ctx = RunContext.get_current()
if ctx and getattr(ctx, "meta", None) and ctx.meta.get("suppress_followup"):
return
except Exception:
pass
# 2) Make a copy of the chat context (or operate on a safe mutable copy)
chat_ctx = self.chat_ctx.copy()
# 3) Find the last assistant message that may contain the pre-function text.
# Adjust index if your flow inserts tool-call messages into the stream.
# This example looks back up to 3 messages to find the assistant entry.
assistant_idx = None
for i in range(len(chat_ctx.messages) - 1, max(-1, len(chat_ctx.messages) - 4), -1):
m = chat_ctx.messages[i]
if getattr(m, "role", None) == "assistant":
assistant_idx = i
break
# 4) Annotate or replace that assistant message to signal it's already spoken.
if assistant_idx is not None:
old_msg = chat_ctx.messages[assistant_idx]
# Option A: Replace with short annotation (safe and easy to debug)
old_msg.content = ["[PREVIOUSLY SPOKEN] " + (old_msg.content[0] if isinstance(old_msg.content, (list, tuple)) else str(old_msg.content))]
# Option B: or replace with a brief summary:
# old_msg.content = ["Previously acknowledged the user; awaiting tool result."]
# 5) Persist the modified chat context so generate_reply() sees the edited history
await self.update_chat_ctx(chat_ctx)
# 6) Trigger a focused continuation. Keep instructions explicit to avoid repetition.
await self.session.generate_reply(
instructions=(
"You previously invoked a tool and the result is available. Continue the assistant's response "
"from where it left off. Do not repeat greetings, acknowledgments, or earlier text already spoken. "
"Use only the new tool output to add fresh information."
),
allow_interruptions=True,
)
If you want, I can:
• produce a variant that removes the prior assistant message instead of annotating it, or
• adapt this to your exact ChatMessage structure (send a sample of a message object and I’ll tailor it). Which would you prefer? Sources: Agents and handoffs | LiveKit docs | External data and RAG | LiveKit docs | Simple Content Filter | LiveKit docs | Pipeline nodes and hooks | LiveKit docs