Skip to content

Part 4: Multi-Agent Orchestration — Pairing Goose with Quarkus Flow State Machines

Tutorial home · Run the example · Enterprise deep dives

Lab contract: You will explore policy categories and HITL state transitions using in-memory state and a demo approval endpoint. A production approval is a security decision: persist it durably, authenticate and authorize the approver, enforce separation of duties, bind it to an immutable request version, expire it, and make execution idempotent.

TL;DR — Bridge Goose agent interactions with Quarkus Flow state machines governed by AGENTS.md rules and integrated via the Agent2Agent (A2A) protocol — the Linux Foundation standard for multi-agent interoperability — to enforce human-in-the-loop approvals on high-risk enterprise workflows.

Enterprise context — Acme FinServ. Parts 1–3 governed Acme's customer-service tools. This part governs the higher-risk platform and financial operations the same team owns — migrate-schema, deploy-production, and process-refund — where an ungoverned agent could alter a production database or move money. This is a change-management / SOX-style controls problem: high-impact changes require a documented, approved, auditable gate. The AGENTS.md policy plus the human-in-the-loop (HITL) approval step is that control. Note the process-refund example enforces a $1,000 approval threshold — a real financial-authority limit — so Sofia (SRE) can auto-run low-risk operations while anything above the line pauses for a named human approver.

The Core Problem

In Part 1 we built a Quarkus MCP tool server. In Part 2 we secured it with agentgateway. In Part 3 we added distributed tracing. The architecture handles single-request tool calls well — but enterprise processes are rarely single-step.

Consider what happens when a developer tells Goose: "Migrate the users table to add an email column, then deploy the updated service to production." This triggers a chain of operations:

  1. Goose calls migrate-schema to alter the production database
  2. Goose calls deploy-production to push the new code
  3. Both operations succeed — without any human ever reviewing the schema change or approving the production deployment

Local developer agents like Goose excel at interactive reasoning, but long-running business processes require:

  • Strict state machines to track multi-step workflows through well-defined phases
  • Max-iteration caps to prevent runaway agent loops that consume resources indefinitely
  • Formal human-in-the-loop (HITL) approvals before destructive operations like database writes, schema migrations, or financial transactions
  • Standardized agent-to-agent delegation so the client agent (Goose) and the backend agent (Quarkus) communicate through a shared protocol, not ad-hoc HTTP calls

Without these guardrails, autonomous agents operate in a governance vacuum — the exact gap that turns helpful automation into unaudited risk.

The Solution: A2A Protocol + Quarkus Flow + AGENTS.md

A2A delegates a business task to an agent; Part 6's model routing selects a model backend for inference. Model fallback does not replace this chapter's workflow policy or human approval gate.

The fix combines three standards into a governed multi-agent architecture:

%%{init: {'look':'handDrawn','theme':'neutral','themeVariables': {'lineColor':'#4A4035'}}}%%
flowchart LR
    G([Goose client]) -->|Discover agent card| CARD["Public agent card"]
    G -->|A2A SendMessage :8082| EXEC

    subgraph QFLOW["Quarkus A2A flow"]
        EXEC["A2A AgentExecutor"]
        GOV["AGENTS.md governance"]
        HITL["HITL approval gate"]
        EXEC --> GOV --> HITL
    end

    HITL -->|MCP :8080| MCP(["Quarkus MCP server<br/>customer-tools"])
    CARD -.-> EXEC
    style G fill:#D4E6F1,stroke:#2E6B8A
    style QFLOW fill:#F5F5F0,stroke:#8B8070
    style CARD fill:#E8E0F0,stroke:#6B5B8A
    style MCP fill:#D8F0D8,stroke:#3D7A3D
  • A2A (Agent2Agent) — the Linux Foundation standard for multi-agent interoperability. Goose discovers the Quarkus backend via an Agent Card and delegates tasks using JSON-RPC over HTTP. The A2A Java SDK (@PublicAgentCard + AgentExecutor) handles protocol compliance automatically.
  • Quarkus Flow — a lightweight state machine implementing the CNCF Serverless Workflow concepts: states, transitions, and action handlers. Each delegated task flows through governance validation, optional HITL approval, and execution.
  • MCP Tool Delegation — auto-approved operations like analyze-logs, health-check, and generate-report delegate to Part 1's MCP server for live tool execution. The quarkus-langchain4j-mcp extension provides a managed McpClient that handles Streamable HTTP transport and MCP sessions automatically.
  • AGENTS.md — a declarative governance file that defines which operations are auto-approved, which require human approval, and which are permanently blocked. The Quarkus server parses this file at startup and enforces it on every incoming A2A task.

