Repository navigation
Closed
Closed
Copy link
Labels
.NETUsage: [Issues, PRs], Target: .NetUsage: [Issues, PRs], Target: .Neta2aUsage: [Issues, PRs], Target: A2AUsage: [Issues, PRs], Target: A2ApythonUsage: [Issues, PRs], Target: PythonUsage: [Issues, PRs], Target: Python
Description
Summary
When using A2A agents as tools in an LLM orchestrator, there's no automatic propagation of ContextId from the orchestrator's thread to the A2A agent calls. This makes it difficult to share session state (e.g., Redis) across multiple A2A agents in the same orchestration.
Expected Behavior (Python Pattern)
In the Python Agent Framework, the orchestrator's thread context automatically flows to all A2A tool calls:
from agent_framework import A2AAgent, AgentThread
from agent_framework.azure import AzureOpenAIChatClient
import uuid
class TriageOrchestrationService:
def __init__(self):
# A2A agents as tools
self.impact_agent = A2AAgent(
name="ImpactAssessor",
url="https://agents.example.com/a2a/impact-assessor"
)
self.assigner_agent = A2AAgent(
name="SmartAssigner",
url="https://agents.example.com/a2a/smart-assigner"
)
# LLM Orchestrator with A2A agents as tools
self.orchestrator = AzureOpenAIChatClient().create_agent(
name="TriageOrchestrator",
instructions="You orchestrate ticket triage...",
tools=[
self.impact_agent.as_tool(name="assess_impact"),
self.assigner_agent.as_tool(name="get_assignment")
]
)
async def triage_ticket(self, ticket_id: str, description: str) -> dict:
# Create a shared context ID
context_id = f"triage-{ticket_id}-{uuid.uuid4().hex[:8]}"
# Create thread with this context ID
thread = AgentThread(id=context_id)
# LLM decides which agents to call
# KEY: The thread's ID automatically becomes contextId for ALL A2A calls
result = await self.orchestrator.run(
f"Triage ticket #{ticket_id}: {description}",
thread=thread
)
return {"context_id": context_id, "result": result.text}
# Usage - context_id "triage-12345-abc123" is automatically used for:
# - ImpactAssessor A2A calls
# - SmartAssigner A2A calls
# - All agents share the same session context
Key mechanism: When orchestrator.run(..., thread=thread) is called, the framework automatically derives contextId from the thread and passes it to all A2A agent tool calls.
Current .NET Behavior
In .NET MAF, AsAIFunction() captures the thread statically at tool creation time, not from the orchestrator's execution context:
// Current implementation in AgentExtensions.cs
public static AIFunction AsAIFunction(this AIAgent agent, AIFunctionFactoryOptions? options = null, AgentThread? thread = null)
{
async Task<string> InvokeAgentAsync(string query, CancellationToken cancellationToken)
{
// Thread is captured at creation time, NOT from orchestrator context
var response = await agent.RunAsync(query, thread: thread, cancellationToken: cancellationToken);
return response.Text;
}
return AIFunctionFactory.Create(InvokeAgentAsync, options);
}
Problem: Each A2A agent tool has its own isolated thread. There's no way for the orchestrator's thread context to flow to the tools.Metadata
Metadata
Labels
.NETUsage: [Issues, PRs], Target: .NetUsage: [Issues, PRs], Target: .Neta2aUsage: [Issues, PRs], Target: A2AUsage: [Issues, PRs], Target: A2ApythonUsage: [Issues, PRs], Target: PythonUsage: [Issues, PRs], Target: Python
Type
Projects
- StatusShow more project fieldsDone
Activity
[-]MAF Feature Request: Automatic ContextId Propagation for A2A Agent Tools[/-][+].NET: MAF Feature Request: Automatic ContextId Propagation for A2A Agent Tools[/+][-].NET: MAF Feature Request: Automatic ContextId Propagation for A2A Agent Tools[/-][+]Python: MAF Feature Request: Automatic ContextId Propagation for A2A Agent Tools[/+]clint-morgan commentedon Dec 9, 2025
Context Propagation Implementation Complete
I've implemented the automatic context propagation feature requested in this issue.
The solution uses
AsyncLocal<AgentThread?>to enable automatic context flow from orchestrators to A2A agent tools.This document outlines the changes made to the
agent-frameworkrepository to enable automatic context propagation viaAsyncLocal. This allows agents and tools to access the current execution context (Thread) without requiring it to be explicitly passed through every method signature.1.
AgentContext.cs(Microsoft.Agents.AI.Abstractions)Change: Introduced
AsyncLocal<AgentThread?>to hold the current thread context.2.
ChatClientAgent.cs(Microsoft.Agents.AI)Change: Updated
RunStreamingAsync(andRunCoreAsync) to set theAgentContext.CurrentThreadbefore invoking the underlyingIChatClient. This ensures that any code running within the chat client's execution scope has access to the thread.3.
AIAgentExtensions.cs(Microsoft.Agents.AI)Change: Updated
AsAIFunctionto automatically resolve theAgentThreadfromAgentContext.CurrentThreadif one is not explicitly provided. This is critical for Tools (which are often wrapped asAIFunctions) to access the context.Summary of Impact
AgentThreadorContextIdto every tool or helper method.AgentContext.CurrentThread.The feature is ready for testing. A2A agents used as tools will now automatically inherit the orchestrator's thread context, enabling shared session state across multiple agents in the same orchestration.
moetarhinikaseya commentedon Dec 10, 2025
Mark Wallace (@markwallace-microsoft) This is the Ticket we discussed during the office hours. Feature Parity between Python and .NET.
markwallace-microsoft commentedon Dec 17, 2025
Clint Morgan (@clint-morgan) moetarhinikaseya Thanks for creating the issue and PR. We are currently reviewing the design and naming of
AgentThread. I've tagged this as av1.0priority so we consider this requirement as part of the redesign.6 remaining items
moetarhinikaseya commentedon Jan 8, 2026
HEllo westey (@westey-m)
I am trying this now and I think am not sure how to read this on the server side.
Your Approach:
ContextIdGenerator (sets A2AContextId.Current = "abc123")
│
▼
Orchestrator Agent
│ calls tool
▼
Custom AsAIFunction (reads A2AContextId.Current)
│ creates thread with contextId
▼
A2AAgent.RunAsync(thread with contextId)
│
▼
A2A Message sent with contextId = "abc123" ✅
On the Server Side:
A2A Message arrives with contextId = "abc123"
│
▼
AIAgentExtensions.OnMessageReceivedAsync
│ extracts contextId from message
│ calls GetOrCreateThreadAsync(contextId)
▼
NoopAgentThreadStore.GetThreadAsync
│ IGNORES conversationId parameter!
│ returns agent.GetNewThread() ← No contextId!
▼
hostAgent.RunAsync(messages, thread) ← No AgentRunOptions!
│
▼
RestaurantAgent executes
│
▼
Tool runs... HOW DOES IT GET contextId?
I believe the Gap:
1-NoopAgentThreadStore.GetThreadAsync() ignores the conversationId and returns a fresh thread
2-hostAgent.RunAsync() is called without AgentRunOptions
3-There's no ambient context (no AgentContext) for tools to read
Can you help me understand the full E2E ? cc Mark Wallace (@markwallace-microsoft)
westey-m commentedon Jan 8, 2026
The A2A Hosting code is designed to host any type of AIAgent, which means there can be no assumption as to what kind of chat message storage it uses and what kind of ids that storage mechanism requires. AIAgents can be implemented using e.g. the Responses service, in which case it uses response ids which change with each invocation. These are incompatible with ContextIds, which stay the same across different requests.
In your case though, since you want to provide your own chat history storage (from redis), you will be limited to AI services that do not use in-service chat history storage. The following therefore won't work for you: responses with store=true, responses with conversations, foundry agents, openai assistants, etc.
See here for a list of services and what chat history storage options they support.
From that list your options are essentially responses with store=false and chat completion.
Also make sure you read through Agent Chat History and Memory, which explains how Chat History storage differs across different AI services.
To pull chat history from your custom redis store and pass that to the agent, I would suggest creating your own
AgentThreadStore.GetThreadAsyncwould be called with the context id as input. You can load the chat history from redis in this method, then construct anInMemoryChatMessageStore, and then create aChatClientAgentThreadwith it by callingChatClientAgent.GetNewThread(ChatMessageStore chatMessageStore). Note that InMemoryChatMessageStore is just aIList<ChatMessage>, so you can add whatever messages you want to it. InAgentThreadStore.SaveThreadAsync, you can read the contents of theChatMessageStoreagain, and persist newly added messages in Redis.Alternatively you can also create your own
ChatMessageStoreand just construct theAgentThreadwith that, and it would be reading from and writing to redis. This may be even easier than the above option using InMemoryChatMessageStore.Here are the steps on the server:
FYI, This is how you configure a responses based agent with store=false:
clint-morgan commentedon Jan 8, 2026
Thanks for the detailed breakdown westey (@westey-m), and +1 to moetarhinikaseya for chasing this end-to-end and clearly calling out the server-side gap. That analysis was super helpful in making the behavior concrete.
Speaking just from my perspective (not a final team position yet), a couple of thoughts this discussion sparked:
The pattern that’s devloping for me is
One related pattern I’m targeting is having toolchain/specialist agents return opaque result handles (instead of mutating shared conversation state). Those handles are then referenced by the orchestrator or subsequent agents, which further reduces reliance on shared threads or ambient context during execution. (A nice side-effect is a substantial reduction in token burn).
Again, this is just my current thinking as I digest Moe’s findings and we continue to align internally. Really appreciate the clarification on the intended 'AgentThreadStore' usage and the concrete guidance on which backends are compatible with external chat history.
Happy to keep iterating on this together.
moetarhinikaseya commentedon Jan 8, 2026
Hello westey (@westey-m) ,
It worked!! I need your help confirming if the code is written correct and I think we have a Happy Ending here.
Before sharing the Sample. I will share the Data Flow of my Sample with one Orchestrator calling two Agents via A2A:
You will notice "contextId": "183d0b1cf0b54e1280d211f6b7ed62bf" is shared always between Orchestrator and Agents. And Agents are able to read this on the server side.
Me Starting the Chat with the AI Orchestrator: Plan a team lunch for 8 people next week
dbug: Microsoft.Agents.AI.A2A.A2AAgent[1886822918]
[RunAsync] A2AAgent 5c58c8a71c8f457cbb3ba3c44bdec37e/RestaurantAgent invoking underlying A2A agent.
info: A2A.Outgoing[0]
=== OUTGOING A2A REQUEST ===
info: A2A.Outgoing[0]
URL: http://localhost:5001/
info: A2A.Outgoing[0]
Body:
{
"jsonrpc": "2.0",
"id": "746d8ff9-c60f-43a7-9ce4-0903b9e119b8",
"method": "message/send",
"params": {
"message": {
"kind": "message",
"role": "user",
"parts": [
{
"kind": "text",
"text": "restaurants suitable for a team lunch for 8 people"
}
],
"messageId": "7d8e53a6bbc74987929056e0dd7c3723",
"contextId": "183d0b1cf0b54e1280d211f6b7ed62bf"
}
}
}
info: A2A.Outgoing[0]
=== INCOMING A2A RESPONSE ===
info: A2A.Outgoing[0]
Status: 200
info: A2A.Outgoing[0]
Body:
{
"jsonrpc": "2.0",
"id": "746d8ff9-c60f-43a7-9ce4-0903b9e119b8",
"result": {
"kind": "message",
"role": "agent",
"parts": [
{
"kind": "text",
"text": "Here are some great restaurant suggestions for your team lunch with 8 people:\n\n1. Dragon Palace - Enjoy traditional Chinese dishes served family-style. Perfect for sharing! (Cuisine: Chinese, Price: $$)\n\n2. The Steakhouse - Indulge in premium steaks and fine dining for a more upscale experience. (Cuisine: American, Price: $$$$)\n\n3. El Mexicano - Dive into authentic Mexican flavors and refreshing margaritas. A fun and vibrant atmosphere! (Cuisine: Mexican, Price: $$)\n\nHope you have a fantastic lunch!"
}
],
"messageId": "chatcmpl-CvkZbjPaeHrCzOml6jncAtbCU8K5K",
"contextId": "183d0b1cf0b54e1280d211f6b7ed62bf"
}
}
info: Microsoft.Agents.AI.A2A.A2AAgent[2113764234]
[RunAsync] A2AAgent 5c58c8a71c8f457cbb3ba3c44bdec37e/RestaurantAgent invoked underlying A2A agent.
dbug: Microsoft.Agents.AI.A2A.A2AAgent[1886822918]
[RunAsync] A2AAgent ab33c13ae9c34d3d8700d1d0a21d4ec1/CalendarAgent invoking underlying A2A agent.
info: A2A.Outgoing[0]
=== OUTGOING A2A REQUEST ===
info: A2A.Outgoing[0]
URL: http://localhost:5101/
info: A2A.Outgoing[0]
Body:
{
"jsonrpc": "2.0",
"id": "96b3d2a0-8f31-4aa8-9de9-94f39ef201a3",
"method": "message/send",
"params": {
"message": {
"kind": "message",
"role": "user",
"parts": [
{
"kind": "text",
"text": "availability for a team lunch for 8 people next week, considering restaurants: Dragon Palace, The Steakhouse, El Mexicano"
}
],
"messageId": "3933b7fe8a6c4899a50f96704d463931",
"contextId": "183d0b1cf0b54e1280d211f6b7ed62bf"
}
}
}
info: A2A.Outgoing[0]
=== INCOMING A2A RESPONSE ===
info: A2A.Outgoing[0]
Status: 200
info: A2A.Outgoing[0]
Body:
{
"jsonrpc": "2.0",
"id": "96b3d2a0-8f31-4aa8-9de9-94f39ef201a3",
"result": {
"kind": "message",
"role": "agent",
"parts": [
{
"kind": "text",
"text": "Here are some optimal time slot recommendations for your team lunch for 8 people next week, considering your restaurant options: Dragon Palace, The Steakhouse, and El Mexicano:\n\n1. 11:30 AM - 1:00 PM\n - This slot allows for an early lunch, giving your team a chance to enjoy a leisurely meal before the lunch rush. Dragon Palace and El Mexicano are good choices as they tend to get busier later, so starting earlier helps avoid long wait times.\n\n2. 12:00 PM - 1:30 PM\n - A great compromise slot that provides ample time to enjoy lunch. This is a common lunchtime, so you will likely encounter a lively atmosphere. El Mexicano could be particularly nice here as guests can savor authentic flavors before the place gets too crowded.\n\n3. 12:30 PM - 2:00 PM\n - This time strikes a balance between starting a bit later while still allowing enough time for a relaxed meal. It works well for The Steakhouse, where you can secure a table before peak hours set in, allowing your team to unwind comfortably.\n\n4. 1:00 PM - 2:30 PM\n - While this slot is later than typical lunch hours, it's ideal for those wanting to avoid the rush. This time suits The Steakhouse perfectly for a more indulgent dining experience, as it’s less likely to be crowded.\n\nChoosing one of these slots will help ensure a fantastic lunch experience tailored to your team's needs!"
}
],
"messageId": "chatcmpl-CvkZoFv3Tvbz2mI3YBA7deupGMNQt",
"contextId": "183d0b1cf0b54e1280d211f6b7ed62bf"
}
}
The samples are attached.
KaseyaLLMA2A.zip
Can you please confirm if the code is written correctly ?
Note: In order to test this I did a local change for A2AAgent as you mentioned it was internal.
cc Clint Morgan (@clint-morgan)
moetarhinikaseya commentedon Jan 12, 2026
Hello westey (@westey-m) I did not hear back from you about the Code and if it looks good. Can you please check it? cc Mark Wallace (@markwallace-microsoft)
westey-m commentedon Jan 12, 2026
moetarhinikaseya, yeah, this looks good.
A couple of small observations:
When creating the orchestrator thread, you can use the orchestrator variable, since it delegates to the innerOrchestrator.GetNewThread.
If you want the chat history of your service agents to be preserved, you will need to serialize and store / load and deserialize the AgentThreads in the ContextAwareAgentThreadStore. See here for an example of how to serialize and deserialize agent threads. I'm not sure if you need/want this, considering that you are using these agents as tools. If you did want to have a multi-turn conversation with a remote A2A agent though, you would need to do the storage.
I originally recommended building your own thread store, since I thought you wanted to load/save chat history, but looking at the code, it looks like you just needed a hook to set the context id.
I'm thinking that maybe it would make sense for the hosting code to automatically have a context (like HttpContext or RequestContext in asp.net core), which users can use to read any of the input values from the A2A request. Both the agent framework hosting code or in the A2A library are candidates for having something like this.
CC Korolev Dmitry (@DeagleGross), for thoughts.
DeagleGross commentedon Jan 12, 2026
Maybe I am missing some info, but today A2A Hosting package already retrieves the ContextId as per A2A SDK.
We will also populate the metadata of the message in
options. So underlyingAIAgentwill have vision into this. Potentially accessing those may be possible by defining your own customAIAgenttype, and one could use the message data before invoking inner agent.However, I agree - having something like
contextcould be useful. It should be some abstract type, and implementations should be specific to the protocol I guess (like A2A, ChatCompletions, Responses, etc).westey-m commentedon Jan 12, 2026
Korolev Dmitry (@DeagleGross), using Additional Properties is a great idea. Note however that while metadata is passed here, it's actually the contextId from the message that is interesting to the user. Perhaps setting the entire
MessageSendParamson AdditionalProperties would be useful.We are not currently passing AdditionalProperties down the stack propertly, but we should, and I've opened #3179 to address.
This would allow someone to access the replicated AdditionalProperties in a function tool via
FunctionInvokingChatClient.CurrentContext.Options.AdditionalProperties.I think this should actually solve the problem without needing additional asynclocal context at the host protocol level.
moetarhinikaseya, #3179 should also simplify the client side implementation of what you are trying to do, since you should be able to pass your custom context id via additional properties directly to the agent run options and read it later in the tool call, like the following. (This is generally closer to the experience you were originally looking for as well.)
moetarhinikaseya commentedon Jan 12, 2026
This makes sense now westey (@westey-m) and much simpler approach. I need to wait for your PR to be merged so I can try it right ? Thank you both for your input, it was an important issue for us.