A user asked to send an email and schedule a meeting in one sentence. The agent invoked both tools correctly. The UI showed two email draft cards and two meeting scheduler cards. Logs showed one invocation each. We spent an hour tweaking prompts before checking the trace parser.
Bedrock Agent trace trees are not a clean list of tool results. The same Lambda JSON appears under actionGroupInvocationOutput.text, nested responseBody["application/json"].body, and again in child nodes. A naive recursive walk counts every appearance. This post is how we fixed that.
Duplicate UI cards
Multi-tool turns return clientActions[] to the chat drawer — one structured card per action. Email draft, meeting scheduler, Jira ticket. When the array contains duplicates, users assume the agent is broken or over-eager.
The model was fine. The extractor counted echoes.
Where tool output actually lives
With enableTrace: true, invokeAgent streams events. Tool payloads may appear as:
Raw JSON in actionGroupInvocationOutput.text. Wrapped in API Gateway shape under responseBody. String body or text fields on nested objects. No published schema guarantees one path.
We unwrap known shapes into a flat { action: "show_new_email_draft", ... } before mapping to UI types.
The trace walker
function walk(node) {
if (!node || seenNodes.has(node)) return
seenNodes.add(node)
if (node.actionGroupInvocationOutput?.text)
pushUniqueInvocationText(text, ...)
if (node.responseBody?.['application/json']?.body)
pushUniquePayload(unwrapped, ...)
for (const value of Object.values(node))
if (typeof value === 'object') walk(value)
}Cycle detection via seenNodes prevents infinite loops on circular trace references. First dedupe layer: normalised invocation text — identical raw strings skipped.
Semantic fingerprints
Second dedupe layer: action-specific fingerprints so structurally identical payloads from different trace paths collapse to one card:
case 'show_new_email_draft':
return `${action}|${to}|${subject}|${body.slice(0, 200)}`
case 'show_meeting_scheduler':
return `${action}|${title}|${startTime}|${endTime}|${JSON.stringify(attendees)}`Too coarse a fingerprint merges distinct drafts; too fine misses dedupe. Email body prefix at 200 chars was the sweet spot for compose actions.
Trace beats assistant text
The agent route calls extractToolResultsFromTrace first. If clientActions.length > 0, return those — skip parsing markdown JSON from the model's prose. Fallback extractAgentClientAction handles legacy sessions where trace walking missed a shape.
Related: agent architecture · multi-tool turns. Join the Kvika beta.