The A2A task lifecycle drives the entire flow:

submitted → working → input-required → working → completed
                                     → failed (rejected)
                    → completed (auto-approved)
                    → failed (blocked by governance)

Prerequisites

Everything from Parts 1–3, plus the A2A Java SDK. Part 4 adds the a2a-java-sdk-reference-jsonrpc dependency for A2A protocol support and connects to Part 1's MCP server for tool execution.

Verify your environment:

java -version    # Java 25+
mvn --version    # Maven 3.9+

Step 1: Agent Discovery with A2A Agent Cards

The A2A protocol starts with discovery. Every A2A-compliant agent publishes an Agent Card at a well-known URL that describes its capabilities, skills, and endpoint:

curl -s http://localhost:8082/.well-known/agent-card.json | jq .
{
  "name": "enterprise-workflow-agent",
  "description": "Multi-step enterprise workflow orchestration with HITL approval gates...",
  "supportedInterfaces": [{ "protocol": "JSONRPC", "url": "http://localhost:8082" }],
  "version": "1.0.0",
  "capabilities": { "streaming": false, "pushNotifications": false },
  "skills": [
    { "id": "analyze-logs", "name": "Log Analysis", "description": "Analyze logs via MCP delegation" },
    { "id": "health-check", "name": "Health Check", "description": "Verify service health via MCP" },
    { "id": "migrate-schema", "name": "Schema Migration", "description": "Requires HITL approval" },
    { "id": "deploy-production", "name": "Production Deployment", "description": "Requires HITL approval" }
  ]
}

The Agent Card tells Goose (or any A2A client) three critical things:

Field Purpose
supportedInterfaces The JSON-RPC endpoint URL and transport protocol
skills The operations the agent can perform — Goose uses these to decide which tasks to delegate
capabilities Whether the agent supports streaming, push notifications, etc.

With the A2A Java SDK, the Agent Card is declared using the @PublicAgentCard CDI qualifier on a producer method — the SDK automatically serves it at /.well-known/agent-card.json and registers the JSON-RPC transport handler:

@ApplicationScoped
public class AgentCardProducer {

    @ConfigProperty(name = "agent.url", defaultValue = "http://localhost:8082")
    String agentUrl;

    @Produces
    @PublicAgentCard
    public AgentCard agentCard() {
        return AgentCard.builder()
                .name("enterprise-workflow-agent")
                .description("Multi-step enterprise workflow orchestration with HITL approval gates...")
                .supportedInterfaces(Collections.singletonList(
                        new AgentInterface("JSONRPC", agentUrl)))
                .version("1.0.0")
                .capabilities(AgentCapabilities.builder()
                        .streaming(false).pushNotifications(false).build())
                .defaultInputModes(Collections.singletonList("text"))
                .defaultOutputModes(Collections.singletonList("text"))
                .skills(List.of(
                        AgentSkill.builder().id("analyze-logs").name("Log Analysis")
                                .description("Analyze logs via MCP tool delegation (auto-approved)")
                                .tags(List.of("observability", "auto-approved")).build(),
                        AgentSkill.builder().id("migrate-schema").name("Schema Migration")
                                .description("Execute database schema migrations (requires HITL approval)")
                                .tags(List.of("database", "hitl-required")).build()
                        // ... additional skills
                ))
                .build();
    }
}

No JAX-RS endpoint needed — the SDK handles agent card serving and JSON-RPC routing automatically.

Step 2: Defining Governance Rules with AGENTS.md

AGENTS.md is a declarative governance file that lives alongside the application code. The Quarkus server reads it at startup and enforces its rules on every incoming A2A task.

Create src/main/resources/AGENTS.md:

# AGENTS.md - Governance Rules for Enterprise Workflow Agent

## Execution Bounds
max-iterations: 10

## Operations

