I'm trying to hardcode a response using a function...
# ask-ai
b
I'm trying to hardcode a response using a function_tool It's working as expected but the problem is that I don't want the LLM_node to then send a follow on message after i use session.say . How can I prevent the llm_node from firing after this function_tool call?
Copy code
def build_answer_regulatory_question_tool(agent_config: RegulatoryToolConfig):
    config_source = getattr(agent_config, "agent_constructor", agent_config)
    configured_default_country = getattr(config_source, "regulatory_default_country", None)

    @function_tool(name=TOOL_NAME)
    async def answer_regulatory_question(
        context: RunContext,
        query: Annotated[
            str,
            (
                "User question about the regulatory body, regulation, or licensing. "
                "Include the full user question so country can be detected. "
                "This tool speaks the final response directly, so no additional assistant reply is needed."
            ),
        ],
        country: Annotated[
            Optional[str],
            (
                "Country mentioned by the user, if available. "
                "Pass the exact country name when the user asks about a specific country."
            ),
        ] = None,
    ) -> str:
        """Respond with country-specific regulatory/licensing guidance."""
        country_responses = {
            "bahrain": (
                "For Bahrain, we are licensed and regulated by the Securities Commission of The Bahamas under licence number SIA-F245. Its registered office is located at Bayside Executive Park on Blake Road and West Bay Street in Nassau, The Bahamas."
            ),
            "oman": (
                "For Oman, we are licensed and regulated by the Securities Commission of The Bahamas under licence number SIA-F245. Its registered office is located at Bayside Executive Park on Blake Road and West Bay Street in Nassau, The Bahamas."
            ),
            "kuwait": (
                "For Kuwait, we are licensed and regulated by the Securities Commission of The Bahamas under licence number SIA-F245. Its registered office is located at Bayside Executive Park on Blake Road and West Bay Street in Nassau, The Bahamas."
            ),
            "uae": (
                "For the UAE, we are regulated and authorised by the Securities and Commodities Authority ("SCA") under licence number 20200000176."
            ),
            "uk": (
                "For the UK, licensing and financial-services regulation are supervised by the "
                "Financial Conduct Authority."
            ),
            "middle east": (
                "We are regulated across many countries in the middle east, including Oman, UAE, Bahrain, Saudi and Qatar."
            ),
        }

        default_country = "uae"
        normalized_query = query.lower()

        detection_aliases = {
            "bahrain": ("bahrain",),
            "oman": ("oman",),
            "uae": ("uae", "u.a.e", "united arab emirates"),
            "uk": ("uk", "u.k", "united kingdom", "britain", "great britain", "UK"),
            "kuwait": ("kuwait", "Kuwait"),
        }
        all_aliases = {
            alias: canonical for canonical, aliases in detection_aliases.items() for alias in aliases
        }

        def _match_supported_country(text: str) -> Optional[str]:
            normalized_text = text.lower().strip()
            for alias, canonical in all_aliases.items():
                if alias in normalized_text:
                    return canonical
            return None

        if configured_default_country:
            default_country = _match_supported_country(configured_default_country) or default_country

        def _extract_country_candidate(text: str) -> Optional[str]:
            # Capture explicit mentions such as "in France" or "for Saudi Arabia".
            patterns = (
                r"\b(?:in|for|about|regarding)\s+([a-z][a-z\s\-]{1,40})",
                r"\b(?:licensed|license|regulation|regulator)\s+(?:in|for)\s+([a-z][a-z\s\-]{1,40})",
            )
            stop_words = {
                "the",
                "our",
                "your",
                "this",
                "that",
                "capital",
                "<http://capital.com|capital.com>",
                "platform",
                "company",
                "service",
                "account",
                "regulation",
                "regulator",
                "licensing",
                "license",
                "body",
            }
            for pattern in patterns:
                match = re.search(pattern, text)
                if not match:
                    continue
                candidate = re.split(r"[?.!,;:]", match.group(1))[0].strip()
                candidate = re.sub(r"\s+", " ", candidate)
                if not candidate:
                    continue
                if candidate in stop_words:
                    continue
                return candidate
            return None

        # Query-first country selection:
        # - If query contains a country, always use that.
        # - If query contains no country, use configured/default country.
        detected_country = _match_supported_country(normalized_query)

        if detected_country is None:
            mentioned_country = _extract_country_candidate(normalized_query)
            if mentioned_country:
                response = (
                    f"We are currently not licensed in {mentioned_country.title()}. "
                    "I can still help with the countries where we are licensed, such as Bahrain, Oman, UAE, and the UK."
                )
                
                await context.session.say(response, allow_interruptions=False)
                await context.wait_for_playout()
                <http://logger.info|logger.info>(
                    "Regulatory lookup tool invoked",
                    requested_country=mentioned_country,
                    supported=False,
                )
                return 'tool call complete. No response required after this.'

            detected_country = default_country

        response = country_responses[detected_country]
        await context.session.say(response, allow_interruptions=False)
        await context.wait_for_playout()
        <http://logger.info|logger.info>("Regulatory lookup tool invoked", country=detected_country, supported=True)
        return 'tool call complete. No response required after this.'
