This message was deleted.
# ask-ai
s
This message was deleted.
t
Thanks for reaching out, Rohan. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I reviewed your logs. I’m responding to the issue you described: the LLM produces an AIMessage ("Hi, this is Alex.") but the LangGraph/LLMAdapter yields 0 messages, so the TTS node receives an empty stream. Root cause (what the logs show) • The LLM returns an
AIMessage
string, but
LLMAdapter.chat()
yields 0 items ("No messages yielded from parent iterator"). • Because nothing is emitted as a stream of strings/ChatChunks, the TTS node receives no text and produces no audio. What you can do next (practical steps) 1. Confirm the workflow yields text ◦ Ensure the final LangGraph node explicitly yields the response text (a
str
) or an object that the adapter can stream. ◦ Example: have the final node yield
ai_message.content
(a plain string) so
LangGraphStream
exposes it. 1. Add quick diagnostic logging around the adapter stream ◦ Right after opening the stream, log every chunk to confirm what the adapter emits:
Copy code
async with llm_adapter.chat(chat_ctx) as stream:
       async for chunk in stream:
           print("LLMAdapter yielded:", repr(chunk))
• If the loop runs 0 times, the graph/workflow is not producing a consumable async iterable of text. 1. Ensure the stream exposes
to_str_iterable()
or yields plain strings ◦ The adapter expects either a stream of
str
or something convertible via
to_str_iterable
. Make the final output compatible. 1. Optional fallback: extract final message from chat context ◦ If you need a quick patch, modify/override the agent’s
llm_node
to yield the last
AIMessage.content
when the parent stream yields nothing:
Copy code
async for chunk in super().llm_node(...):
       if isinstance(chunk, str) and chunk.strip():
           yield chunk
   # fallback
   last = chat_ctx.messages[-1]
   if hasattr(last, "content") and isinstance(last.content, str):
       yield last.content
• This ensures TTS still receives text when the adapter fails to stream. What I can help with next • If you’d like, paste the final node code for your LangGraph workflow (or the graph compile/return shape). I can check whether it returns/yields a string or a structure that the adapter can’t convert. • I can also help craft the exact override for
llm_node
based on your agent code. Would you like me to review the final node or the adapter wrapper code? Sources: LangChain integration guide | LiveKit Docs | Agent speech and audio | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Tool definition and use | LiveKit docs | Modifying LLM output before TTS | LiveKit docs
Thanks — I’m looking at the workflow you posted. You likely have the issue in the
agent_node
(the final/decision node): the graph structure is fine, but the adapter needs the workflow to yield or return a stream of plain strings (or ChatChunk-like objects) that LLMAdapter can convert to text for TTS. From your logs the LLM produced an
AIMessage
, but nothing was emitted from the workflow iterator. What to check and change (concise steps) 1. Inspect agent_node output • Confirm
agent_node
either yields strings (async generator) or returns a structure the adapter can stream (e.g., an object with
messages
containing
AIMessage
). • If
agent_node
returns a single
AIMessage
object but does not yield its
.content
, the adapter will see an empty stream. 1. Make the final node explicitly yield text • If the node is synchronous/returns a value: return a dict like
{"messages": [AIMessage(content=...)]}
or a string. • Prefer making it an async generator that yields the content:
Copy code
async def agent_node(state):
    response = ...  # create AIMessage or get content
    yield response.content  # yields plain str to stream
1. Ensure streaming compatibility • The compiled workflow must support streaming: the iterator returned by
.chat()
should produce values. Add a quick debug inside
agent_node
to log/yield debug chunks to validate the stream. 1. Quick diagnostic to confirm the failure point • Add this where you use the adapter to see what it emits:
Copy code
async with llm_adapter.chat(chat_ctx) as stream:
    async for chunk in stream:
        print("EMITTED:", repr(chunk))
• If this loop never runs, the workflow isn’t yielding strings. 1. Short-term fallback (if you want immediate TTS) • In your agent wrapper, after the graph returns, inspect the last message in
chat_ctx.messages
and yield
last.content
if the stream was empty. This covers adapter extraction failures while you fix the graph. If you want, paste the
agent_node
implementation (and how you construct its return value), and I’ll point out exactly where to yield or how to restructure it so LLMAdapter yields text for TTS.
b
LangGraph nodes can't be async generators, right?
t
Correct — LangGraph nodes cannot be async generators. They run as normal (sync or async) functions that return state, not as streaming generators. That means you can’t directly yield text from inside a LangGraph node to produce a streaming async iterable. What to do instead (concise, actionable) • Return the message(s) in a stream-compatible shape from the final node: ◦ Make your final
agent_node
return a dict containing messages, e.g.:
Copy code
{"messages": [AIMessage(content="Hi, this is Alex.")]}
• The LLMAdapter will extract messages from that returned structure and convert them into a stream if the workflow output shape is compatible. • Or produce a field the adapter knows how to convert to strings: ◦ Return a plain string field like
{"text": "Hi, this is Alex."}
if your adapter is configured to read that field, or ensure the compiled workflow exposes a `.messages`/`.content` path the adapter expects. • Add an explicit “emitter” node if you need streaming behavior: ◦ Keep
agent_node
producing the final AIMessage in state, then add a small node (e.g.,
emit_text
) that reads that state and returns the plain string or
{"messages":[AIMessage(...)]}
. Hook
emit_text
as the finish node so the adapter sees the correct shape. • Diagnostic quick-check ◦ After compile, log what the adapter stream actually emits:
Copy code
async with llm_adapter.chat(chat_ctx) as stream:
        async for chunk in stream:
            print("EMITTED:", repr(chunk))
