Agent-to-Agent (A2A) Framework for Database Queries

Agentic AI Systems advanced Building with AI ~35 min read

Overview

As AI agents become more capable, complex tasks increasingly require coordination between multiple specialized agents rather than relying on a single monolithic agent. The Agent-to-Agent (A2A) protocol provides a standardized framework for agents to discover each other’s capabilities, delegate subtasks, exchange structured data, and coordinate multi-step workflows. When applied to database systems, A2A enables sophisticated query pipelines where a planning agent decomposes a complex query into sub-queries, delegates each to specialized data agents, and assembles the results — all through a well-defined protocol rather than ad-hoc function calls.

In a production system spanning multiple data sources, A2A coordinates database queries that require different types of expertise. A natural language question like “Which customers had the highest growth rate last quarter compared to their industry average?” might require one agent to translate the question into SQL, another to fetch industry benchmark data from an external API, a third to compute statistical aggregations, and a coordinator agent to assemble the final answer. Each agent advertises its capabilities through an A2A-compatible agent card, and the coordinator discovers and invokes them dynamically.

This topic covers the A2A protocol’s core concepts — agent cards, capability discovery, task lifecycle, and message formats — applied specifically to database query orchestration. It examines how dedicated query planning, skills library, and client framework components implement A2A participants that handle query planning, execution, caching, and result transformation across a data platform.

Core Concepts

Agent Cards and Capability Discovery

Every A2A-compliant agent publishes an agent card — a JSON document describing who the agent is, what it can do, and how to communicate with it. The card includes the agent’s name, description, supported input/output formats, authentication requirements, and a list of skills (capabilities). When a coordinator agent needs to decompose a complex query, it queries a registry of agent cards to find agents with the right skills — e.g., an agent with a “sql-generation” skill for natural language to SQL translation, or a “time-series-aggregation” skill for temporal data analysis.

Task Lifecycle: Submit, Working, Complete

A2A models work as tasks with a well-defined lifecycle. A client agent submits a task to a server agent, which transitions the task through states: submitted → working → completed (or failed). For database queries, this lifecycle maps naturally: submission includes the query or question, the working phase covers query planning and execution, and completion returns the result set or error. Long-running queries use streaming updates so the coordinator can show progress or cancel work that’s taking too long.

Message Parts and Structured Data Exchange

A2A messages contain typed parts — text, structured data (JSON), files, or binary blobs. For database queries, this is essential because agents need to exchange more than plain text: query plans as JSON ASTs, result sets as typed arrays, schema descriptions, execution statistics, and visualization instructions. Each message part has a MIME type and optional metadata, enabling agents to communicate with type safety rather than relying on string parsing.

Multi-Agent Query Decomposition

Complex analytical queries often cannot be answered by a single database query against a single data source. A2A enables a decomposition pattern where a planner agent breaks the high-level question into subtasks, each handled by a specialist: one agent queries the OLTP database for transactional data, another queries the analytics warehouse for aggregations, a third fetches external reference data. The planner agent coordinates the dependency graph — subtasks may run in parallel where independent, or sequentially where one depends on another’s results.

Capability-Based Routing vs. Hardcoded Pipelines

Traditional query pipelines hardcode the sequence of processing steps. A2A replaces this with capability-based routing: the coordinator describes what it needs (“generate SQL from this natural language query”), and the A2A registry returns agents that can fulfill the request. This decouples the coordinator from specific agent implementations — a SQL generation agent can be swapped, upgraded, or replicated without changing the coordinator. It also enables dynamic scaling: if the SQL generation agent is overloaded, a second instance with the same capability can be spun up and discovered automatically.

How It Works

The A2A framework for database queries involves four main components: agent card registration, task submission and lifecycle management, inter-agent message passing, and result assembly. The following examples demonstrate these components using TypeScript.

Agent Card Definition