t
Thanks for reaching out, Amir. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — this is clear and a great question. You're trying to have your tool speak the final reply via
context.session.say
and stop the agent's LLM node from generating an additional follow-up message. You already do the right thing by sending the spoken response inside the tool; the remaining step is to ensure the tool does not return a value the agent will treat as content to echo. What to change • After you call
await context.session.say(...)
(and
await context.wait_for_playout()
), return
None
from the tool instead of returning a string. • The LiveKit agent treats any non-
None
return value from a function tool as a result to send back to the LLM. Returning
None
tells the agent the tool has finished and no LLM reply is required. Example change 1. Replace:
Copy code
return 'tool call complete. No response required after this.'
with:
Copy code
return None
Why this works • Returning
None
prevents the agent from passing a tool result to the LLM, so no follow-up assistant message will be generated after your tool already spoke via
session.say
. If you want, I can: • Show a patch of your function with the change applied. • Suggest an alternate pattern if you ever need the tool to both speak and produce a structured result for downstream logic (e.g., a small dict or a flag that the agent knows to ignore for LLM output). Which would you prefer? Sources: Tool definition and use | LiveKit Documentation
b
Is there a way to ensure that the session.say is added to the transcript? I'm recording the calls in a manual way and the session.say does not show up on the transcript. this is my agent:
Copy code
def _rebuild_turn_transcripts(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
        turns: list[dict[str, Any]] = []
        pending_user = ""
        consumed_by_tool = False
        for msg in messages:
            role = str(msg.get("role", "")).lower()
            content = str(msg.get("content", "") or "").strip()
            if not content:
                continue
            if role == "user":
                pending_user = content
                consumed_by_tool = False
                continue
            if role == "tool":
                # Prefer tool output as the assistant answer in tests because certain
                # tool-spoken answers don't always appear as normal assistant text.
                if pending_user:
                    match = re.search(
                        r"^\[Tool Result \((?P<tool>[^)]+)\): (?P<result>.*)\]$",
                        content,
                        flags=re.DOTALL,
                    )
                    result_text = match.group("result").strip() if match else content
                    if result_text:
                        turns.append(
                            {
                                "conversation_id": self.conversation_id,
                                "turn_id": len(turns) + 1,
                                "timestamp": datetime.now().isoformat(),
                                "user": pending_user,
                                "assistant": result_text,
                            }
                        )
                        pending_user = ""
                        consumed_by_tool = True
                continue
            if role == "assistant":
                # If a tool already answered this user turn, skip trailing fallback text.
                if consumed_by_tool and not pending_user:
                    continue
                turns.append(
                    {
                        "conversation_id": self.conversation_id,
                        "turn_id": len(turns) + 1,
                        "timestamp": datetime.now().isoformat(),
                        "user": pending_user,
                        "assistant": content,
                    }
                )
                pending_user = ""
        return turns

    def llm_node(  # type: ignore[override]
        self,
        chat_ctx: ChatContext,
        tools: list[FunctionTool | RawFunctionTool],
        model_settings: ModelSettings,
    ) -> AsyncIterable[Any]:
        async def _wrapped() -> AsyncIterable[Any]:
            turn_index = len(self.llm_debug_dumps) + 1
            response_parts: list[str] = []

            async for chunk in Agent.default.llm_node(self, chat_ctx, tools, model_settings):
                if isinstance(chunk, str):
                    response_parts.append(chunk)
                elif isinstance(chunk, ChatChunk) and chunk.delta:
                    delta = chunk.delta
                    if delta.content:
                        response_parts.append(str(delta.content))
                yield chunk

            # Prefer captured session conversation items since they include
            # function-tool speech produced through context.session.say(...).
            if self._captured_conversation_messages:
                messages = list(self._captured_conversation_messages)
            else:
                messages = [_serialize_message(msg) for msg in chat_ctx.items]
            assistant_text = "".join(response_parts).strip()
            if assistant_text:
                messages.append({"role": "assistant", "content": assistant_text})
            self.turn_transcripts = self._rebuild_turn_transcripts(messages)
            self.turn_id = len(self.turn_transcripts)

            # Persist rich per-turn debug state for transcript diagnostics.
            self.llm_debug_dumps.append(
                {
                    "conversation_id": self.conversation_id,
                    "turn_index": turn_index,
                    "timestamp": datetime.now().isoformat(),
                    "tools": [getattr(t, "__name__", type(t).__name__) for t in tools],
                    "model_settings": {
                        "tool_choice": str(getattr(model_settings, "tool_choice", None)),
                    },
                    "chat_ctx_item_count": len(chat_ctx.items),
                    "chat_ctx_serialized": _serialize_chat_context(chat_ctx),
                    "captured_conversation_message_count": len(self._captured_conversation_messages),
                    "captured_conversation_messages": list(self._captured_conversation_messages),
                    "streamed_response_parts": list(response_parts),
                    "assistant_text": assistant_text,
                    "messages_used_for_rebuild": messages,
                    "rebuilt_turn_transcripts": list(self.turn_transcripts),
                }
            )
            if self.turn_transcripts:
                print(json.dumps(self.turn_transcripts[-1], ensure_ascii=False))

        return _wrapped()
t
Thanks — you're asking how to make sure the spoken replies created with
session.say(...)
are recorded in your manual transcript. You already have the right rebuild logic, but the missing piece is capturing the session items that
session.say
emits so they appear in
messages
when you rebuild turns. Two reliable approaches you can use: • Capture conversation items with the conversation_item_added event (recommended) a. Initialize a container on your agent instance:
Copy code
self._captured_conversation_messages = []
1. Add a listener after
self.session
is available:
Copy code
def _on_conversation_item_added(self, item):
         # normalize to the dict form your transcript builder expects
         self._captured_conversation_messages.append({
             "role": getattr(item, "role", "assistant"),
             "content": getattr(item, "content", "") or "",
         })

     # attach the listener once session exists
     self.session.on("conversation_item_added", self._on_conversation_item_added)
1. Your existing code already prefers
_captured_conversation_messages
when rebuilding; this ensures
session.say(...)
items are present and included. Notes: • The listener must be attached after
self.session
is assigned (e.g., during agent init or once the session becomes active). • Be sure to normalize any fields (role/content/timestamp) to match what
_rebuild_turn_transcripts
expects. • Use session.history directly (alternate simpler option) ◦ If your session object exposes a history collection that contains every conversation item (including
session.say
), you can replace captured messages with the session history:
Copy code
messages = [msg.model_dump() for msg in self.session.history]
• This removes the need for a manual capture list and guarantees consistency with what the session stores. Why your current code might miss
session.say
•
session.say
adds a conversation item asynchronously. If you do not listen for
conversation_item_added
or read
session.history
, those items won't end up in
chat_ctx.items
or in whatever source you're serializing for rebuild. Capturing the event or using history ensures those items are present. Next steps I can help with • Show a small patched example integrating the event listener into your agent class. • Help normalize additional fields (timestamps, metadata) if you want richer transcripts. • Walk through debug techniques to verify
conversation_item_added
fires when you call
session.say
. Which option would you like to try first — adding the event listener or switching to
session.history
? Sources: Agent session | LiveKit Documentation | How to capture real-time agent transcriptions during conversations | Building multi-agent architectures with LiveKit agents | LiveKit
b
How would I add a listener somewhere on the agent to ensure it adds anything that was emitted by session.say ? this should happen outside of the llm_node
t
Good question — you can (and should) attach a listener to the AgentSession’s conversation_item_added event during session setup so it captures every message that gets added (including messages produced by
session.say
) outside of
llm_node
. Add the listener once
self.session
(or your session variable) is available — for example, in your agent initialization or the entrypoint that starts the session. What to do (concise) • Create a container on the agent to hold captured items (if you want a copy). • Register a handler for
conversation_item_added
after the session is created. • In the handler, normalize and append the item to your container (or immediately persist it). Example pattern (Python) 1. Initialize storage and attach the listener after session exists:
Copy code
class MyAgent(Agent):
    def on_session_ready(self, session: AgentSession):
        # called once the session is available (or call this from your entrypoint)
        self.session = session
        self._captured_conversation_messages = []

        @self.session.on("conversation_item_added")
        def _on_conversation_item_added(item):
            # Normalize to the dict shape your transcript builder expects
            role = getattr(item, "role", None) or getattr(item, "message", {}).get("role", "assistant")
            content = getattr(item, "content", None) or getattr(item, "message", {}).get("content", "") or ""
            self._captured_conversation_messages.append({"role": role, "content": content})
1. Use this captured list when rebuilding transcripts (your existing preference for
_captured_conversation_messages
will now include
session.say
items):
Copy code
# inside your llm_node wrapper or wherever you rebuild
if self._captured_conversation_messages:
    messages = list(self._captured_conversation_messages)
else:
    messages = [_serialize_message(msg) for msg in chat_ctx.items]
Notes and tips • Attach the listener only after
self.session
is assigned. If you attach earlier (before session creation), it will not receive events. • The event handler runs whenever any conversation item is added: user messages, tool outputs, LLM assistant messages, and messages produced by
session.say
. • Normalize fields (role, content, timestamp) inside the handler so they match what
_rebuild_turn_transcripts
expects. • If you prefer not to maintain your own list, use
self.session.history
(if exposed) which already contains every conversation item in order. If you want, I can: • Patch your agent code to show exactly where to attach this in your project structure (e.g., entrypoint vs Agent constructor). • Provide a snippet that also captures timestamps and metadata so your manual recording includes everything. Which would you prefer? Sources: Text and transcriptions | LiveKit Documentation