### Auto-Approved
- analyze-logs: Read-only log analysis across services
- health-check: Service health and resource utilization verification
- generate-report: Compliance and metrics report generation from existing data

### Requires Human Approval
- migrate-schema: Database schema migrations on production tables
- process-refund: Customer refund processing (auto-approved below $1000 threshold)
- scale-infrastructure: Infrastructure replica scaling operations
- deploy-production: Production environment deployments

### Blocked
- drop-database: Destructive operation — never allowed via agent delegation
- truncate-table: Destructive operation — never allowed via agent delegation
- delete-all-data: Destructive operation — never allowed via agent delegation

Three categories, three outcomes:

Category What Happens Example
Auto-Approved Task executes immediately, no human involved analyze-logs, health-check
Requires Human Approval Task pauses at input-required state until a human approves or rejects migrate-schema, deploy-production
Blocked Task fails immediately with a governance violation drop-database, truncate-table

The GovernanceEngine parses this file at startup and validates every operation:

public GovernanceResult validate(String operation, Map<String, String> args) {
    if (blocked.containsKey(operation)) {
        return new GovernanceResult(Decision.BLOCKED,
            "Operation '" + operation + "' is blocked by governance policy", "critical");
    }

    if (requiresApproval.containsKey(operation)) {
        // Conditional: process-refund auto-approves below $1000
        if (operation.equals("process-refund")) {
            int amount = Integer.parseInt(args.getOrDefault("amount", "0"));
            if (amount <= 1000) {
                return new GovernanceResult(Decision.AUTO_APPROVED, "...", "low");
            }
        }
        return new GovernanceResult(Decision.REQUIRES_APPROVAL, reason, "high");
    }

    if (autoApproved.containsKey(operation)) {
        return new GovernanceResult(Decision.AUTO_APPROVED, "...", "low");
    }

    // Unknown operations default to requiring approval
    return new GovernanceResult(Decision.REQUIRES_APPROVAL, "...", "medium");
}

Notice the conditional logic for process-refund — governance rules can include thresholds, not just binary allow/deny. A $500 refund flows through automatically; a $2,500 refund pauses for human review.

Step 3: Implementing the Workflow State Machine

Each A2A task flows through a state machine with six possible states:

%%{init: {'look':'handDrawn','theme':'neutral','themeVariables': {'lineColor':'#4A4035'}}}%%
stateDiagram-v2
    [*] --> SUBMITTED
    SUBMITTED --> WORKING
    WORKING --> INPUT_REQUIRED: approval required
    INPUT_REQUIRED --> WORKING: approved
    INPUT_REQUIRED --> FAILED: rejected / expired
    WORKING --> COMPLETED: auto-approved or executed
    WORKING --> FAILED: governance blocked / execution error
    COMPLETED --> [*]
    FAILED --> [*]

The WorkflowEngine manages this lifecycle:

public Map<String, Object> submitTask(String taskId, String messageText) {
    TaskInstance task = tasks.computeIfAbsent(taskId, TaskInstance::new);
    task.incrementIteration();

    // Safety bound: prevent runaway agent loops
    if (task.getIteration() > governance.getMaxIterations()) {
        task.setState(TaskState.FAILED);
        task.addMessage("agent", "Max iterations exceeded. Task terminated.");
        return task.toA2AResponse();
    }

    String operation = parseOperation(messageText);
    Map<String, String> args = parseArguments(messageText);
    task.setState(TaskState.WORKING);

    // Governance gate
    GovernanceResult gov = governance.validate(operation, args);

    return switch (gov.decision()) {
        case BLOCKED -> {
            task.setState(TaskState.FAILED);
            task.addMessage("agent", gov.reason());
            yield task.toA2AResponse();
        }
        case REQUIRES_APPROVAL -> {
            task.setState(TaskState.INPUT_REQUIRED);
            task.addMessage("agent", "HITL approval required: " + gov.reason());
            yield task.toA2AResponse();
        }
        case AUTO_APPROVED -> {
            String result = execute(operation, args);
            task.setState(TaskState.COMPLETED);
            task.addMessage("agent", result);
            yield task.toA2AResponse();
        }
    };
}

The A2A Java SDK handles JSON-RPC routing automatically. You implement the AgentExecutor interface — the SDK calls your execute() method for each incoming SendMessage request:

