Kvika Team·· 7 min read

Deduplicating Bedrock Agent Trace Results

One user message produced two email drafts and two meeting cards. The agent did not call tools twice — the trace tree echoed the same Lambda JSON in four places. How we parse and fingerprint tool output.

aibedrockengineeringdebugging

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

typescript
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:

typescript
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.

Instruct the agent not to echo tool JSON in final assistant text. Dual extraction (trace + text parse) can still duplicate if the model disobeys — trace path is primary.

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.

Kvika Team

Kvika unifies your calendar, email, and tasks across Google and Microsoft. Join the beta at kvika.work/waitlist.