brief-dress-9950
03/11/2026, 11:48 AMdef 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.'tall-airline-16756
03/11/2026, 11:49 AMtall-airline-16756
03/11/2026, 11:49 AMcontext.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:
return 'tool call complete. No response required after this.'
with:
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 Documentationbrief-dress-9950
03/11/2026, 12:05 PMdef _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()tall-airline-16756
03/11/2026, 12:06 PMsession.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:
self._captured_conversation_messages = []
1. Add a listener after self.session is available:
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:
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 | LiveKitbrief-dress-9950
03/11/2026, 12:13 PMtall-airline-16756
03/11/2026, 12:14 PMsession.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:
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):
# 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