steep-balloon-41261
12/15/2025, 1:02 AMtall-airline-16756
12/15/2025, 1:02 AMtall-airline-16756
12/15/2025, 1:02 AMAIMessage 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:
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:
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 docstall-airline-16756
12/15/2025, 1:06 AMagent_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:
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:
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.blue-branch-38615
12/15/2025, 1:20 AMtall-airline-16756
12/15/2025, 1:21 AMagent_node return a dict containing messages, e.g.:
{"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:
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.tall-airline-16756
12/15/2025, 1:29 AMagent_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:
return {"messages": [AIMessage(content=response.content)]}
or if response is already an AIMessage:
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:
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:
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:
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.tall-airline-16756
12/15/2025, 1:38 AMmessages 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.tall-airline-16756
12/15/2025, 5:05 AMemit_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:
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.tall-airline-16756
12/15/2025, 8:34 PMwriter 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:
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:
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:
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.tall-airline-16756
12/15/2025, 9:01 PMagent_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:
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:
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:
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