[Documentation] Langchain/LangGraph async tool calls using the context argument in the tool definition
Describe the bug Traces appear to be broken when used with agent.ainvoke(..) async function in langgraph
To Reproduce Run a multistep agent using ainvoke
result = await chat_agent.ainvoke(
{
"question": message.query,
"messages": HumanMessage(content=message.query),
"observations": [],
},
config=config,
)
Expected behavior Traces should appear in correct order with proper nesting and aggregation
Screenshots Without async (using invoke(..))
With async (using ainvoke(..))
Desktop (please complete the following information):
- OS: Windows 11
Additional context Here is my code for running the agent
async run():
# ....
with (
using_attributes(session_id=str(thread_id), user_id=uid)
if settings.ENABLE_TRACING
else nullcontext()
):
chat_agent_workflow = AgfiChatV2Agent(memory=checkpointer)
chat_agent = chat_agent_workflow.agent
callbacks = []
if settings.ENABLE_LANGGRAPH_DEBUG:
callbacks.append(ConsoleCallbackHandler())
config = {
"configurable": {
"thread_id": thread_id,
},
"metadata": {"uid": uid, "chat_type": "global"},
"callbacks": callbacks,
}
result = await chat_agent.ainvoke(
{
"question": message.query,
"messages": HumanMessage(content=message.query),
"observations": [],
},
config=config,
)
result["final_answer"].sources = []
# ....
Here is the setup for tracing which is run on startup
from openinference.instrumentation.langchain import LangChainInstrumentor
from opentelemetry.instrumentation.asyncio import AsyncioInstrumentor
from phoenix.otel import BatchSpanProcessor, register
from app.config import settings
def setup_tracing_pheonix():
if not settings.ENABLE_TRACING:
print("Tracing is disabled")
return
# AsyncioInstrumentor().instrument()
tracer_provider = register(
project_name="mlb", auto_instrument=True, batch=True, protocol="http/protobuf"
)
batch_processor = BatchSpanProcessor(protocol="http/protobuf")
tracer_provider.add_span_processor(batch_processor)
LangChainInstrumentor(tracer_provider=tracer_provider).instrument(
skip_dep_check=True,
)
print("Tracing enabled for langgraph with arize phoenix")
Other Information
- I am using fastapi as backend server
- If i simply use the blocking invoke it works perfectly, but on using ainvoke it creates traces that are not grouped and not in order
Your async traces are not grouped or nested correctly because AsyncioInstrumentor is required for context propagation in async Python code. Without it, OpenTelemetry (and thus OpenInference/Arize Phoenix) can't maintain parent-child relationships between spans in async workflows, which breaks trace grouping for ainvoke and similar async methods. This is a well-documented requirement in both OpenTelemetry and OpenInference code and docs—there is no custom async context patching; instrumenting asyncio is the prescribed approach.
To fix your issue, uncomment and enable AsyncioInstrumentor().instrument() in your tracing setup before any async agent calls. This should restore correct trace order and nesting for async agent runs, matching what you see with the sync invoke method. See the OpenInference code and docstring guidance for more details on why this is necessary.
Also, make sure you are using the latest versions of openinference-instrumentation-agno and related packages, as recent updates (post-2025-08-19) include explicit async context management and fixes for parent-child span relationships in async agent runs (OpenInference PR #2090).
If you are passing config to ainvoke, ensure it is the same config object as for invoke, as missing or mismatched config can also affect trace hierarchy (example).
Let me know if enabling AsyncioInstrumentor resolves your trace grouping!
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
The issue persists even after adding AsyncioInstrumentor().instrument() in the tracing setup, and i have updated the installed library but the issue is still persisting
If traces are still not grouped or nested correctly after enabling AsyncioInstrumentor and updating your libraries, the most common root causes are:
- Not passing the RunnableConfig to ainvoke. Make sure you always call
await agent.ainvoke(..., config=config)and that config is the same object as for sync invoke. This is required for correct trace hierarchy in async LangGraph workflows (example). - Not starting a parent span before invoking the agent. For proper context propagation, explicitly create a parent span using
tracer.start_as_current_spanbefore calling ainvoke. This ensures the span context is available and helps maintain trace nesting (details). - Mixing sync and async nodes in your LangGraph can cause race conditions and break trace grouping. Try making all nodes async if possible (discussion).
- If you use FastAPI, check for any custom middleware, contextvars, or other async frameworks that might interfere with OpenTelemetry context propagation. OpenInference relies on OpenTelemetry's async context propagation and does not add FastAPI-specific logic (reference).
If you want to isolate the issue, try running the minimal FastAPI+LangGraph+Phoenix async tracing example at phoenix-tracing-fastapi-async to see if the problem is environmental or code-specific.
If none of these resolve the issue, enable OpenTelemetry debug logging to check for context propagation failures at runtime. Let me know if you want a code snippet for the parent span setup or if you see any specific errors in your logs.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
the config is passed, and there are no sync operation so far. can you explain how to use tracer.start_as_current_span in a sample code?
To create a parent span for proper trace grouping in async workflows, use tracer.start_as_current_span as a context manager around your async agent call. Here’s a minimal example:
from opentelemetry.trace import get_tracer
tracer = get_tracer(__name__)
async def run_agent(chat_agent, message, config):
# Create a parent span for this agent run
with tracer.start_as_current_span("agent_run"):
result = await chat_agent.ainvoke(
{
"question": message.query,
"messages": HumanMessage(content=message.query),
"observations": [],
},
config=config,
)
return result
This ensures the parent span context is active during the async agent execution, so all child spans (from LangGraph, LLM calls, etc.) are properly nested and grouped in your traces. This pattern works the same way inside FastAPI endpoints or any async function. If you see harmless "Failed to detach context" errors with async generators, you can safely ignore them—they don’t affect tracing functionality (example).
Make sure you use the tracer instance from your configured tracer provider, and that AsyncioInstrumentor is enabled for context propagation. This approach is recommended for both sync and async tracing scenarios (reference).
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
using tacer.start_as_current_span does prevent creating multiple traces, but the span is empty
The empty parent span you’re seeing after using tracer.start_as_current_span is a known limitation when combining manual span creation with LangChain’s auto-instrumentation: child spans from LangChain (like those created by ainvoke) don’t always nest under manually created parent spans, especially in async workflows. This happens because LangChain’s instrumentation manages its own context and doesn’t reliably attach its spans to a manually created parent, even if OpenTelemetry context propagation is set up correctly reference.
If you’re passing the config to ainvoke and have AsyncioInstrumentor enabled, but still see only an empty parent span, it means the child spans are being created as separate traces or not at all. This is a limitation of how LangChain and OpenInference interact in async contexts reference.
For now, the best approach is to rely on auto-instrumentation (LangChainInstrumentor) and avoid manual parent spans if you want correct nesting. If you need to annotate or group spans, use the config and callback mechanisms provided by LangChain, and ensure all nodes and calls are async. You can also review the minimal FastAPI + LangGraph + Phoenix async tracing example for a working setup reference.
If you need to capture additional metadata, consider setting attributes on the spans created by LangChain itself, rather than relying on manual parent spans. This will ensure your trace hierarchy is preserved and visible in Phoenix.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
@dosu the solutions didnt work, i tried what you mentioned, any specific workaround or guides you could suggest? or anybody in the team who can help?
Async trace grouping in LangGraph/OpenInference is tricky—manual parent spans (with tracer.start_as_current_span) often show up empty because LangChain’s auto-instrumentation doesn’t reliably nest its child spans under them in async workflows. The best-supported pattern is to rely on auto-instrumentation (just LangChainInstrumentor().instrument(...)) and pass the config object to every ainvoke call; avoid manual parent spans if you want correct nesting reference.
If you’re already doing this and traces are still broken, compare your setup to the minimal working example at phoenix-tracing-fastapi-async—it demonstrates correct async trace grouping with FastAPI, LangGraph, and Phoenix. Sometimes, subtle differences in instrumentation order, config structure, or dependency versions can break context propagation reference.
If you need to annotate or group spans, use LangChain’s config and callback mechanisms, and ensure all nodes and calls are async. For complex graphs, mixing sync and async nodes can cause race conditions and annotation loss—making all nodes async helps, but some edge cases remain reference.
For advanced troubleshooting, contributors like BenMcH, Arize-Labs, jacobmarks, and axiomofjoy are active around async tracing issues. If you can isolate the problem with a minimal reproducible example, sharing it in the repo or discussion threads often gets targeted help.
Let me know if you want help comparing your setup to the example repo or have specific error logs—sometimes the fix is a small config or version tweak.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Upon debugging i realised when invoking the agent in sync (with just invoke()), the TraceID is same for all the invokations happening internally. In my case each of the graph node are having some kind of mini internal chain which is invoked to get some intermediary results which are then captured for tracing. Thus i get a clean trace where all the items are captured and well nested. Something like this:
But when i am using async ainvoke this spawns different coroutines and probably setting a different trace id for each invokation. But the good thing is somehow the context of using_attributes(session_id=thread_id, user_id=uid) is preserved ensuring that all the fragmented traces are captured under the same session, is there a way for me to provide a trace_id manually to ensure it is consistent?
Thank you for filing this issue. Because this actually relies on LangChain's internal plumbing for trace_id propagation, it indicates a difference with how trace_ids are propagated internally between the sync and async methods. Do you know how AgfiChatV2Agent is constructed, or are you able to reproduce this issue with simple agents in general?
@RogerHYang Thanks for responding, I haven't verified if a simple graph is having this issue, I'll let you know once it's verified. I tried enabling logs for langgraph and it seems all the nodes are executed in proper order with async.
@RogerHYang I checked with a very basic agent written by gpt and the issue still persists
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.graph import END, MessagesState, StateGraph
from app.memory.global_memory import get_global_async_checkpointer
# Initialize LLM
llm = ChatOpenAI(model="gpt-4o-mini")
# Guardrail check node
async def guardrail_check(state: MessagesState):
query = state["messages"][-1].content # latest user input
check_prompt = (
f"Is this query safe and respectful? Reply 'SAFE' or 'UNSAFE'.\nQuery: {query}"
)
result = await llm.ainvoke([HumanMessage(content=check_prompt)])
resp = result.content.strip().upper()
if resp != "SAFE":
# Block and respond
state["messages"].append(
AIMessage(content="⚠️ Sorry, your query violates the rules.")
)
# End flow early
return state, END
return state
# Answer node
async def answer_node(state: MessagesState):
resp = await llm.ainvoke(state["messages"])
state["messages"].append(AIMessage(content=resp.content))
return state
# Build graph with memory
def get_test_agent(checkpointer):
args = {}
args["checkpointer"] = checkpointer
graph = StateGraph(MessagesState)
graph.add_node("guardrail", guardrail_check)
graph.add_node("answer", answer_node)
graph.set_entry_point("guardrail")
graph.add_edge("guardrail", "answer")
graph.add_edge("answer", END)
test_agent = graph.compile(**args)
return test_agent
This is how i am invoking it
@app.post("/test-simple-graph")
async def test_simple_graph(payload: TestGraph):
uid = uuid.uuid4()
checkpointer = get_global_async_checkpointer()
test_agent = get_test_agent(checkpointer)
with (
using_attributes(session_id=payload.thread_id, user_id=str(uid))
if settings.ENABLE_TRACING
else nullcontext()
):
callbacks = []
if settings.ENABLE_LANGGRAPH_DEBUG:
callbacks.append(ConsoleCallbackHandler())
config = {
"configurable": {
"thread_id": payload.thread_id,
},
"metadata": {"uid": uid, "chat_type": "test"},
"callbacks": callbacks,
}
result = await test_agent.ainvoke(
{
"question": payload.query,
"messages": [HumanMessage(content=payload.query)],
},
config=config,
)
return result["messages"][-1].content
You can see that multiple traces are generated
Thanks for the additional info. Can you try passing along config in every node? For example, in the answer_node you could do something like below.
async def answer_node(state: MessagesState, config: RunnableConfig):
resp = await llm.ainvoke(state["messages"], config=config)
state["messages"].append(AIMessage(content=resp.content))
return state
Hi @RogerHYang, although passing the config to the nodes themselves is trivial as they only expect the state variable as argument (i am not sure if you can pass any additional arguments through them) but i managed to do so by making a closure. Even after passing the config individually the issue remains as it is
I mentioned it because I saw it in their type union here, which indicates that you could just declare it in the node function signature. I also tested it with a small example, and it didn’t reject it.
@RogerHYang Oh i didn't knew that! thanks for mentioning.
Just as a note, is there a way to provide a traceId manually? it seems the sessionid and uid context is penetrating pretty well, if there's a way to have the traceid in this context as well
Currently, trace IDs are designed to be randomly generated and unique for each trace, so there isn’t a straightforward way to pass one in manually.
got it, let me know if there's any fix for the current issue
Just to confirm: you added config: RunnableConfig to your node definitions and included it in llm.ainvoke like it's shown here—not via closure, but just changing the function signature and definition—and it did not resolve the issue. Is that correct?
Hi, i tried this it seems to solve some parts of problem, basically when i am adding config any of the ainvoke it works fine for llm's but in my custom tools there are also some code that invoke retrievers, i am unable to use this approach there. As the arguments for the tool come from LLM, even when i make the config argument for the tool as InjectedToolArg, it keeps complaining that the argument wasnt parsed.
see below:
@tool
async def knowledge_base_reference_tool(
query: str, config: Annotated[any, InjectedToolArg]
) -> Tuple[str, List[RagResult]]:
...
for tool_call in ai_message.tool_calls:
tool_name = tool_call["name"]
call_id = tool_call["id"]
args = tool_call.get("args", {})
args["config"] = config
print(f"Calling tool {tool_name}, args: {args}\n\n")
tool: BaseTool = tools_dict.get(tool_name)
if not tool:
continue
try:
content, documents = await tool.ainvoke(input=args, config=config)
tool_messages.append(
RagToolExecutorResult(
content=content, documents=documents, call_id=call_id
)
)
except Exception as e:
tool_messages = []
print("ERROR ", e)
Gives this error
ERROR knowledge_base_reference_tool() missing 1 required positional argument: 'config'
What happens if you don't add config to your tool definition? Only ainvoke needs config=config. The tool itself doesn't need it.
@tool
async def knowledge_base_reference_tool(
query: str,
) -> Tuple[str, List[RagResult]]:
...
It is able to trace the tool, but the tool itself internally uses various retriever which end up creating broken traces
i was trying to pass the config into the tool to try and pass it to the retriever but getting this error
Have you tried adding config: RunnableConfig (making sure it's of the type RunnableConfig and not Annotated[any, InjectedToolArg]) directly without changing anything else? I don't know enough about how the rest of your code looks like, but screenshot below shows that the config variable is populated automatically.
Bingo! this fixed the issue. Now i am getting a single trace. However, is there a simple approach to this problem? considering everything works in sync invoke without much tinkering compared to async one, If there's a simple approach do let me know. Feel free to close the issue but would highly appreciate if there's an option to manually create a trace id for a given context for simplifying.
Thanks @RogerHYang!
Thanks for confirming! Async patterns can get tricky and introduce subtle issues, so explicitly passing the context is usually the safest and most robust choice.