Skip to content

Commit d612c3b

Browse files
author
James Nguyen
committed
refactor(dea): simplify A2A tool metrics extraction and transcript telemetry
- Implement stateless single-pass A2A ConversationToken decoding in gcp_data_engineering_agent.py - Extract function call parameters, outputs, and failure flags without state locks or diffing - Format structured per-turn tool telemetry inside conversation_history in dataengineeringagentevaluator.py - Add typed state attributes to EvalDeaRequest in dataengineeringagentinput.py
1 parent 2e71a59 commit d612c3b

3 files changed

Lines changed: 137 additions & 15 deletions

File tree

‎evalbench/dataset/dataengineeringagentinput.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import copy
3+
from typing import Any
34

45

56
class EvalDeaRequest:
@@ -21,8 +22,11 @@ def __init__(self, raw_dict: dict, job_id: str = "", trace_id: str = ""):
2122
self.payload_str = json.dumps(raw_dict)
2223
self.payload = self.payload_str
2324

24-
self.agent_results = []
25-
self.scoring_results = []
25+
self.agent_results: list[dict[str, Any]] = []
26+
self.scoring_results: list[dict[str, Any]] = []
27+
28+
self.accumulated_tools: list[str] = []
29+
self.this_turn_tool_details: list[dict[str, Any]] = []
2630

2731
@classmethod
2832
def init_from_proto(cls, proto):

‎evalbench/evaluator/dataengineeringagentevaluator.py‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,37 @@ def process_scenario(
200200
agent_text
201201
)
202202

203+
this_turn_tools = getattr(
204+
eval_result, "this_turn_tool_details", []
205+
)
206+
207+
tools_by_name = {}
208+
for t in this_turn_tools:
209+
name = t["name"]
210+
params = t["params"]
211+
output = t.get("output", {})
212+
fail = t.get("fail", 0)
213+
214+
tool_data = tools_by_name.setdefault(
215+
name, {"parameters": [], "outputs": [], "fail": 0}
216+
)
217+
tool_data["parameters"].append(params)
218+
tool_data["outputs"].append(output)
219+
if fail:
220+
tool_data["fail"] = 1
221+
222+
agent_json = {
223+
"response": agent_text,
224+
"stats": {
225+
"tools": {
226+
"byName": tools_by_name
227+
}
228+
}
229+
}
230+
203231
conversation_history.append({
204232
"user": current_prompt,
205-
"agent": agent_text,
233+
"agent": json.dumps(agent_json),
206234
})
207235

208236
# Simulated User checks conversation plan and generates next prompt
@@ -316,7 +344,9 @@ def _finalize_scenario(
316344
"prompt": scenario["starting_prompt"],
317345
"conversation_history": json.dumps(conversation_history, indent=2),
318346
"scenario": scenario,
319-
"accumulated_tools": [],
347+
"accumulated_tools": getattr(
348+
eval_result, "accumulated_tools", []
349+
),
320350
"accumulated_skills": [],
321351
"job_id": job_id,
322352
"metadata": metadata,

‎evalbench/generators/models/gcp_data_engineering_agent.py‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
2+
import base64
23
import collections.abc
34
import concurrent.futures
45
import datetime
56
import json
67
import logging
8+
import re
79
import threading
810
import uuid
911
from typing import Any, Coroutine
@@ -197,6 +199,81 @@ def _find_agent_text_recursive(obj: Any) -> str:
197199
return "\n\n".join(texts)
198200

199201

202+
def _find_json_objects(
203+
text: str,
204+
) -> collections.abc.Iterable[dict[str, Any]]:
205+
"""Finds top-level JSON objects starting with {"id": in text."""
206+
decoder = json.JSONDecoder()
207+
for match in re.finditer(r'\{"id":', text):
208+
start = match.start()
209+
try:
210+
obj, _ = decoder.raw_decode(text, start)
211+
if isinstance(obj, dict):
212+
yield obj
213+
except json.JSONDecodeError:
214+
continue
215+
216+
217+
def _extract_tool_details_from_token(
218+
token_str: str,
219+
) -> list[dict[str, Any]]:
220+
"""Extracts tool execution details from A2A conversation token."""
221+
if not token_str:
222+
return []
223+
224+
cleaned_token_str = re.sub(r"[^A-Za-z0-9+/=]", "", token_str)
225+
226+
try:
227+
decoded_bytes = base64.b64decode(cleaned_token_str)
228+
decoded_str = decoded_bytes.decode("utf-8", errors="ignore")
229+
except (ValueError, UnicodeDecodeError) as e:
230+
logger.exception("Failed to decode token: %s", e)
231+
return []
232+
233+
calls = {}
234+
responses = {}
235+
236+
for js in _find_json_objects(decoded_str):
237+
content = js.get("content", {})
238+
if not isinstance(content, dict):
239+
continue
240+
parts = content.get("parts")
241+
if not isinstance(parts, list):
242+
continue
243+
for part in parts:
244+
if not isinstance(part, dict):
245+
continue
246+
if "functionCall" in part:
247+
fc = part["functionCall"]
248+
fc_id = fc.get("id")
249+
if fc_id:
250+
calls[fc_id] = {
251+
"id": fc_id,
252+
"name": fc["name"],
253+
"params": fc.get("args", {}),
254+
"output": {},
255+
"fail": 0,
256+
}
257+
if "functionResponse" in part:
258+
fr = part["functionResponse"]
259+
fr_id = fr.get("id")
260+
if fr_id:
261+
responses[fr_id] = fr.get("response", {})
262+
263+
# Match calls and responses to determine failure and store output
264+
for fr_id, resp_payload in responses.items():
265+
if fr_id in calls:
266+
calls[fr_id]["output"] = resp_payload
267+
if isinstance(resp_payload, dict):
268+
if resp_payload.get("error") or resp_payload.get("errors"):
269+
calls[fr_id]["fail"] = 1
270+
elif isinstance(resp_payload, str):
271+
if "error" in resp_payload.lower():
272+
calls[fr_id]["fail"] = 1
273+
274+
return list(calls.values())
275+
276+
200277
class DataEngineeringAgentGenerator(QueryGenerator):
201278
"""Data Engineering Agent (DEA) Query Generator using the A2A SDK."""
202279

@@ -261,7 +338,12 @@ def generate_internal(self, prompt: EvalDeaRequest) -> EvalDeaRequest:
261338
)
262339