• If you see no iterations, the compiled workflow’s final return shape is not being recognized as streamable. If you paste your
agent_node
implementation (the code that currently creates the AIMessage / response), I’ll point out the exact minimal change to make its return shape compatible with LLMAdapter so TTS receives the text.
Thanks — I reviewed your
agent_node
. You're returning {"messages": [response]} which is the right idea, but the adapter still yields nothing because the compiled workflow's final return shape isn't being exposed as a stream the LLMAdapter can convert to strings. Two likely causes and precise fixes 1) Adapter expects top-level
messages
with plain message objects (not nested or wrapped) • Confirm
response
is an actual
AIMessage
instance (not a custom wrapper). Return exactly:
Copy code
return {"messages": [AIMessage(content=response.content)]}
or if
response
is already an AIMessage:
Copy code
return {"messages": [response]}
• Ensure no extra nesting (e.g., don't return {"output": {"messages": [...]}}). The adapter looks for
messages
at the top level. 2) The compiler/runtime is dropping the returned value from
agent_node
(common when finish node isn't the returned node) • Ensure the node you return from is the workflow's finish node. If
agent_node
may route to
flush_stream
or
tools
and those become final, their return shape can be what the adapter sees (possibly empty). • Fix: add a tiny emitter/finish node that reads state["messages"] or state["last_response"] and returns the top-level
{"messages":[AIMessage(...)]}
shape. Example:
Copy code
def emit_node(state):
      last = state.get("last_response") or state.get("messages")[-1]
      return {"messages": [AIMessage(content=getattr(last, "content", str(last)))]}
Then set
emit_node
as the workflow finish point. Quick diagnostics to confirm where it drops • After compile, run this to see what the adapter actually emits:
Copy code
async with llm_adapter.chat(chat_ctx) as stream:
      async for chunk in stream:
          print("EMITTED:", repr(chunk))
If zero iterations, inspect the compiled workflow return directly:
Copy code
result = compiled_workflow.run_sync(input_state)  # or the appropriate run call
  print("compiled result:", result)
Check that
result
has top-level
messages
with AIMessage(s) whose
.content
is a str. Short-term fallback in agent_node (safe patch) • Set
state["last_response"] = response
before returning, and add/ensure an emitter node reads that and returns top-level
{"messages":[AIMessage(...)]}
. This avoids relying on the adapter to dig into nested state. If you want, paste: • The exact value/type of
response
(repr(response)), and • Which node is set as the workflow finish node (or the compile call options). I’ll point out the minimal change to ensure the adapter yields the text to TTS.
You're very close — the final-state log shows a top-level
messages
key with an
AIMessage
, but the adapter still yields 0 items. To pinpoint why the LLMAdapter isn't extracting text, I need two quick confirmations: 1. Which node is set as the workflow finish node? Is it your new
emit_text
node (the one logging the top-level
messages
) or could
flush_stream
/
agent
be the finish node instead? 2. Paste the exact runtime shape of the emitted AIMessage (repr): what does
repr(result["messages"][0])
show when you inspect the compiled workflow result just before handing it to the adapter? I need the concrete type and fields (e.g., class name and
.content
value). Also, please paste the
flush_stream
node implementation (if it exists). Common causes I’ll check after you confirm: • The finish node is not the node returning the
messages
key (so the adapter sees a different final return). • The returned
AIMessage
is a wrapper type the adapter doesn't recognize (needs to be an actual
AIMessage
with a
content
str). • The compiled workflow returns the messages but under a different key or nested structure at runtime. Send the two items above and I’ll give the exact minimal change to make the adapter yield the text to TTS.
Thanks — your
emit_text
and graph look correct and are the right approach. To pinpoint why LLMAdapter still yields 0 messages, I need two quick confirmations and one small diagnostic you can run. Please confirm: 1. How you instantiate/wrap the workflow in the adapter — paste the code where you create the LLMAdapter (or call
langchain.LLMAdapter(graph=...)
) and how you call
.chat(...)
. Is streaming explicitly enabled or left default? 2. The exact
should_continue
implementation (or paste its code). If it routes to END/emit_text correctly, the adapter should see the emit_text return; if it returns an unexpected branch, the adapter may see a different final node. Quick diagnostic to run (one-shot): • Right after you get the compiled workflow result but before handing it to the adapter, print the compiled output exactly as seen at runtime:
Copy code
result = compiled_workflow.run_sync(input_state)  # or the sync/async runner you use
print("COMPILED_WORKFLOW_RESULT:", repr(result))
print("MESSAGES_KEY_PRESENT:", "messages" in result)
if "messages" in result:
    print("MSG0_REPR:", repr(result["messages"][0]))
    print("MSG0_TYPE:", type(result["messages"][0]))
    print("MSG0_CONTENT_TYPE:", type(result["messages"][0].content), "CONTENT_REPR:", repr(result["messages"][0].content))