// Agent card for a SQL generation agent.
// Published at: https://sql-agent.agent.internal/.well-known/agent.json
const sqlAgentCard = {
  name: "db-sql-generator",
  description: "Translates natural language questions into SQL queries for the database",
  url: "https://sql-agent.agent.internal",
  version: "1.0.0",
  capabilities: {
    streaming: true,
    pushNotifications: false,
  },
  skills: [
    {
      id: "nl-to-sql",
      name: "Natural Language to SQL",
      description: "Converts English questions into optimized SQL for the database schema",
      inputModes: ["text/plain", "application/json"],
      outputModes: ["application/sql", "application/json"],
    },
    {
      id: "sql-explain",
      name: "SQL Query Explanation",
      description: "Explains a SQL query's execution plan in natural language",
      inputModes: ["application/sql"],
      outputModes: ["text/plain", "application/json"],
    },
  ],
  authentication: {
    schemes: ["bearer"],
  },
};

// Agent card for a query execution agent.
const executorAgentCard = {
  name: "db-query-executor",
  description: "Executes SQL queries against database instances with caching and rate limiting",
  url: "https://executor.agent.internal",
  version: "1.0.0",
  capabilities: {
    streaming: true,
    pushNotifications: true,
  },
  skills: [
    {
      id: "execute-sql",
      name: "Execute SQL Query",
      description: "Runs SQL against the database and returns typed result sets",
      inputModes: ["application/sql"],
      outputModes: ["application/json"],
    },
    {
      id: "execute-sql-streaming",
      name: "Execute SQL with Streaming Results",
      description: "Streams large result sets row-by-row for memory-efficient processing",
      inputModes: ["application/sql"],
      outputModes: ["application/x-ndjson"],
    },
  ],
};

Capability Discovery and Agent Selection

interface AgentCard {
  name: string;
  description: string;
  url: string;
  skills: Skill[];
}

interface Skill {
  id: string;
  name: string;
  description: string;
  inputModes: string[];
  outputModes: string[];
}

class AgentRegistry {
  private agents: Map<string, AgentCard> = new Map();

  // Register an agent by fetching its card from the well-known URL.
  async register(agentUrl: string): Promise<void> {
    const cardUrl = `${agentUrl}/.well-known/agent.json`;
    const response = await fetch(cardUrl);
    const card: AgentCard = await response.json();
    this.agents.set(card.name, card);
    console.log(`Registered agent: ${card.name} with ${card.skills.length} skills`);
  }

  // Find agents that have a specific skill.
  findBySkill(skillId: string): AgentCard[] {
    return Array.from(this.agents.values()).filter(agent =>
      agent.skills.some(s => s.id === skillId)
    );
  }

  // Find agents that accept a specific input type and produce a specific output type.
  findByIO(inputMode: string, outputMode: string): AgentCard[] {
    return Array.from(this.agents.values()).filter(agent =>
      agent.skills.some(s =>
        s.inputModes.includes(inputMode) && s.outputModes.includes(outputMode)
      )
    );
  }
}

// Usage: discover agents at startup.
const registry = new AgentRegistry();
await registry.register("https://sql-agent.agent.internal");
await registry.register("https://executor.agent.internal");

// Find an agent that can convert text to SQL.
const sqlAgents = registry.findBySkill("nl-to-sql");
console.log(`Found ${sqlAgents.length} SQL generation agents`);

Task Submission and Lifecycle

interface A2ATask {
  id: string;
  status: "submitted" | "working" | "completed" | "failed" | "canceled";
  messages: A2AMessage[];
  artifacts: A2AArtifact[];
  metadata: Record<string, unknown>;
}

interface A2AMessage {
  role: "user" | "agent";
  parts: MessagePart[];
  timestamp: string;
}

type MessagePart =
  | { type: "text"; text: string }
  | { type: "data"; mimeType: string; data: unknown }
  | { type: "file"; mimeType: string; uri: string };

interface A2AArtifact {
  name: string;
  mimeType: string;
  data: unknown;
}

class A2AClient {
  constructor(private agentUrl: string, private authToken: string) {}

