Files
langflow/test-a2a-client.py
Jordan Frazier c288c84c25 chore: add A2A client test script for manual testing
Interactive Python client that talks to Langflow A2A agents:
- Discovers AgentCard, authenticates, sends messages
- Handles INPUT_REQUIRED: shows agent's question, prompts for response,
  sends follow-up to same taskId
- Supports streaming (--stream) and single-message (--message) modes
- Multi-turn conversation with shared contextId

Usage:
  python test-a2a-client.py --slug my-agent
  python test-a2a-client.py --slug my-agent --stream
  python test-a2a-client.py --slug my-agent -m "Deploy my app"
  python test-a2a-client.py --slug my-agent --discover-only
2026-03-27 21:55:34 -04:00

334 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""A2A client script for testing Langflow's A2A server.
Sends messages to a Langflow A2A agent and handles multi-turn
conversations including INPUT_REQUIRED (agent asks for clarification).
Usage:
# Interactive mode — chat with the agent
python test-a2a-client.py --slug my-agent
# Single message
python test-a2a-client.py --slug my-agent --message "Deploy my app"
# Streaming mode
python test-a2a-client.py --slug my-agent --stream
# Custom Langflow URL
python test-a2a-client.py --slug my-agent --url http://localhost:7860
Setup:
1. Start Langflow: uv run langflow run
2. Build a flow with ChatInput → Agent → ChatOutput
3. Configure the Agent's LLM (OpenAI, etc.)
4. Enable A2A on the flow via the UI (A2A Agents tab) or API:
curl -X PUT http://localhost:7860/api/v1/flows/<id>/a2a-config \\
-H "Authorization: Bearer <token>" \\
-H "Content-Type: application/json" \\
-d '{"a2a_enabled": true, "a2a_agent_slug": "my-agent"}'
5. Run this script
To test INPUT_REQUIRED specifically:
Set the Agent's system message to something like:
"Before performing any action, always ask the user to confirm
by using the request_input tool. Ask what environment they want."
"""
from __future__ import annotations
import argparse
import json
import sys
import uuid
import httpx
def get_token(base_url: str, username: str, password: str) -> str | None:
"""Login and get a bearer token."""
try:
resp = httpx.post(
f"{base_url}/api/v1/login",
data={"username": username, "password": password},
timeout=10,
)
if resp.status_code == 200:
return resp.json()["access_token"]
except Exception as e:
print(f" Login failed: {e}")
return None
def discover_agent(base_url: str, slug: str) -> dict | None:
"""Fetch the AgentCard (no auth needed)."""
resp = httpx.get(
f"{base_url}/a2a/{slug}/.well-known/agent-card.json",
timeout=10,
)
if resp.status_code == 200:
return resp.json()
print(f" Agent not found: {resp.status_code} {resp.text}")
return None
def send_message(
base_url: str,
slug: str,
text: str,
headers: dict,
context_id: str,
task_id: str | None = None,
) -> dict:
"""Send a message via POST /message:send."""
payload: dict = {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": text}],
"contextId": context_id,
},
}
if task_id:
payload["taskId"] = task_id
resp = httpx.post(
f"{base_url}/a2a/{slug}/v1/message:send",
json=payload,
headers=headers,
timeout=120,
)
if resp.status_code != 200:
print(f" Error: {resp.status_code} {resp.text}")
return {"status": {"state": "failed"}}
return resp.json()
def send_message_stream(
base_url: str,
slug: str,
text: str,
headers: dict,
context_id: str,
) -> dict | None:
"""Send a message via POST /message:stream and print events."""
payload = {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": text}],
"contextId": context_id,
},
}
last_task = None
with httpx.stream(
"POST",
f"{base_url}/a2a/{slug}/v1/message:stream",
json=payload,
headers=headers,
timeout=120,
) as resp:
if resp.status_code != 200:
print(f" Error: {resp.status_code}")
return None
for line in resp.iter_lines():
if not line.startswith("data: "):
continue
event = json.loads(line[6:])
kind = event.get("kind", "")
if kind == "status-update":
state = event["status"]["state"]
msg = ""
status_msg = event.get("status", {}).get("message", {})
if status_msg:
parts = status_msg.get("parts", [])
msg = " ".join(p.get("text", "") for p in parts)
print(f" [{state}] {msg}")
last_task = event
elif kind == "artifact-update":
artifact = event.get("artifact", {})
for part in artifact.get("parts", []):
if part.get("kind") == "text":
print(part["text"], end="", flush=True)
print()
return last_task
def get_artifact_text(task: dict) -> str:
"""Extract text from a task's artifacts."""
texts = []
for artifact in task.get("artifacts", []):
for part in artifact.get("parts", []):
if part.get("kind") == "text":
texts.append(part["text"])
return "\n".join(texts)
def get_status_message(task: dict) -> str:
"""Extract the status message from a task."""
msg = task.get("status", {}).get("message", {})
if not msg:
return ""
parts = msg.get("parts", [])
return " ".join(p.get("text", "") for p in parts)
def run_conversation(
base_url: str,
slug: str,
headers: dict,
initial_message: str | None,
stream: bool,
):
"""Run an interactive conversation with an A2A agent."""
context_id = str(uuid.uuid4())
print(f"\n Context: {context_id}")
print(f" Type 'quit' to exit.\n")
# Get first message
if initial_message:
user_input = initial_message
else:
user_input = input("You: ").strip()
if not user_input or user_input.lower() == "quit":
return
while True:
# Send message
if stream:
task = send_message_stream(
base_url, slug, user_input, headers, context_id
)
if not task:
break
else:
print(" Thinking...")
task = send_message(
base_url, slug, user_input, headers, context_id
)
state = task.get("status", {}).get("state", "unknown")
task_id = task.get("taskId") or task.get("id")
if state == "completed":
if not stream:
text = get_artifact_text(task)
print(f"\nAgent: {text}\n")
else:
print()
elif state == "input-required":
# The agent is asking for clarification!
question = get_status_message(task)
print(f"\nAgent needs input: {question}\n")
user_input = input("You (follow-up): ").strip()
if not user_input or user_input.lower() == "quit":
break
# Send follow-up to the SAME task
print(" Sending follow-up...")
task = send_message(
base_url, slug, user_input, headers,
context_id, task_id=task_id,
)
state = task.get("status", {}).get("state", "unknown")
if state == "completed":
text = get_artifact_text(task)
print(f"\nAgent: {text}\n")
elif state == "input-required":
# Agent asked again — loop back
continue
else:
print(f"\n Task state: {state}")
msg = get_status_message(task)
if msg:
print(f" Message: {msg}")
break
elif state == "failed":
msg = get_status_message(task)
print(f"\n Failed: {msg}\n")
break
else:
print(f"\n Unexpected state: {state}")
break
# Next turn
if not initial_message:
user_input = input("You: ").strip()
if not user_input or user_input.lower() == "quit":
break
else:
break # Single message mode
def main():
parser = argparse.ArgumentParser(
description="A2A client for testing Langflow agents"
)
parser.add_argument(
"--slug", required=True, help="Agent slug (e.g. 'my-agent')"
)
parser.add_argument(
"--url", default="http://localhost:7860", help="Langflow base URL"
)
parser.add_argument(
"--message", "-m", help="Send a single message (non-interactive)"
)
parser.add_argument(
"--stream", "-s", action="store_true", help="Use SSE streaming"
)
parser.add_argument("--username", default="langflow", help="Login username")
parser.add_argument("--password", default="langflow", help="Login password")
parser.add_argument(
"--discover-only", action="store_true", help="Just fetch and print the AgentCard"
)
args = parser.parse_args()
base_url = args.url.rstrip("/")
# Step 1: Discover
print(f"\n--- Discovering agent '{args.slug}' ---")
card = discover_agent(base_url, args.slug)
if not card:
sys.exit(1)
print(f" Name: {card['name']}")
print(f" Description: {card.get('description', 'N/A')}")
print(f" Skills: {len(card.get('skills', []))}")
print(f" Streaming: {card.get('capabilities', {}).get('streaming', False)}")
if args.discover_only:
print(f"\n Full card:")
print(json.dumps(card, indent=2))
return
# Step 2: Login
print(f"\n--- Logging in ---")
token = get_token(base_url, args.username, args.password)
if not token:
print(" Failed to login. Check credentials.")
sys.exit(1)
print(f" OK (token: {token[:20]}...)")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
# Step 3: Conversation
mode = "streaming" if args.stream else "synchronous"
print(f"\n--- Starting {mode} conversation ---")
run_conversation(
base_url, args.slug, headers, args.message, args.stream
)
if __name__ == "__main__":
main()