If you use async runner, run equivalent await and prints. Send me the three confirmation items (adapter instantiation snippet, should_continue code, and the printed diagnostic output) and I’ll point out the exact mismatch and the minimal change to make LLMAdapter yield the text to TTS.
Thanks — you’ve given everything I need. You’re very close; the workflow return shape is correct, but the adapter still yields 0 because the LLMAdapter expects the workflow to expose streamable chunks (via the StreamWriter or ChatChunk-like outputs) rather than only returning a final dict. Since LangGraph nodes can't be async generators, the fix is to write the text into the workflow's
writer
during the agent step (or in emit_text) so the adapter sees a stream. What to change (minimal, actionable) 1) Write text into the StreamWriter from the agent node (preferred) • In your
agent_node
, after you obtain
response.content
, call the supplied
writer
to emit the content as a chunk before returning:
Copy code
if writer is not None and response_content:
    await writer.write(response_content)
• Keep returning
{"messages":[AIMessage(content=response_content)]}
so the final state remains correct. Why: Writing to
writer
produces the streaming chunks the LLMAdapter consumes. Returning only a
messages
dict produces a final value but no streamed items, so the adapter yields 0. 2) OR emit chunks from
emit_text
(if you prefer central emitter) • If you want streaming only at the end, have
emit_text
use the writer:
Copy code
def emit_text(state, writer=None):
    last = ...
    if writer is not None and last.content:
        # if writer is sync, await as needed; match signature used by your runtime
        await writer.write(last.content)
    return {"messages":[AIMessage(content=str(last.content))]}
• Ensure
emit_text
is actually invoked and is the final node before END (you already route to it). 3) Quick verification (after change) • Run this debug when using the adapter to confirm streamed chunks:
Copy code
async with adapter.chat(chat_ctx) as stream:
    async for chunk in stream:
        print("STREAMED:", repr(chunk))
You should see the text chunks (or a single string) printed, and the adapter will yield >0 messages. Notes and caution • Use
await writer.write(...)
if
writer
is async. If the runtime provides a sync writer, call accordingly. • Keep returning
{"messages":[AIMessage(...)]}
so post-run state remains compatible with other logic. • Do not rely only on the final return for streaming; the adapter reads streaming chunks via the writer/stream interface. If you want, paste the signature/type of
StreamWriter
(is it async and does it expose
write()
), and I’ll give the exact one-line insertion for your
agent_node
and an example for
emit_text
matching your runtime.
Thanks — your updated
agent_node
is almost correct. The key issues: 1. Calling
writer({...})
as a function won’t stream text the LLMAdapter consumes. The adapter expects streaming output from the LangGraph execution (chunks exposed via the graph’s streaming protocol), not arbitrary side-effect calls. 2. You should emit streamable chunks from the graph execution path. Since LangGraph nodes can't be async generators, use the graph's streaming API pattern: the node must be implemented to produce streaming chunks that the adapter will see (e.g., via the graph's astream/streaming runnable pattern). Practically, that means yielding/returning chunk objects in a streaming-aware node (or using the LLM’s
.astream()
inside the node and yielding AIMessageChunk-like pieces), not calling a local
writer
function. Minimal recommended changes (pick one): • Preferred (stream from agent_node): ◦ Use the LLM’s streaming API inside
agent_node
and yield chunks the adapter understands. ◦ Example pattern:
Copy code
async def agent_node(state, config=None):
        async for chunk in llm.astream(messages, config=config):
            yield {"messages": [AIMessageChunk(content=str(chunk.content))]}
        final = await llm.ainvoke(messages)
        yield {"messages": [final]}
• Ensure your node signature accepts
config
(RunnableConfig) so
.astream()
can be used. • Alternate (emit at the end): ◦ Keep
emit_text
as the final node, but make it produce streaming output in the graph’s streaming protocol (yield chunks) rather than only returning the final dict. If you cannot stream incrementally, at least ensure the compiled graph exposes the final
{"messages":[AIMessage(...)]}
as the stream's final yield (not only a return value swallowed by the runner). Quick verification steps 1. After changes, run:
Copy code
async with adapter.chat(chat_ctx) as stream:
       async for chunk in stream:
           print("STREAMED:", repr(chunk))
You should see streamed chunks or at least one final chunk. 1. If still zero, print the compiled run result right before the adapter:
Copy code
result = await compiled_graph.arun(input_state)
   print("COMPILED_RESULT:", repr(result))
Confirm
result
contains top-level
messages
with an
AIMessage
whose
.content
is a str. If you want, paste: • The exact
llm
object you call (does it support
.astream()
?), and • Whether your framework calls the graph via
.astream()
(adapter should do this), or via a non-streaming runner. I can then give the exact one-line change to your
agent_node
(or a minimal streaming snippet for
emit_text
) that will make the adapter yield text to TTS. Sources: LangChain integration guide | LiveKit Docs | Pipeline nodes and hooks | LiveKit docs