  // Submit a new task to the agent.
  async submitTask(messages: A2AMessage[]): Promise<A2ATask> {
    const response = await fetch(`${this.agentUrl}/tasks`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${this.authToken}`,
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        method: "tasks/send",
        params: { messages },
      }),
    });
    const result = await response.json();
    return result.result as A2ATask;
  }

  // Poll task status until completion.
  async waitForCompletion(taskId: string, timeoutMs = 30000): Promise<A2ATask> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const response = await fetch(`${this.agentUrl}/tasks/${taskId}`, {
        headers: { Authorization: `Bearer ${this.authToken}` },
      });
      const task: A2ATask = (await response.json()).result;

      if (task.status === "completed" || task.status === "failed") {
        return task;
      }

      // Exponential backoff: 100ms, 200ms, 400ms, ...
      await new Promise(r => setTimeout(r, Math.min(1000, 100 * (2 ** ((Date.now() - start) / 1000)))));
    }
    throw new Error(`Task ${taskId} timed out after ${timeoutMs}ms`);
  }
}

Multi-Agent Query Coordinator

class QueryCoordinator {
  private registry: AgentRegistry;
  private clients: Map<string, A2AClient> = new Map();

  constructor(registry: AgentRegistry, authToken: string) {
    this.registry = registry;
    // Create clients for all registered agents.
    for (const [name, card] of registry.entries()) {
      this.clients.set(name, new A2AClient(card.url, authToken));
    }
  }

  // Handle a natural language database question end-to-end.
  async answerQuestion(question: string): Promise<QueryResult> {
    // Step 1: Find a SQL generation agent and convert NL to SQL.
    const sqlAgents = this.registry.findBySkill("nl-to-sql");
    if (sqlAgents.length === 0) {
      throw new Error("No SQL generation agent available");
    }
    const sqlClient = this.clients.get(sqlAgents[0].name)!;

    const sqlTask = await sqlClient.submitTask([
      {
        role: "user",
        parts: [
          { type: "text", text: question },
          {
            type: "data",
            mimeType: "application/json",
            data: { database: "exampledb", schemaVersion: "v2" },
          },
        ],
        timestamp: new Date().toISOString(),
      },
    ]);

    const sqlResult = await sqlClient.waitForCompletion(sqlTask.id);
    if (sqlResult.status === "failed") {
      throw new Error(`SQL generation failed: ${JSON.stringify(sqlResult.messages)}`);
    }

    // Extract the generated SQL from the task artifacts.
    const sqlArtifact = sqlResult.artifacts.find(a => a.mimeType === "application/sql");
    if (!sqlArtifact) throw new Error("No SQL artifact in response");
    const sql = sqlArtifact.data as string;

    // Step 2: Find an executor agent and run the SQL.
    const executors = this.registry.findBySkill("execute-sql");
    const execClient = this.clients.get(executors[0].name)!;

    const execTask = await execClient.submitTask([
      {
        role: "user",
        parts: [{ type: "data", mimeType: "application/sql", data: sql }],
        timestamp: new Date().toISOString(),
      },
    ]);

    const execResult = await execClient.waitForCompletion(execTask.id);
    if (execResult.status === "failed") {
      throw new Error(`Query execution failed: ${JSON.stringify(execResult.messages)}`);
    }

    // Step 3: Assemble the final result.
    const resultData = execResult.artifacts.find(a => a.mimeType === "application/json");
    return {
      question,
      sql,
      rows: resultData?.data as Record<string, unknown>[],
      executionTime: execResult.metadata.executionTimeMs as number,
    };
  }
}

interface QueryResult {
  question: string;
  sql: string;
  rows: Record<string, unknown>[];
  executionTime: number;
}

Practical Applications

In a typical implementation, the A2A framework powers a natural language query interface — where users type questions about their data and receive SQL-backed answers. The coordinator agent receives the question, discovers available data agents (SQL generator, query executor, result formatter), and orchestrates the query pipeline. Because agents are discovered dynamically, the system gracefully handles agent downtime: if the primary SQL generator is unavailable, the coordinator falls back to a secondary agent with the same skill.

A skills library contains a collection of reusable agent skills that can be composed into different query pipelines. A “schema-introspection” skill examines the database schema to help the SQL generator produce valid queries. A “result-cache” skill stores recent query results and short-circuits execution for repeated questions. A “query-guard” skill validates generated SQL against security policies (preventing DROP/DELETE, enforcing row limits). These skills are packaged as independent A2A agents, each with its own agent card, and composed by the coordinator.

A TypeScript client framework provides the client libraries and server scaffolding for building A2A agents. It includes middleware for agent card serving, task lifecycle management, authentication, and observability (request tracing across agent boundaries). The framework handles the protocol boilerplate so that agent developers focus on implementing skills — a new agent is a function that receives typed input, performs domain-specific work, and returns typed output.

Implementation Patterns

Parallel Sub-Query Execution

When a complex question decomposes into independent sub-queries, the coordinator should execute them in parallel rather than sequentially. A2A’s task model supports this naturally: submit multiple tasks to different agents simultaneously, then await all completions. The coordinator builds a dependency graph of subtasks and executes each level of the graph in parallel, only blocking when a subtask depends on a prior result.

async function executeParallelSubQueries(
  coordinator: QueryCoordinator,
  subQueries: { sql: string; agent: string }[]
): Promise<Map<string, unknown[]>> {
  // Submit all independent queries in parallel.
  const taskPromises = subQueries.map(async (sq) => {
    const client = coordinator.getClient(sq.agent);
    const task = await client.submitTask([
      {
        role: "user",
        parts: [{ type: "data", mimeType: "application/sql", data: sq.sql }],
        timestamp: new Date().toISOString(),
      },
    ]);
    const result = await client.waitForCompletion(task.id);
    return { sql: sq.sql, result };
  });

  // Await all — failures are collected, not thrown immediately.
  const results = await Promise.allSettled(taskPromises);
  const resultMap = new Map<string, unknown[]>();

  for (const r of results) {
    if (r.status === "fulfilled") {
      const artifact = r.value.result.artifacts
        .find((a: A2AArtifact) => a.mimeType === "application/json");
      resultMap.set(r.value.sql, artifact?.data as unknown[] ?? []);
    } else {
      console.error(`Sub-query failed: ${r.reason}`);
    }
  }

  return resultMap;
}

Agent Health Checks and Circuit Breaking

In a multi-agent system, individual agents may become slow or unresponsive. The coordinator should implement circuit breaking: after N consecutive failures or timeouts from an agent, stop routing tasks to it for a cooldown period and fall back to alternative agents with the same skill. This prevents a single degraded agent from blocking the entire query pipeline.

class CircuitBreaker {
  private failures = 0;
  private lastFailure = 0;
  private state: "closed" | "open" | "half-open" = "closed";

  constructor(
    private threshold: number = 3,
    private cooldownMs: number = 30000
  ) {}

  canExecute(): boolean {
    if (this.state === "closed") return true;
    if (this.state === "open") {
      // Check if cooldown has elapsed.
      if (Date.now() - this.lastFailure > this.cooldownMs) {
        this.state = "half-open";
        return true; // Allow one probe request.
      }
      return false;
    }
    return true; // half-open: allow probe
  }

  recordSuccess(): void {
    this.failures = 0;
    this.state = "closed";
  }

  recordFailure(): void {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.threshold) {
      this.state = "open";
      console.warn(`Circuit breaker opened after ${this.failures} failures`);
    }
  }
}

Common Pitfalls

Cascading Failures Across Agent Chains

When agents depend on each other in a chain (planner → SQL generator → executor → formatter), a failure in any agent can cascade. If the SQL generator returns malformed SQL, the executor fails, the formatter receives no data, and the coordinator returns an opaque error. Implement validation between each step: the coordinator should validate the SQL generator’s output against a basic SQL parser before forwarding to the executor, and each agent should return structured error messages that the coordinator can surface to the user.

Unbounded Task Queues Leading to Resource Exhaustion

If the coordinator submits tasks faster than agents can process them, the agent’s task queue grows unboundedly, consuming memory and increasing latency. Agents should enforce queue depth limits and return HTTP 429 (Too Many Requests) when full. The coordinator should respect backpressure signals: retry with exponential backoff, shed load by rejecting low-priority queries, or route to alternative agents. Never implement unlimited retry loops — they amplify the overload.

Schema Version Mismatches Between Agents

The SQL generator and executor agents must agree on the database schema. If the generator produces SQL referencing a column that was renamed in a recent migration, the executor will return a “column not found” error. Include schema version metadata in A2A messages so agents can detect mismatches. The coordinator should refresh the schema cache and retry the generation step when a schema-related execution error occurs.

Missing Timeout Propagation

When a user sets a 5-second timeout on their query, that deadline must propagate through the entire agent chain. If the coordinator spends 3 seconds on SQL generation, only 2 seconds remain for execution. Pass remaining deadline (not just a static timeout) in each task’s metadata. Agents should check the deadline before starting expensive operations and return early with a timeout error rather than completing work that will be discarded.

Further Reading

Further Reading