263340
try:
264-
prompt.generated_nl_response = self.run_async(coro)
341+
reply_text, new_token = self.run_async(coro)
342+
prompt.generated_nl_response = reply_text
343+
if new_token:
344+
all_tools = _extract_tool_details_from_token(new_token)
345+
prompt.this_turn_tool_details = all_tools
346+
prompt.accumulated_tools = list({t["name"] for t in all_tools})
265347
except Exception:
266348
logger.exception("A2A SDK messaging error")
267349
raise
@@ -272,7 +354,7 @@ async def _run_client(
272354
prompt: str,
273355
conversation_id: str | None,
274356
target_workspace: str,
275-
) -> str:
357+
) -> tuple[str, str]:
276358
"""Core asynchronous A2A SDK connection loop."""
277359
# Configure Client in standard Non-Streaming Mode
278360
config = ClientConfig(
@@ -326,11 +408,13 @@ async def _run_client(
326408
}
327409

328410
# Handle ConversationToken state memory thread-safely
329-
token = ""
411+
conversation_token = ""
330412
with self._token_lock:
331-
token = self._conversation_token_cache.get(conversation_id, "")
332-
if token:
333-
message_req.metadata[CONVERSATION_TOKEN_URI] = token
413+
conversation_token = self._conversation_token_cache.get(
414+
conversation_id, ""
415+
)
416+
if conversation_token:
417+
message_req.metadata[CONVERSATION_TOKEN_URI] = conversation_token
334418

335419
context = ClientCallContext(
336420
timeout=300.0,
@@ -340,7 +424,7 @@ async def _run_client(
340424
)
341425

342426
reply_text = ""
343-
new_token = ""
427+
new_conversation_token = ""
344428

345429
try:
346430
async for resp in client.send_message(
@@ -358,19 +442,23 @@ async def _run_client(
358442
resp.HasField("task")
359443
and CONVERSATION_TOKEN_URI in resp.task.metadata
360444
):
361-
new_token = resp.task.metadata[CONVERSATION_TOKEN_URI]
445+
new_conversation_token = resp.task.metadata[
446+
CONVERSATION_TOKEN_URI
447+
]
362448
except Exception as e:
363449
self._log_api_error_details(e)
364450
raise
365451
finally:
366452
await client.close()
367453

368454
# Cache the new token thread-safely
369-
if new_token:
455+
if new_conversation_token:
370456
with self._token_lock:
371-
self._conversation_token_cache[conversation_id] = new_token
457+
self._conversation_token_cache[
458+
conversation_id
459+
] = new_conversation_token
372460

373-
return reply_text.strip()
461+
return reply_text.strip(), new_conversation_token
374462

375463
@staticmethod
376464
def run_async(coro: Coroutine[Any, Any, Any]) -> Any:

0 commit comments

Comments
 (0)