@ApplicationScoped
public class AgentExecutorProducer {

    @Inject WorkflowEngine engine;

    @Produces
    public AgentExecutor agentExecutor() {
        return new AgentExecutor() {
            @Override
            public void execute(RequestContext context, AgentEmitter emitter) throws A2AError {
                String messageText = extractText(context.getMessage());
                String taskId = context.getTaskId();

                // HITL follow-up: task already exists in INPUT_REQUIRED state
                if (context.getTask() != null &&
                    context.getTask().status().state() == TaskState.TASK_STATE_INPUT_REQUIRED) {
                    handleHitlFollowUp(taskId, messageText, emitter);
                    return;
                }

                // New task: submit to workflow engine for governance check
                TaskInstance task = engine.submitTask(taskId, messageText);

                switch (task.getState()) {
                    case FAILED -> emitter.fail(agentMessage(emitter, task.getLastAgentMessage()));
                    case INPUT_REQUIRED -> {
                        emitter.startWork();
                        emitter.requiresInput(agentMessage(emitter, task.getLastAgentMessage()));
                    }
                    case COMPLETED -> {
                        emitter.startWork();
                        emitter.addArtifact(List.of(new TextPart(task.getLastAgentMessage())));
                        emitter.complete();
                    }
                }
            }
            // ...
        };
    }
}

The AgentEmitter manages the A2A task lifecycle — startWork(), requiresInput(), complete(), fail() — while the WorkflowEngine handles governance validation and MCP tool execution.

Step 4: Human-in-the-Loop Approval Gates

When Goose submits a high-risk operation, the workflow pauses at input-required. The task stays frozen until a human explicitly approves or rejects it.

Triggering HITL

Submit a schema migration — this is a high-risk operation that requires approval:

curl -s http://localhost:8082/ \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{
    "message":{"messageId":"migration-msg-1","role":"ROLE_USER","parts":[{"text":"migrate-schema --database production --table users --changes add-column-email"}]},
    "configuration":{"returnImmediately":true}
  }}' | jq .result.task.status
{
  "state": "TASK_STATE_INPUT_REQUIRED",
  "message": {
    "role": "ROLE_AGENT",
    "parts": [{
      "text": "HITL approval required. Reason: Database schema migrations on production tables. Workflow paused at state INPUT_REQUIRED. Awaiting human decision."
    }]
  }
}

Polling for Status

Goose polls GetTask and sees the task is paused. It can alert the developer:

curl -s http://localhost:8082/ \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{"id":"<task-id-from-above>"}}' \
  | jq .result.status.state
"TASK_STATE_INPUT_REQUIRED"

Approving the Task

A human reviews the operation and approves it through the admin API (or the SPA):

curl -s -X POST http://localhost:8082/api/tasks/migration-1/approve | jq .status
{
  "state": "COMPLETED",
  "message": {
    "role": "agent",
    "parts": [{
      "text": "Schema migration completed successfully. Database: production. Table: users. Changes: add-column-email..."
    }]
  }
}

Rejecting a Task

If the human decides the migration is unsafe:

curl -s -X POST http://localhost:8082/api/tasks/migration-2/reject \
  -H "Content-Type: application/json" \
  -d '{"reason":"Schema change not reviewed by DBA team"}' | jq .status.state
"failed"

The rejection reason is recorded in the task history — Goose receives it on the next GetTask poll and can relay it to the developer.

Contrast: Auto-Approved and Blocked Operations

Not every operation triggers HITL. Compare three scenarios:

# Auto-approved: executes immediately
curl -s http://localhost:8082/ \
  -H "Content-Type: application/json" -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":3,"method":"SendMessage","params":{
    "message":{"messageId":"logs-msg-1","role":"ROLE_USER","parts":[{"text":"analyze-logs --service api-gateway --timeframe 24h"}]}
  }}' | jq .result.task.status.state
# → "TASK_STATE_COMPLETED"

# Blocked: fails immediately
curl -s http://localhost:8082/ \
  -H "Content-Type: application/json" -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":4,"method":"SendMessage","params":{
    "message":{"messageId":"drop-msg-1","role":"ROLE_USER","parts":[{"text":"drop-database --target production"}]},
    "configuration":{"returnImmediately":true}
  }}' | jq .result.task.status.state
