We're encountering an issue where the assistant so...
# ask-ai
b
We're encountering an issue where the assistant sometimes becomes silent after executing a tool (function) defined via function markup in the assistant response. This happens particularly when the assistant's response ends with a function call, like:
Copy code
I'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.
t
Thanks for reaching out, Patrick. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’m responding to your question about the assistant going silent when its last message ends in function markup and how to automatically resume LLM generation after a tool executes. Short answer • Use an explicit continuation call after the tool finishes: call
await 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:
Copy code
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:
Copy code
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 docs
b
Thanks, that makes sense! A universal approach would work best for us — particularly one that doesn’t require modifying every tool individually. Could you please provide: ✅ A sample
llm_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.
t
Thanks — I’m responding to your request for a universal
llm_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.
Copy code
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 Docs
b
Thanks for the detailed example — this is extremely helpful. Could you confirm one implementation detail? 👉 When using the
generate_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.
t
Thanks — good questions. I’ll respond to both parts: safety of calling
self.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 docs
b
Thanks — that's really clear and helpful. We’d love to move forward with the llm_node-based universal follow-up, but with tool-level opt-outs and a solid cooldown guard. Could you help with: A version of the
llm_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.
t
Thanks — I’ll provide a ready-to-use
llm_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.
Copy code
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:
Copy code
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 docs
b
We’re using the
session.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:
Copy code
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?
t
Short answer: Yes — your current approach (passing focused
instructions
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 docs
b
We’re building a real‑time voice agent using the LiveKit Agents framework with function tool support and
session.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.
t
Thanks — I’ll answer directly and clearly. Short answer • LiveKit does not provide an official, non-heuristic API (message flag, parameter on
generate_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 docs
c
• 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
t
Thanks — here’s a concise, ready-to-use example that does exactly what you asked: safely replace/annotate the prior assistant message in the chat context, call
update_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.
Copy code
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