Skip to content

Commit 2d8fb3a

Browse files
committed
fix: use child task IDs in a2a_mcp workflows
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
1 parent 6603ba3 commit 2d8fb3a

6 files changed

Lines changed: 247 additions & 96 deletions

File tree

samples/go/agents/deepresearch/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ require (
3939
go.opentelemetry.io/otel/trace v1.43.0 // indirect
4040
golang.org/x/crypto v0.51.0 // indirect
4141
golang.org/x/mod v0.35.0 // indirect
42-
golang.org/x/net v0.54.0 // indirect
43-
golang.org/x/sys v0.44.0 // indirect
42+
golang.org/x/net v0.55.0 // indirect
43+
golang.org/x/sys v0.45.0 // indirect
4444
golang.org/x/text v0.37.0 // indirect
4545
google.golang.org/api v0.279.0 // indirect
4646
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect

samples/go/agents/deepresearch/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
7373
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
7474
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
7575
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
76-
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
77-
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
76+
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
77+
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
7878
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
7979
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
80-
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
81-
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
80+
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
81+
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
8282
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
8383
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
8484
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=

samples/python/agents/a2a_mcp/src/a2a_mcp/agents/orchestrator_agent.py

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# ruff: noqa
12
import json
23
import logging
34

@@ -39,9 +40,7 @@ async def generate_summary(self) -> str:
3940
client = genai.Client()
4041
response = client.models.generate_content(
4142
model='gemini-2.0-flash',
42-
contents=prompts.SUMMARY_COT_INSTRUCTIONS.replace(
43-
'{travel_data}', str(self.results)
44-
),
43+
contents=prompts.SUMMARY_COT_INSTRUCTIONS.replace('{travel_data}', str(self.results)),
4544
config={'temperature': 0.0},
4645
)
4746
return response.text
@@ -51,9 +50,7 @@ def answer_user_question(self, question) -> str:
5150
client = genai.Client()
5251
response = client.models.generate_content(
5352
model='gemini-2.0-flash',
54-
contents=prompts.QA_COT_PROMPT.replace(
55-
'{TRIP_CONTEXT}', str(self.travel_context)
56-
)
53+
contents=prompts.QA_COT_PROMPT.replace('{TRIP_CONTEXT}', str(self.travel_context))
5754
.replace('{CONVERSATION_HISTORY}', str(self.query_history))
5855
.replace('{TRIP_QUESTION}', question),
5956
config={
@@ -66,9 +63,7 @@ def answer_user_question(self, question) -> str:
6663
logger.info(f'Error answering user question: {e}')
6764
return '{"can_answer": "no", "answer": "Cannot answer based on provided context"}'
6865

69-
def set_node_attributes(
70-
self, node_id, task_id=None, context_id=None, query=None
71-
):
66+
def set_node_attributes(self, node_id, task_id=None, context_id=None, query=None):
7267
attr_val = {}
7368
if task_id:
7469
attr_val['task_id'] = task_id
@@ -81,21 +76,23 @@ def set_node_attributes(
8176

8277
def add_graph_node(
8378
self,
84-
task_id,
8579
context_id,
8680
query: str,
8781
node_id: str = None,
8882
node_key: str = None,
8983
node_label: str = None,
9084
) -> WorkflowNode:
91-
"""Add a node to the graph."""
92-
node = WorkflowNode(
93-
task=query, node_key=node_key, node_label=node_label
94-
)
85+
"""Add a node to the graph.
86+
87+
Child nodes must not inherit the orchestrator's task_id. A2A
88+
treats a present taskId as a resume of a task on the receiving
89+
agent, and the child has not created that task yet.
90+
"""
91+
node = WorkflowNode(task=query, node_key=node_key, node_label=node_label)
9592
self.graph.add_node(node)
9693
if node_id:
9794
self.graph.add_edge(node_id, node.id)
98-
self.set_node_attributes(node.id, task_id, context_id, query)
95+
self.set_node_attributes(node.id, context_id=context_id, query=query)
9996
return node
10097

10198
def clear_state(self):
@@ -104,9 +101,7 @@ def clear_state(self):
104101
self.travel_context.clear()
105102
self.query_history.clear()
106103

107-
async def stream(
108-
self, query, context_id, task_id
109-
) -> AsyncIterable[dict[str, any]]:
104+
async def stream(self, query, context_id, task_id) -> AsyncIterable[dict[str, any]]:
110105
"""Execute and stream response."""
111106
logger.info(
112107
f'Running {self.agent_name} stream for session {context_id}, task {task_id} - {query}'
@@ -124,7 +119,6 @@ async def stream(
124119
if not self.graph:
125120
self.graph = WorkflowGraph()
126121
planner_node = self.add_graph_node(
127-
task_id=task_id,
128122
context_id=context_id,
129123
query=query,
130124
node_key='planner',
@@ -138,59 +132,45 @@ async def stream(
138132

139133
# This loop can be avoided if the workflow graph is dynamic or
140134
# is built from the results of the planner when the planner
141-
# iself is not a part of the graph.
135+
# itself is not a part of the graph.
142136
# TODO: Make the graph dynamically iterable over edges
143137
while True:
144-
# Set attributes on the node so we propagate task and context
138+
# Propagate context only. Child task ids are stored on the
139+
# node from the child's stream and must not be overwritten
140+
# with the orchestrator's task_id.
145141
self.set_node_attributes(
146142
node_id=start_node_id,
147-
task_id=task_id,
148143
context_id=context_id,
149144
)
150145
# Resume workflow, used when the workflow nodes are updated.
151146
should_resume_workflow = False
152-
async for chunk in self.graph.run_workflow(
153-
start_node_id=start_node_id
154-
):
147+
async for chunk in self.graph.run_workflow(start_node_id=start_node_id):
155148
if isinstance(chunk.root, SendStreamingMessageSuccessResponse):
156-
# The graph node retured TaskStatusUpdateEvent
149+
# The graph node returned TaskStatusUpdateEvent
157150
# Check if the node is complete and continue to the next node
158151
if isinstance(chunk.root.result, TaskStatusUpdateEvent):
159152
task_status_event = chunk.root.result
160153
context_id = task_status_event.context_id
161-
if (
162-
task_status_event.status.state
163-
== TaskState.completed
164-
and context_id
165-
):
166-
## yeild??
154+
if task_status_event.status.state == TaskState.completed and context_id:
155+
## yield??
167156
continue
168-
if (
169-
task_status_event.status.state
170-
== TaskState.input_required
171-
):
172-
question = task_status_event.status.message.parts[
173-
0
174-
].root.text
157+
if task_status_event.status.state == TaskState.input_required:
158+
question = task_status_event.status.message.parts[0].root.text
175159

176160
try:
177-
answer = json.loads(
178-
self.answer_user_question(question)
179-
)
161+
answer = json.loads(self.answer_user_question(question))
180162
logger.info(f'Agent Answer {answer}')
181163
if answer['can_answer'] == 'yes':
182164
# Orchestrator can answer on behalf of the user set the query
183165
# Resume workflow from paused state.
184166
query = answer['answer']
185167
start_node_id = self.graph.paused_node_id
186-
self.set_node_attributes(
187-
node_id=start_node_id, query=query
188-
)
168+
self.set_node_attributes(node_id=start_node_id, query=query)
189169
should_resume_workflow = True
190170
except Exception:
191171
logger.info('Cannot convert answer data')
192172

193-
# The graph node retured TaskArtifactUpdateEvent
173+
# The graph node returned TaskArtifactUpdateEvent
194174
# Store the node and continue.
195175
if isinstance(chunk.root.result, TaskArtifactUpdateEvent):
196176
artifact = chunk.root.result.artifact
@@ -205,11 +185,8 @@ async def stream(
205185
)
206186
# Define the edges
207187
current_node_id = start_node_id
208-
for idx, task_data in enumerate(
209-
artifact_data['tasks']
210-
):
188+
for idx, task_data in enumerate(artifact_data['tasks']):
211189
node = self.add_graph_node(
212-
task_id=task_id,
213190
context_id=context_id,
214191
query=task_data['description'],
215192
node_id=current_node_id,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ruff: noqa
2+
"""Helpers for child-agent A2A message payloads."""
3+
4+
from typing import Any
5+
from uuid import uuid4
6+
7+
8+
def create_child_message_payload(
9+
query: str,
10+
context_id: str,
11+
task_id: str | None = None,
12+
) -> dict[str, Any]:
13+
"""Build a child-agent message payload.
14+
15+
A2A treats a present taskId as a resume of an existing task on the
16+
receiving server. New work must omit taskId so the child creates one.
17+
"""
18+
message: dict[str, Any] = {
19+
'role': 'user',
20+
'parts': [{'kind': 'text', 'text': query}],
21+
'messageId': uuid4().hex,
22+
'contextId': context_id,
23+
}
24+
if task_id:
25+
message['taskId'] = task_id
26+
return {'message': message}
27+
28+
29+
def child_task_id_from_result(result: object) -> str | None:
30+
"""Return the child-local task id from a streaming result, if present."""
31+
event_task_id = getattr(result, 'task_id', None)
32+
if isinstance(event_task_id, str) and event_task_id:
33+
return event_task_id
34+
# Task objects expose id plus a status; request wrappers expose only id.
35+
result_id = getattr(result, 'id', None)
36+
if isinstance(result_id, str) and result_id and hasattr(result, 'status'):
37+
return result_id
38+
return None

samples/python/agents/a2a_mcp/src/a2a_mcp/common/workflow.py

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# ruff: noqa
12
import json
23
import logging
34
import uuid
@@ -19,6 +20,10 @@
1920
TaskState,
2021
TaskStatusUpdateEvent,
2122
)
23+
from a2a_mcp.common.child_message import (
24+
child_task_id_from_result,
25+
create_child_message_payload,
26+
)
2227
from a2a_mcp.common.utils import get_mcp_server_config
2328
from a2a_mcp.mcp import client
2429

@@ -61,21 +66,15 @@ def __init__(
6166
async def get_planner_resource(self) -> AgentCard | None:
6267
logger.info(f'Getting resource for node {self.id}')
6368
config = get_mcp_server_config()
64-
async with client.init_session(
65-
config.host, config.port, config.transport
66-
) as session:
67-
response = await client.find_resource(
68-
session, 'resource://agent_cards/planner_agent'
69-
)
69+
async with client.init_session(config.host, config.port, config.transport) as session:
70+
response = await client.find_resource(session, 'resource://agent_cards/planner_agent')
7071
data = json.loads(response.contents[0].text)
7172
return AgentCard(**data['agent_card'][0])
7273

7374
async def find_agent_for_task(self) -> AgentCard | None:
7475
logger.info(f'Find agent for task - {self.task}')
7576
config = get_mcp_server_config()
76-
async with client.init_session(
77-
config.host, config.port, config.transport
78-
) as session:
77+
async with client.init_session(config.host, config.port, config.transport) as session:
7978
result = await client.find_agent(session, self.task)
8079
agent_card_json = json.loads(result.content[0].text)
8180
logger.debug(f'Found agent {agent_card_json} for task {self.task}')
@@ -84,7 +83,7 @@ async def find_agent_for_task(self) -> AgentCard | None:
8483
async def run_node(
8584
self,
8685
query: str,
87-
task_id: str,
86+
task_id: str | None,
8887
context_id: str,
8988
) -> AsyncIterable[dict[str, any]]:
9089
logger.info(f'Executing node {self.id}')
@@ -96,24 +95,20 @@ async def run_node(
9695
async with httpx.AsyncClient() as httpx_client:
9796
client = A2AClient(httpx_client, agent_card)
9897

99-
payload: dict[str, any] = {
100-
'message': {
101-
'role': 'user',
102-
'parts': [{'kind': 'text', 'text': query}],
103-
'messageId': uuid4().hex,
104-
'taskId': task_id,
105-
'contextId': context_id,
106-
},
107-
}
98+
payload = create_child_message_payload(
99+
query=query,
100+
context_id=context_id,
101+
task_id=task_id,
102+
)
108103
request = SendStreamingMessageRequest(
109104
id=str(uuid4()), params=MessageSendParams(**payload)
110105
)
111106
response_stream = client.send_message_streaming(request)
112107
async for chunk in response_stream:
113108
# Save the artifact as a result of the node
114-
if isinstance(
115-
chunk.root, SendStreamingMessageSuccessResponse
116-
) and (isinstance(chunk.root.result, TaskArtifactUpdateEvent)):
109+
if isinstance(chunk.root, SendStreamingMessageSuccessResponse) and (
110+
isinstance(chunk.root.result, TaskArtifactUpdateEvent)
111+
):
117112
artifact = chunk.root.result.artifact
118113
self.results = artifact
119114
yield chunk
@@ -142,9 +137,7 @@ def add_edge(self, from_node_id: str, to_node_id: str) -> None:
142137

143138
self.graph.add_edge(from_node_id, to_node_id)
144139

145-
async def run_workflow(
146-
self, start_node_id: str | None = None
147-
) -> AsyncIterable[dict[str, any]]:
140+
async def run_workflow(self, start_node_id: str | None = None) -> AsyncIterable[dict[str, any]]:
148141
logger.info('Executing workflow graph')
149142
if not start_node_id or start_node_id not in self.nodes:
150143
start_nodes = [n for n, d in self.graph.in_degree() if d == 0]
@@ -172,21 +165,21 @@ async def run_workflow(
172165
# When the workflow node is paused, do not yield any chunks
173166
# but, let the loop complete.
174167
if node.state != Status.PAUSED:
175-
if isinstance(
176-
chunk.root, SendStreamingMessageSuccessResponse
177-
) and (
178-
isinstance(chunk.root.result, TaskStatusUpdateEvent)
179-
):
180-
task_status_event = chunk.root.result
181-
context_id = task_status_event.context_id
182-
if (
183-
task_status_event.status.state
184-
== TaskState.input_required
185-
and context_id
186-
):
187-
node.state = Status.PAUSED
188-
self.state = Status.PAUSED
189-
self.paused_node_id = node.id
168+
if isinstance(chunk.root, SendStreamingMessageSuccessResponse):
169+
result = chunk.root.result
170+
child_task_id = child_task_id_from_result(result)
171+
if child_task_id:
172+
self.set_node_attribute(node_id, 'task_id', child_task_id)
173+
if isinstance(result, TaskStatusUpdateEvent):
174+
task_status_event = result
175+
context_id = task_status_event.context_id
176+
if (
177+
task_status_event.status.state == TaskState.input_required
178+
and context_id
179+
):
180+
node.state = Status.PAUSED
181+
self.state = Status.PAUSED
182+
self.paused_node_id = node.id
190183
yield chunk
191184
if self.state == Status.PAUSED:
192185
break

0 commit comments

Comments
 (0)