steep-balloon-41261
12/22/2025, 11:51 PMtall-airline-16756
12/22/2025, 11:51 PMtall-airline-16756
12/22/2025, 11:52 PMcontext) which exposes session.llm (the LLM client) and session.chat_ctx (the conversation context). Inside your tool function you can call the LLM directly — for example, opening a chat stream with async with context.session.llm.chat(chat_ctx=chat_ctx) as stream: and processing responses — without involving the agent's main response flow.
Would you like a short code example in the language you’re using (Python or TypeScript) or guidance on handling streaming vs. single-response calls?
Sources: LLM-Powered Content Filter | LiveKit docsgray-finland-12154
12/22/2025, 11:52 PMtall-airline-16756
12/22/2025, 11:52 PMsession.llm inside a tool call for both Python and TypeScript.
Python (async tool):
from livekit import function_tool, RunContext
@function_tool()
async def lookup_weather(context: RunContext, location: str):
chat_ctx = context.session.chat_ctx # current conversation context
async with context.session.llm.chat(chat_ctx=chat_ctx) as stream:
response_text = ""
async for chunk in stream:
# stream chunks (tokens) as they arrive
response_text += chunk.token
return {"location": location, "weather": response_text}
TypeScript (async tool):
import { functionTool, RunContext } from "livekit";
export const lookupWeather = functionTool(async (context: RunContext, location: string) => {
const chatCtx = context.session.chat_ctx;
const stream = await context.session.llm.chat({ chat_ctx: chatCtx });
let responseText = "";
for await (const chunk of stream) {
// process streaming chunks
responseText += chunk.token;
}
return { location, weather: responseText };
});
Notes:
• Use context.session.chat_ctx if you want the LLM to continue the current conversation context.
• You can also make single-response calls if you prefer (non-streaming), depending on your LLM client API.
• Tell me which pattern you want (streaming vs single response) or share your runtime and I’ll tailor the snippet.gray-finland-12154
12/22/2025, 11:59 PMtall-airline-16756
12/22/2025, 11:59 PMChatContext and appending a ChatMessage with the prompt is fine; the LLM will use that context for the call.
• Use the run_context.session.llm.chat(chat_ctx=...) call as you wrote to open a streaming chat.
• In the stream loop, handle missing fields safely and prefer reading token text consistently.
Suggested minor revision (preserves your logic, safer extraction of token content):
temp_chat_ctx = ChatContext()
temp_chat_ctx.messages.append(ChatMessage(role="user", content=prompt))
response_text = ""
async with run_context.session.llm.chat(chat_ctx=temp_chat_ctx) as stream:
async for chunk in stream:
# some providers put token text in different fields; this safely concatenates available content
token = None
try:
token = chunk.choices[0].delta.content
except Exception:
token = getattr(chunk, "token", None)
response_text += token or ""
If you want the temporary context to include prior conversation messages (so the LLM sees history plus the new prompt), I can show how to copy run_context.session.chat_ctx and append the new message instead. Which behavior do you want: fresh prompt-only context or history+prompt?gray-finland-12154
12/23/2025, 12:12 AMtall-airline-16756
12/23/2025, 12:12 AMrun_context.session (or run_context.session.llm) is None when you call .chat. Let’s narrow it down.
Please check/send these quick diagnostics:
1. Where the tool is defined — paste the function signature and decorator you use (e.g., @function_tool() and params).
2. How the tool is invoked — show the call path or the snippet that triggers the tool.
3. Add this runtime check right before the .chat call and share the printed results:
print("run_context:", run_context)
print("run_context.session:", getattr(run_context, "session", None))
print("run_context.session.llm:", getattr(getattr(run_context, "session", None), "llm", None))
Common causes and fixes:
• The tool received a different context object (not RunContext). Ensure your tool signature accepts the correct context type and the agent runtime passes it.
• Session was not initialized for that execution path. If session is None, ensure the agent/handler attaches a session before calling tools.
• Variable naming mismatch (e.g., you used context elsewhere but run_context here). Use the same param name the framework provides.
If you prefer, paste the tool function and the invocation snippet and I’ll point out the exact line to fix.gray-finland-12154
12/23/2025, 12:21 AMtall-airline-16756
12/23/2025, 12:22 AMrun_context.session is valid but run_context.session.llm is None.
Quick checklist and targeted diagnostics — please run or share the indicated items:
1) Confirm how you create the session
• Paste the AgentSession (or session) initialization. It must include an llm (either a model id string or an LLM instance). Example:
session = AgentSession(llm="openai/gpt-4.1-mini", ...)
2) Confirm how you start the agent
• Show the call that starts the agent or session (e.g., await session.start(agent=MyAgent(), room=...)). If the agent is initialized outside the session flow, the LLM may not be attached.
3) Quick runtime print (right before your .chat call)
• Add/return these prints and paste results:
print("session:", run_context.session)
print("session.llm:", getattr(run_context.session, "llm", None))
print("type(session.llm):", type(getattr(run_context.session, "llm", None)))
4) Possible causes to check now
• You omitted llm when constructing the session (most common).
• Your agent class or some initialization step overrides or sets session.llm = None.
• You passed a plugin or different LLM wrapper that wasn’t assigned to session.llm (ensure you pass it into AgentSession).
If you share the AgentSession construction and the agent start snippet, I’ll point out exactly where the LLM should be set or why it’s getting lost. Which code snippet can you paste?
Sources: Large language models (LLM) | LiveKit Docs | Kimi LLM | LiveKit docs | LLM-Powered Content Filter | LiveKit docs | OpenAI LLM plugin guide | LiveKit docsgray-finland-12154
12/23/2025, 12:34 AMtall-airline-16756
12/23/2025, 12:35 AMsession = AgentSession[...] without passing an llm. That makes session valid but session.llm None.
What to do
• Pass an LLM (model id or LLM instance) when constructing the session. Example:
session = AgentSession[UserData](
userdata=userdata,
turn_detection="manual",
llm="openai/gpt-4.1-mini", # or llm=inference.LLM(model="openai/gpt-4.1-mini")
)
• Then start the agent as you already do:
agent = MyAgent(instructions=assembled_instructions, conversation_logger=conversation_logger)
await session.start(agent=agent)
Quick checks
• If you already set an LLM elsewhere, confirm it’s set before await session.start(...) by printing session.llm.
• Ensure no code later overwrites session.llm = None or your agent class removes it.
If you want, paste how you construct MyAgent (its init) or any code that might touch session.llm and I’ll point out exactly where to set or preserve the LLM.tall-airline-16756
12/23/2025, 12:39 AMllm into AgentSession — good.
2) The new error "'ChatChunk' object has no attribute 'choices'" means your code assumes the stream yields OpenAI-like chunks (chunk.choices[0].delta.content) but the LiveKit LLM stream yields chunks with different fields (commonly a token or text field, or chunk-level `.delta`/`.token` directly). Read tokens from the chunk safely.
Suggested minimal fix (safe extraction and streaming):
temp_chat_ctx = ChatContext()
temp_chat_ctx.items.append(ChatMessage(role="user", content=[prompt]))
response_text = ""
async with context.session.llm.chat(chat_ctx=temp_chat_ctx) as stream:
async for chunk in stream:
# try common fields in order
token = None
# preferred: provider may expose token/text directly
token = getattr(chunk, "token", None) or getattr(chunk, "text", None)
# fallback: delta-style
try:
token = token or chunk.delta.content
except Exception:
pass
response_text += token or ""
Or, if your LLM uses a single-response API (non-streaming), call it without streaming:
resp = await context.session.llm.chat_once(chat_ctx=temp_chat_ctx)
response_text = resp.text # or resp.content depending on the API
If you share one example chunk (print a chunk) I can give the exact field to use. Do you want streaming or single-response behavior?gray-finland-12154
12/23/2025, 12:41 AMtall-airline-16756
12/23/2025, 12:42 AMchat_once method on the LLM, and the stream yields chunks with different fields than OpenAI-style choices. Here's a concise, working pattern and quick checks.
Why the error happens
• The LLM only exposes chat (streaming). chat_once does not exist.
• Chunks are ChatChunk objects; they often expose token, text, or delta.content — not choices.
Working async streaming snippet (safe extraction):
temp_chat_ctx = ChatContext()
temp_chat_ctx.items.append(ChatMessage(role="user", content=[prompt]))
response_text = ""
async with context.session.llm.chat(chat_ctx=temp_chat_ctx) as stream:
async for chunk in stream:
# try common fields in order
token = getattr(chunk, "token", None) or getattr(chunk, "text", None)
if not token:
# delta may be present
token = getattr(getattr(chunk, "delta", None), "content", None)
# final fallback: string representation
token = token or str(chunk)
response_text += token or ""
Alternative (non-streaming feel)
• If you want a single-response style, still use chat but accumulate and stop after first chunk or after stream completes. There's no direct chat_once.
Quick diagnostics if still broken
• Print a single chunk to see real structure:
async with context.session.llm.chat(chat_ctx=temp_chat_ctx) as stream:
async for chunk in stream:
print(repr(chunk))
break
Share that output and I’ll point exactly which field to read.
Do you want the snippet adapted to stop after the first complete response (non-streaming behavior) or to stream tokens to downstream components (TTS, UI)?
Sources: Pipeline nodes and hooks | LiveKit docs | Large language models (LLM) | LiveKit Docs | Modifying LLM output before TTS | LiveKit docs | LLM-Powered Content Filter | LiveKit docs | Simple Content Filter | LiveKit docsgray-finland-12154
12/23/2025, 12:44 AMtall-airline-16756
12/23/2025, 12:44 AMcontext.session.chat_ctx.
• From inside an Agent instance: use self.session.chat_ctx.
Inspect messages
chat_ctx = context.session.chat_ctx
for msg in chat_ctx.messages:
# msg is a ChatMessage object
print(msg.role, msg.get_text()) # get_text() safely extracts text parts
Create a new or temporary context
from livekit.agents import ChatContext, ChatMessage
# fresh context
temp = ChatContext()
temp.add_message(role="user", content="Hello")
# or copy existing and append
copied = context.session.chat_ctx.copy()
copied.add_message(role="user", content="Follow-up question")
Use a ChatContext with the LLM (streaming chat)
async with context.session.llm.chat(chat_ctx=copied) as stream:
full = ""
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
full += token or ""
Persist changes to session (if you modify history and want it kept)
• After modifying a context in an agent, call your session update method (e.g., await self.update_chat_ctx(chat_ctx)) if your flow requires persisting changes beyond the current turn.
Quick notes
• Use .copy() to avoid mutating the live session context when building temporary prompts.
• ChatMessage content can be multiparts; use get_text() or inspect fields to extract raw text.
• If you want me to tailor an example for your tool function, paste the function signature and whether you want a fresh context or history+prompt. Sources: Large language models (LLM) | LiveKit Docs | Tool definition and use | LiveKit docs | Vision | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Agents and handoffs | LiveKit docsgray-finland-12154
12/23/2025, 1:13 AMtall-airline-16756
12/23/2025, 1:14 AMchat_ctx is not on AgentSession. It's owned by the active agent and injected into the RunContext for tools.
What to change
• In a function tool, use context.chat_ctx (the RunContext) — not self.session.chat_ctx or session.chat_ctx.
• If you're inside agent methods and have a bound agent instance, use self.chat_ctx.
Fix for your traceback Replace the call that does:
return await _conversation_spark(self.session.chat_ctx, context)
with:
return await _conversation_spark(context.chat_ctx, context)
(or self.chat_ctx if the tool is executed as a bound agent method and self is the agent).
Quick checks
• Print context right before the call to confirm it has `chat_ctx`:
print("run context:", context)
print("has chat_ctx:", hasattr(context, "chat_ctx"), getattr(context, "chat_ctx", None))
• If context.chat_ctx is None, share how the agent is started and the tool is invoked so I can point out where the agent/chat context is not being set.
If you want, paste the _conversation_spark signature and the tool decorator/signature and I’ll show the exact one-line change.
Sources: Agents and handoffs | LiveKit docs | LLM-Powered Content Filter | LiveKit docsgray-finland-12154
12/23/2025, 1:17 AMtall-airline-16756
12/23/2025, 1:18 AMRunContext doesn't have chat_ctx directly. Use context.session.chat_ctx inside your tool.
Fix example (use this in your function tool):
@function_tool()
async def conversation_spark(context: RunContext):
# get last 6 messages safely
chat_ctx = context.session.chat_ctx
recent = chat_ctx.messages[-6:]
for msg in recent:
print(msg.role, msg.get_text())
# build a temporary context if you want to avoid mutating live history
temp = chat_ctx.copy()
temp.add_message(role="user", content=prompt)
# stream from LLM and assemble text
response_text = ""
async with context.session.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
response_text += token or ""
return response_text.strip()
Quick checks:
• Confirm your tool signature is async def fn(context: RunContext, ...).
• Print print("has session:", hasattr(context, "session"), "has chat_ctx:", hasattr(getattr(context, "session", None), "chat_ctx")) if you still hit errors.
• If context.session.chat_ctx is None, share how/where you start the session and agent so I can point out why chat context isn't initialized.
Which behavior do you want: use history+prompt or a fresh prompt-only context?
Sources: Tool definition and use | LiveKit docs | External data and RAG | LiveKit docs | Agents and handoffs | LiveKit docsgray-finland-12154
12/23/2025, 1:19 AMtall-airline-16756
12/23/2025, 1:19 AM# correct: access agent's chat_ctx via session.agent
chat_ctx = context.session.agent.chat_ctx
for msg in chat_ctx.messages[-6:]:
print(msg.role, msg.get_text())
Or, if the tool is a bound agent method, use `self.chat_ctx`:
# inside an Agent method
for msg in self.chat_ctx.messages[-6:]:
...
Quick debugging prints to confirm what you have at runtime:
print("has session:", hasattr(context, "session"))
print("has agent on session:", hasattr(context.session, "agent"))
print("has chat_ctx on agent:", hasattr(getattr(context.session, "agent", None), "chat_ctx"))
If context.session.agent is missing or agent.chat_ctx is None, share how you start the session and agent (the await session.start(agent=...) call) and the agent class constructor — I’ll point out where the agent/chat context is not being set.
Sources: Agents and handoffs | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | External data and RAG | LiveKit docsgray-finland-12154
12/23/2025, 1:19 AMtall-airline-16756
12/23/2025, 1:19 AMcontext.session.agent.chat_ctx.
• AgentSession itself does not have chat_ctx.
2) Minimal fix for your tool Replace any use of context.session.chat_ctx or self.session.chat_ctx with context.session.agent.chat_ctx. Example:
@function_tool()
async def conversation_spark(context: RunContext):
chat_ctx = context.session.agent.chat_ctx
recent = chat_ctx.messages[-6:]
# build temporary copy to avoid mutating live history
temp = chat_ctx.copy()
temp.add_message(role="user", content=[prompt])
response_text = ""
async with context.session.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
response_text += token or ""
return response_text.strip()
Quick debug prints to confirm runtime shape:
print("has session:", hasattr(context, "session"))
print("has agent on session:", hasattr(context.session, "agent"))
print("has chat_ctx on agent:", hasattr(getattr(context.session, "agent", None), "chat_ctx"))
If context.session.agent is missing or agent.chat_ctx is None, paste how you construct MyAgent (its init) and the await session.start(agent=agent) call — I’ll point out exactly where the agent/chat context isn’t being set.gray-finland-12154
12/23/2025, 1:20 AMtall-airline-16756
12/23/2025, 1:21 AMcontext.session at runtime isn't populated with an agent attribute, so context.session.agent.chat_ctx fails.
Quick targeted checks and fixes:
1) Confirm session.start completed before tools run
• Ensure await session.start(agent=agent) finished before any RPCs or tools invoke conversation_spark. Tools called before the agent is attached will see an incomplete session.
2) Print runtime shape immediately in the tool to see what's available:
print("context:", type(context))
print("has session:", hasattr(context, "session"))
print("session type:", type(getattr(context, "session", None)))
print("has agent on session:", hasattr(getattr(context, "session", None), "agent"))
print("session attrs:", vars(getattr(context, "session", None)) if getattr(context, "session", None) else None)
Share that output if you want me to interpret it.
3) Safe access pattern (avoid AttributeError)
sess = getattr(context, "session", None)
agent = getattr(sess, "agent", None)
if agent is None:
# bail or handle gracefully
logger.warning("Agent not attached to session yet")
return ""
chat_ctx = agent.chat_ctx
recent = chat_ctx.messages[-6:]
4) Common root causes
• Tools invoked before session.start(...) completes (race condition).
• You started a plain AgentSession but never attached/started your agent instance properly.
• Custom agent/session subclass overrides that remove agent reference.
If you paste the output from the debug prints above and confirm when conversation_spark is called relative to await session.start(...), I’ll point to the exact line to change or the ordering bug to fix.
Sources: Agents (Python) | livekit/livekit_composite | DeepWiki | Agents and handoffs | LiveKit docsgray-finland-12154
12/23/2025, 1:25 AMtall-airline-16756
12/23/2025, 1:25 AM_chat_ctx and _agent-related fields, but no public agent attribute. However it exposes current_agent and llm publicly.
• So the safe, supported way to get the active chat context in your tool is via context.session.current_agent.chat_ctx (not context.session.agent or context.session.chat_ctx).
Minimal fix Replace your access with:
sess = getattr(context, "session", None)
agent = getattr(sess, "current_agent", None)
if not agent:
logger.warning("No active agent on session yet")
return ""
chat_ctx = agent.chat_ctx
for msg in chat_ctx.messages[-6:]:
...
Safer streaming example using a temp copy:
chat_ctx = agent.chat_ctx
temp = chat_ctx.copy()
temp.add_message(role="user", content=[prompt])
response_text = ""
async with sess.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
response_text += token or ""
return response_text.strip()
Debug tips
• If current_agent is None, tools are being invoked before await session.start(agent=...) completes or the agent was never attached.
• Print print("current_agent:", getattr(sess, "current_agent", None)) right before use to confirm.
• Avoid accessing private _chat_ctx unless you must — use current_agent.chat_ctx.
If you want, paste the exact tool decorator/signature and where the RPC is queued relative to await session.start(...) and I’ll point out the one-line ordering change to guarantee current_agent is set before the tool runs.gray-finland-12154
12/23/2025, 1:35 AMtall-airline-16756
12/23/2025, 1:36 AMfrom livekit.agents import function_tool, RunContext, ChatContext, ChatMessage
@function_tool()
async def conversation_spark(context: RunContext) -> str:
"""
Generate one short, curiosity-provoking question to shift conversation energy.
Returns a single question (<= 25 words). Falls back to a safe default on error.
"""
<http://logger.info|logger.info>("Generating conversation spark...")
# Safe session / agent access
sess = getattr(context, "session", None)
if not sess:
logger.warning("Session not available in context")
return "If you could change one thing about how today went, what would it be?"
agent = getattr(sess, "current_agent", None)
if not agent:
logger.warning("No active agent on session")
return "If you could change one thing about how today went, what would it be?"
chat_ctx = getattr(agent, "chat_ctx", None)
if chat_ctx is None:
logger.warning("chat_ctx not available on agent")
return "If you could change one thing about how today went, what would it be?"
# Build a temporary context (copy last N messages to avoid mutating live history)
recent = chat_ctx.messages[-6:]
temp_ctx = chat_ctx.copy()
# trim to last 6 messages if copy preserves full history
temp_ctx.messages = list(recent)
# Compose instruction + recent conversation for the LLM
conversation_history = "\n".join(f"{m.role}: {m.get_text()}" for m in recent)
system_instructions = (
"You are helping a voice AI produce ONE specific, conversational question (<=25 words) "
"that: is interesting, reveals thinking, avoids therapy prompts, and can shift flat energy. "
"Return ONLY the question."
)
temp_ctx.add_message(role="system", content=system_instructions)
temp_ctx.add_message(role="user", content=f"Recent conversation:\n{conversation_history}")
# Stream from LLM and assemble text robustly
try:
response_text = ""
async with sess.llm.chat(chat_ctx=temp_ctx) as stream:
async for chunk in stream:
# resilient extraction of token/text/delta
token = getattr(chunk, "token", None) or getattr(chunk, "text", None)
if not token:
delta = getattr(chunk, "delta", None)
token = getattr(delta, "content", None) if delta else None
token = token or (str(chunk) if chunk is not None else "")
response_text += token or ""
spark = response_text.strip()
logger.info(f"Generated conversation spark: {spark}")
# final safety: return fallback if empty or too long
if not spark:
raise ValueError("Empty LLM response")
if len(spark.split()) > 30: # generous safeguard vs 25 words requirement
spark = "If you could change one thing about how today went, what would it be?"
return spark
except Exception as e:
logger.error(f"Error generating conversation spark: {e}")
return "If you could change one thing about how today went, what would it be?"
If you want, I can:
• Trim the system/user prompt to make the LLM more likely to obey the 25-word limit.
• Return streaming tokens to TTS/UI instead of accumulating first. Which do you prefer?tall-airline-16756
12/23/2025, 1:40 AM.messages. Use get_messages() (or other accessor methods) to read history. Here's a minimal, corrected version of your tool that handles the read-only context, builds a temporary prompt from the last 6 messages, streams the LLM safely, and returns a short spark with fallbacks.
from livekit.agents import function_tool, RunContext, ChatContext, ChatMessage
@function_tool()
async def conversation_spark(context: RunContext) -> str:
<http://logger.info|logger.info>("Generating conversation spark...")
sess = getattr(context, "session", None)
if not sess:
logger.warning("Session not available in context")
return "If you could change one thing about how today went, what would it be?"
agent = getattr(sess, "current_agent", None) or getattr(sess, "agent", None)
if not agent:
logger.warning("No active agent on session")
return "If you could change one thing about how today went, what would it be?"
chat_ctx = getattr(agent, "chat_ctx", None)
if chat_ctx is None:
logger.warning("chat_ctx not available on agent")
return "If you could change one thing about how today went, what would it be?"
# Use read-only accessor
msgs = chat_ctx.get_messages()
recent = msgs[-6:] if msgs else []
# Build a temporary ChatContext (copy of recent messages)
temp = ChatContext()
for m in recent:
temp.add_message(role=m.role, content=m.get_text())
# Add system prompt guiding the LLM to return one short question
system_instructions = (
"You are helping a voice AI produce ONE specific conversational question (<=25 words) "
"that is interesting, reveals thinking, avoids therapy-style prompts, and can shift flat energy. "
"Return ONLY the question."
)
temp.add_message(role="system", content=system_instructions)
# Use LLM streaming and extract token/text/delta robustly
try:
response_text = ""
async with sess.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None)
if not token:
delta = getattr(chunk, "delta", None)
token = getattr(delta, "content", None) if delta else None
token = token or (str(chunk) if chunk is not None else "")
response_text += token or ""
spark = response_text.strip()
if not spark:
raise ValueError("Empty LLM response")
# safety: enforce length constraint
if len(spark.split()) > 30:
return "If you could change one thing about how today went, what would it be?"
<http://logger.info|logger.info>(f"Generated conversation spark: {spark}")
return spark
except Exception as e:
logger.error(f"Error generating conversation spark: {e}")
return "If you could change one thing about how today went, what would it be?"
If you want, I can:
• Shorten the system prompt to improve adherence to the 25-word limit.
• Return streaming tokens to TTS/UI instead of waiting for the full response.
Which would you prefer?
Sources: Agents and handoffs | LiveKit docs | External data and RAG | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Live Conversations with AI using ChatGPT and WebRTC | Tool definition and use | LiveKit docsgray-finland-12154
12/23/2025, 1:43 AMtall-airline-16756
12/23/2025, 1:44 AM.messages property on that context object (each item is a ChatMessage). Do not call get_messages().
Minimal corrected pattern for your tool (focus on the failing lines):
# inside your function tool
# get the read-only chat context from the active agent
sess = getattr(context, "session", None)
agent = getattr(sess, "current_agent", None) or getattr(sess, "agent", None)
if not agent or not getattr(agent, "chat_ctx", None):
logger.warning("No active agent/chat_ctx")
return "If you could change one thing about how today went, what would it be?"
chat_ctx = agent.chat_ctx # read-only context object
msgs = chat_ctx.messages # <-- correct: use .messages, not get_messages()
recent = msgs[-6:] if msgs else []
conversation_history = "\n".join(f"{m.role}: {m.get_text()}" for m in recent)
When calling the LLM, build a temporary ChatContext (so you don't mutate the read-only one), add system/user messages, then stream:
temp = ChatContext()
temp.add_message(role="system", content=system_instructions)
temp.add_message(role="user", content=conversation_history)
response_text = ""
async with sess.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
response_text += token or ""
Quick checklist to avoid similar errors:
• Use context.session.current_agent.chat_ctx (or context.session.agent.chat_ctx if present) to get the chat context.
• Read history via .messages.
• Copy into a new ChatContext() for LLM calls; do not mutate the read-only object.
• Use resilient extraction for streamed chunks (token/text/delta).
If you want, paste the exact few lines around where you build conversation_history and I’ll patch them inline into your function.
Sources: External data and RAG | LiveKit docs | Tool definition and use | LiveKit docsgray-finland-12154
12/23/2025, 1:53 AMtall-airline-16756
12/23/2025, 1:54 AM.messages, get_messages(), get_messages) may not exist.
What to do now — pick one of these reliable approaches and apply it exactly:
1) Use session.history (recommended, works across versions)
• Access full conversation items from the session:
history = context.session.history # list of conversation items
recent_msgs = [it for it in history if getattr(it, "type", None) == "message"][-6:]
conversation_history = "\n".join(f"{m.role}: {m.text_content}" for m in recent_msgs)
2) If you need the chat context object specifically, iterate the read-only context (if supported) or copy it
• Try iterating or copying (some versions support this):
# attempt 1: iterate (async or sync depending on version)
msgs = [m for m in context.chat_ctx] # or: [m async for m in context.chat_ctx]
# attempt 2: copy to mutable ChatContext and inspect
copied = context.chat_ctx.copy()
msgs = copied.messages # now accessible on the copied object
3) Persist a mutable history yourself (most robust)
• Maintain a simple list in session.userdata when messages arrive, then read it in tools:
# when handling new messages elsewhere:
context.session.userdata.messages.append({"role": role, "text": text})
# in tool:
msgs = context.session.userdata.messages[-6:]
Minimal working replacement for your tool using session.history (copy this into your function):
sess = getattr(context, "session", None)
if not sess:
return "If you could change one thing about how today went, what would it be?"
history = getattr(sess, "history", []) or []
recent = [it for it in history if getattr(it, "type", None) == "message"][-6:]
conversation_history = "\n".join(f"{m.role}: {getattr(m, 'text_content', getattr(m, 'text', ''))}" for m in recent)
# build temp ChatContext for LLM
temp = ChatContext()
temp.add_message(role="system", content=system_instructions)
temp.add_message(role="user", content=conversation_history)
# stream from LLM
response_text = ""
async with sess.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None) \
or getattr(getattr(chunk, "delta", None), "content", None) \
or str(chunk)
response_text += token or ""
return (response_text.strip() or "If you could change one thing about how today went, what would it be?")
If you want, tell me:
• Your LiveKit Agents package version (output of pip show livekit-agents), and
• Whether context.chat_ctx.copy() works (you can try quickly and paste the exception/output).
With that I’ll give the exact one-line fix tailored to your version.
Sources: External data and RAG | LiveKit docs | Agents and handoffs | LiveKit docsgray-finland-12154
12/23/2025, 1:54 AMtall-airline-16756
12/23/2025, 1:55 AMcontext.session.history. The read-only chat context passed into tools doesn't expose .messages or .get_messages(), so use session.history, pick the last message items you need, copy them into a temporary ChatContext, then call session.llm.chat(...).
Minimal, version-compatible snippet you can drop into your tool:
from livekit.agents import function_tool, RunContext, ChatContext
@function_tool()
async def conversation_spark(context: RunContext) -> str:
<http://logger.info|logger.info>("Generating conversation spark...")
sess = getattr(context, "session", None)
if not sess:
return "If you could change one thing about how today went, what would it be?"
# session.history is the supported source of message items
history = getattr(sess, "history", []) or []
recent = [it for it in history if getattr(it, "type", None) == "message"][-6:]
# Build a temporary ChatContext from recent history
temp = ChatContext()
for item in recent:
text = getattr(item, "text_content", None) or getattr(item, "text", "") or str(getattr(item, "content", ""))
temp.add_message(role=getattr(item, "role", "user"), content=text)
# Add instructions for the spark
system_instructions = (
"You are helping a voice AI produce ONE specific, conversational question (<=25 words) "
"that is interesting, reveals thinking, avoids therapy-style prompts, and can shift flat energy. "
"Return ONLY the question."
)
temp.add_message(role="system", content=system_instructions)
# Stream from the session's LLM and assemble text robustly
try:
response_text = ""
async with sess.llm.chat(chat_ctx=temp) as stream:
async for chunk in stream:
token = getattr(chunk, "token", None) or getattr(chunk, "text", None)
if not token:
delta = getattr(chunk, "delta", None)
token = getattr(delta, "content", None) if delta else None
response_text += token or ""
spark = response_text.strip()
if not spark:
raise ValueError("Empty LLM response")
if len(spark.split()) > 30:
return "If you could change one thing about how today went, what would it be?"
return spark
except Exception as e:
logger.error("Error generating conversation spark: %s", e)
return "If you could change one thing about how today went, what would it be?"
If you want, I can:
• Shorten the system prompt to increase adherence to the 25-word limit.
• Return streaming tokens to TTS instead of waiting for the full response.
Which would you prefer?
Sources: External data and RAG | LiveKit docs | Large language models (LLM) | LiveKit Docs