# → "TASK_STATE_FAILED"

Step 5: Running the Complete Demo

Start all services with the one-command script:

cd part4-multi-agent
./start-all.sh

The script builds Part 1's MCP server and Part 4's A2A Flow server, starts both (MCP on :8080, A2A on :8082), and launches the interactive demo SPA. Auto-approved tasks like analyze-logs delegate to Part 1's MCP tools for live data.

Open the A2A Multi-Agent Console at http://localhost:8889/index.html.

If discovery reports Failed to fetch, stop the launcher with Ctrl+C, update the code, run ./start-all.sh again, and reload the page. The console needs the backend's quarkus.http.cors.enabled=true setting to read responses across ports.

This single-user lab binds the A2A server to localhost and explicitly sets a2a.authorization.required=false. A2A SDK 1.3+ requires a TaskAuthorizationProvider by default; shared deployments need authenticated users and that provider. The lab's AGENTS.md operation rules and HITL gates remain active.

Demo Walkthrough

The SPA provides four guided steps that showcase the full governance spectrum:

Step 1 — Discover Agent: Click Discover Agent to fetch the Agent Card from /.well-known/agent-card.json. The architecture diagram animates the discovery flow and loads the AGENTS.md governance rules. The stat tiles show the agent is online and governance rules are loaded.

Step 2 — Auto-Approved Task (analyze-logs): Click Auto-Approved Task to submit a read-only log analysis operation. The task flows through AGENTS.md validation (auto-approved), skips the HITL gate, and delegates to Part 1's MCP tools via McpToolClient. The state machine transitions: SUBMITTED → WORKING → GOVERNANCE → EXECUTING → COMPLETED.

Step 3 — HITL Task (migrate-schema): Click HITL Task to submit a high-risk database migration. The task pauses at INPUT_REQUIRED after governance flags it for human review. An approval panel appears with Approve and Reject buttons, using the task ID returned by the server. Click Approve to run the simulated schema migration, or Reject to terminate the task. State machine: SUBMITTED → WORKING → GOVERNANCE → HITL GATE → (approve) → EXECUTING → COMPLETED.

Step 4 — Blocked Task (drop-database): Click Blocked Task to submit a destructive operation. AGENTS.md governance rejects it immediately — the task never reaches the HITL gate or MCP delegation. State machine: SUBMITTED → WORKING → GOVERNANCE → FAILED.

Connecting Goose

When Goose adds A2A support, configure it to discover the backend agent:

goose session --a2a-discover http://localhost:8082

Goose will fetch the Agent Card, enumerate available skills, and delegate operations via SendMessage. High-risk operations will pause until a human approves them through the SPA or CLI.

What We Achieved

Starting from the observable architecture in Part 3, we added multi-agent orchestration with governance and HITL without changing any existing MCP tool code:

Layer What We Added Key File
A2A Protocol Agent Card + JSON-RPC via A2A Java SDK AgentCardProducer.java, AgentExecutorProducer.java
Governance AGENTS.md rule parsing and enforcement GovernanceEngine.java, AGENTS.md
Workflow State machine with HITL approval gates WorkflowEngine.java
MCP Integration Tool delegation via quarkus-langchain4j-mcp McpToolClient.java, application.properties
Admin REST API + SPA for human approvals AdminEndpoint.java, index.html

Production Considerations

Concern Local (this tutorial) Production
Task storage In-memory ConcurrentHashMap PostgreSQL + Flyway migrations
HITL notifications Browser polling from SPA WebSocket push or Slack/Teams integration
Governance rules AGENTS.md file on classpath Policy engine (OPA, Cedar, or Keycloak policies)
A2A transport Plain HTTP JSON-RPC mTLS + OAuth2 client credentials
Workflow engine Custom state machine SonataFlow (CNCF Serverless Workflow on Quarkus)
Audit trail In-memory task history Append-only event store with Kafka

Coming Up in Part 5

With governance, security, tracing, and multi-agent orchestration in place, the stack is production-capable — but how do you know it stays correct across code changes? In Part 5, we will add automated agent evaluation and regression testing — using golden datasets to verify that MCP tool outputs, Bean Validation boundaries, and multi-step agent workflows produce correct, consistent results as the codebase evolves.