From 0770a36d229a06cb4c64d370fb09dce8bd5c6426 Mon Sep 17 00:00:00 2001 From: Irem Yuksel <113098562+iremyux@users.noreply.github.com> Date: Fri, 19 Sep 2025 18:12:52 +0200 Subject: [PATCH 01/14] Add Dotnet Samples (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a collection of .NET samples demonstrating various A2A communication patterns and use cases, from basic echo agents to AI-powered intelligent agents using Microsoft Semantic Kernel. What's Added - BasicA2ADemo - Foundation Samples - A2ACliDemo - Command-Line Interface - A2ASemanticKernelDemo - AI-Powered Agents Folder Structure samples/dotnet/ ├── README.md # Overview and getting started guide ├── BasicA2ADemo/ # Foundation A2A patterns │ ├── EchoServer/ # Simple echo agent │ ├── CalculatorServer/ # Math operations agent │ ├── SimpleClient/ # Basic A2A client │ └── A2ADotnetSample.sln # Visual Studio solution ├── A2ACliDemo/ # CLI-focused samples │ ├── CLIServer/ # Command-line agent │ └── CLIClient/ # Interactive CLI client └── A2ASemanticKernelDemo/ # AI-powered agents ├── AIServer/ # Semantic Kernel agent └── AIClient/ # AI interaction client Note on the Prerelease A2A dependency: All project files explicitly specify [Version="0.1.0-preview.2"] for A2A packages, preventing NuGet from failing to find prereleased package. Fixes https://github.com/a2aproject/a2a-dotnet/issues/84 --- .../A2ACliDemo/CLIClient/CLIClient.csproj | 14 + .../dotnet/A2ACliDemo/CLIClient/Program.cs | 199 +++++++++ .../dotnet/A2ACliDemo/CLIServer/CLIAgent.cs | 259 +++++++++++ .../A2ACliDemo/CLIServer/CLIServer.csproj | 14 + .../dotnet/A2ACliDemo/CLIServer/Program.cs | 46 ++ samples/dotnet/A2ACliDemo/README.md | 149 +++++++ samples/dotnet/A2ACliDemo/run-demo.bat | 19 + .../AIClient/AIClient.csproj | 15 + .../A2ASemanticKernelDemo/AIClient/Program.cs | 396 +++++++++++++++++ .../A2ASemanticKernelDemo/AIServer/AIAgent.cs | 416 ++++++++++++++++++ .../AIServer/AIServer.csproj | 16 + .../A2ASemanticKernelDemo/AIServer/Program.cs | 104 +++++ .../dotnet/A2ASemanticKernelDemo/README.md | 129 ++++++ .../dotnet/A2ASemanticKernelDemo/run_demo.bat | 37 ++ .../dotnet/BasicA2ADemo/A2ADotnetSample.sln | 62 +++ .../CalculatorServer/CalculatorAgent.cs | 129 ++++++ .../CalculatorServer/CalculatorServer.csproj | 14 + .../BasicA2ADemo/CalculatorServer/Program.cs | 55 +++ .../BasicA2ADemo/EchoServer/EchoAgent.cs | 78 ++++ .../BasicA2ADemo/EchoServer/EchoServer.csproj | 14 + .../dotnet/BasicA2ADemo/EchoServer/Program.cs | 48 ++ samples/dotnet/BasicA2ADemo/README.md | 92 ++++ .../BasicA2ADemo/SimpleClient/Program.cs | 186 ++++++++ .../SimpleClient/SimpleClient.csproj | 14 + samples/dotnet/BasicA2ADemo/run-demo.bat | 41 ++ samples/dotnet/README.md | 54 +++ 26 files changed, 2600 insertions(+) create mode 100644 samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj create mode 100644 samples/dotnet/A2ACliDemo/CLIClient/Program.cs create mode 100644 samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs create mode 100644 samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj create mode 100644 samples/dotnet/A2ACliDemo/CLIServer/Program.cs create mode 100644 samples/dotnet/A2ACliDemo/README.md create mode 100644 samples/dotnet/A2ACliDemo/run-demo.bat create mode 100644 samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj create mode 100644 samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs create mode 100644 samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs create mode 100644 samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj create mode 100644 samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs create mode 100644 samples/dotnet/A2ASemanticKernelDemo/README.md create mode 100644 samples/dotnet/A2ASemanticKernelDemo/run_demo.bat create mode 100644 samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln create mode 100644 samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs create mode 100644 samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj create mode 100644 samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs create mode 100644 samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs create mode 100644 samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj create mode 100644 samples/dotnet/BasicA2ADemo/EchoServer/Program.cs create mode 100644 samples/dotnet/BasicA2ADemo/README.md create mode 100644 samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs create mode 100644 samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj create mode 100644 samples/dotnet/BasicA2ADemo/run-demo.bat create mode 100644 samples/dotnet/README.md diff --git a/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj b/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj new file mode 100644 index 00000000..43f93dda --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/samples/dotnet/A2ACliDemo/CLIClient/Program.cs b/samples/dotnet/A2ACliDemo/CLIClient/Program.cs new file mode 100644 index 00000000..3df25a12 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIClient/Program.cs @@ -0,0 +1,199 @@ +using A2A; +using System.Text.Json; + +namespace CLIClient; + +/// +/// Interactive CLI client that demonstrates how to send commands to the CLI Agent. +/// This shows how clients can interact with specialized agents. +/// +internal static class Program +{ + private static readonly string AgentUrl = "http://localhost:5003"; + + static async Task Main(string[] args) + { + Console.WriteLine("🖥️ CLI Agent Client"); + Console.WriteLine("=================="); + Console.WriteLine(); + + try + { + // Test connection and get agent info + await TestAgentConnection(); + + // Start interactive session + await StartInteractiveSession(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("Make sure the CLI Agent server is running on http://localhost:5003"); + Console.WriteLine("Start it with: cd CLIServer && dotnet run"); + } + } + + /// + /// Tests the connection to the CLI Agent and displays its capabilities. + /// + private static async Task TestAgentConnection() + { + Console.WriteLine("🔍 Connecting to CLI Agent..."); + + // Create agent card resolver + var agentCardResolver = new A2ACardResolver(new Uri(AgentUrl)); + + // Get agent card to verify connection + var agentCard = await agentCardResolver.GetAgentCardAsync(); + + Console.WriteLine($"✅ Connected to: {agentCard.Name}"); + Console.WriteLine($" 📝 Description: {agentCard.Description}"); + Console.WriteLine($" 🔢 Version: {agentCard.Version}"); + Console.WriteLine($" 🎯 Streaming: {agentCard.Capabilities?.Streaming}"); + Console.WriteLine(); + } + + /// + /// Starts an interactive session where users can send commands to the agent. + /// + private static async Task StartInteractiveSession() + { + var agentClient = new A2AClient(new Uri(AgentUrl)); + + Console.WriteLine("🚀 Interactive CLI Session Started!"); + Console.WriteLine("Type commands to execute on the agent (e.g., 'dir', 'git status', 'dotnet --version')"); + Console.WriteLine("Type 'help' for examples, 'exit' to quit"); + Console.WriteLine(); + + while (true) + { + // Get user input + Console.Write("CLI> "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrEmpty(input)) + continue; + + // Handle special commands + switch (input.ToLower()) + { + case "exit" or "quit": + Console.WriteLine("👋 Goodbye!"); + return; + + case "help": + ShowHelp(); + continue; + + case "examples": + await RunExamples(agentClient); + continue; + + default: + // Send command to agent + await ExecuteCommand(agentClient, input); + break; + } + } + } + + /// + /// Executes a single command through the CLI Agent. + /// + private static async Task ExecuteCommand(A2AClient agentClient, string command) + { + try + { + Console.WriteLine($"⏳ Executing: {command}"); + Console.WriteLine(); + + // Create the message + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = command }] + }; + + // Send to agent and get response + var response = await agentClient.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage) + { + var responseText = responseMessage.Parts?.OfType().FirstOrDefault()?.Text ?? "No response"; + Console.WriteLine(responseText); + } + else + { + Console.WriteLine("❌ Unexpected response type"); + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error executing command: {ex.Message}"); + } + + Console.WriteLine(); + } + + /// + /// Shows help information with available commands and examples. + /// + private static void ShowHelp() + { + Console.WriteLine("📚 CLI Agent Help"); + Console.WriteLine("================="); + Console.WriteLine(); + Console.WriteLine("🔧 Available Commands:"); + Console.WriteLine(" • File operations: dir, ls, pwd, cat, type"); + Console.WriteLine(" • System info: whoami, date, time"); + Console.WriteLine(" • Process info: ps, tasklist"); + Console.WriteLine(" • Network: ping, ipconfig, netstat"); + Console.WriteLine(" • Development: git, dotnet, node, npm, python"); + Console.WriteLine(); + Console.WriteLine("💡 Example Commands:"); + Console.WriteLine(" • dir - List current directory (Windows)"); + Console.WriteLine(" • ls -la - List directory with details (Linux/Mac)"); + Console.WriteLine(" • git status - Check git repository status"); + Console.WriteLine(" • dotnet --version - Check .NET version"); + Console.WriteLine(" • ping google.com - Test network connectivity"); + Console.WriteLine(); + Console.WriteLine("🎮 Special Commands:"); + Console.WriteLine(" • help - Show this help"); + Console.WriteLine(" • examples - Run pre-defined examples"); + Console.WriteLine(" • exit - Quit the application"); + Console.WriteLine(); + } + + /// + /// Runs a series of example commands to demonstrate the CLI Agent's capabilities. + /// + private static async Task RunExamples(A2AClient agentClient) + { + Console.WriteLine("🎯 Running Example Commands"); + Console.WriteLine("============================"); + Console.WriteLine(); + + var examples = new[] + { + "whoami", // Show current user + "date", // Show current date + "dotnet --version", // Check .NET version + "git --version", // Check Git version (if available) + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( + System.Runtime.InteropServices.OSPlatform.Windows) ? "dir" : "ls" // List directory + }; + + foreach (var example in examples) + { + await ExecuteCommand(agentClient, example); + + // Small delay between commands for better readability + await Task.Delay(1000); + } + + Console.WriteLine("✅ Examples completed!"); + Console.WriteLine(); + } +} diff --git a/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs b/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs new file mode 100644 index 00000000..84411581 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs @@ -0,0 +1,259 @@ +using A2A; +using System.Diagnostics; +using System.Text.Json; +using System.Runtime.InteropServices; + +namespace CLIServer; + +/// +/// Represents the result of a command execution with all relevant details. +/// +/// The full command that was executed +/// The exit code returned by the process +/// The standard output lines from the command +/// The standard error lines from the command +/// Whether the command executed successfully (exit code 0) +internal record CommandExecutionResult( + string Command, + int ExitCode, + IReadOnlyList Output, + IReadOnlyList Errors, + bool Success); + +/// +/// A CLI agent that can execute command-line tools and return results. +/// This demonstrates how to bridge AI agents with system-level operations. +/// +public class CLIAgent +{ + private static readonly HashSet AllowedCommands = new() + { + // Safe read-only commands + "dir", "ls", "pwd", "whoami", "date", "time", + "echo", "cat", "type", "head", "tail", + "ps", "tasklist", "netstat", "ipconfig", "ping", + "git", "dotnet", "node", "npm", "python" + }; + + /// + /// Gets the list of allowed commands that this agent can execute. + /// + public IReadOnlyCollection GetAllowedCommands() => AllowedCommands; + + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Processes incoming messages and executes CLI commands safely. + /// + private async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + Console.WriteLine($"[CLI Agent] Received command: {userText}"); + + try + { + // Parse the command + var commandResult = await ExecuteCommandAsync(userText, cancellationToken); + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = commandResult }] + }; + + Console.WriteLine($"[CLI Agent] Command executed successfully"); + return responseMessage; + } + catch (Exception ex) + { + var errorText = $"Error executing command '{userText}': {ex.Message}"; + + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = errorText }] + }; + + Console.WriteLine($"[CLI Agent] Error: {ex.Message}"); + return errorMessage; + } + } + + /// + /// Executes a CLI command safely with security checks. + /// This is the core functionality that makes this agent useful! + /// + private async Task ExecuteCommandAsync(string input, CancellationToken cancellationToken) + { + // Parse command and arguments + var parts = ParseCommand(input); + var command = parts.Command; + var arguments = parts.Arguments; + + // Security check: Only allow whitelisted commands + if (!IsCommandAllowed(command)) + { + return $"❌ Command '{command}' is not allowed for security reasons.\n" + + $"Allowed commands: {string.Join(", ", AllowedCommands)}"; + } + + // Execute the command + using var process = new Process(); + + // Configure process based on operating system + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + process.StartInfo.FileName = "cmd.exe"; + process.StartInfo.Arguments = $"/c {command} {arguments}"; + } + else + { + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"{command} {arguments}\""; + } + + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + + var output = new List(); + var errors = new List(); + + // Capture output and errors + process.OutputDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + output.Add(e.Data); + }; + + process.ErrorDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + errors.Add(e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Wait for completion with timeout + await process.WaitForExitAsync(cancellationToken); + + // Process has completed normally + + var result = new CommandExecutionResult( + Command: $"{command} {arguments}", + ExitCode: process.ExitCode, + Output: output.AsReadOnly(), + Errors: errors.AsReadOnly(), + Success: process.ExitCode == 0 + ); + + return FormatCommandResult(result); + } + + /// + /// Parses user input into command and arguments. + /// + private static (string Command, string Arguments) ParseCommand(string input) + { + var trimmed = input.Trim(); + var spaceIndex = trimmed.IndexOf(' '); + + if (spaceIndex == -1) + { + return (trimmed, string.Empty); + } + + return (trimmed.Substring(0, spaceIndex), trimmed.Substring(spaceIndex + 1)); + } + + /// + /// Security check: Ensures only safe commands are executed. + /// This is CRITICAL for security! + /// + private static bool IsCommandAllowed(string command) + { + return AllowedCommands.Contains(command.ToLowerInvariant()); + } + + /// + /// Formats the command execution result in a user-friendly way. + /// + private static string FormatCommandResult(CommandExecutionResult result) + { + var output = new List(); + + output.Add($"🖥️ Command: {result.Command}"); + output.Add($"✅ Exit Code: {result.ExitCode}"); + + if (result.Output.Count > 0) + { + output.Add("\n📤 Output:"); + foreach (string line in result.Output) + { + output.Add($" {line}"); + } + } + + if (result.Errors.Count > 0) + { + output.Add("\n❌ Errors:"); + foreach (string line in result.Errors) + { + output.Add($" {line}"); + } + } + + if (result.Output.Count == 0 && result.Errors.Count == 0) + { + output.Add("\n✅ Command completed successfully (no output)"); + } + + return string.Join("\n", output); + } + + /// + /// Retrieves the agent card information for the CLI Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "CLI Agent", + Description = "Executes command-line tools safely. Supports common commands like 'dir', 'ls', 'git status', 'dotnet build', etc.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + return message.Parts?.OfType().FirstOrDefault()?.Text ?? string.Empty; + } +} diff --git a/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj b/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj new file mode 100644 index 00000000..e70a88a9 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/A2ACliDemo/CLIServer/Program.cs b/samples/dotnet/A2ACliDemo/CLIServer/Program.cs new file mode 100644 index 00000000..30dd0c33 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/Program.cs @@ -0,0 +1,46 @@ +using A2A; +using A2A.AspNetCore; +using CLIServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); +builder.Logging.SetMinimumLevel(LogLevel.Information); + +var app = builder.Build(); + +// Create the task manager +var taskManager = new TaskManager(); + +// Create and attach the CLI agent +var cliAgent = new CLIAgent(); +cliAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "CLI Agent", + Timestamp = DateTimeOffset.UtcNow, + AllowedCommands = cliAgent.GetAllowedCommands() +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "🖥️ CLI Agent is running!", + Description = "Send CLI commands like 'dir', 'ls', 'git status', 'dotnet --version'", + Endpoint = "/", + Health = "/health" +})); + +Console.WriteLine("🖥️ CLI Agent starting..."); +Console.WriteLine("📍 Available at: http://localhost:5003"); +Console.WriteLine($"🔧 Allowed commands: {string.Join(", ", cliAgent.GetAllowedCommands())}"); +Console.WriteLine("⚠️ Security: Only whitelisted commands are allowed"); + +app.Run("http://localhost:5003"); diff --git a/samples/dotnet/A2ACliDemo/README.md b/samples/dotnet/A2ACliDemo/README.md new file mode 100644 index 00000000..638b52b3 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/README.md @@ -0,0 +1,149 @@ +# CLI Agent Demo + +This demo shows how to build an A2A agent that can execute command-line tools safely. It demonstrates bridging AI agents with system-level operations. + +## What's Included + +- **CLIServer**: An agent that executes CLI commands with security restrictions +- **CLIClient**: An interactive console client that sends commands to the agent + +## Key Features + +### 🔒 Security First +- **Whitelist approach**: Only safe commands are allowed +- **No dangerous operations**: Commands like `rm`, `del`, `format` are blocked +- **Input validation**: All commands are parsed and validated + +### 🖥️ Cross-Platform Support +- **Windows**: Uses `cmd.exe` for command execution +- **Linux/Mac**: Uses `/bin/bash` for command execution +- **Automatic detection**: Determines the OS at runtime + +### 📊 Rich Output +- **Structured results**: Shows command, exit code, output, and errors +- **Real-time feedback**: Displays execution status +- **Error handling**: Graceful handling of failed commands + +## Getting Started + +### Option 1: Quick Start (Windows) +```bash +run-demo.bat +``` + +### Option 2: Manual Setup + +#### 1. Start the CLI Agent Server +```bash +cd CLIServer +dotnet run +``` +The server will start on `http://localhost:5003` + +#### 2. Run the Interactive Client +```bash +cd CLIClient +dotnet run +``` + +## Example Commands + +### File Operations +```bash +CLI> dir # List directory (Windows) +CLI> ls -la # List directory with details (Linux/Mac) +CLI> pwd # Show current directory +``` + +### System Information +```bash +CLI> whoami # Show current user +CLI> date # Show current date and time +``` + +### Development Tools +```bash +CLI> git status # Check git repository status +CLI> dotnet --version # Check .NET version +CLI> node --version # Check Node.js version +CLI> npm --version # Check npm version +``` + +### Network Commands +```bash +CLI> ping google.com # Test network connectivity +CLI> ipconfig # Show network configuration (Windows) +``` + +## Security Considerations + +### Allowed Commands +The agent only allows these command categories: +- **File operations**: `dir`, `ls`, `pwd`, `cat`, `type`, `head`, `tail` +- **System info**: `whoami`, `date`, `time` +- **Process info**: `ps`, `tasklist` +- **Network**: `ping`, `ipconfig`, `netstat` +- **Development tools**: `git`, `dotnet`, `node`, `npm`, `python` + +### Blocked Commands +Dangerous commands are blocked for security: +- File deletion: `rm`, `del`, `rmdir` +- System modification: `format`, `fdisk`, `sudo` +- Process control: `kill`, `killall` + +### Best Practices +1. **Run with limited privileges**: Don't run as administrator/root +2. **Network isolation**: Consider running in a sandboxed environment +3. **Audit logging**: Monitor command execution in production +4. **Regular updates**: Keep the whitelist updated as needed + +## Project Structure + +```text +CLIAgent/ +├── README.md # This file +├── CLIServer/ +│ ├── CLIServer.csproj # Server project file +│ ├── Program.cs # Server startup code +│ └── CLIAgent.cs # CLI agent implementation +└── CLIClient/ + ├── CLIClient.csproj # Client project file + └── Program.cs # Interactive client implementation +``` + +## Extending the Agent + +### Adding New Commands +1. Add the command to the `AllowedCommands` set in `CLIAgent.cs` +2. Test thoroughly to ensure security +3. Update documentation + +### Custom Command Handlers +You can add special handling for specific commands: + +```csharp +private async Task ExecuteCommandAsync(string input, CancellationToken cancellationToken) +{ + // Special handling for specific commands + if (input.StartsWith("git")) + { + return await ExecuteGitCommand(input, cancellationToken); + } + + // Default command execution + return await ExecuteGenericCommand(input, cancellationToken); +} +``` + +### Output Formatting +Customize how results are presented: + +```csharp +private static string FormatCommandResult(dynamic result) +{ + // Custom formatting based on command type + // Add JSON output, markdown formatting, etc. +} +``` + +This demo provides a foundation for understanding how to safely integrate system-level operations with A2A agents while maintaining security and usability. diff --git a/samples/dotnet/A2ACliDemo/run-demo.bat b/samples/dotnet/A2ACliDemo/run-demo.bat new file mode 100644 index 00000000..2d593ab8 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/run-demo.bat @@ -0,0 +1,19 @@ +@echo off +echo Starting CLI Agent Demo... +echo. + +echo Starting CLI Agent Server... +start "CLI Agent Server" cmd /c "cd CLIServer && dotnet run && pause" + +echo Waiting for server to start... +timeout /t 3 /nobreak > nul + +echo Starting CLI Agent Client... +start "CLI Agent Client" cmd /c "cd CLIClient && dotnet run && pause" + +echo. +echo Both CLI Agent Server and Client are starting in separate windows. +echo The server runs on http://localhost:5003 +echo. +echo Press any key to exit this script... +pause > nul diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj b/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj new file mode 100644 index 00000000..07d1efaa --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs b/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs new file mode 100644 index 00000000..3e855fac --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs @@ -0,0 +1,396 @@ +using A2A; + +namespace AIClient; + +class Program +{ + private const string AI_AGENT_URL = "http://localhost:5000"; + + static async Task Main(string[] args) + { + Console.WriteLine("🤖 A2A Semantic Kernel AI Client"); + Console.WriteLine("=================================="); + Console.WriteLine(); + + try + { + // Test connection by getting capabilities + await TestConnection(); + + Console.WriteLine("✅ Connected successfully!"); + Console.WriteLine(); + + // Show help menu + await ShowHelp(); + + // Main interaction loop + await InteractionLoop(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("🔧 Troubleshooting:"); + Console.WriteLine(" 1. Make sure the AI Server is running"); + Console.WriteLine(" 2. Check if port 5000 is available"); + Console.WriteLine(" 3. Verify the server URL is correct"); + } + } + + static async Task TestConnection() + { + var client = new A2AClient(new Uri(AI_AGENT_URL)); + + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "help" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + if (response is not Message) + { + throw new Exception("Failed to connect to AI Agent"); + } + + Console.WriteLine($"📡 Connected to AI Agent at {AI_AGENT_URL}..."); + } + + static async Task InteractionLoop() + { + while (true) + { + Console.Write("\n🎯 Choose an option (1-6, 'help', or 'quit'): "); + var input = Console.ReadLine()?.Trim().ToLower(); + + try + { + switch (input) + { + case "1" or "summarize": + await HandleSummarize(); + break; + case "2" or "sentiment": + await HandleSentiment(); + break; + case "3" or "ideas": + await HandleIdeas(); + break; + case "4" or "translate": + await HandleTranslate(); + break; + case "5" or "demo": + await RunDemoScenarios(); + break; + case "6" or "capabilities": + await ShowCapabilities(); + break; + case "help" or "h" or "?": + await ShowHelp(); + break; + case "quit" or "exit" or "q": + Console.WriteLine("👋 Goodbye!"); + return; + case "": + continue; + default: + Console.WriteLine("❓ Unknown option. Type 'help' for available commands."); + break; + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + } + } + } + + static async Task HandleSummarize() + { + Console.WriteLine("\n📝 Text Summarization"); + Console.WriteLine("Enter the text you want to summarize (press Enter twice to finish):"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Summarizing..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"summarize: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Summary Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Summarization failed."); + } + } + + static async Task HandleSentiment() + { + Console.WriteLine("\n😊 Sentiment Analysis"); + Console.WriteLine("Enter the text to analyze:"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Analyzing sentiment..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"sentiment: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Sentiment Analysis Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Sentiment analysis failed."); + } + } + + static async Task HandleIdeas() + { + Console.WriteLine("\n💡 Idea Generation"); + Console.Write("Enter a topic or challenge: "); + var topic = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(topic)) + { + Console.WriteLine("❌ No topic provided."); + return; + } + + Console.WriteLine("\n🔄 Generating ideas..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"ideas: {topic}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Generated Ideas:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Idea generation failed."); + } + } + + static async Task HandleTranslate() + { + Console.WriteLine("\n🌍 Text Translation"); + Console.WriteLine("Enter the text to translate:"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Translating to Spanish..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"translate: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Translation Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Translation failed."); + } + } + + static async Task RunDemoScenarios() + { + Console.WriteLine("\n🎬 Running Demo Scenarios..."); + Console.WriteLine("====================================="); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + + // Demo 1: Text Summarization + Console.WriteLine("\n1️⃣ Text Summarization Demo"); + var demoText = "Artificial Intelligence has rapidly evolved over the past decade, transforming industries and reshaping how we work and live. Machine learning algorithms can now process vast amounts of data, recognize patterns, and make predictions with unprecedented accuracy."; + + var message1 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"summarize: {demoText}" }] + }; + var response1 = await client.SendMessageAsync(new MessageSendParams { Message = message1 }); + if (response1 is Message responseMessage1 && responseMessage1.Parts.Count > 0) + { + var textPart1 = responseMessage1.Parts.OfType().FirstOrDefault(); + if (textPart1 != null) + { + Console.WriteLine("✅ Summary Result:"); + Console.WriteLine(textPart1.Text); + } + } + + // Demo 2: Sentiment Analysis + Console.WriteLine("\n2️⃣ Sentiment Analysis Demo"); + var sentimentText = "I absolutely love working with this new technology! It's incredibly powerful and makes our development process so much more efficient. The team is excited about the possibilities."; + + var message2 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"sentiment: {sentimentText}" }] + }; + var response2 = await client.SendMessageAsync(new MessageSendParams { Message = message2 }); + if (response2 is Message responseMessage2 && responseMessage2.Parts.Count > 0) + { + var textPart2 = responseMessage2.Parts.OfType().FirstOrDefault(); + if (textPart2 != null) + { + Console.WriteLine("✅ Sentiment Result:"); + Console.WriteLine(textPart2.Text); + } + } + + // Demo 3: Idea Generation + Console.WriteLine("\n3️⃣ Idea Generation Demo"); + var message3 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "ideas: sustainable software development" }] + }; + var response3 = await client.SendMessageAsync(new MessageSendParams { Message = message3 }); + if (response3 is Message responseMessage3 && responseMessage3.Parts.Count > 0) + { + var textPart3 = responseMessage3.Parts.OfType().FirstOrDefault(); + if (textPart3 != null) + { + Console.WriteLine("✅ Ideas Result:"); + Console.WriteLine(textPart3.Text); + } + } + + Console.WriteLine("\n✅ Demo completed!"); + } + + static async Task ShowCapabilities() + { + Console.WriteLine("\n🔍 AI Agent Capabilities"); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "help" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("✅ Available functions:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Failed to get capabilities."); + } + } + + static Task ShowHelp() + { + Console.WriteLine("🎯 Available Options:"); + Console.WriteLine(); + Console.WriteLine(" 1. 📝 Summarize Text - Condense long text into key points"); + Console.WriteLine(" 2. 😊 Sentiment Analysis - Analyze emotional tone of text"); + Console.WriteLine(" 3. 💡 Generate Ideas - Create brainstorming suggestions"); + Console.WriteLine(" 4. 🌍 Translate Text - Convert text to Spanish"); + Console.WriteLine(" 5. 🎬 Run Demo - See all features in action"); + Console.WriteLine(" 6. 🔍 Show Capabilities - List all AI agent functions"); + Console.WriteLine(); + Console.WriteLine("Commands: help, quit"); + Console.WriteLine(); + + return Task.CompletedTask; + } + + static string ReadMultilineInput() + { + var lines = new List(); + string? line; + + while ((line = Console.ReadLine()) != null) + { + if (string.IsNullOrEmpty(line)) + break; + lines.Add(line); + } + + return string.Join(" ", lines); + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs new file mode 100644 index 00000000..29b52607 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs @@ -0,0 +1,416 @@ +using A2A; +using A2A.AspNetCore; +using Microsoft.SemanticKernel; +using System.ComponentModel; +using System.Text.Json; + +namespace AIServer; + +/// +/// AI Agent that uses Semantic Kernel for intelligent text processing and analysis +/// +public class AIAgent +{ + private readonly Kernel _kernel; + private readonly ILogger _logger; + + public AIAgent(Kernel kernel, ILogger logger) + { + _kernel = kernel; + _logger = logger; + } + + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and routes them to the appropriate AI function + /// + private async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + _logger.LogInformation("Processing AI request: {UserText}", userText); + + try + { + // Parse the command - for now we'll use simple text parsing + // In production, you'd want more sophisticated parsing + var lowerText = userText.ToLower(); + + if (lowerText.StartsWith("summarize:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await SummarizeTextAsync(text); + } + else if (lowerText.StartsWith("sentiment:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await AnalyzeSentimentAsync(text); + } + else if (lowerText.StartsWith("ideas:")) + { + var topic = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await GenerateIdeasAsync(topic); + } + else if (lowerText.StartsWith("translate:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await TranslateTextAsync(text, "Spanish"); // Default target language + } + else if (lowerText.Contains("help")) + { + return await GetCapabilitiesAsync(); + } + else + { + return await GetCapabilitiesAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing AI request"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = $"Error processing request: {ex.Message}" }] + }; + } + } + + /// + /// Extracts text content from a message + /// + private string GetTextFromMessage(Message message) + { + if (message.Parts?.Any() == true) + { + foreach (var part in message.Parts) + { + if (part is TextPart textPart && !string.IsNullOrEmpty(textPart.Text)) + { + return textPart.Text; + } + } + } + return string.Empty; + } + + /// + /// Summarizes the provided text using AI + /// + private async Task SummarizeTextAsync(string text) + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = contextId, + Parts = [new TextPart { Text = "Error: No text provided for summarization" }] + }; + return errorMessage; + } + + _logger.LogInformation("Summarizing text of length: {Length}", text.Length); + + var prompt = $@" +Summarize the following text in 2-3 sentences: + +{text} + +Summary:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var summary = result.GetValue() ?? "Unable to generate summary"; + + var response = new + { + OriginalLength = text.Length, + Summary = summary.Trim(), + CompressionRatio = Math.Round((double)summary.Length / text.Length * 100, 1), + Function = "Text Summarization" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error summarizing text"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Summarization failed: {ex.Message}" }] + }; + } + } + + /// + /// Analyzes the sentiment of the provided text + /// + private async Task AnalyzeSentimentAsync(string text) + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No text provided for sentiment analysis" }] + }; + } + + _logger.LogInformation("Analyzing sentiment for text of length: {Length}", text.Length); + + var prompt = $@" +Analyze the sentiment of the following text and provide: +1. Overall sentiment (Positive, Negative, or Neutral) +2. Confidence score (0-100) +3. Key emotional indicators found in the text + +Text: {text} + +Respond in this format: +Sentiment: [Positive/Negative/Neutral] +Confidence: [0-100] +Emotions: [emotion1, emotion2, ...] +Explanation: [Brief explanation]"; + + var result = await _kernel.InvokePromptAsync(prompt); + var analysis = result.GetValue() ?? "Analysis unavailable"; + + var response = new + { + OriginalText = text, + Analysis = analysis, + Function = "Sentiment Analysis" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing sentiment"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Sentiment analysis failed: {ex.Message}" }] + }; + } + } + + /// + /// Generates creative ideas based on a topic or prompt + /// + private async Task GenerateIdeasAsync(string topic) + { + try + { + if (string.IsNullOrWhiteSpace(topic)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No topic provided for idea generation" }] + }; + } + + _logger.LogInformation("Generating ideas for topic: {Topic}", topic); + + var prompt = $@" +Generate 5 creative and practical ideas related to: {topic} + +Format each idea as: +- **Idea Name**: Brief description + +Ideas:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var ideas = result.GetValue() ?? "No ideas generated"; + + var response = new + { + Topic = topic, + Ideas = ideas.Trim(), + Function = "Idea Generation" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating ideas"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Idea generation failed: {ex.Message}" }] + }; + } + } + + /// + /// Translates text between languages + /// + private async Task TranslateTextAsync(string text, string targetLanguage = "Spanish") + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No text provided for translation" }] + }; + } + + _logger.LogInformation("Translating text to: {Language}", targetLanguage); + + var prompt = $@" +Translate the following text to {targetLanguage}: + +{text} + +Translation:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var translation = result.GetValue() ?? "Translation unavailable"; + + var response = new + { + OriginalText = text, + TranslatedText = translation.Trim(), + TargetLanguage = targetLanguage, + Function = "Text Translation" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error translating text"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Translation failed: {ex.Message}" }] + }; + } + } + + /// + /// Returns the capabilities of this AI agent + /// + private Task GetCapabilitiesAsync() + { + var capabilities = new + { + Agent = "AI Agent powered by Semantic Kernel", + Functions = new[] + { + "📝 summarize:[text] - Summarizes long text into key points", + "😊 sentiment:[text] - Analyzes emotional sentiment of text", + "💡 ideas:[topic] - Generates creative ideas for any topic", + "🌍 translate:[text] - Translates text to Spanish", + "❓ help - Shows this help information" + }, + Examples = new[] + { + "summarize: Artificial intelligence is revolutionizing the way we work...", + "sentiment: I love this new technology! It's amazing and so helpful.", + "ideas: sustainable software development", + "translate: Hello, how are you today?", + "help" + }, + PoweredBy = "Microsoft Semantic Kernel", + Version = "1.0.0" + }; + + var responseText = JsonSerializer.Serialize(capabilities, new JsonSerializerOptions { WriteIndented = true }); + + return Task.FromResult(new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }); + } + + /// + /// Returns agent information for discovery + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + var agentCard = new AgentCard + { + Name = "AI Agent", + Description = "AI-powered agent using Semantic Kernel for text processing and analysis", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = false } + }; + + return Task.FromResult(agentCard); + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj new file mode 100644 index 00000000..a39a22ac --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs b/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs new file mode 100644 index 00000000..6596eac2 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs @@ -0,0 +1,104 @@ +using A2A; +using A2A.AspNetCore; +using AIServer; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +// Configure Semantic Kernel +builder.Services.AddKernel(); + +// Configure AI model (you'll need to set up your preferred AI service) +// For Azure OpenAI: +// builder.Services.AddAzureOpenAIChatCompletion( +// deploymentName: "your-deployment-name", +// endpoint: "your-azure-openai-endpoint", +// apiKey: "your-api-key"); + +// For OpenAI: +// builder.Services.AddOpenAIChatCompletion( +// modelId: "gpt-3.5-turbo", +// apiKey: "your-openai-api-key"); + +// For development/testing, you can use a mock service +builder.Services.AddSingleton(provider => +{ + // This is a simple mock for demonstration - replace with real AI service + return new MockChatCompletionService(); +}); + +// Register the AI Agent +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the AI agent +var aiAgent = app.Services.GetRequiredService(); +aiAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +app.Run(); + +/// +/// Mock chat completion service for demonstration purposes +/// Replace this with a real AI service in production +/// +public class MockChatCompletionService : IChatCompletionService +{ + public IReadOnlyDictionary Attributes { get; } = new Dictionary(); + + public Task> GetChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + // Simple mock responses based on prompt content + var lastMessage = chatHistory.LastOrDefault()?.Content ?? ""; + + string response = lastMessage.ToLower() switch + { + var msg when msg.Contains("summarize") => "This is a brief summary of the provided text with key points highlighted.", + var msg when msg.Contains("sentiment") => """{"sentiment": "Positive", "confidence": 75, "emotions": ["optimism", "enthusiasm"], "explanation": "The text shows generally positive sentiment with optimistic language."}""", + var msg when msg.Contains("translate") => "Ceci est la traduction française du texte fourni.", + var msg when msg.Contains("ideas") => +""" +- **Digital Innovation**: Leverage technology to create new solutions +- **Sustainable Practices**: Implement eco-friendly approaches +- **Community Engagement**: Build stronger connections with stakeholders +- **Creative Collaboration**: Foster cross-functional teamwork +- **Data-Driven Insights**: Use analytics to guide decision making +""", + _ => "I'm a mock AI service. Please configure a real AI provider (Azure OpenAI, OpenAI, etc.) for full functionality." + }; + + var result = new List + { + new(AuthorRole.Assistant, response) + }; + + return Task.FromResult>(result); + } + + public async IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var messages = await GetChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken); + foreach (var message in messages) + { + yield return new StreamingChatMessageContent(message.Role, message.Content); + } + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/README.md b/samples/dotnet/A2ASemanticKernelDemo/README.md new file mode 100644 index 00000000..082f3740 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/README.md @@ -0,0 +1,129 @@ +# A2A Semantic Kernel AI Demo + +This demo showcases how to build **AI-powered agents** using the A2A framework with Microsoft Semantic Kernel. The demo includes intelligent text processing capabilities like summarization, sentiment analysis, idea generation, and translation. + +## 🎯 What You'll Learn + +- **AI Agent Integration**: How to combine A2A with Semantic Kernel +- **Intelligent Functions**: Building agents that understand and process natural language +- **AI Service Configuration**: Setting up different AI providers (Azure OpenAI, OpenAI, etc.) +- **Advanced Scenarios**: Real-world AI agent use cases + +## 🚀 Quick Start + +### Option 1: One-Click Demo +```bash +run_demo.bat +``` + +### Option 2: Manual Setup + +**Terminal 1 - AI Server:** +```bash +cd AIServer +dotnet run --urls=http://localhost:5000 +``` + +**Terminal 2 - AI Client:** +```bash +cd AIClient +dotnet run +``` + +## 🤖 Available AI Functions + +### 📝 Text Summarization +- **Function**: `summarize_text` +- **Purpose**: Condenses long text into key points +- **Example**: Summarize articles, reports, or documentation + +### 😊 Sentiment Analysis +- **Function**: `analyze_sentiment` +- **Purpose**: Analyzes emotional tone and sentiment +- **Example**: Evaluate customer feedback or social media content + +### 💡 Idea Generation +- **Function**: `generate_ideas` +- **Purpose**: Generates creative suggestions for any topic +- **Example**: Brainstorming, problem-solving, innovation + +### 🌍 Text Translation +- **Function**: `translate_text` +- **Purpose**: Translates between different languages +- **Example**: Multilingual communication and content localization + +### 🔍 Capabilities Discovery +- **Function**: `get_capabilities` +- **Purpose**: Lists all available AI functions +- **Example**: Dynamic discovery of agent capabilities + +## 🛠️ Configuration + +### AI Service Setup + +The demo includes a **mock AI service** for immediate testing. For production use, configure a real AI provider. + +### Environment Variables +```bash +# For Azure OpenAI +AZURE_OPENAI_ENDPOINT=your-endpoint +AZURE_OPENAI_API_KEY=your-key +AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment + +# For OpenAI +OPENAI_API_KEY=your-key +``` + +## 🎬 Demo Scenarios + +### 1. Document Summarization +```text +Input: Long research paper or article +Output: Concise 2-3 sentence summary with key insights +``` + +### 2. Customer Feedback Analysis +```text +Input: Customer reviews or feedback +Output: Sentiment classification with confidence scores +``` + +### 3. Creative Brainstorming +```text +Input: Business challenge or topic +Output: Multiple creative solutions and approaches +``` + +### 4. Multilingual Content +```text +Input: Text in any language +Output: Professional translation to target language +``` + +## 🏗️ Architecture + +```text +┌─────────────────┐ HTTP/A2A ┌─────────────────┐ +│ AI Client │ ──────────────► │ AI Server │ +│ │ │ │ +│ • Interactive │ │ • AIAgent │ +│ • Demonstrations│ │ • Semantic │ +│ • Examples │ │ Kernel │ +└─────────────────┘ │ • AI Functions │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ AI Provider │ + │ │ + │ • Azure OpenAI │ + │ • OpenAI │ + │ • Other Models │ + └─────────────────┘ +``` + +## 🎓 Learning Resources + +- **[Semantic Kernel Documentation](https://learn.microsoft.com/en-us/semantic-kernel/)** +- **[A2A Framework Guide](../README.md)** +- **[Azure OpenAI Service](https://azure.microsoft.com/en-us/products/ai-services/openai-service)** diff --git a/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat b/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat new file mode 100644 index 00000000..4d444ae0 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat @@ -0,0 +1,37 @@ +@echo off +echo =================================== +echo A2A Semantic Kernel AI Demo +echo =================================== +echo. + +echo Starting AI Server (Semantic Kernel)... +echo. + +cd /d "%~dp0AIServer" +start "AI Server" cmd /k "echo AI Server starting... && dotnet run --urls=http://localhost:5000" + +echo Waiting for server to start... +timeout /t 5 /nobreak >nul + +echo. +echo Starting AI Client... +echo. + +cd /d "%~dp0AIClient" +start "AI Client" cmd /k "echo AI Client starting... && dotnet run" + +echo. +echo Both applications are starting in separate windows. +echo. +echo What's running: +echo AI Server: http://localhost:5000 (Semantic Kernel AI Agent) +echo AI Client: Interactive console application +echo. +echo Try these features: +echo Text summarization +echo Sentiment analysis +echo Idea generation +echo Text translation +echo. +echo Press any key to close this window... +pause >nul diff --git a/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln b/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln new file mode 100644 index 00000000..b673dad7 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln @@ -0,0 +1,62 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalculatorServer", "CalculatorServer\CalculatorServer.csproj", "{7A1CD73A-1874-4E77-95B6-F7ED658E5273}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleClient", "SimpleClient\SimpleClient.csproj", "{78BDD62D-4849-4AE8-AB56-5DEE8AB23333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServer", "EchoServer\EchoServer.csproj", "{BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x64.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x86.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|Any CPU.Build.0 = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x64.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x64.Build.0 = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x86.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x86.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x64.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x64.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x86.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x86.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|Any CPU.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x64.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x64.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x86.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x86.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x64.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x86.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|Any CPU.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x64.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x64.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x86.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs new file mode 100644 index 00000000..86efd1c5 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs @@ -0,0 +1,129 @@ +using A2A; +using System.Text.RegularExpressions; + +namespace CalculatorServer; + +/// +/// A simple calculator agent that can perform basic math operations. +/// This demonstrates how to implement business logic in an A2A agent. +/// +public class CalculatorAgent +{ + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and performs calculations. + /// + private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + + Console.WriteLine($"[Calculator Agent] Received expression: {userText}"); + + try + { + var result = EvaluateExpression(userText); + var responseText = $"{userText} = {result}"; + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = responseText }] + }; + + Console.WriteLine($"[Calculator Agent] Calculated result: {responseText}"); + + return Task.FromResult(responseMessage); + } + catch (Exception ex) + { + var errorText = $"Sorry, I couldn't calculate '{userText}'. Error: {ex.Message}. Please try a simple expression like '5 + 3' or '10 * 2'."; + + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = errorText }] + }; + + Console.WriteLine($"[Calculator Agent] Error: {ex.Message}"); + + return Task.FromResult(errorMessage); + } + } + + /// + /// Retrieves the agent card information for the Calculator Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "Simple Calculator Agent", + Description = "A basic calculator that can perform addition, subtraction, multiplication, and division. Send math expressions like '5 + 3' or '10 * 2'.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? ""; + } + + /// + /// Evaluates a simple math expression. + /// Supports +, -, *, / operations with decimal numbers. + /// + private static double EvaluateExpression(string expression) + { + // Clean up the expression + expression = expression.Trim(); + + // Use regex to parse simple expressions like "5 + 3" or "10.5 * 2" + var pattern = @"^\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)\s*$"; + var match = Regex.Match(expression, pattern); + + if (!match.Success) + { + throw new ArgumentException("Please use format like '5 + 3' or '10.5 * 2'. I support +, -, *, / operations."); + } + + var leftOperand = double.Parse(match.Groups[1].Value); + var operation = match.Groups[2].Value; + var rightOperand = double.Parse(match.Groups[3].Value); + + return operation switch + { + "+" => leftOperand + rightOperand, + "-" => leftOperand - rightOperand, + "*" => leftOperand * rightOperand, + "/" => rightOperand == 0 ? throw new DivideByZeroException("Cannot divide by zero") : leftOperand / rightOperand, + _ => throw new ArgumentException($"Unsupported operation: {operation}") + }; + } +} diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj new file mode 100644 index 00000000..e70a88a9 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs b/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs new file mode 100644 index 00000000..a116a721 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs @@ -0,0 +1,55 @@ +using A2A; +using A2A.AspNetCore; +using CalculatorServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the calculator agent +var calculatorAgent = new CalculatorAgent(); +calculatorAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "Calculator Agent", + Timestamp = DateTimeOffset.UtcNow +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "Calculator Agent Server is running!", + Examples = new[] { + "5 + 3", + "10 - 4", + "7 * 8", + "15 / 3" + }, + Endpoints = new + { + AgentCard = "/.well-known/agent.json", + A2A = "/ (POST for JSON-RPC)", + Health = "/health" + } +})); + +Console.WriteLine("🧮 Calculator Agent Server starting..."); +Console.WriteLine("🌐 Server will be available at: http://localhost:5002"); +Console.WriteLine("📋 Agent card: http://localhost:5002/.well-known/agent.json"); +Console.WriteLine("🔍 Health check: http://localhost:5002/health"); +Console.WriteLine("💬 Send math expressions via A2A protocol to: http://localhost:5002/"); +Console.WriteLine("📝 Example expressions: '5 + 3', '10 * 2', '15 / 3'"); + +app.Run("http://localhost:5002"); diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs b/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs new file mode 100644 index 00000000..1df4dc34 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs @@ -0,0 +1,78 @@ +using A2A; + +namespace EchoServer; + +/// +/// A simple echo agent that responds with the same message it receives. +/// This demonstrates the basic structure of an A2A agent. +/// +public class EchoAgent +{ + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and echoes them back. + /// + private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + // Extract the text from the incoming message + var userText = GetTextFromMessage(messageSendParams.Message); + + Console.WriteLine($"[Echo Agent] Received message: {userText}"); + + // Create an echo response + var responseText = $"Echo: {userText}"; + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = responseText }] + }; + + Console.WriteLine($"[Echo Agent] Sending response: {responseText}"); + + return Task.FromResult(responseMessage); + } + + /// + /// Retrieves the agent card information for the Echo Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "Simple Echo Agent", + Description = "A basic agent that echoes back any message you send to it. Perfect for testing A2A communication.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? "[No text content]"; + } +} diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj b/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj new file mode 100644 index 00000000..e70a88a9 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs b/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs new file mode 100644 index 00000000..2fa57da0 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs @@ -0,0 +1,48 @@ +using A2A; +using A2A.AspNetCore; +using EchoServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the echo agent +var echoAgent = new EchoAgent(); +echoAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "Echo Agent", + Timestamp = DateTimeOffset.UtcNow +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "Echo Agent Server is running!", + Endpoints = new + { + AgentCard = "/.well-known/agent.json", + A2A = "/ (POST for JSON-RPC)", + Health = "/health" + } +})); + +Console.WriteLine("🔊 Echo Agent Server starting..."); +Console.WriteLine("🌐 Server will be available at: http://localhost:5001"); +Console.WriteLine("📋 Agent card: http://localhost:5001/.well-known/agent.json"); +Console.WriteLine("🔍 Health check: http://localhost:5001/health"); +Console.WriteLine("💬 Send messages via A2A protocol to: http://localhost:5001/"); + +app.Run("http://localhost:5001"); diff --git a/samples/dotnet/BasicA2ADemo/README.md b/samples/dotnet/BasicA2ADemo/README.md new file mode 100644 index 00000000..eeb8bbe8 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/README.md @@ -0,0 +1,92 @@ +# Basic A2A .NET Demo + +This is a simple demonstration of the A2A (Agent-to-Agent) .NET SDK that shows the basics of agent communication. + +## What's Included + +- **EchoServer**: A simple agent that echoes back any message you send to it +- **CalculatorServer**: A basic calculator agent that can perform simple math operations +- **SimpleClient**: A console application that demonstrates how to communicate with both agents + +## Getting Started + +### Option 1: Quick Start (Windows) + +Use the provided batch script to run everything automatically: + +```bash +run-demo.bat +``` + +This will start both agent servers and the client in separate windows. + +### Option 2: Manual Setup + +#### 1. Start the Agent Servers + +In separate terminals, start each server: + +```bash +# Start the Echo Agent (runs on port 5001) +cd EchoServer +dotnet run + +# Start the Calculator Agent (runs on port 5002) +cd CalculatorServer +dotnet run +``` + +#### 2. Run the Client + +In another terminal: + +```bash +cd SimpleClient +dotnet run +``` + +The client will automatically discover and communicate with both agents, demonstrating: +- Agent discovery via agent cards +- Message-based communication +- Task-based communication +- Error handling + +## Key Concepts Demonstrated + +### Agent Discovery +- How agents advertise their capabilities through agent cards +- How clients discover and connect to agents + +### Message-Based Communication +- Simple request-response pattern +- Immediate responses without task tracking + +### Task-Based Communication +- Creating persistent tasks +- Tracking task progress and status +- Retrieving task results + +### Multiple Agents +- Running multiple agents simultaneously +- Client communicating with different agent types +- Agent-specific functionality + +## Project Structure + +```text +BasicA2ADemo/ +├── README.md # This file +├── EchoServer/ +│ ├── EchoServer.csproj # Echo agent project +│ ├── Program.cs # Echo server startup +│ └── EchoAgent.cs # Echo agent implementation +├── CalculatorServer/ +│ ├── CalculatorServer.csproj # Calculator agent project +│ ├── Program.cs # Calculator server startup +│ └── CalculatorAgent.cs # Calculator agent implementation +└── SimpleClient/ + ├── SimpleClient.csproj # Client project + └── Program.cs # Client implementation +``` + +This demo provides a foundation for understanding how to build agent-to-agent communication systems with the A2A .NET SDK. diff --git a/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs b/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs new file mode 100644 index 00000000..b0545d30 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs @@ -0,0 +1,186 @@ +using A2A; + +namespace SimpleClient; + +/// +/// A simple client that demonstrates how to communicate with A2A agents. +/// This shows the basic patterns for agent discovery and communication. +/// +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("🤖 Basic A2A .NET Demo Client"); + Console.WriteLine("=============================="); + Console.WriteLine(); + + try + { + // Demonstrate agent discovery and communication + await DemonstrateAgentCommunication(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ An error occurred: {ex.Message}"); + Console.WriteLine("💡 Make sure both agent servers are running:"); + Console.WriteLine(" - Echo Agent: http://localhost:5001"); + Console.WriteLine(" - Calculator Agent: http://localhost:5002"); + } + + Console.WriteLine(); + Console.WriteLine("Demo completed! Press any key to exit."); + Console.ReadKey(); + } + + /// + /// Demonstrates the complete workflow of discovering and communicating with agents. + /// + static async Task DemonstrateAgentCommunication() + { + // Define our agent endpoints + var agents = new[] + { + new { Name = "Echo Agent", Url = "http://localhost:5001/" }, + new { Name = "Calculator Agent", Url = "http://localhost:5002/" } + }; + + foreach (var agentInfo in agents) + { + Console.WriteLine($"🔍 Discovering {agentInfo.Name}..."); + + try + { + // Step 1: Discover the agent and get its capabilities + var agentCard = await DiscoverAgent(agentInfo.Url); + + // Step 2: Communicate with the agent + await CommunicateWithAgent(agentInfo.Name, agentInfo.Url, agentCard); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to communicate with {agentInfo.Name}: {ex.Message}"); + } + + Console.WriteLine(); + } + } + + /// + /// Discovers an agent by fetching its agent card. + /// + static async Task DiscoverAgent(string agentUrl) + { + var cardResolver = new A2ACardResolver(new Uri(agentUrl)); + var agentCard = await cardResolver.GetAgentCardAsync(); + + Console.WriteLine($"✅ Found agent: {agentCard.Name}"); + Console.WriteLine($" 📝 Description: {agentCard.Description}"); + Console.WriteLine($" 🔢 Version: {agentCard.Version}"); + Console.WriteLine($" 🎯 Capabilities: Streaming = {agentCard.Capabilities?.Streaming}"); + + // Show additional agent information + if (agentCard.DefaultInputModes?.Count > 0) + { + Console.WriteLine($" 📥 Input modes: {string.Join(", ", agentCard.DefaultInputModes)}"); + } + + if (agentCard.DefaultOutputModes?.Count > 0) + { + Console.WriteLine($" 📤 Output modes: {string.Join(", ", agentCard.DefaultOutputModes)}"); + } + + return agentCard; + } + + /// + /// Demonstrates communication with a discovered agent. + /// + static async Task CommunicateWithAgent(string agentName, string agentUrl, AgentCard agentCard) + { + Console.WriteLine($"💬 Communicating with {agentName}..."); + + // Create an A2A client for this agent + var client = new A2AClient(new Uri(agentUrl)); + + // Send appropriate messages based on the agent type + if (agentName.Contains("Echo")) + { + await SendEchoMessages(client); + } + else if (agentName.Contains("Calculator")) + { + await SendCalculatorMessages(client); + } + } + + /// + /// Sends test messages to the Echo Agent. + /// + static async Task SendEchoMessages(A2AClient client) + { + var testMessages = new[] + { + "Hello, Echo Agent!", + "Can you repeat this message?", + "Testing A2A communication 🚀" + }; + + foreach (var testMessage in testMessages) + { + var message = CreateMessage(testMessage); + Console.WriteLine($" 📤 Sending: {testMessage}"); + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + var responseText = GetTextFromMessage((Message)response); + + Console.WriteLine($" 📥 Received: {responseText}"); + } + } + + /// + /// Sends math expressions to the Calculator Agent. + /// + static async Task SendCalculatorMessages(A2AClient client) + { + var mathExpressions = new[] + { + "5 + 3", + "10 * 7", + "15 / 3", + "20.5 - 5.3" + }; + + foreach (var expression in mathExpressions) + { + var message = CreateMessage(expression); + Console.WriteLine($" 📤 Calculating: {expression}"); + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + var responseText = GetTextFromMessage((Message)response); + + Console.WriteLine($" 📥 Result: {responseText}"); + } + } + + /// + /// Creates a message with the specified text. + /// + static Message CreateMessage(string text) + { + return new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = text }] + }; + } + + /// + /// Extracts text from a message response. + /// + static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? "[No text content]"; + } +} diff --git a/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj b/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj new file mode 100644 index 00000000..3ae9727f --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/run-demo.bat b/samples/dotnet/BasicA2ADemo/run-demo.bat new file mode 100644 index 00000000..89b94be2 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/run-demo.bat @@ -0,0 +1,41 @@ +@echo off +echo. +echo Basic A2A .NET Demo +echo ====================== +echo. +echo This script will start both agent servers and then run the client. +echo. +echo Starting agents in separate windows... +echo. + +REM Start Echo Agent in a new window +start "Echo Agent Server" cmd /k "cd EchoServer && dotnet run && pause" + +REM Wait a moment for the first server to start +timeout /t 3 /nobreak > nul + +REM Start Calculator Agent in a new window +start "Calculator Agent Server" cmd /k "cd CalculatorServer && dotnet run && pause" + +REM Wait a moment for servers to fully start +echo Waiting for servers to start... +timeout /t 5 /nobreak > nul + +echo. +echo Agent servers should be starting in separate windows +echo Echo Agent: http://localhost:5001 +echo Calculator Agent: http://localhost:5002 +echo. +echo Press any key to run the client demo... +pause > nul + +REM Run the client +echo. +echo Running client demo... +cd SimpleClient +dotnet run + +echo. +echo Demo completed! The agent servers are still running in separate windows. +echo Close those windows when you're done experimenting. +pause diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md new file mode 100644 index 00000000..6306b29d --- /dev/null +++ b/samples/dotnet/README.md @@ -0,0 +1,54 @@ +# .NET A2A Samples + +This folder contains .NET demonstrations of the A2A (Agent-to-Agent) SDK, showcasing different patterns and use cases for building intelligent agents. + +## Available Demos + +### 🏗️ BasicA2ADemo +A foundational example demonstrating core A2A concepts with simple agents. + +**What's included:** +- **EchoServer**: Basic message echoing agent +- **CalculatorServer**: Simple math operations agent +- **SimpleClient**: Interactive client for both agents + +### 🖥️ CLIAgent (A2ACliDemo) +Shows how to build agents that can execute system commands safely. + +**What's included:** +- **CLIServer**: Agent that executes whitelisted CLI commands +- **CLIClient**: Interactive command-line interface + +### 🤖 AI-Powered Agent (A2ASemanticKernelDemo) +Demonstrates intelligent agents using Microsoft Semantic Kernel for AI capabilities. + +**What's included:** +- **AIServer**: Intelligent agent with text processing capabilities +- **AIClient**: Interactive client for AI features + +**AI Capabilities:** +- 📝 Text summarization +- 😊 Sentiment analysis +- 💡 Idea generation +- 🌍 Text translation + +## Getting Started + +Each demo includes: +- 📖 Detailed README with setup instructions +- 🚀 Quick-start batch scripts for Windows +- 💡 Example commands and use cases +- 🔧 Complete source code with comments + +## Requirements + +- .NET 9.0 SDK +- Windows, Linux, or macOS +- A2A SDK (included via NuGet) + +## Quick Start + +1. **Choose a demo** from the list above +2. **Follow the README** in that demo's folder +3. **Run the batch script** for instant setup, or +4. **Manual setup** with `dotnet run` commands From 037abfae12476e8d9fd5423e85d67fb8c3a9933c Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Tue, 23 Sep 2025 10:48:49 -0400 Subject: [PATCH 02/14] chore: Upgrade Java agents to A2A Java SDK 0.3.0.Beta1 (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). Fixes # 🦕 --- samples/java/agents/content_editor/pom.xml | 2 +- samples/java/agents/content_writer/pom.xml | 2 +- samples/java/agents/weather_mcp/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/java/agents/content_editor/pom.xml b/samples/java/agents/content_editor/pom.xml index 099b19d3..abd1e323 100644 --- a/samples/java/agents/content_editor/pom.xml +++ b/samples/java/agents/content_editor/pom.xml @@ -12,7 +12,7 @@ 17 17 UTF-8 - 0.3.0.Alpha1 + 0.3.0.Beta1 4.1.0 3.22.3 1.0.0 diff --git a/samples/java/agents/content_writer/pom.xml b/samples/java/agents/content_writer/pom.xml index 5c248990..548523e6 100644 --- a/samples/java/agents/content_writer/pom.xml +++ b/samples/java/agents/content_writer/pom.xml @@ -12,7 +12,7 @@ 17 17 UTF-8 - 0.3.0.Alpha1 + 0.3.0.Beta1 4.1.0 3.22.3 1.0.0 diff --git a/samples/java/agents/weather_mcp/pom.xml b/samples/java/agents/weather_mcp/pom.xml index 351b3206..b6b7e35e 100644 --- a/samples/java/agents/weather_mcp/pom.xml +++ b/samples/java/agents/weather_mcp/pom.xml @@ -12,7 +12,7 @@ 17 17 UTF-8 - 0.3.0.Alpha1 + 0.3.0.Beta1 4.1.0 3.22.3 1.0.0 From 0544f18623f905a8479a21fe7be257dac37f74ee Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Tue, 23 Sep 2025 11:48:13 -0400 Subject: [PATCH 03/14] feat: Add a multi-transport sample for A2A Java SDK (#353) # Description This PR adds a multi-transport sample using the A2A Java SDK. The server agent supports both gRPC and JSON-RPC, with gRPC being the preferred transport. The client supports both as well so the gRPC will be the selected transport. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). --- samples/java/agents/README.md | 4 + .../dice_agent_multi_transport/README.md | 123 ++++++++++ .../dice_agent_multi_transport/client/pom.xml | 54 ++++ .../com/samples/a2a/client/TestClient.java | 232 ++++++++++++++++++ .../samples/a2a/client/TestClientRunner.java | 43 ++++ .../com/samples/a2a/client/package-info.java | 4 + .../agents/dice_agent_multi_transport/pom.xml | 80 ++++++ .../server/.env.example | 1 + .../dice_agent_multi_transport/server/pom.xml | 56 +++++ .../main/java/com/samples/a2a/DiceAgent.java | 48 ++++ .../samples/a2a/DiceAgentCardProducer.java | 82 +++++++ .../a2a/DiceAgentExecutorProducer.java | 111 +++++++++ .../main/java/com/samples/a2a/DiceTools.java | 78 ++++++ .../java/com/samples/a2a/package-info.java | 4 + .../src/main/resources/application.properties | 4 + .../agents/dice_agent_grpc/test_client.py | 21 +- 16 files changed, 936 insertions(+), 9 deletions(-) create mode 100644 samples/java/agents/dice_agent_multi_transport/README.md create mode 100644 samples/java/agents/dice_agent_multi_transport/client/pom.xml create mode 100644 samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java create mode 100644 samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java create mode 100644 samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java create mode 100644 samples/java/agents/dice_agent_multi_transport/pom.xml create mode 100644 samples/java/agents/dice_agent_multi_transport/server/.env.example create mode 100644 samples/java/agents/dice_agent_multi_transport/server/pom.xml create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java create mode 100644 samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties diff --git a/samples/java/agents/README.md b/samples/java/agents/README.md index 6027374e..2a3d80aa 100644 --- a/samples/java/agents/README.md +++ b/samples/java/agents/README.md @@ -15,6 +15,10 @@ Each agent can be run as its own A2A server with the instructions in its README. * [**Weather Agent**](weather_mcp/README.md) Sample agent to provide weather information. To make use of this agent in a multi-language, multi-agent system, check out the [weather_and_airbnb_planner](../../python/hosts/weather_and_airbnb_planner/README.md) sample. +* [**Dice Agent (Multi-Transport)**](dice_agent_multi_transport/README.md) + Sample agent that can roll dice of different sizes and check if numbers are prime. This agent demonstrates + multi-transport capabilities. + ## Disclaimer Important: The sample code provided is for demonstration purposes and illustrates the diff --git a/samples/java/agents/dice_agent_multi_transport/README.md b/samples/java/agents/dice_agent_multi_transport/README.md new file mode 100644 index 00000000..0ed3176b --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/README.md @@ -0,0 +1,123 @@ +# Dice Agent (Multi-Transport) + +This sample agent can roll dice of different sizes and check if numbers are prime. This agent demonstrates +multi-transport capabilities, supporting both gRPC and JSON-RPC transport protocols. The agent is written +using Quarkus LangChain4j and makes use of the [A2A Java](https://github.com/a2aproject/a2a-java) SDK. + +## Prerequisites + +- Java 17 or higher +- Access to an LLM and API Key + +## Running the Sample + +This sample consists of an A2A server agent, which is in the `server` directory, and an A2A client, +which is in the `client` directory. + +### Running the A2A Server Agent + +1. Navigate to the `dice_agent_multi_transport` sample directory: + + ```bash + cd samples/java/agents/dice_agent_multi_transport/server + ``` + +2. Set your Google AI Studio API Key as an environment variable: + + ```bash + export QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + + Alternatively, you can create a `.env` file in the `dice_agent_multi_transport` directory: + + ```bash + QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + +3. Start the A2A server agent + + **NOTE:** + By default, the agent will start on port 11000. To override this, add the `-Dquarkus.http.port=YOUR_PORT` + option at the end of the command below. + + ```bash + mvn quarkus:dev + ``` + +### Running the A2A Java Client + +The Java `TestClient` communicates with the Dice Agent using the A2A Java SDK. + +Since the A2A server agent's [preferred transport](server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java) is gRPC and since our client +also [supports](client/src/main/java/com/samples/a2a/TestClient.java) gRPC, the gRPC transport will be used. + +1. Make sure you have [JBang installed](https://www.jbang.dev/documentation/guide/latest/installation.html) + +2. Run the client using the JBang script: + ```bash + cd samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client + jbang TestClientRunner.java + ``` + + Or specify a custom server URL: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11001 + ``` + + Or specify a custom message: + ```bash + jbang TestClientRunner.java --message "Can you roll a 12-sided die and check if the result is prime?" + ``` + +### Running the A2A Python Client + +You can also use a Python client to communicate with the Dice Agent using the A2A +Python SDK. + +Since the A2A server agent's [preferred transport](server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java) is gRPC and since our [client](client/src/main/java/com/samples/a2a/TestClient.java) also supports gRPC, the gRPC +transport will be used. + +1. In a separate terminal, run the A2A client and use it to send a message to the + agent: + + ```bash + cd samples/python/agents/dice_agent_grpc + uv run test_client.py + ``` + +## Expected Client Output + +Both the Java and Python A2A clients will: +1. Connect to the dice agent +2. Fetch the agent card +3. Automatically select gRPC as the transport to be used +4. Send the message "Can you roll a 5 sided die?" +5. Display the dice roll result from the agent + +## Multi-Transport Support + +This sample demonstrates multi-transport capabilities by supporting both gRPC and +JSON-RPC protocols. The A2A server agent is configured to use a unified port +(11000 by default) for both transport protocols, as specified in the +`application.properties` file with `quarkus.grpc.server.use-separate-server=false`. + +You can tweak the transports supported by the server or the client to experiment +with different transport protocols. + +## Disclaimer +Important: The sample code provided is for demonstration purposes and illustrates the +mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, +it is critical to treat any agent operating outside of your direct control as a +potentially untrusted entity. + +All data received from an external agent—including but not limited to its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide an AgentCard containing crafted data in its +fields (e.g., description, name, skills.description). If this data is used without +sanitization to construct prompts for a Large Language Model (LLM), it could expose +your application to prompt injection attacks. Failure to properly validate and +sanitize this data before use can introduce security vulnerabilities into your +application. + +Developers are responsible for implementing appropriate security measures, such as +input validation and secure handling of credentials to protect their systems and users. diff --git a/samples/java/agents/dice_agent_multi_transport/client/pom.xml b/samples/java/agents/dice_agent_multi_transport/client/pom.xml new file mode 100644 index 00000000..c323ee53 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + com.samples.a2a + dice-agent-multi-transport + 0.1.0 + + + dice-agent-client + Dice Agent Client + A2A Dice Agent Test Client + + + + io.github.a2asdk + a2a-java-sdk-client + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${io.a2a.sdk.version} + + + com.fasterxml.jackson.core + jackson-databind + + + io.grpc + grpc-netty-shaded + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.codehaus.mojo + exec-maven-plugin + + com.samples.a2a.TestClient + + + + + diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java new file mode 100644 index 00000000..0267d19c --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java @@ -0,0 +1,232 @@ +package com.samples.a2a.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.a2a.A2A; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Artifact; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** Creates an A2A client that sends a test message to the A2A server agent. */ +public final class TestClient { + + /** The default server URL to use. */ + private static final String DEFAULT_SERVER_URL = "http://localhost:11000"; + + /** The default message text to send. */ + private static final String MESSAGE_TEXT = "Can you roll a 5 sided die?"; + + /** Object mapper to use. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestClient() { + // this avoids a lint issue + } + + /** + * Client entry point. + * @param args can optionally contain the --server-url and --message to use + */ + public static void main(final String[] args) { + String serverUrl = DEFAULT_SERVER_URL; + String messageText = MESSAGE_TEXT; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--server-url": + if (i + 1 < args.length) { + serverUrl = args[i + 1]; + i++; + } else { + System.err.println("Error: --server-url requires a value"); + printUsageAndExit(); + } + break; + case "--message": + if (i + 1 < args.length) { + messageText = args[i + 1]; + i++; + } else { + System.err.println("Error: --message requires a value"); + printUsageAndExit(); + } + break; + case "--help": + case "-h": + printUsageAndExit(); + break; + default: + System.err.println("Error: Unknown argument: " + args[i]); + printUsageAndExit(); + } + } + + try { + System.out.println("Connecting to dice agent at: " + serverUrl); + + // Fetch the public agent card + AgentCard publicAgentCard = + new A2ACardResolver(serverUrl).getAgentCard(); + System.out.println("Successfully fetched public agent card:"); + System.out.println(OBJECT_MAPPER.writeValueAsString(publicAgentCard)); + System.out.println("Using public agent card for client initialization."); + + // Create a CompletableFuture to handle async response + final CompletableFuture messageResponse + = new CompletableFuture<>(); + + // Create consumers for handling client events + List> consumers + = getConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = (error) -> { + System.out.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + + // Create channel factory for gRPC transport + Function channelFactory = agentUrl -> { + return ManagedChannelBuilder.forTarget(agentUrl).usePlaintext().build(); + }; + + ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("Text")) + .build(); + + // Create the client with both JSON-RPC and gRPC transport support. + // The A2A server agent's preferred transport is gRPC, since the client + // also supports gRPC, this is the transport that will get used + Client client = Client.builder(publicAgentCard) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler) + .withTransport(GrpcTransport.class, + new GrpcTransportConfig(channelFactory)) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig()) + .clientConfig(clientConfig) + .build(); + + // Create and send the message + Message message = A2A.toUserMessage(messageText); + + System.out.println("Sending message: " + messageText); + client.sendMessage(message); + System.out.println("Message sent successfully. Waiting for response..."); + + try { + // Wait for response with timeout + String responseText = messageResponse.get(); + System.out.println("Final response: " + responseText); + } catch (Exception e) { + System.err.println("Failed to get response: " + e.getMessage()); + e.printStackTrace(); + } + + } catch (Exception e) { + System.err.println("An error occurred: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static List> getConsumers( + final CompletableFuture messageResponse) { + List> consumers = new ArrayList<>(); + consumers.add( + (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + String text = extractTextFromParts(responseMessage.getParts()); + System.out.println("Received message: " + text); + messageResponse.complete(text); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + if (updateEvent + instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { + System.out.println( + "Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + if (taskStatusUpdateEvent.isFinal()) { + StringBuilder textBuilder = new StringBuilder(); + List artifacts + = taskUpdateEvent.getTask().getArtifacts(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + String text = textBuilder.toString(); + messageResponse.complete(text); + } + } else if (updateEvent instanceof TaskArtifactUpdateEvent + taskArtifactUpdateEvent) { + List> parts = taskArtifactUpdateEvent + .getArtifact() + .parts(); + String text = extractTextFromParts(parts); + System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + System.out.println("Received task event: " + + taskEvent.getTask().getId()); + } + }); + return consumers; + } + + private static String extractTextFromParts(final List> parts) { + final StringBuilder textBuilder = new StringBuilder(); + if (parts != null) { + for (final Part part : parts) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + private static void printUsageAndExit() { + System.out.println("Usage: TestClient [OPTIONS]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --server-url URL " + + "The URL of the A2A server agent (default: " + + DEFAULT_SERVER_URL + ")"); + System.out.println(" --message TEXT " + + "The message to send to the agent " + + "(default: \"" + MESSAGE_TEXT + "\")"); + System.out.println(" --help, -h " + + "Show this help message and exit"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" TestClient --server-url http://localhost:11001"); + System.out.println(" TestClient --message " + + "\"Can you roll a 12-sided die?\""); + System.out.println(" TestClient --server-url http://localhost:11001 " + + "--message \"Is 17 prime?\""); + System.exit(0); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java new file mode 100644 index 00000000..bb5a1d77 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -0,0 +1,43 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta1 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta1 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta1 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 +//DEPS io.grpc:grpc-netty-shaded:1.69.1 +//SOURCES TestClient.java + +/** + * JBang script to run the A2A TestClient example for the Dice Agent. This + * script automatically handles the dependencies and runs the client. + * + *

+ * Prerequisites: - JBang installed (see + * https://www.jbang.dev/documentation/guide/latest/installation.html) - A + * running Dice Agent server (see README.md for instructions on setting up the + * agent) + * + *

+ * Usage: $ jbang TestClientRunner.java + * + *

+ * Or with a custom server URL: $ jbang TestClientRunner.java + * --server-url=http://localhost:10000 + * + *

+ * The script will communicate with the Dice Agent server and send the message + * "Can you roll a 5 sided die" to demonstrate the A2A protocol interaction. + */ +public final class TestClientRunner { + + private TestClientRunner() { + // this avoids a lint issue + } + + /** + * Client entry point. + * @param args can optionally contain the --server-url and --message to use + */ + public static void main(final String[] args) { + com.samples.a2a.client.TestClient.main(args); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java new file mode 100644 index 00000000..74a05d0c --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java @@ -0,0 +1,4 @@ +/** + * Dice Agent package. + */ +package com.samples.a2a.client; diff --git a/samples/java/agents/dice_agent_multi_transport/pom.xml b/samples/java/agents/dice_agent_multi_transport/pom.xml new file mode 100644 index 00000000..a9354c06 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + com.samples.a2a + dice-agent-multi-transport + 0.1.0 + pom + + + server + client + + + + 17 + 17 + UTF-8 + 4.31.1 + 0.3.0.Beta1 + 4.1.0 + 3.26.1 + 1.0.0 + + + + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + + + diff --git a/samples/java/agents/dice_agent_multi_transport/server/.env.example b/samples/java/agents/dice_agent_multi_transport/server/.env.example new file mode 100644 index 00000000..cb2fe891 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/.env.example @@ -0,0 +1 @@ +QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here diff --git a/samples/java/agents/dice_agent_multi_transport/server/pom.xml b/samples/java/agents/dice_agent_multi_transport/server/pom.xml new file mode 100644 index 00000000..d21b1e22 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + com.samples.a2a + dice-agent-multi-transport + 0.1.0 + + + dice-agent-server + Dice Agent Server + A2A Dice Agent Server Implementation + + + + io.github.a2asdk + a2a-java-sdk-reference-grpc + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-jsonrpc + ${io.a2a.sdk.version} + + + io.quarkus + quarkus-rest-jackson + + + jakarta.enterprise + jakarta.enterprise.cdi-api + ${jakarta.enterprise.cdi-api.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ai-gemini + ${quarkus.langchain4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.quarkus + quarkus-maven-plugin + + + + diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java new file mode 100644 index 00000000..fb8f060e --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java @@ -0,0 +1,48 @@ +package com.samples.a2a; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; + +/** Dice agent. */ +@RegisterAiService(tools = DiceTools.class) +@ApplicationScoped +public interface DiceAgent { + + /** + * Rolls dice and provides information about the outcome of dice roles. + * + * @param question the users' question + * @return the answer + */ + @SystemMessage( + """ + You roll dice and answer questions about the outcome of the dice rolls. + You can roll dice of different sizes. The only things you do are roll + dice for the user and discuss the outcomes. + It is ok to discuss previous dice roles, and comment on the dice rolls. + When you are asked to roll a die, you must call the rollDice tool with + the number of sides. + Be sure to pass in an integer. Do not pass in a string. + You should never roll a die on your own. + When checking prime numbers, call the checkPrime tool + with a list of integers. + Be sure to pass in a list of integers. You should never pass in a + string. + You should not check prime numbers before calling the tool. + When you are asked to roll a die and check prime numbers, + you should always make the following two function calls: + 1. You should first call the rollDice tool to get a roll. + Wait for the function response before calling the checkPrime tool. + 2. After you get the function response from rollDice tool, you + should call the checkPrime tool with the rollDice result. + 2.1 If user asks you to check primes based on previous rolls, + make sure you include the previous rolls in the list. + 3. When you respond, you must include the rollDice result from step 1. + You should always perform the previous 3 steps when asking for a roll + and checking prime numbers. + You should not rely on the previous history on prime results. + """) + String rollAndAnswer(@UserMessage String question); +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java new file mode 100644 index 00000000..3ba7adba --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java @@ -0,0 +1,82 @@ +package com.samples.a2a; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import io.a2a.spec.TransportProtocol; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * Producer for dice agent card configuration. + */ +@ApplicationScoped +public final class DiceAgentCardProducer { + + /** The HTTP port for the agent service. */ + @Inject + @ConfigProperty(name = "quarkus.http.port") + private int httpPort; + + /** + * Produces the agent card for the dice agent. + * + * @return the configured agent card + */ + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return new AgentCard.Builder() + .name("Dice Agent") + .description( + "Rolls an N-sided dice and answers questions about the " + + "outcome of the dice rolls. Can also answer questions " + + "about prime numbers.") + .preferredTransport(TransportProtocol.GRPC.asString()) + .url("localhost:" + httpPort) + .version("1.0.0") + .documentationUrl("http://example.com/docs") + .capabilities( + new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(false) + .stateTransitionHistory(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills( + List.of( + new AgentSkill.Builder() + .id("dice_roller") + .name("Roll dice") + .description("Rolls dice and discusses outcomes") + .tags(List.of("dice", "games", "random")) + .examples( + List.of("Can you roll a 6-sided die?")) + .build(), + new AgentSkill.Builder() + .id("prime_checker") + .name("Check prime numbers") + .description("Checks if given numbers are prime") + .tags(List.of("math", "prime", "numbers")) + .examples( + List.of( + "Is 17 a prime number?", + "Which of these numbers are prime: 1, 4, 6, 7")) + .build())) + .protocolVersion("0.3.0") + .additionalInterfaces( + List.of( + new AgentInterface(TransportProtocol.GRPC.asString(), + "localhost:" + httpPort), + new AgentInterface( + TransportProtocol.JSONRPC.asString(), + "http://localhost:" + httpPort))) + .build(); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java new file mode 100644 index 00000000..46c5a6f0 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java @@ -0,0 +1,111 @@ +package com.samples.a2a; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.Task; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; + +/** Producer for dice agent executor. */ +@ApplicationScoped +public final class DiceAgentExecutorProducer { + + /** The dice agent instance. */ + @Inject private DiceAgent diceAgent; + + /** + * Produces the agent executor for the dice agent. + * + * @return the configured agent executor + */ + @Produces + public AgentExecutor agentExecutor() { + return new DiceAgentExecutor(diceAgent); + } + + /** Dice agent executor implementation. */ + private static class DiceAgentExecutor implements AgentExecutor { + + /** The dice agent instance. */ + private final DiceAgent agent; + + /** + * Constructor for DiceAgentExecutor. + * + * @param diceAgentInstance the dice agent instance + */ + DiceAgentExecutor(final DiceAgent diceAgentInstance) { + this.agent = diceAgentInstance; + } + + @Override + public void execute(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + + // mark the task as submitted and start working on it + if (context.getTask() == null) { + updater.submit(); + } + updater.startWork(); + + // extract the text from the message + final String assignment = extractTextFromMessage(context.getMessage()); + + // call the dice agent with the message + final String response = agent.rollAndAnswer(assignment); + + // create the response part + final TextPart responsePart = new TextPart(response, null); + final List> parts = List.of(responsePart); + + // add the response as an artifact and complete the task + updater.addArtifact(parts, null, null, null); + updater.complete(); + } + + private String extractTextFromMessage(final Message message) { + final StringBuilder textBuilder = new StringBuilder(); + if (message.getParts() != null) { + for (final Part part : message.getParts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + @Override + public void cancel(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final Task task = context.getTask(); + + if (task.getStatus().state() == TaskState.CANCELED) { + // task already cancelled + throw new TaskNotCancelableError(); + } + + if (task.getStatus().state() == TaskState.COMPLETED) { + // task already completed + throw new TaskNotCancelableError(); + } + + // cancel the task + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java new file mode 100644 index 00000000..0c15886f --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java @@ -0,0 +1,78 @@ +package com.samples.a2a; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** Service class that provides dice rolling and prime number functionality. */ +@ApplicationScoped +public class DiceTools { + + /** For generating rolls. */ + private final Random random = new Random(); + + /** Default number of sides to use. */ + private static final int DEFAULT_NUM_SIDES = 6; + + /** + * Rolls an N sided dice. If number of sides aren't given, uses 6. + * + * @param n the number of the side of the dice to roll + * @return A number between 1 and N, inclusive + */ + @Tool("Rolls an n sided dice. If number of sides aren't given, uses 6.") + public int rollDice(final int n) { + int sides = n; + if (sides <= 0) { + sides = DEFAULT_NUM_SIDES; // Default to 6 sides if invalid input + } + return random.nextInt(sides) + 1; + } + + /** + * Check if a given list of numbers are prime. + * + * @param nums The list of numbers to check + * @return A string indicating which number is prime + */ + @Tool("Check if a given list of numbers are prime.") + public String checkPrime(final List nums) { + Set primes = new HashSet<>(); + + for (Integer number : nums) { + if (number == null) { + continue; + } + + int num = number.intValue(); + if (num <= 1) { + continue; + } + + boolean isPrime = true; + for (int i = 2; i <= Math.sqrt(num); i++) { + if (num % i == 0) { + isPrime = false; + break; + } + } + + if (isPrime) { + primes.add(num); + } + } + + if (primes.isEmpty()) { + return "No prime numbers found."; + } else { + return primes.stream() + .sorted() + .map(String::valueOf) + .collect(java.util.stream.Collectors.joining(", ")) + + " are prime numbers."; + } + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java new file mode 100644 index 00000000..502874d1 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java @@ -0,0 +1,4 @@ +/** + * Dice Agent package. + */ +package com.samples.a2a; diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties new file mode 100644 index 00000000..631da733 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Use the same port for gRPC and HTTP +quarkus.grpc.server.use-separate-server=false +quarkus.http.port=11000 +quarkus.langchain4j.ai.gemini.timeout=40000 diff --git a/samples/python/agents/dice_agent_grpc/test_client.py b/samples/python/agents/dice_agent_grpc/test_client.py index a5e02a49..038121b1 100644 --- a/samples/python/agents/dice_agent_grpc/test_client.py +++ b/samples/python/agents/dice_agent_grpc/test_client.py @@ -46,15 +46,18 @@ async def main(agent_card_url: str, grpc_endpoint: str | None) -> None: # specifies if authenticated card should be fetched. # If an authenticated agent card is provided, client should use it for interacting with the gRPC service try: - logger.info( - 'Attempting to fetch authenticated agent card from grpc endpoint' - ) - proto_card = await stub.GetAgentCard(a2a_pb2.GetAgentCardRequest()) - logger.info('Successfully fetched agent card:') - logger.info(proto_card) - final_agent_card_to_use = proto_utils.FromProto.agent_card( - proto_card - ) + if agent_card.supports_authenticated_extended_card: + logger.info( + 'Attempting to fetch authenticated agent card from grpc endpoint' + ) + proto_card = await stub.GetAgentCard(a2a_pb2.GetAgentCardRequest()) + logger.info('Successfully fetched agent card:') + logger.info(proto_card) + final_agent_card_to_use = proto_utils.FromProto.agent_card( + proto_card + ) + else: + final_agent_card_to_use = agent_card except Exception: logging.exception('Failed to get authenticated agent card. Exiting.') return From e6690c287ce17f65ad5a4fa6febfe0b21a2e14d7 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 26 Sep 2025 11:53:02 -0400 Subject: [PATCH 04/14] fix: Explicitly set the Gemini chat model id for the Java agents (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR explicitly sets the Gemini chat model to use for the Java agents. The agents were previously relying on `gemini-1.5.-flash`, the [default Gemini chat model](https://github.com/quarkiverse/quarkus-langchain4j/issues/1819) set by Quarkus LangChain4j, but this chat model has been discontinued so updating these agents to use a newer chat model instead. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). Fixes # 🦕 --- .../content_editor/src/main/resources/application.properties | 3 ++- .../content_writer/src/main/resources/application.properties | 3 ++- .../server/src/main/resources/application.properties | 2 ++ .../weather_mcp/src/main/resources/application.properties | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/samples/java/agents/content_editor/src/main/resources/application.properties b/samples/java/agents/content_editor/src/main/resources/application.properties index 4f588190..be82040f 100644 --- a/samples/java/agents/content_editor/src/main/resources/application.properties +++ b/samples/java/agents/content_editor/src/main/resources/application.properties @@ -1,2 +1,3 @@ quarkus.http.port=10003 -quarkus.langchain4j.ai.gemini.timeout=40000 \ No newline at end of file +quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash diff --git a/samples/java/agents/content_writer/src/main/resources/application.properties b/samples/java/agents/content_writer/src/main/resources/application.properties index 22aec47e..0b9a8222 100644 --- a/samples/java/agents/content_writer/src/main/resources/application.properties +++ b/samples/java/agents/content_writer/src/main/resources/application.properties @@ -1,2 +1,3 @@ quarkus.http.port=10002 -quarkus.langchain4j.ai.gemini.timeout=40000 \ No newline at end of file +quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties index 631da733..b36c8790 100644 --- a/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties @@ -2,3 +2,5 @@ quarkus.grpc.server.use-separate-server=false quarkus.http.port=11000 quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash + diff --git a/samples/java/agents/weather_mcp/src/main/resources/application.properties b/samples/java/agents/weather_mcp/src/main/resources/application.properties index 5e1433e9..a4d0cb3c 100644 --- a/samples/java/agents/weather_mcp/src/main/resources/application.properties +++ b/samples/java/agents/weather_mcp/src/main/resources/application.properties @@ -1,3 +1,4 @@ quarkus.http.port=10001 quarkus.langchain4j.mcp.weather.transport-type=stdio -quarkus.langchain4j.mcp.weather.command=uv,--directory,mcp,run,weather_mcp.py \ No newline at end of file +quarkus.langchain4j.mcp.weather.command=uv,--directory,mcp,run,weather_mcp.py +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash From c3efbe4f2a57e3c6f8426ac5f4ceb7da05f158a3 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 26 Sep 2025 11:53:18 -0400 Subject: [PATCH 05/14] fix: Remove unnecessary dependency for the weather_mcp Java agent (#371) # Description This PRs removes a dependency that isn't actually needed for the `weather_mcp` Java agent: Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). --- samples/java/agents/weather_mcp/pom.xml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/samples/java/agents/weather_mcp/pom.xml b/samples/java/agents/weather_mcp/pom.xml index b6b7e35e..7d4664c7 100644 --- a/samples/java/agents/weather_mcp/pom.xml +++ b/samples/java/agents/weather_mcp/pom.xml @@ -59,18 +59,6 @@ quarkus-langchain4j-mcp ${quarkus.langchain4j.version} - - io.quarkiverse.langchain4j - quarkus-langchain4j-mcp-deployment - ${quarkus.langchain4j.version} - pom - - - * - * - - - From f53b6a565c4e59335ed1fbf28a41a1ec1fba9043 Mon Sep 17 00:00:00 2001 From: gulliantonio <167304324+gulliantonio@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:42:33 +0200 Subject: [PATCH 06/14] feat: Add secure-passport sample from google-octo/ag gulli@google.com (#370) This proposal extends the A2A protocol with a lightweight "Secure Passport" feature, enabling calling agents to share verified information (e.g., user preferences, session history, tool access permissions ..) with contacted agents. This enhances agent interactions from anonymous transactions to trusted, context-aware partnerships, improving reliability and personalization within the agent ecosystem. A reference implementation is provided. Go Link: [go/a2a-secure-passport](http://goto.google.com/a2a-secure-passport) --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- extensions/secure-passport/HOWTORUN.md | 97 +++++ extensions/secure-passport/README.md | 23 ++ .../v1/samples/python/README.md | 148 ++++++++ .../v1/samples/python/poetry.lock | 334 ++++++++++++++++++ .../v1/samples/python/pyproject.toml | 25 ++ .../secure-passport/v1/samples/python/run.py | 125 +++++++ .../src/secure_passport_ext/__init__.py | 108 ++++++ .../python/src/secure_passport_ext/py.typed | 1 + .../python/tests/test_secure_passport.py | 186 ++++++++++ extensions/secure-passport/v1/spec.md | 133 +++++++ 10 files changed, 1180 insertions(+) create mode 100644 extensions/secure-passport/HOWTORUN.md create mode 100644 extensions/secure-passport/README.md create mode 100644 extensions/secure-passport/v1/samples/python/README.md create mode 100644 extensions/secure-passport/v1/samples/python/poetry.lock create mode 100644 extensions/secure-passport/v1/samples/python/pyproject.toml create mode 100644 extensions/secure-passport/v1/samples/python/run.py create mode 100644 extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py create mode 100644 extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed create mode 100644 extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py create mode 100644 extensions/secure-passport/v1/spec.md diff --git a/extensions/secure-passport/HOWTORUN.md b/extensions/secure-passport/HOWTORUN.md new file mode 100644 index 00000000..0b20cbf9 --- /dev/null +++ b/extensions/secure-passport/HOWTORUN.md @@ -0,0 +1,97 @@ +# HOW TO RUN the Secure Passport Extension Sample + +This guide provides step-by-step instructions for setting up the environment and running the Python sample code for the **Secure Passport Extension v1**. + +The sample is located in the `samples/python/` directory. + +--- + +## 1. Prerequisites + +You need the following installed on your system: + +* **Python** (version 3.9 or higher) +* **Poetry** (Recommended for dependency management via `pyproject.toml`) + +--- + +## 2. Setup and Installation + +1. **From the repository root, navigate** to the sample project directory: + ```bash + cd extensions/secure-passport/v1/samples/python + ``` + +2. **Install Dependencies** using Poetry. This command reads `pyproject.toml`, creates a virtual environment, and installs `pydantic` and `pytest`. + ```bash + poetry install + ``` + +3. **Activate** the virtual environment: + ```bash + poetry shell + ``` + + *(Note: All subsequent commands are run from within this activated environment.)* + +--- + +## 3. Execution + +There are two ways to run the code: using the automated unit tests or using a manual script. + +### A. Run Unit Tests (Recommended) + +Running the tests is the most complete way to verify the extension's data modeling, integrity checks, and validation logic. + +```bash +# Execute Pytest against the test directory +pytest tests/ + +### B. Run Middleware Demo Script + +Execute `run.py` to see the full client/server middleware pipeline in action for all four use cases: + +```bash +python run.py + +### Expected Console Output + +The output below demonstrates the successful execution of the four use cases via the simulated middleware pipeline: + +========================================================= Secure Passport Extension Demo (Middleware) +--- Use Case: Efficient Currency Conversion (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://travel-orchestrator.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=GBP, Tier=Silver + +--- Use Case: Personalized Travel Booking (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://travel-portal.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=Unknown, Tier=Platinum + +--- Use Case: Proactive Retail Assistance (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://ecommerce-front.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: False +[Agent Core] Task received for processing. +[Agent Core] Executing task with unverified context (proceeding cautiously). + +--- Use Case: Marketing Agent seek insights (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://marketing-agent.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=Unknown, Tier=Standard diff --git a/extensions/secure-passport/README.md b/extensions/secure-passport/README.md new file mode 100644 index 00000000..14712b39 --- /dev/null +++ b/extensions/secure-passport/README.md @@ -0,0 +1,23 @@ +# Secure Passport Extension + +This directory contains the specification and a Python sample implementation for the **Secure Passport Extension v1** for the Agent2Agent (A2A) protocol. + +## Purpose + +The Secure Passport extension introduces a **trusted, contextual layer** for A2A communication. It allows a calling agent to securely and voluntarily share a structured subset of its current contextual state with the callee agent. This is designed to transform anonymous, transactional calls into collaborative partnerships, enabling: + +* **Immediate Personalization:** Specialist agents can use context (like loyalty tier or preferred currency) immediately. +* **Reduced Overhead:** Eliminates the need for multi-turn conversations to establish context. +* **Enhanced Trust:** Includes a **`signature`** field for cryptographic verification of the data's origin and integrity. + +## Specification + +The full technical details, including data models, required fields, and security considerations, are documented here: + +➡️ **[Full Specification (v1)](./v1/spec.md)** + +## Sample Implementation + +A runnable example demonstrating the implementation of the `CallerContext` data model and the utility functions for integration with the A2A SDK is provided in the `samples` directory. + +➡️ **[Python Sample Usage Guide](./v1/samples/python/README.md)** \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/README.md b/extensions/secure-passport/v1/samples/python/README.md new file mode 100644 index 00000000..e2de5a1c --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/README.md @@ -0,0 +1,148 @@ +# Secure Passport Python Sample + +This sample provides the runnable code for the **Secure Passport Extension v1** for the Agent2Agent (A2A) protocol, demonstrating its implementation and usage in a Python environment. + +## 1. Extension Overview + +The core of this extension is the **`CallerContext`** data model, which is attached to the A2A message metadata under the extension's unique URI. This enables the secure transfer of trusted contextual state between collaborating agents. + +### Extension URI + +The unique identifier for this extension is: +`https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport` + +--- + +## 2. Comprehensive Usage and Middleware Demonstration + +The `run.py` script demonstrates the full client-server pipeline using the conceptual **middleware layers** for seamless integration. + +### A. Use Case Code Demonstration + +The following code demonstrates how to create the specific `CallerContext` payloads for the four core use cases, verifying that the structure and integrity checks work as intended. + +```python +from secure_passport_ext import ( + CallerContext, + A2AMessage, + add_secure_passport, + get_secure_passport +) + +def demonstrate_use_case(title: str, client_id: str, state: dict, signature: str | None = None, session_id: str | None = None): + print(f"\n--- Demonstrating: {title} ---") + + passport = CallerContext( + client_id=client_id, + session_id=session_id, + signature=signature, + state=state + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + if retrieved: + print(f" Source: {retrieved.client_id}") + print(f" Verified: {retrieved.is_verified}") + print(f" Context: {retrieved.state}") + else: + print(" [ERROR] Passport retrieval failed.") + +# 1. Efficient Currency Conversion (Low Context, High Trust) + +demonstrate_use_case( + title="1. Currency Conversion (GBP)", + client_id="a2a://travel-orchestrator.com", + state={"user_preferred_currency": "GBP", "user_id": "U001"}, + signature="sig-currency-1" +) + +# 2. Personalized Travel Booking (High Context, Session Data) + +demonstrate_use_case( + title="2. Personalized Travel (Platinum Tier)", + client_id="a2a://travel-portal.com", + session_id="travel-session-999", + state={ + "destination": "Bali, Indonesia", + "loyalty_tier": "Platinum" + }, + signature="sig-travel-2" +) + +# 3. Proactive Retail Assistance (Unsigned, Quick Context) + +demonstrate_use_case( + title="3. Retail Assistance (Unverified)", + client_id="a2a://ecommerce-front.com", + state={"product_sku": "Nikon-Z-50mm-f1.8", "user_intent": "seeking_reviews"}, + signature=None +) + +# 4. Marketing Agent seek insights (High Trust, Secured Scope) + +demonstrate_use_case( + title="4. Secured DB Access (Finance)", + client_id="a2a://marketing-agent.com", + state={ + "query_type": "quarterly_revenue", + "access_scope": ["read:finance_db", "user:Gulli"] + }, + signature="sig-finance-4" +) +``` + +### B. Convenience Method: AgentCard Declaration + +The `SecurePassportExtension` class provides a static method to easily generate the necessary JSON structure for including this extension in an agent's `AgentCard`. This ensures the structure is always compliant. + +```python +from secure_passport_ext import SecurePassportExtension + +# Scenario 1: Agent supports basic Secure Passport +simple_declaration = SecurePassportExtension.get_agent_card_declaration() +# Output will be: {'uri': '...', 'params': {'receivesCallerContext': True}} + +# Scenario 2: Agent supports specific keys (e.g., the Travel Agent) +travel_keys = ["destination", "loyalty_tier", "dates"] +complex_declaration = SecurePassportExtension.get_agent_card_declaration(travel_keys) +# Output will include: 'supportedStateKeys': ['destination', 'loyalty_tier', 'dates'] +``` + +## 3. How to Run the Sample 🚀 + +To run the sample and execute the comprehensive unit tests, follow these steps. + +### A. Setup and Installation + +1. **Navigate** to the Python sample directory: + ```bash + cd extensions/secure-passport/v1/samples/python + ``` +2. **Install Dependencies** (using Poetry): + ```bash + poetry install + + # Activate the virtual environment + poetry shell + ``` + +### B. Verification and Execution + +#### 1. Run Unit Tests (Recommended) + +Confirm all 11 core logic and validation tests pass: + +```bash +pytest tests/ +``` + +#### 2. Run Middleware Demo Script + +Execute `run.py` to see the full client/server middleware pipeline in action for all four use cases: + +```bash +python run.py +``` diff --git a/extensions/secure-passport/v1/samples/python/poetry.lock b/extensions/secure-passport/v1/samples/python/poetry.lock new file mode 100644 index 00000000..1287032e --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/poetry.lock @@ -0,0 +1,334 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.11.9" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, + {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.11\""} + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "cd3dd12d740734da980a168ae2b4bbe435aa15f03376e722545d5592844e475f" diff --git a/extensions/secure-passport/v1/samples/python/pyproject.toml b/extensions/secure-passport/v1/samples/python/pyproject.toml new file mode 100644 index 00000000..1106f9da --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/pyproject.toml @@ -0,0 +1,25 @@ +# pyproject.toml + +[tool.poetry] +name = "secure-passport-ext" +version = "1.0.0" +description = "A2A Protocol Extension Sample: Secure Passport for Contextual State Sharing" +authors = ["Google Octo "] +license = "Apache-2.0" + +# --- FIX: 'packages' is a key directly under [tool.poetry] --- +packages = [ + { include = "secure_passport_ext", from = "src" } +] +# ----------------------------------------------------------- + +[tool.poetry.dependencies] +python = "^3.9" +pydantic = "^2.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/run.py b/extensions/secure-passport/v1/samples/python/run.py new file mode 100644 index 00000000..5337684d --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/run.py @@ -0,0 +1,125 @@ +# run.py + +from secure_passport_ext import ( + CallerContext, + A2AMessage, # CORRECTED: Importing the standardized A2AMessage type + SecurePassportExtension # Import the extension utility class +) + +# --- Define Mock Handlers for the Pipeline --- + +def mock_transport_send(message: A2AMessage): # CORRECTED: Signature uses A2AMessage + """Mocks the final step of the client sending the message over the wire.""" + print(" [Transport] Message sent over the wire.") + return message # Returns the message the server would receive + +def mock_agent_core_handler(message: A2AMessage, passport: CallerContext | None): # CORRECTED: Signature uses A2AMessage + """ + Mocks the agent's core logic, which receives context from the Server Middleware. + """ + print(" [Agent Core] Task received for processing.") + + if passport and passport.is_verified: + # NOTE: Accessing the context attributes with snake_case + currency = passport.state.get("user_preferred_currency", "Unknown") + tier = passport.state.get("loyalty_tier", "Standard") + print(f" [Agent Core] Executing task with verified context: Currency={currency}, Tier={tier}") + elif passport and not passport.is_verified: + print(" [Agent Core] Executing task with unverified context (proceeding cautiously).") + else: + print(" [Agent Core] Executing task with no external context.") + + +def create_and_run_passport_test(client_id: str, session_id: str | None, state: dict, signature: str | None, use_case_title: str): + """ + Demonstrates a full communication cycle using the conceptual middleware. + """ + + print(f"\n--- Use Case: {use_case_title} (via Middleware) ---") + + # 1. Orchestrator (Client) creates the Passport + client_passport = CallerContext( + client_id=client_id, + session_id=session_id, + signature=signature, + state=state + ) + + # Mock A2A Message Container + client_message = A2AMessage() + + # --- CLIENT-SIDE PIPELINE --- + print(" [PIPELINE] Client Side: Middleware -> Transport") + + message_over_wire = SecurePassportExtension.client_middleware( + next_handler=mock_transport_send, + message=client_message, + context=client_passport + ) + + # --- SERVER-SIDE PIPELINE --- + print(" [PIPELINE] Server Side: Middleware -> Agent Core") + + # Server Middleware is executed, wrapping the Agent Core Handler. + SecurePassportExtension.server_middleware( + next_handler=mock_agent_core_handler, + message=message_over_wire + ) + + +def run_all_samples(): + print("=========================================================") + print(" Secure Passport Extension Demo (Middleware)") + print("=========================================================") + + # --- Use Case 1: Efficient Currency Conversion (High Trust Example) --- + create_and_run_passport_test( + client_id="a2a://travel-orchestrator.com", + session_id=None, + state={"user_preferred_currency": "GBP", "loyalty_tier": "Silver"}, + signature="sig-currency-1", + use_case_title="Efficient Currency Conversion" + ) + + # --- Use Case 2: Personalized Travel Booking (High Context Example) --- + create_and_run_passport_test( + client_id="a2a://travel-portal.com", + session_id="travel-booking-session-999", + state={ + "destination": "Bali, Indonesia", + "loyalty_tier": "Platinum" + }, + signature="sig-travel-2", + use_case_title="Personalized Travel Booking" + ) + + # --- Use Case 3: Proactive Retail Assistance (Unsigned/Low Trust Example) --- + create_and_run_passport_test( + client_id="a2a://ecommerce-front.com", + session_id="cart-session-404", + state={ + "product_sku": "Nikon-Z-50mm-f1.8", + "user_intent": "seeking_reviews" + }, + signature=None, # Explicitly missing signature + use_case_title="Proactive Retail Assistance" + ) + + # --- Use Case 4: Marketing Agent seek insights (Secured Scope Example) --- + create_and_run_passport_test( + client_id="a2a://marketing-agent.com", + session_id=None, + state={ + "query_type": "quarterly_revenue", + "access_scope": ["read:finance_db"] + }, + signature="sig-finance-4", + use_case_title="Marketing Agent seek insights" + ) + + +if __name__ == "__main__": + run_all_samples() + + + \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py new file mode 100644 index 00000000..918396f4 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py @@ -0,0 +1,108 @@ +from typing import Optional, Dict, Any, List, Callable +from pydantic import BaseModel, Field, ValidationError, ConfigDict +from copy import deepcopy + +# --- Extension Definition --- + +SECURE_PASSPORT_URI = "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport" + +class CallerContext(BaseModel): + """ + The Secure Passport payload containing contextual state shared by the calling agent. + """ + # *** CORE CHANGE: agent_id renamed to client_id *** + client_id: str = Field(..., alias='clientId', description="The verifiable unique identifier of the calling client.") + signature: Optional[str] = Field(None, alias='signature', description="A cryptographic signature of the 'state' payload.") + session_id: Optional[str] = Field(None, alias='sessionId', description="A session or conversation identifier for continuity.") + state: Dict[str, Any] = Field(..., description="A free-form JSON object containing the contextual data.") + + # Use ConfigDict for Pydantic V2 compatibility and configuration + model_config = ConfigDict( + populate_by_name=True, + extra='forbid' + ) + + @property + def is_verified(self) -> bool: + """ + Conceptually checks if the passport contains a valid signature. + """ + return self.signature is not None + +# --- Helper Functions (Core Protocol Interaction) --- + +class BaseA2AMessage(BaseModel): + metadata: Dict[str, Any] = Field(default_factory=dict) + +try: + from a2a.types import A2AMessage +except ImportError: + A2AMessage = BaseA2AMessage + +def add_secure_passport(message: A2AMessage, context: CallerContext) -> None: + """Adds the Secure Passport (CallerContext) to the message's metadata.""" + + message.metadata[SECURE_PASSPORT_URI] = context.model_dump(by_alias=True, exclude_none=True) + +def get_secure_passport(message: A2AMessage) -> Optional[CallerContext]: + """Retrieves and validates the Secure Passport from the message metadata.""" + passport_data = message.metadata.get(SECURE_PASSPORT_URI) + if not passport_data: + return None + + try: + return CallerContext.model_validate(deepcopy(passport_data)) + except ValidationError as e: + import logging + logging.warning(f"ERROR: Received malformed Secure Passport data. Ignoring payload: {e}") + return None + +# ====================================================================== +# Convenience and Middleware Concepts +# ====================================================================== + +class SecurePassportExtension: + """ + A conceptual class containing static methods for extension utilities + and defining middleware layers for seamless integration. + """ + @staticmethod + def get_agent_card_declaration(supported_state_keys: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Generates the JSON structure required to declare support for this + extension in an A2A AgentCard. + """ + declaration = { + "uri": SECURE_PASSPORT_URI, + "params": {} + } + if supported_state_keys: + declaration["params"]["supportedStateKeys"] = supported_state_keys + + return declaration + + @staticmethod + def client_middleware(next_handler: Callable[[A2AMessage], Any], message: A2AMessage, context: CallerContext): + """ + [Conceptual Middleware Layer: Client/Calling Agent] + """ + # ACCESS UPDATED: Use context.client_id + print(f"[Middleware: Client] Attaching Secure Passport for {context.client_id}") + add_secure_passport(message, context) + return next_handler(message) + + @staticmethod + def server_middleware(next_handler: Callable[[A2AMessage, Optional[CallerContext]], Any], message: A2AMessage): + """ + [Conceptual Middleware Layer: Server/Receiving Agent] + """ + passport = get_secure_passport(message) + + if passport: + print(f"[Middleware: Server] Extracted Secure Passport. Verified: {passport.is_verified}") + else: + print("[Middleware: Server] No Secure Passport found or validation failed.") + + return next_handler(message, passport) + + diff --git a/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed new file mode 100644 index 00000000..339827c3 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed @@ -0,0 +1 @@ +# This file is intentionally left empty. diff --git a/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py b/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py new file mode 100644 index 00000000..b082b1d3 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py @@ -0,0 +1,186 @@ +import pytest +from secure_passport_ext import ( + CallerContext, + A2AMessage, + add_secure_passport, + get_secure_passport, + SECURE_PASSPORT_URI, +) + +# ====================================================================== +## Fixtures for Core Tests +# ====================================================================== + +@pytest.fixture +def valid_passport_data(): + """ + Returns a dictionary for creating a valid CallerContext. + Keys are snake_case to align with the final CallerContext model attributes. + """ + return { + "client_id": "a2a://orchestrator.com", # CORRECTED: Changed agent_id to client_id + "session_id": "session-123", + "state": {"currency": "USD", "tier": "silver"}, + "signature": "mock-signature-xyz" + } + +# ====================================================================== +## Core Functionality Tests +# ====================================================================== + +def test_add_and_get_passport_success(valid_passport_data): + """Tests successful serialization and deserialization in a round trip.""" + passport = CallerContext(**valid_passport_data) + message = A2AMessage() + + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved is not None + assert retrieved.client_id == "a2a://orchestrator.com" # CORRECTED: Access via client_id + assert retrieved.state == {"currency": "USD", "tier": "silver"} + +def test_get_passport_when_missing(): + """Tests retrieving a passport from a message that doesn't have one.""" + message = A2AMessage() + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_validation_failure_missing_required_field(valid_passport_data): + """Tests validation fails when a required field (client_id) is missing.""" + invalid_data = valid_passport_data.copy() + del invalid_data['client_id'] # CORRECTED: Deleting client_id key + + message = A2AMessage() + message.metadata[SECURE_PASSPORT_URI] = invalid_data + + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_validation_failure_extra_field(valid_passport_data): + """Tests validation fails when an unknown field is present (due to extra='forbid').""" + invalid_data = valid_passport_data.copy() + invalid_data['extra_field'] = 'unsupported' + + message = A2AMessage() + message.metadata[SECURE_PASSPORT_URI] = invalid_data + + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_is_verified_with_signature(valid_passport_data): + """Tests that the is_verified property is True when a signature is present.""" + passport = CallerContext(**valid_passport_data) + assert passport.is_verified is True + +def test_passport_is_unverified_without_signature(valid_passport_data): + """Tests that the is_verified property is False when the signature is missing.""" + data_without_sig = valid_passport_data.copy() + data_without_sig['signature'] = None + passport = CallerContext(**data_without_sig) + assert passport.is_verified is False + +def test_retrieved_passport_is_immutable_from_message_data(valid_passport_data): + """Tests that modifying the retrieved copy's state does not change the original message metadata (due to deepcopy).""" + passport = CallerContext(**valid_passport_data) + message = A2AMessage() + add_secure_passport(message, passport) + + retrieved = get_secure_passport(message) + retrieved.state['new_key'] = 'changed_value' + + original_data = message.metadata[SECURE_PASSPORT_URI]['state'] + + assert 'new_key' not in original_data + assert original_data['currency'] == 'USD' + + +# ====================================================================== +## Use Case Integration Tests +# ====================================================================== + +def test_use_case_1_currency_conversion(): + """Verifies the structure for passing a user's currency preference.""" + state_data = { + "user_preferred_currency": "GBP", + "user_id": "U001" + } + + passport = CallerContext( + client_id="a2a://travel-orchestrator.com", # CORRECTED: Using client_id keyword + state=state_data, + signature="sig-currency-1" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("user_preferred_currency") == "GBP" + assert retrieved.is_verified is True + +def test_use_case_2_personalized_travel_booking(): + """Verifies the structure for passing detailed session and loyalty data.""" + state_data = { + "destination": "Bali, Indonesia", + "dates": "2025-12-01 to 2025-12-15", + "loyalty_tier": "Platinum" + } + + passport = CallerContext( + client_id="a2a://travel-portal.com", # CORRECTED: Using client_id keyword + session_id="travel-booking-session-999", + state=state_data, + signature="sig-travel-2" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.session_id == "travel-booking-session-999" + assert retrieved.state.get("loyalty_tier") == "Platinum" + assert retrieved.is_verified is True + +def test_use_case_3_proactive_retail_assistance(): + """Verifies the structure for passing product context for assistance.""" + state_data = { + "product_sku": "Nikon-Z-50mm-f1.8", + "cart_status": "in_cart", + "user_intent": "seeking_reviews" + } + + passport = CallerContext( + client_id="a2a://ecommerce-front.com", # CORRECTED: Using client_id keyword + state=state_data, + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("product_sku") == "Nikon-Z-50mm-f1.8" + assert retrieved.is_verified is False + assert retrieved.session_id is None + +def test_use_case_4_secured_db_insights(): + """Verifies the structure for passing required request arguments for a secured DB/ERP agent.""" + state_data = { + "query_type": "quarterly_revenue", + "time_period": {"start": "2025-07-01", "end": "2025-09-30"}, + "access_scope": ["read:finance_db", "user:Gulli"] + } + + passport = CallerContext( + client_id="a2a://marketing-agent.com", # CORRECTED: Using client_id keyword + state=state_data, + signature="sig-finance-4" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("query_type") == "quarterly_revenue" + assert "read:finance_db" in retrieved.state.get("access_scope") + assert retrieved.is_verified is True diff --git a/extensions/secure-passport/v1/spec.md b/extensions/secure-passport/v1/spec.md new file mode 100644 index 00000000..0e8c984d --- /dev/null +++ b/extensions/secure-passport/v1/spec.md @@ -0,0 +1,133 @@ +# A2A Protocol Extension: Secure Passport (v1) + +- **URI:** `https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport` +- **Type:** Profile Extension / Data-Only Extension +- **Version:** 1.0.0 + +## Abstract + +This extension enables an Agent2Agent (A2A) client to securely and optionally share a structured, verifiable contextual state—the **Secure Passport**—with the callee agent. This context is intended to transform anonymous A2A calls into trusted, context-aware partnerships. + +## 1. Structure and Flow Overview + +The Secure Passport is the core payload (`CallerContext`), which enables a simple, two-part request flow designed for efficiency and trust. + +### A. Primary Payload Fields and Significance + +The `CallerContext` object is placed in the message metadata and must contain the following fields: + +| Field | Significance | +| :--- | :--- | +| **`clientId`** | **Identity:** Uniquely identifies the client/agent originating the context. | +| **`state`** | **Context:** Contains the custom, structured data needed to fulfill the request without further questions. | +| **`signature`** | **Trust:** A digital signature over the `state`, allowing the receiver to cryptographically verify data integrity and origin. | + +### B. Expected Request Flow + +The extension defines two points of interaction (which should typically be handled by SDK middleware): + +1. **Client-Side (Attaching):** The client generates the `CallerContext` (including the signature, if required for high-trust) and inserts the entire payload into he A2A message's metadata map. +2. **Server-Side (Extracting):** The callee agent extracts the `CallerContext` from the metadata, validates the signature, and uses the `state` object to execute the task. + +*** + +## 2. Agent Declaration and Negotiation + +An A2A Agent that is capable of **receiving** and utilizing the Secure Passport context **MUST** declare its support in its `AgentCard` under the **`extensions`** part of the `AgentCapabilities` object. + +### Example AgentCard Declaration + +The callee agent uses the `supportedStateKeys` array to explicitly declare which contextual data keys it understands and is optimized to use. + +```json +{ + "uri": "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport", + "params": { + "supportedStateKeys": ["user_preferred_currency", "loyalty_tier"] + } +} +``` + +## 3. Data Structure: CallerContext Payload + +The `callerContext` object is the Secure Passport payload. It is **optional** and is included in the `metadata` map of a core A2A message structure. + +| Field | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **`clientId`** | `string` | Yes | The verifiable unique identifier of the calling agent. | +| **`signature`** | `string` | No | A digital signature of the entire `state` object, signed by the calling agent's private key, used for cryptographic verification of trust. | +| **`sessionId`** | `string` | No | A session or conversation identifier to maintain thread continuity. | +| **`state`** | `object` | Yes | A free-form JSON object containing the contextual data (e.g., user preferences, loyalty tier). | + +### Example CallerContext Payload + +```json +{ + "clientId": "a2a://orchestrator-agent.com", + "sessionId": "travel-session-xyz", + "signature": "MOCK-SIG-123456...", + "state": { + "user_preferred_currency": "GBP", + "loyalty_tier": "Gold" + } +} +``` + +## 4. Message Augmentation and Example Usage + +The `CallerContext` payload is embedded directly into the `metadata` map of the A2A `Message` object. The key used **MUST** be the extension's URI: `https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport`. + +### Example A2A Message Request (Simplified) + +This example shows the request body for an A2A `tasks/send` RPC call. + +```json +{ + "jsonrpc": "2.0", + "id": "req-123", + "method": "tasks/send", + "params": { + "message": { + "messageId": "msg-456", + "role": "user", + "parts": [ + {"kind": "text", "content": "Book a flight for me."} + ], + "metadata": { + "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport": { + "clientId": "a2a://orchestrator-agent.com", + "sessionId": "travel-session-xyz", + "signature": "MOCK-SIG-123456...", + "state": { + "user_preferred_currency": "GBP", + "loyalty_tier": "Gold" + } + } + } + } + } +} +``` + +## 5. Implementation Notes and Best Practices + +This section addresses the use of SDK helpers and conceptual implementation patterns. + +### SDK Helper Methods + +For development efficiency, A2A SDKs **SHOULD** provide convenience methods for this extension, such as: + +* **AgentCard Utility:** A method to automatically generate the necessary JSON structure for the AgentCard declaration. +* **Attachment/Extraction:** Simple functions or methods to add (`add_secure_passport`) and retrieve (`get_secure_passport`) the payload from a message object. + +### Conceptual Middleware Layer + +The most robust integration for the Secure Passport involves a **middleware layer** in the A2A SDK: + +* **Client Middleware:** Executes immediately before transport, automatically **attaching** the signed `CallerContext` to the message metadata. +* **Server Middleware:** Executes immediately upon receiving the message, **extracting** the `CallerContext`, performing the cryptographic verification, and injecting the resulting context object into the client's execution environment. + +### Security and Callee Behavior + +1. **Verification:** A callee agent **SHOULD** verify the provided **`signature`** before relying on the `state` content for high-privilege actions. +2. **Sensitive Data:** Agents **MUST NOT** include sensitive or mutable data in the `state` object unless robust, end-to-end cryptographic verification is implemented and required by the callee. From d01ac65352488565125994839e300cb97a35920a Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:01:47 -0500 Subject: [PATCH 07/14] ci: Remove Biome from Super Linter (#382) --- .github/workflows/linter.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 06d7c5a8..379922ec 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -5,17 +5,21 @@ on: branches: [main] jobs: - build: + lint: name: Lint Code Base runs-on: ubuntu-latest + permissions: + contents: read + packages: read + statuses: write steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Lint Code Base + - name: GitHub Super Linter uses: super-linter/super-linter/slim@v8 env: DEFAULT_BRANCH: main @@ -44,3 +48,5 @@ jobs: VALIDATE_JUPYTER_NBQA_PYLINT: false VALIDATE_JUPYTER_NBQA_RUFF: false VALIDATE_TRIVY: false + VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false From a1a80ebabc2229c81d3f38b3b4a0e3909e33e09d Mon Sep 17 00:00:00 2001 From: gulliantonio <167304324+gulliantonio@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:27:03 +0200 Subject: [PATCH 08/14] feat: Implement AGP (Agent Gateway Protocol) for policy-based routing (#381) This PR introduces the Agent Gateway Protocol (AGP) as an architectural enhancement layer designed to solve scalability and policy-enforcement challenges in large, distributed A2A systems. The AGP shifts routing from a flat mesh to a hierarchical model, using central Gateway Agents to manage specialized Autonomous Squads (ASqs). Key Innovation: Policy-Based Routing (PBR) The core code implements PBR logic, verifying that routing decisions are driven by compliance before cost, as verified by the unit tests: 1. Policy Enforcement: The Gateway successfully routes Intents based on strict policy constraints (requires_PII: True), filtering out cheaper, non-compliant vendor routes in favor of secure internal squads. 2. Economic Optimization: When multiple routes are compliant, the Gateway correctly selects the option with the lowest announced cost. 3. Meta-Intent Coverage: The tests cover complex decomposition (e.g., breaking a single HR request into Payroll, Hardware, and Legal sub-intents). --- extensions/agp/agp_run.py | 124 ++++++ extensions/agp/poetry.lock | 391 ++++++++++++++++ extensions/agp/pyproject.toml | 25 ++ extensions/agp/spec.md | 112 +++++ extensions/agp/src/agp_protocol/__init__.py | 170 +++++++ extensions/agp/tests/test_agp.py | 470 ++++++++++++++++++++ 6 files changed, 1292 insertions(+) create mode 100644 extensions/agp/agp_run.py create mode 100644 extensions/agp/poetry.lock create mode 100644 extensions/agp/pyproject.toml create mode 100644 extensions/agp/spec.md create mode 100644 extensions/agp/src/agp_protocol/__init__.py create mode 100644 extensions/agp/tests/test_agp.py diff --git a/extensions/agp/agp_run.py b/extensions/agp/agp_run.py new file mode 100644 index 00000000..e10b50f3 --- /dev/null +++ b/extensions/agp/agp_run.py @@ -0,0 +1,124 @@ +import logging + +from agp_protocol import ( + AGPTable, + AgentGatewayProtocol, + CapabilityAnnouncement, + IntentPayload, +) + + +# Set logging level to WARNING so only our custom routing failures are visible +logging.basicConfig(level=logging.WARNING) + + +def run_simulation(): + """Simulates the core routing process of the Agent Gateway Protocol (AGP), + demonstrating Policy-Based Routing and cost optimization. + """ + # --- PHASE 1: Setup and Announcement --- + + # 1. Initialize the central routing table + corporate_agp_table = AGPTable() + + # 2. Initialize the Corporate Gateway Agent (Router) + corporate_gateway = AgentGatewayProtocol( + squad_name='Corporate_GW', agp_table=corporate_agp_table + ) + + # 3. Squads announce their capabilities to the Corporate Gateway + + print('===============================================================') + print(' AGENT GATEWAY PROTOCOL (AGP) ROUTING SIMULATION') + print('===============================================================') + print('\n--- PHASE 1: SQUAD ANNOUNCEMENTS ---') + + # --- Announcement 1: Engineering Squad (Internal, Secure) --- + # Can provision VMs, handles sensitive data (PII), but is more expensive than the external vendor. + eng_announcement = CapabilityAnnouncement( + capability='infra:provision:vm', + version='1.0', + cost=0.10, # Higher cost + policy={'security_level': 5, 'requires_PII': True}, + ) + corporate_gateway.announce_capability( + eng_announcement, path='Squad_Engineering/vm_provisioner' + ) + + # --- Announcement 2: External Vendor Squad (Cheapest, Low Security) --- + # Can provision VMs, but fails the PII check and only meets standard security. + vendor_announcement = CapabilityAnnouncement( + capability='infra:provision:vm', + version='1.1', + cost=0.05, # Lowest cost + policy={'security_level': 3, 'requires_PII': False}, + ) + corporate_gateway.announce_capability( + vendor_announcement, path='External_Vendor/vm_provisioning_api' + ) + + # --- Announcement 3: Finance Squad (Standard Analysis) --- + finance_announcement = CapabilityAnnouncement( + capability='financial_analysis:quarterly', + version='2.0', + cost=0.15, + policy={'security_level': 3, 'geo': 'US'}, + ) + corporate_gateway.announce_capability( + finance_announcement, path='Squad_Finance/analysis_tool' + ) + + # --- PHASE 2: Intent Routing Simulation --- + + print('\n--- PHASE 2: INTENT ROUTING ---') + + # Intent A: Standard VM provisioning (Cost-driven, minimal policy) + # Expected: Route to External Vendor (Cost: 0.05) because it's cheapest and complies with security_level: 3. + intent_a = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'standard', 'user': 'bob'}, + policy_constraints={'security_level': 3}, + ) + print( + '\n[Intent A] Requesting standard VM provisioning (Lowest cost, Security Level 3).' + ) + corporate_gateway.route_intent(intent_a) + + # Intent B: Sensitive VM provisioning (Policy-driven, requires PII) + # Expected: Route to Engineering Squad (Cost: 0.10) because the External Vendor (0.05) fails the PII policy. + # The router uses the sufficiency check (5 >= 5 is True). + intent_b = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'sensitive', 'user': 'alice', 'data': 'ssn_data'}, + policy_constraints={'security_level': 5, 'requires_PII': True}, + ) + print( + '\n[Intent B] Requesting sensitive VM provisioning (Requires PII and Security Level 5).' + ) + corporate_gateway.route_intent(intent_b) + + # Intent C: Requesting provisioning with security level 7 (Unmatched Policy) + # Expected: Fails because no announced route can satisfy level 7. + intent_c = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'max_security'}, + policy_constraints={'security_level': 7}, + ) + print( + '\n[Intent C] Requesting provisioning with security level 7 (Unmatched Policy).' + ) + corporate_gateway.route_intent(intent_c) + + # Intent D: Requesting HR onboarding (Unknown Capability) + # Expected: Fails because the capability was never announced. + intent_d = IntentPayload( + target_capability='hr:onboard:new_hire', + payload={'employee': 'Charlie'}, + policy_constraints={}, + ) + print('\n[Intent D] Requesting HR onboarding (Unknown Capability).') + corporate_gateway.route_intent(intent_d) + + +if __name__ == '__main__': + run_simulation() diff --git a/extensions/agp/poetry.lock b/extensions/agp/poetry.lock new file mode 100644 index 00000000..9a0ccb67 --- /dev/null +++ b/extensions/agp/poetry.lock @@ -0,0 +1,391 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "autoflake" +version = "2.3.1" +description = "Removes unused imports and unused variables" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, + {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, +] + +[package.dependencies] +pyflakes = ">=3.0.0" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.11.9" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, + {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.13.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c"}, + {file = "ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2"}, + {file = "ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989"}, + {file = "ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3"}, + {file = "ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2"}, + {file = "ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330"}, + {file = "ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e"}, +] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.11\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "afa56f22f84c1320786f16aa43157a3fbea2db221eb6e09e88de60a4f5a5ec0a" diff --git a/extensions/agp/pyproject.toml b/extensions/agp/pyproject.toml new file mode 100644 index 00000000..c6497659 --- /dev/null +++ b/extensions/agp/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "agp-protocol" +version = "1.0.0" +description = "Agent Gateway Protocol (AGP) routing layer implementation." +authors = ["Google Octo "] +license = "Apache-2.0" + +# Defines where the source code package 'agp_protocol' is located (inside the 'src' folder) +packages = [ + { include = "agp_protocol", from = "src" } +] + +[tool.poetry.dependencies] +python = "^3.9" +pydantic = "^2.0.0" + +[tool.poetry.group.dev.dependencies] +# Dependencies needed only for development and testing +pytest = "^8.0.0" +ruff = "^0.13.3" +autoflake = "^2.3.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/extensions/agp/spec.md b/extensions/agp/spec.md new file mode 100644 index 00000000..6ad88ce1 --- /dev/null +++ b/extensions/agp/spec.md @@ -0,0 +1,112 @@ +# Agent Gateway Protocol (AGP) Specification (V1) + +* **URI:** `https://github.com/a2aproject/a2a-samples/tree/main/extensions/agp` + +* **Type:** Core Protocol Layer / Routing Extension + +* **Version:** 1.0.0 + +## Abstract + +The Agent Gateway Protocol (AGP) proposes a hierarchical architecture for distributed AI systems, **enhancing the capabilities** of the flat A2A mesh by introducing a structure of interconnected Autonomous Squads (ASq). AGP routes **Intent** payloads based on declared **Capabilities**, mirroring the Border Gateway Protocol (BGP) for Internet scalability and policy enforcement. This structure divides agents into **hierarchical domains**, with each domain focusing on specific Agent Capabilities that reflect enterprise organizational needs (e.g., Finance, Engineering, HR, BUs, and so on) - gulli@google.com + +## 1. Data Structure: Capability Announcement + +This payload is used by a Squad Gateway Agent to announce the services its squad can fulfill to its peers (other squads). + +### CapabilityAnnouncement Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `capability` | string | Yes | The function or skill provided (e.g., `financial_analysis:quarterly`). | +| `version` | string | Yes | Version of the capability schema/interface (e.g., `1.5`). | +| `cost` | number | No | Estimated cost metric (e.g., `0.05` USD, or token count). | +| `policy` | object | Yes | Key-value pairs defining required policies (e.g., `requires_pii:true`, `security_level:5`). | + +### Example Announcement Payload + +```json +{ + "capability": "financial_analysis:quarterly", + "version": "1.5", + "cost": 0.05, + "policy": { + "requires_auth": "level_3" + } +} +``` + +## 2. Data Structure: Intent Payload + +This payload defines the *what* (the goal) and *constraints* (metadata), replacing a standard request. + +### Intent Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `target_capability` | string | Yes | The capability the Intent seeks to fulfill. | +| `payload` | object | Yes | The core data arguments required for the task. | +| `policy_constraints` | object | No | Client-defined constraints that must be matched against the announced `policy` during routing. | + +### Example Intent Payload + +```json +{ + "target_capability": "billing:invoice:generate", + "payload": { + "customer_id": 123, + "amount": 99.99 + }, + "policy_constraints": { + "requires_pii": true + } +} +``` + +## 3. Core Routing and Table Structures + +The protocol relies on the Gateway Agent maintaining an **AGP Table** (a routing table) built from Capability Announcements. This section defines the core structures used internally by the Gateway Agent. + +### A. RouteEntry Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `path` | string | Yes | The destination Squad/API path (e.g., `Squad_Finance/gateway`). | +| `cost` | number | Yes | The cost metric for this route (used for lowest-cost selection). | +| `policy` | object | Yes | Policies of the destination, used for matching Intent constraints. | + +### B. AGPTable Object + +The AGPTable maps a `capability` key to a list of potential `RouteEntry` objects. + +## 4. Agent Declaration and Role + +To participate in the AGP hierarchy, an A2A agent **MUST** declare its role as a Gateway and the supported AGP version within its Agent Card, using the A2A extension mechanism. + +### AgentCard AGP Declaration + +This declaration is placed within the `extensions` array of the Agent Card's `AgentCapabilities`. + +```json +{ + "uri": "https://github.com/a2aproject/a2a-samples/tree/main/extensions/agp", + "params": { + "agent_role": "gateway", + "supported_agp_versions": ["1.0"] + } +} +``` + +## 5. Extension Error Reference + +When a Gateway Agent attempts to route an Intent but fails due to policy or availability issues, it **MUST** return a JSON-RPC error with specific AGP-defined codes. + +| Code | Name | Description | Routing Consequence | + | ----- | ----- | ----- | ----- | +| **-32200** | `AGP_ROUTE_NOT_FOUND` | No agent or squad has announced the requested `target_capability`. | Intent cannot be routed; returned to sender. | +| **-32201** | `AGP_POLICY_VIOLATION` | Routes were found, but none satisfied the constraints in the Intent's `metadata` (e.g., no squad accepts PII data). | Intent cannot be routed safely; returned to sender. | +| **-32202** | `AGP_TABLE_STALE` | The Agent Gateway's routing table is outdated and needs a refresh via a standard AGP refresh mechanism. | Gateway attempts refresh before re-routing, or returns error. | + +## 6. Conclusion + +The Agent Gateway Protocol (AGP) offers a powerful and necessary enhancement layer over the foundational A2A structure. By implementing Policy-Based Routing, AGP ensures that distributed AI systems are not only efficient and financially optimized but also secure and policy-compliant—a critical step toward trustworthy, industrial-scale multi-agent collaboration. diff --git a/extensions/agp/src/agp_protocol/__init__.py b/extensions/agp/src/agp_protocol/__init__.py new file mode 100644 index 00000000..84f6b3d6 --- /dev/null +++ b/extensions/agp/src/agp_protocol/__init__.py @@ -0,0 +1,170 @@ +import logging + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# --- Core Data Structures --- + + +class CapabilityAnnouncement(BaseModel): + """Data structure for a service announcement by a Gateway Agent.""" + + capability: str = Field( + ..., + description="The function or skill provided (e.g., 'financial_analysis:quarterly').", + ) + version: str = Field(..., description='Version of the capability schema.') + cost: float | None = Field(None, description='Estimated cost metric.') + policy: dict[str, Any] = Field( + ..., + description='Key-value pairs defining required security/data policies.', + ) + + model_config = ConfigDict(extra='forbid') + + +class IntentPayload(BaseModel): + """The request payload routed by AGP.""" + + target_capability: str = Field( + ..., description='The capability the Intent seeks to fulfill.' + ) + payload: dict[str, Any] = Field( + ..., description='The core data arguments required for the task.' + ) + # FIX APPLIED: Renaming internal field to policy_constraints for clarity + policy_constraints: dict[str, Any] = Field( + default_factory=dict, + description='Client-defined constraints that must be matched against the announced policy.', + alias='policy_constraints', + ) + + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + +# --- AGP Routing Structures --- + + +class RouteEntry(BaseModel): + """A single possible route to fulfill a fulfill a capability.""" + + path: str = Field( + ..., + description="The destination Squad/API path (e.g., 'Squad_Finance/gateway').", + ) + cost: float = Field(..., description='Cost metric for this route.') + policy: dict[str, Any] = Field( + ..., + description='Policies of the destination, used for matching Intent constraints.', + ) + + +class AGPTable(BaseModel): + """The central routing table maintained by a Gateway Agent.""" + + routes: dict[str, list[RouteEntry]] = Field(default_factory=dict) + + model_config = ConfigDict(extra='forbid') + + +# --- Core AGP Routing Logic --- + + +class AgentGatewayProtocol: + """ + Simulates the core functions of an Autonomous Squad Gateway Agent. + Handles Capability Announcements and Policy-Based Intent Routing. + The primary routing logic is in _select_best_route to allow easy overriding via subclassing. + """ + + def __init__(self, squad_name: str, agp_table: AGPTable): + self.squad_name = squad_name + self.agp_table = agp_table + + def announce_capability( + self, announcement: CapabilityAnnouncement, path: str + ): + """Simulates receiving a capability announcement and updating the AGP Table.""" + entry = RouteEntry( + path=path, + cost=announcement.cost or 0.0, + policy=announcement.policy, + ) + + capability_key = announcement.capability + + # Use setdefault to initialize the list if the key is new + self.agp_table.routes.setdefault(capability_key, []).append(entry) + + print( + f'[{self.squad_name}] ANNOUNCED: {capability_key} routed via {path}' + ) + + # Protected method containing the core, overridable routing logic + def _select_best_route(self, intent: IntentPayload) -> RouteEntry | None: + """ + Performs Policy-Based Routing to find the best available squad. + + Routing Logic: + 1. Find all routes matching the target_capability. + 2. Filter routes based on matching all policy constraints (PBR). + 3. Select the lowest-cost route among the compliant options. + """ + target_cap = intent.target_capability + # CRITICAL CHANGE: Use the correct snake_case attribute name for constraints + intent_constraints = intent.policy_constraints + + if target_cap not in self.agp_table.routes: + logging.warning( + f"[{self.squad_name}] ROUTING FAILED: Capability '{target_cap}' is unknown." + ) + return None + + possible_routes = self.agp_table.routes[target_cap] + + # --- 2. Policy Filtering (Optimized using list comprehension and all()) --- + compliant_routes = [ + route + for route in possible_routes + if all( + # Check if the constraint key exists in the route policy AND the values are sufficient. + key in route.policy + and ( + # If the key is 'security_level' and both values are numeric, check for >= sufficiency. + route.policy[key] >= value + if key == 'security_level' + and isinstance(route.policy.get(key), (int, float)) + and isinstance(value, (int, float)) + # Otherwise (e.g., boolean flags like 'requires_PII'), require exact equality. + else route.policy[key] == value + ) + for key, value in intent_constraints.items() + ) + ] + + if not compliant_routes: + logging.warning( + f'[{self.squad_name}] ROUTING FAILED: No compliant route found for constraints: {intent_constraints}' + ) + return None + + # --- 3. Best Route Selection (Lowest Cost) --- + best_route = min(compliant_routes, key=lambda r: r.cost) + + return best_route + + # Public method that is typically called by the A2A endpoint + def route_intent(self, intent: IntentPayload) -> RouteEntry | None: + """ + Public entry point for routing an Intent payload. + Calls the internal selection logic and prints the result. + """ + best_route = self._select_best_route(intent) + + if best_route: + print( + f"[{self.squad_name}] ROUTING SUCCESS: Intent for '{intent.target_capability}' routed to {best_route.path} (Cost: {best_route.cost})" + ) + return best_route diff --git a/extensions/agp/tests/test_agp.py b/extensions/agp/tests/test_agp.py new file mode 100644 index 00000000..083986a6 --- /dev/null +++ b/extensions/agp/tests/test_agp.py @@ -0,0 +1,470 @@ +import pytest + +from agp_protocol import ( + AGPTable, + AgentGatewayProtocol, + CapabilityAnnouncement, + IntentPayload, + RouteEntry, +) + + +# --- Fixtures for Routing Table Setup --- + + +@pytest.fixture +def all_available_routes() -> list[RouteEntry]: + """Defines a list of heterogeneous routes covering all capabilities needed for testing.""" + return [ + # 1. Base License/Legal Route (Security Level 3, Geo US) - Cost 0.20 + RouteEntry( + path='Squad_Legal/licensing_api', + cost=0.20, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 2. Secure/PII Route (Security Level 5, PII Handling True, Geo US) - Cost 0.10 + RouteEntry( + path='Squad_Finance/payroll_service', + cost=0.10, + policy={'security_level': 5, 'requires_pii': True, 'geo': 'US'}, + ), + # 3. External Route (Cheapest, Low Security, Geo EU) - Cost 0.05 + RouteEntry( + path='Vendor_EU/proxy_gateway', + cost=0.05, + policy={'security_level': 1, 'geo': 'EU'}, + ), + # 4. Hardware Provisioning Route (Engineering, Security Level 3, Geo US) - Cost 0.08 + RouteEntry( + path='Squad_Engineering/hardware_tool', + cost=0.08, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 5. NDA Contract Generation Route (Legal, Security Level 3, Geo US) - Cost 0.15 + RouteEntry( + path='Squad_Legal/contracts_tool', + cost=0.15, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 6. Low-Cost US Route (Security Level 2, Geo US) - Cost 0.07 + RouteEntry( + path='Vendor_US/data_service', + cost=0.07, + policy={'security_level': 2, 'geo': 'US'}, + ), + # 7. Zero-Cost Internal Route (Security Level 3, Geo US) - Cost 0.00 (NEW) + RouteEntry( + path='Internal/Free_Cache', + cost=0.00, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 8. High-Cost Geo EU Route (Security Level 4, Geo EU) - Cost 0.30 (NEW) + RouteEntry( + path='Vendor_Secure_EU/proxy_gateway', + cost=0.30, + policy={'security_level': 4, 'geo': 'EU'}, + ), + ] + + +@pytest.fixture +def populated_agp_table(all_available_routes) -> AGPTable: + """Creates an AGPTable populated with routes for all test capabilities.""" + table = AGPTable() + + # Routes for Core Routing Tests (Tests 1-19 use 'procure:license') + table.routes['procure:license'] = [ + all_available_routes[0], + all_available_routes[1], + all_available_routes[2], + all_available_routes[5], + all_available_routes[6], # Zero Cost Route + all_available_routes[7], # Secure EU Route + ] + + # Routes for Decomposition Test (Test 6) + table.routes['provision:hardware'] = [all_available_routes[3]] + table.routes['provision:payroll'] = [all_available_routes[1]] + table.routes['contract:nda:generate'] = [all_available_routes[4]] + + return table + + +@pytest.fixture +def gateway(populated_agp_table) -> AgentGatewayProtocol: + """Provides a configured Gateway Agent instance for testing.""" + return AgentGatewayProtocol( + squad_name='Test_Gateway', agp_table=populated_agp_table + ) + + +# --- Test Scenarios (19 Total Tests) --- + + +def test_01_lowest_cost_compliant_route_with_sufficiency( + gateway: AgentGatewayProtocol, +): + """ + Verifies routing selects the lowest cost COMPLIANT route, checking for sufficiency (>=). + Constraint: security_level: 3, geo: US. Route 7 (Cost 0.00) is the cheapest compliant route. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Standard License'}, + policy_constraints={'security_level': 3, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_02_policy_filtering_sensitive_data(gateway: AgentGatewayProtocol): + """ + Verifies strict policy filtering excludes non-compliant routes regardless of cost. + Constraint: requires_pii: True. Only Route 2 complies (Cost 0.10). + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Client Data License'}, + policy_constraints={'requires_pii': True}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_03_route_not_found(gateway: AgentGatewayProtocol): + """Tests routing failure when the target capability is not in the AGPTable.""" + intent = IntentPayload( + target_capability='unknown:capability', payload={'data': 'test'} + ) + best_route = gateway.route_intent(intent) + assert best_route is None + + +def test_04_policy_violation_unmatched_constraint( + gateway: AgentGatewayProtocol, +): + """ + Tests routing failure when the Intent imposes a constraint that no announced route can meet. + Constraint: security_level: 7. No route announces level 7 or higher. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Executive Access'}, + policy_constraints={'security_level': 7}, + ) + best_route = gateway.route_intent(intent) + assert best_route is None + + +def test_05_announcement_updates_table(gateway: AgentGatewayProtocol): + """Tests that announce_capability correctly adds a new entry to the AGPTable.""" + announcement = CapabilityAnnouncement( + capability='test:add:new', + version='1.0', + cost=1.0, + policy={'test': True, 'security_level': 1}, + ) + path = 'TestSquad/target' + + # Check table before announcement + assert 'test:add:new' not in gateway.agp_table.routes + + gateway.announce_capability(announcement, path) + + # Check table after announcement + assert 'test:add:new' in gateway.agp_table.routes + assert len(gateway.agp_table.routes['test:add:new']) == 1 + assert gateway.agp_table.routes['test:add:new'][0].path == path + + +def test_06_meta_intent_decomposition(gateway: AgentGatewayProtocol): + """ + Simulates the Corporate Enterprise flow: decomposition into three sub-intents + and verifies each sub-intent routes to the correct specialist squad based on policies. + """ + + # 1. Hardware Sub-Intent (Standard Engineering Task, requires level 3) + intent_hardware = IntentPayload( + target_capability='provision:hardware', + payload={'developer': 'Alice'}, + policy_constraints={'security_level': 3}, + ) + route_hw = gateway.route_intent(intent_hardware) + assert route_hw is not None + assert route_hw.path == 'Squad_Engineering/hardware_tool' + + # 2. Payroll Sub-Intent (Requires PII Handling - must go to secure Finance squad) + intent_payroll = IntentPayload( + target_capability='provision:payroll', + payload={'salary': 100000}, + policy_constraints={'requires_pii': True, 'security_level': 3}, + ) + route_payroll = gateway.route_intent(intent_payroll) + assert route_payroll is not None + assert route_payroll.path == 'Squad_Finance/payroll_service' + + # 3. Legal Sub-Intent (Simple route for contract:nda:generate, requires level 3) + intent_legal = IntentPayload( + target_capability='contract:nda:generate', + payload={'contract_type': 'NDA'}, + policy_constraints={'security_level': 3}, + ) + route_legal = gateway.route_intent(intent_legal) + assert route_legal is not None + assert route_legal.path == 'Squad_Legal/contracts_tool' + + +# --- NEW SECURITY AND COMPLIANCE TESTS --- + + +def test_07_geo_fencing_violation(gateway: AgentGatewayProtocol): + """ + Tests routing failure when an Intent requires US processing, but the cheapest route is EU-locked. + Constraint: geo: US. External Vendor (Cost 0.05, EU) fails geo-check. + Expected: Routed to cheapest compliant US vendor (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'data': 'US-user-request'}, + policy_constraints={'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_08_required_security_tier_sufficiency(gateway: AgentGatewayProtocol): + """ + Tests routing when a request requires a moderate security level (4). + The router must choose Route 2 (Level 5) because Route 1 (Level 3) and Route 6 (Level 2) fail the sufficiency check. + Constraint: security_level: 4. + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'data': 'moderate_access'}, + policy_constraints={'security_level': 4}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_09_policy_chaining_cost_after_geo(gateway: AgentGatewayProtocol): + """ + Tests routing for a complex chain: Intent requires US geo AND Level 2 security. + Compliant routes: Route 7 (0.00, L3), Route 6 (0.07, L2), Route 2 (0.10, L5), Route 1 (0.20, L3). + Expected: Cheapest compliant US route (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_data_pull'}, + policy_constraints={'security_level': 2, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_10_zero_cost_priority(gateway: AgentGatewayProtocol): + """ + Tests that the absolute cheapest route (Cost 0.00) is prioritized when compliant. + Constraint: security_level: 3, geo: US. Route 7 (Cost 0.00) meets the need. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'cache_check'}, + policy_constraints={'security_level': 3, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_11_minimum_security_level_one_selection(gateway: AgentGatewayProtocol): + """ + Tests routing for the absolute lowest security requirement. + Constraint: security_level: 1. Route 7 (Cost 0.00) is the cheapest compliant route. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + # NOTE: All routes are compliant (L1, L3, L5, L2, L3, L3, L4). Cheapest is Route 7 (Cost 0.00). + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'public_data_access'}, + policy_constraints={'security_level': 1}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_12_strict_geo_exclusion(gateway: AgentGatewayProtocol): + """ + Tests routing failure when requested geo (NA) is not available anywhere. + Constraint: geo: NA. No route advertises 'NA'. + Expected: Fails to route. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'NA_access'}, + policy_constraints={'geo': 'NA'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is None + + +def test_13_cost_tie_breaker(gateway: AgentGatewayProtocol): + """ + Tests the tie-breaker mechanism when two compliant routes have the exact same cost. + Constraint: security_level: 5, geo: US. Only Route 2 (Cost 0.10, Level 5) is compliant. + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'high_security_check'}, + policy_constraints={'security_level': 5, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_14_no_constraint_default_cheapest(gateway: AgentGatewayProtocol): + """ + Tests routing when the Intent provides no constraints (empty metadata). + Expected: Router must select the absolute cheapest route available (Route 7, Cost 0.00). + """ + # NOTE: Route 7 (Cost 0.00) is the cheapest overall. + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_unsecured'}, + policy_constraints={}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_15_compound_exclusion(gateway: AgentGatewayProtocol): + """ + Tests routing failure when two mandatory constraints cannot be met by the same route. + Constraint: geo: EU AND security_level: 5. + Expected: Failure (Route 8 is EU but only L4; Route 2 is L5 but US). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'EU_secure_data'}, + policy_constraints={'geo': 'EU', 'security_level': 5}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is None + + +def test_16_decomposition_check_pii_only_route(gateway: AgentGatewayProtocol): + """ + Verifies that the decomposition test logic for Payroll correctly chooses the PII-handling route. + This is a redundant check to ensure Test 06's complexity is fully stable. + """ + intent_payroll = IntentPayload( + target_capability='provision:payroll', + payload={'salary': 100000}, + policy_constraints={'requires_pii': True, 'security_level': 3}, + ) + route_payroll = gateway.route_intent(intent_payroll) + assert route_payroll is not None + assert route_payroll.path == 'Squad_Finance/payroll_service' + + +def test_17_cost_wins_after_sufficiency_filter(gateway: AgentGatewayProtocol): + """ + Tests that after filtering for sufficiency (Level >= 2), the cheapest route is chosen. + Compliant routes: Route 7 (0.00, L3), Route 6 (0.07, L2), Route 2 (0.10, L5), Route 1 (0.20, L3). + Expected: Cheapest compliant route (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_data_pull'}, + policy_constraints={'security_level': 2}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_18_sufficiency_check_for_level_1_route_wins( + gateway: AgentGatewayProtocol, +): + """ + Tests that a request for L1 security is satisfied by the cheapest overall route (L1, 0.05). + Constraint: security_level: 1. + Expected: Router must select the absolute cheapest route available (Route 7, Cost 0.00). + """ + # NOTE: All routes are L1 or higher. Cheapest is Route 7 (Cost 0.00). + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'lowest_security'}, + policy_constraints={'security_level': 1}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_19_compound_geo_and_sufficiency_win(gateway: AgentGatewayProtocol): + """ + Tests a chain of filters: Needs geo: US AND security_level: 5. + Expected: Route 2 (Cost 0.10) is the only one that meets both. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'US_secure_finance'}, + policy_constraints={'security_level': 5, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 From 834c1e7b843115d94722668b1ce27c0584dfda6e Mon Sep 17 00:00:00 2001 From: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:53:26 -0500 Subject: [PATCH 09/14] ci: Remove lint checks for Zizmor and AGP --- .github/workflows/linter.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 379922ec..29099b9a 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -27,7 +27,7 @@ jobs: LOG_LEVEL: WARN SHELLCHECK_OPTS: -e SC1091 -e 2086 VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/).*" + FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/).*|CODE_OF_CONDUCT.md|(extensions/agp/).*" VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false VALIDATE_PYTHON_ISORT: false @@ -50,3 +50,4 @@ jobs: VALIDATE_TRIVY: false VALIDATE_BIOME_FORMAT: false VALIDATE_BIOME_LINT: false + VALIDATE_GITHUB_ACTIONS_ZIZMOR: false From 3b98b0f6eaad811b99a682c37445714ee8f7fc00 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 24 Oct 2025 14:24:09 -0400 Subject: [PATCH 10/14] feat: Add a sample that shows how to configure A2A client and A2A server security for all three transports with the A2A Java SDK (#390) # Description This PR adds a new sample that demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when sending requests. The agent is written using Quarkus LangChain4j and makes use of the [A2A Java](https://github.com/a2aproject/a2a-java) SDK. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). --- samples/java/agents/README.md | 3 + samples/java/agents/content_editor/pom.xml | 45 +--- samples/java/agents/content_writer/pom.xml | 45 +--- .../samples/a2a/client/TestClientRunner.java | 6 +- .../agents/dice_agent_multi_transport/pom.xml | 72 +----- .../agents/magic_8_ball_security/README.md | 120 +++++++++ .../magic_8_ball_security/client/pom.xml | 74 ++++++ .../KeycloakOAuth2CredentialService.java | 55 +++++ .../com/samples/a2a/client/TestClient.java | 119 +++++++++ .../samples/a2a/client/TestClientRunner.java | 231 ++++++++++++++++++ .../com/samples/a2a/client/package-info.java | 2 + .../samples/a2a/client/util/CachedToken.java | 91 +++++++ .../a2a/client/util/EventHandlerUtil.java | 118 +++++++++ .../samples/a2a/client/util/KeycloakUtil.java | 95 +++++++ .../samples/a2a/client/util/package-info.java | 2 + .../client/src/main/resources/keycloak.json | 9 + .../java/agents/magic_8_ball_security/pom.xml | 20 ++ .../magic_8_ball_security/server/.env.example | 1 + .../magic_8_ball_security/server/pom.xml | 65 +++++ .../java/com/samples/a2a/Magic8BallAgent.java | 41 ++++ .../a2a/Magic8BallAgentCardProducer.java | 96 ++++++++ .../a2a/Magic8BallAgentExecutorProducer.java | 118 +++++++++ .../java/com/samples/a2a/Magic8BallTools.java | 59 +++++ .../java/com/samples/a2a/package-info.java | 2 + .../src/main/resources/application.properties | 7 + samples/java/agents/pom.xml | 87 +++++++ samples/java/agents/weather_mcp/pom.xml | 45 +--- 27 files changed, 1442 insertions(+), 186 deletions(-) create mode 100644 samples/java/agents/magic_8_ball_security/README.md create mode 100644 samples/java/agents/magic_8_ball_security/client/pom.xml create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java create mode 100644 samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json create mode 100644 samples/java/agents/magic_8_ball_security/pom.xml create mode 100644 samples/java/agents/magic_8_ball_security/server/.env.example create mode 100644 samples/java/agents/magic_8_ball_security/server/pom.xml create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java create mode 100644 samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties create mode 100644 samples/java/agents/pom.xml diff --git a/samples/java/agents/README.md b/samples/java/agents/README.md index 2a3d80aa..d405553a 100644 --- a/samples/java/agents/README.md +++ b/samples/java/agents/README.md @@ -19,6 +19,9 @@ Each agent can be run as its own A2A server with the instructions in its README. Sample agent that can roll dice of different sizes and check if numbers are prime. This agent demonstrates multi-transport capabilities. +* [**Magic 8 Ball Agent (Security)**](magic_8_ball_security/README.md) + Sample agent that can respond to yes/no questions by consulting a Magic 8 Ball. This sample demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when sending requests. + ## Disclaimer Important: The sample code provided is for demonstration purposes and illustrates the diff --git a/samples/java/agents/content_editor/pom.xml b/samples/java/agents/content_editor/pom.xml index abd1e323..afe616fa 100644 --- a/samples/java/agents/content_editor/pom.xml +++ b/samples/java/agents/content_editor/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - content-editor - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Beta1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + content-editor @@ -57,25 +39,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - diff --git a/samples/java/agents/content_writer/pom.xml b/samples/java/agents/content_writer/pom.xml index 548523e6..2a371e69 100644 --- a/samples/java/agents/content_writer/pom.xml +++ b/samples/java/agents/content_writer/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - content-writer - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Beta1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + content-writer @@ -57,25 +39,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java index bb5a1d77..44f7f6e7 100644 --- a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -1,7 +1,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta1 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta1 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta1 +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta2 //DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 //DEPS io.grpc:grpc-netty-shaded:1.69.1 //SOURCES TestClient.java diff --git a/samples/java/agents/dice_agent_multi_transport/pom.xml b/samples/java/agents/dice_agent_multi_transport/pom.xml index a9354c06..a2cc4a33 100644 --- a/samples/java/agents/dice_agent_multi_transport/pom.xml +++ b/samples/java/agents/dice_agent_multi_transport/pom.xml @@ -4,77 +4,17 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a + + com.samples.a2a + agents-parent + 0.1.0 + + dice-agent-multi-transport - 0.1.0 pom server client - - - 17 - 17 - UTF-8 - 4.31.1 - 0.3.0.Beta1 - 4.1.0 - 3.26.1 - 1.0.0 - - - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - - com.google.protobuf - protobuf-java - ${protobuf.version} - - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - - 17 - - - - io.quarkus - quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - - diff --git a/samples/java/agents/magic_8_ball_security/README.md b/samples/java/agents/magic_8_ball_security/README.md new file mode 100644 index 00000000..fe42084f --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/README.md @@ -0,0 +1,120 @@ +# Magic 8-Ball Security Agent + +This sample agent responds to yes/no questions by consulting a Magic 8-Ball. + +This sample demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when +sending requests. The agent is written using Quarkus LangChain4j and makes use of the +[A2A Java](https://github.com/a2aproject/a2a-java) SDK. + +## Prerequisites + +- Java 17 or higher +- Access to an LLM and API Key +- A working container runtime (Docker or [Podman](https://quarkus.io/guides/podman)) + +>**NOTE**: We'll be making use of Quarkus Dev Services in this sample to automatically create and configure a Keycloak instance that we'll use as our OAuth2 provider. For more details on using Podman with Quarkus, see this [guide](https://quarkus.io/guides/podman). + +## Running the Sample + +This sample consists of an A2A server agent, which is in the `server` directory, and an A2A client, +which is in the `client` directory. + +### Running the A2A Server Agent + +1. Navigate to the `magic-8-ball-security` sample directory: + + ```bash + cd samples/java/agents/magic-8-ball-security/server + ``` + +2. Set your Google AI Studio API Key as an environment variable: + + ```bash + export QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + + Alternatively, you can create a `.env` file in the `magic-8-ball-security/server` directory: + + ```bash + QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + +3. Start the A2A server agent + + **NOTE:** + By default, the agent will start on port 11000. To override this, add the `-Dquarkus.http.port=YOUR_PORT` + option at the end of the command below. + + ```bash + mvn quarkus:dev + ``` + +### Running the A2A Java Client + +The Java `TestClient` communicates with the Magic 8-Ball Agent using the A2A Java SDK. + +The client supports specifying which transport protocol to use ("jsonrpc", "rest", or "grpc"). By default, it uses JSON-RPC. + +1. Make sure you have [JBang installed](https://www.jbang.dev/documentation/jbang/latest/installation.html) + +2. Run the client using the JBang script: + ```bash + cd samples/java/agents/magic-8-ball-security/client/src/main/java/com/samples/a2a/client + jbang TestClientRunner.java + ``` + + Or specify a custom server URL: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11000 + ``` + + Or specify a custom message: + ```bash + jbang TestClientRunner.java --message "Should I refactor this code?" + ``` + + Or specify a specific transport (jsonrpc, grpc, or rest): + ```bash + jbang TestClientRunner.java --transport grpc + ``` + + Or combine multiple options: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11000 --message "Will my tests pass?" --transport rest + ``` + +## Expected Client Output + +The Java A2A client will: +1. Connect to the Magic 8-Ball agent +2. Fetch the agent card +3. Use the specified transport (JSON-RPC by default, or as specified via --transport option) +4. Send the message "Should I deploy this code on Friday?" (or your custom message) +5. Display the Magic 8-Ball's mystical response from the agent + +## Keycloak OAuth2 Authentication + +This sample includes a `KeycloakOAuth2CredentialService` that implements the `CredentialService` interface from the A2A Java SDK to retrieve tokens from Keycloak +using Keycloak `AuthzClient`. + +## Multi-Transport Support + +This sample demonstrates multi-transport capabilities by supporting the JSON-RPC, HTTP+JSON/REST, and gRPC transports. The A2A server agent is configured to use a unified port for all three transports. + +## Disclaimer +Important: The sample code provided is for demonstration purposes and illustrates the +mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, +it is critical to treat any agent operating outside of your direct control as a +potentially untrusted entity. + +All data received from an external agent—including but not limited to its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide an AgentCard containing crafted data in its +fields (e.g., description, name, skills.description). If this data is used without +sanitization to construct prompts for a Large Language Model (LLM), it could expose +your application to prompt injection attacks. Failure to properly validate and +sanitize this data before use can introduce security vulnerabilities into your +application. + +Developers are responsible for implementing appropriate security measures, such as +input validation and secure handling of credentials to protect their systems and users. diff --git a/samples/java/agents/magic_8_ball_security/client/pom.xml b/samples/java/agents/magic_8_ball_security/client/pom.xml new file mode 100644 index 00000000..0aac5388 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + com.samples.a2a + magic-8-ball-security + 0.1.0 + + + magic-8-ball-security-client + Magic 8-Ball Security Agent Client + A2A Magic 8-Ball Security Agent Test Client + + + + io.github.a2asdk + a2a-java-sdk-client + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-rest + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${io.a2a.sdk.version} + + + io.grpc + grpc-netty-shaded + runtime + + + com.fasterxml.jackson.core + jackson-databind + + + com.google.auth + google-auth-library-oauth2-http + 1.19.0 + + + com.google.http-client + google-http-client-jackson2 + 1.43.3 + + + org.keycloak + keycloak-authz-client + 25.0.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.codehaus.mojo + exec-maven-plugin + + com.samples.a2a.TestClient + + + + + diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java new file mode 100644 index 00000000..e553beef --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java @@ -0,0 +1,55 @@ +package com.samples.a2a.client; + +import com.samples.a2a.client.util.CachedToken; +import com.samples.a2a.client.util.KeycloakUtil; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.auth.CredentialService; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.keycloak.authorization.client.AuthzClient; + +/** + * A CredentialService implementation that provides OAuth2 access tokens + * using Keycloak. This service is used by the A2A client transport + * authentication interceptors. + */ +public final class KeycloakOAuth2CredentialService implements CredentialService { + + /** OAuth2 scheme name. */ + private static final String OAUTH2_SCHEME_NAME = "oauth2"; + + /** Token cache. */ + private final ConcurrentMap tokenCache + = new ConcurrentHashMap<>(); + + /** Keycloak authz client. */ + private final AuthzClient authzClient; + + /** + * Creates a new KeycloakOAuth2CredentialService using the + * default keycloak.json file. + * + * @throws IllegalArgumentException if keycloak.json cannot be found/loaded + */ + public KeycloakOAuth2CredentialService() { + this.authzClient = KeycloakUtil.createAuthzClient(); + } + + @Override + public String getCredential(final String securitySchemeName, + final ClientCallContext clientCallContext) { + if (!OAUTH2_SCHEME_NAME.equals(securitySchemeName)) { + throw new IllegalArgumentException("Unsupported security scheme: " + + securitySchemeName); + } + + try { + return KeycloakUtil.getAccessToken(securitySchemeName, + tokenCache, authzClient); + } catch (Exception e) { + throw new RuntimeException( + "Failed to obtain OAuth2 access token for scheme: " + + securitySchemeName, e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java new file mode 100644 index 00000000..429084e5 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java @@ -0,0 +1,119 @@ +package com.samples.a2a.client; + +import com.samples.a2a.client.util.EventHandlerUtil; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfigBuilder; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import io.a2a.client.transport.rest.RestTransport; +import io.a2a.client.transport.rest.RestTransportConfigBuilder; +import io.a2a.client.transport.spi.interceptors.auth.AuthInterceptor; +import io.a2a.client.transport.spi.interceptors.auth.CredentialService; +import io.a2a.spec.AgentCard; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Test client utility for creating A2A clients with HTTP-based transports + * and OAuth2 authentication. + * + *

This class encapsulates the complexity of setting up A2A clients with + * multiple transport options (gRPC, REST, JSON-RPC) and Keycloak OAuth2 + * authentication, providing simple methods to create configured clients + * for testing and development. + */ +public final class TestClient { + + private TestClient() { + } + + /** + * Creates an A2A client with the specified transport and + * OAuth2 authentication. + * + * @param agentCard the agent card to connect to + * @param messageResponse CompletableFuture for handling responses + * @param transport the transport type to use ("grpc", "rest", or "jsonrpc") + * @return configured A2A client + */ + public static Client createClient( + final AgentCard agentCard, + final CompletableFuture messageResponse, + final String transport) { + + // Create consumers for handling client events + List> consumers = + EventHandlerUtil.createEventConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = + EventHandlerUtil.createStreamingErrorHandler(messageResponse); + + // Create credential service for OAuth2 authentication + CredentialService credentialService + = new KeycloakOAuth2CredentialService(); + + // Create shared auth interceptor for all transports + AuthInterceptor authInterceptor = new AuthInterceptor(credentialService); + + // Create channel factory for gRPC transport + Function channelFactory = + agentUrl -> { + return ManagedChannelBuilder + .forTarget(agentUrl) + .usePlaintext() + .build(); + }; + + // Create the A2A client with the specified transport + try { + var builder = + Client.builder(agentCard) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler); + + // Configure only the specified transport + switch (transport.toLowerCase()) { + case "grpc": + builder.withTransport( + GrpcTransport.class, + new GrpcTransportConfigBuilder() + .channelFactory(channelFactory) + .addInterceptor(authInterceptor) // auth config + .build()); + break; + case "rest": + builder.withTransport( + RestTransport.class, + new RestTransportConfigBuilder() + .addInterceptor(authInterceptor) // auth config + .build()); + break; + case "jsonrpc": + builder.withTransport( + JSONRPCTransport.class, + new JSONRPCTransportConfigBuilder() + .addInterceptor(authInterceptor) // auth config + .build()); + break; + default: + throw new IllegalArgumentException( + "Unsupported transport type: " + + transport + + ". Supported types are: grpc, rest, jsonrpc"); + } + + return builder.clientConfig(new ClientConfig.Builder().build()).build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create A2A client", e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java new file mode 100644 index 00000000..096d6655 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -0,0 +1,231 @@ +/// usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-rest:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-spi:0.3.0.Beta2 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 +//DEPS io.grpc:grpc-netty-shaded:1.69.1 +//DEPS org.keycloak:keycloak-authz-client:25.0.1 +//SOURCES TestClient.java +//SOURCES util/KeycloakUtil.java +//SOURCES util/EventHandlerUtil.java +//SOURCES util/CachedToken.java +//SOURCES KeycloakOAuth2CredentialService.java +//FILES ../../../../../resources/keycloak.json + +package com.samples.a2a.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.a2a.A2A; +import io.a2a.client.Client; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Message; +import java.util.concurrent.CompletableFuture; + +/** + * JBang script to run the A2A HTTP TestClient example with OAuth2 + * authentication. This script automatically handles the dependencies + * and runs the client with a specified transport. + * + *

This is a self-contained script that demonstrates how to: + * + *

    + *
  • Connect to an A2A agent using a specific transport + * (gRPC, REST, or JSON-RPC) with OAuth2 authentication + *
  • Send messages and receive responses + *
  • Handle agent interactions + *
+ * + *

Prerequisites: + * + *

    + *
  • JBang installed + * (see https://www.jbang.dev/documentation/guide/latest/installation.html) + *
  • A running Magic 8 Ball A2A server agent that supports the specified + * transport with OAuth2 authentication + *
  • A valid keycloak.json configuration file in the classpath + *
  • A running Keycloak server with properly configured client + *
+ * + *

Usage: + * + *

{@code
+ * $ jbang TestClientRunner.java
+ * }
+ * + *

Or with custom parameters: + * + *

{@code
+ * $ jbang TestClientRunner.java --server-url http://localhost:11001
+ * $ jbang TestClientRunner.java --message "Should I refactor this code?"
+ * $ jbang TestClientRunner.java --transport grpc
+ * $ jbang TestClientRunner.java --server-url http://localhost:11001
+ *  --message "Will my tests pass?" --transport rest
+ * }
+ * + *

The script will: + * + *

    + *
  • Create the specified transport config with auth config + *
  • Communicate with the Magic 8 Ball A2A server agent + *
  • Automatically include OAuth2 Bearer tokens in all requests + *
  • Handle A2A protocol interactions and display responses + *
+ * + *

The heavy lifting for client setup is handled by {@link TestClient}. + */ +public final class TestClientRunner { + + /** The default server URL to use. */ + private static final String DEFAULT_SERVER_URL = "http://localhost:11000"; + + /** The default message text to send. */ + private static final String MESSAGE_TEXT + = "Should I deploy this code on Friday?"; + + /** The default transport to use. */ + private static final String DEFAULT_TRANSPORT = "jsonrpc"; + + /** Object mapper to use. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestClientRunner() { + // Utility class, prevent instantiation + } + + /** Prints usage information and exits. */ + private static void printUsageAndExit() { + System.out.println("Usage: jbang TestClientRunner.java [OPTIONS]"); + System.out.println(); + System.out.println("Options:"); + System.out.println( + " --server-url URL The URL of the A2A server agent (default: " + + DEFAULT_SERVER_URL + + ")"); + System.out.println( + " --message TEXT The message to send to the agent " + + "(default: \"" + + MESSAGE_TEXT + + "\")"); + System.out.println( + " --transport TYPE " + + "The transport type to use: jsonrpc, grpc, or rest " + + "(default: " + + DEFAULT_TRANSPORT + + ")"); + System.out.println(" --help, -h Show this help message and exit"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" jbang TestClientRunner.java " + + "--server-url http://localhost:11001"); + System.out.println(" jbang TestClientRunner.java " + + "--message \"Should I refactor this code?\""); + System.out.println(" jbang TestClientRunner.java --transport grpc"); + System.out.println( + " jbang TestClientRunner.java --server-url http://localhost:11001 " + + "--message \"Will my tests pass?\" --transport rest"); + System.exit(0); + } + + /** + * Client entry point. + * + * @param args can optionally contain the --server-url, + * --message, and --transport to use + */ + public static void main(final String[] args) { + System.out.println("=== A2A Client with OAuth2 Authentication Example ==="); + + String serverUrl = DEFAULT_SERVER_URL; + String messageText = MESSAGE_TEXT; + String transport = DEFAULT_TRANSPORT; + + // Parse command line arguments + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--server-url": + if (i + 1 < args.length) { + serverUrl = args[i + 1]; + i++; + } else { + System.err.println("Error: --server-url requires a value"); + printUsageAndExit(); + } + break; + case "--message": + if (i + 1 < args.length) { + messageText = args[i + 1]; + i++; + } else { + System.err.println("Error: --message requires a value"); + printUsageAndExit(); + } + break; + case "--transport": + if (i + 1 < args.length) { + transport = args[i + 1]; + i++; + } else { + System.err.println("Error: --transport requires a value"); + printUsageAndExit(); + } + break; + case "--help": + case "-h": + printUsageAndExit(); + break; + default: + System.err.println("Error: Unknown argument: " + args[i]); + printUsageAndExit(); + } + } + + try { + System.out.println("Connecting to agent at: " + serverUrl); + System.out.println("Using transport: " + transport); + + // Fetch the public agent card + AgentCard publicAgentCard = new A2ACardResolver(serverUrl).getAgentCard(); + System.out.println("Successfully fetched public agent card:"); + System.out.println(OBJECT_MAPPER.writeValueAsString(publicAgentCard)); + System.out.println("Using public agent card for client initialization."); + + // Create a CompletableFuture to handle async response + final CompletableFuture messageResponse + = new CompletableFuture<>(); + + // Create the A2A client with the specified transport using TestClient + Client client = TestClient.createClient(publicAgentCard, + messageResponse, transport); + + // Create and send the message + Message message = A2A.toUserMessage(messageText); + + System.out.println("Sending message: " + messageText); + System.out.println("Using " + transport + + " transport with OAuth2 Bearer token"); + try { + client.sendMessage(message); + } catch (A2AClientException e) { + messageResponse.completeExceptionally(e); + } + System.out.println("Message sent successfully. Waiting for response..."); + + try { + // Wait for response + String responseText = messageResponse.get(); + System.out.println("Final response: " + responseText); + } catch (Exception e) { + System.err.println("Failed to get response: " + e.getMessage()); + e.printStackTrace(); + } + + } catch (Exception e) { + System.err.println("An error occurred: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java new file mode 100644 index 00000000..183f90b6 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java @@ -0,0 +1,2 @@ +/** A2A Client examples and utilities with OAuth2 authentication support. */ +package com.samples.a2a.client; diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java new file mode 100644 index 00000000..7ee6d1bb --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java @@ -0,0 +1,91 @@ +package com.samples.a2a.client.util; + +/** + * Represents a cached token with expiration information. + * + *

This utility class is used to cache OAuth2 access tokens and + * provides expiration checking to avoid using expired tokens. + */ +public final class CachedToken { + /** Expiration buffer. */ + private static final long EXPIRATION_BUFFER_MS = 5 * 60 * 1000; // 5 minutes + + /** Converstion to milliseconds. */ + private static final long SECONDS_TO_MS = 1000; + + /** Cached token. */ + private final String token; + + /** Expiration time. */ + private final long expirationTime; + + /** + * Creates a new CachedToken with the specified token and expiration time. + * + * @param token the access token string + * @param expirationTime the expiration time in milliseconds since epoch + */ + public CachedToken(final String token, final long expirationTime) { + this.token = token; + this.expirationTime = expirationTime; + } + + /** + * Gets the cached token. + * + * @return the access token string + */ + public String getToken() { + return token; + } + + /** + * Gets the expiration time. + * + * @return the expiration time in milliseconds since epoch + */ + public long getExpirationTime() { + return expirationTime; + } + + /** + * Checks if the token is expired or will expire soon. + * + *

Returns true if the token will expire within 5 minutes to provide + * a buffer for token refresh. + * + * @return true if the token is expired or will expire soon + */ + public boolean isExpired() { + // Consider token expired if it expires within 5 minutes (300,000 ms) + return System.currentTimeMillis() + >= (expirationTime - EXPIRATION_BUFFER_MS); + } + + /** + * Creates a CachedToken from an access token response + * with expires_in seconds. + * + * @param token the access token string + * @param expiresInSeconds the number of seconds until expiration + * @return a new CachedToken instance + */ + public static CachedToken fromExpiresIn(final String token, + final long expiresInSeconds) { + long expirationTime = System.currentTimeMillis() + + (expiresInSeconds * SECONDS_TO_MS); + return new CachedToken(token, expirationTime); + } + + @Override + public String toString() { + return "CachedToken{" + + "token=***" + + // Don't log the actual token for security + ", expirationTime=" + + expirationTime + + ", expired=" + + isExpired() + + '}'; + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java new file mode 100644 index 00000000..1e434c39 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java @@ -0,0 +1,118 @@ +package com.samples.a2a.client.util; + +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Artifact; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** Utility class for handling A2A client events and responses. */ +public final class EventHandlerUtil { + + private EventHandlerUtil() { + } + + /** + * Creates event consumers for handling A2A client events. + * + * @param messageResponse CompletableFuture to complete + * @return list of event consumers + */ + public static List> createEventConsumers( + final CompletableFuture messageResponse) { + List> consumers = new ArrayList<>(); + consumers.add( + (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + String text = extractTextFromParts(responseMessage.getParts()); + System.out.println("Received message: " + text); + messageResponse.complete(text); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + if (updateEvent + instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { + System.out.println( + "Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + if (taskStatusUpdateEvent.isFinal()) { + String text = extractTextFromArtifacts( + taskUpdateEvent.getTask().getArtifacts()); + messageResponse.complete(text); + } + } else if (updateEvent + instanceof + TaskArtifactUpdateEvent taskArtifactUpdateEvent) { + List> parts = taskArtifactUpdateEvent + .getArtifact() + .parts(); + String text = extractTextFromParts(parts); + System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + System.out.println("Received task event: " + + taskEvent.getTask().getId()); + if (taskEvent.getTask().getStatus().state().isFinal()) { + String text = extractTextFromArtifacts( + taskEvent.getTask().getArtifacts()); + messageResponse.complete(text); + } + } + }); + return consumers; + } + + private static String extractTextFromArtifacts( + final List artifacts) { + StringBuilder textBuilder = new StringBuilder(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + return textBuilder.toString(); + } + + /** + * Creates a streaming error handler for A2A client. + * + * @param messageResponse CompletableFuture to complete exceptionally on error + * @return error handler + */ + public static Consumer createStreamingErrorHandler( + final CompletableFuture messageResponse) { + return (error) -> { + System.out.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + } + + /** + * Extracts text content from a list of parts. + * + * @param parts the parts to extract text from + * @return concatenated text content + */ + public static String extractTextFromParts(final List> parts) { + final StringBuilder textBuilder = new StringBuilder(); + if (parts != null) { + for (final Part part : parts) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java new file mode 100644 index 00000000..c6c4cb44 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java @@ -0,0 +1,95 @@ +package com.samples.a2a.client.util; + +import java.io.InputStream; +import java.util.concurrent.ConcurrentMap; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.representations.AccessTokenResponse; + +/** Utility class for common Keycloak operations and token caching. */ +public final class KeycloakUtil { + + private KeycloakUtil() { + // Utility class, prevent instantiation + } + + /** + * Creates a Keycloak AuthzClient from the default keycloak.json + * configuration file. + * + * @return a configured AuthzClient + * @throws IllegalArgumentException if keycloak.json cannot be found/loaded + */ + public static AuthzClient createAuthzClient() { + return createAuthzClient("keycloak.json"); + } + + private static AuthzClient createAuthzClient(final String configFileName) { + try { + InputStream configStream = null; + + // First try to load from current directory (for JBang) + try { + java.io.File configFile = new java.io.File(configFileName); + if (configFile.exists()) { + configStream = new java.io.FileInputStream(configFile); + } + } catch (Exception ignored) { + // Fall back to classpath + } + + // If not found in current directory, try classpath + if (configStream == null) { + configStream = KeycloakUtil.class + .getClassLoader() + .getResourceAsStream(configFileName); + } + + if (configStream == null) { + throw new IllegalArgumentException("Config file not found: " + + configFileName); + } + + return AuthzClient.create(configStream); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to load Keycloak configuration from " + configFileName, e); + } + } + + /** + * Gets a valid access token for the specified cache key, using the + * provided cache and AuthzClient. Uses caching to avoid unnecessary + * token requests. + * + * @param cacheKey the cache key to use for storing/retrieving the token + * @param tokenCache the concurrent map to use for token caching + * @param authzClient the Keycloak AuthzClient to use for token requests + * @return a valid access token + * @throws RuntimeException if token acquisition fails + */ + public static String getAccessToken( + final String cacheKey, + final ConcurrentMap tokenCache, final AuthzClient authzClient) { + CachedToken cached = tokenCache.get(cacheKey); + + // Check if we have a valid cached token + if (cached != null && !cached.isExpired()) { + return cached.getToken(); + } + + try { + // Obtain a new access token from Keycloak + AccessTokenResponse tokenResponse = authzClient.obtainAccessToken(); + + // Cache the token with expiration info + CachedToken newToken = + CachedToken.fromExpiresIn(tokenResponse.getToken(), + tokenResponse.getExpiresIn()); + tokenCache.put(cacheKey, newToken); + + return tokenResponse.getToken(); + } catch (Exception e) { + throw new RuntimeException("Failed to obtain token from Keycloak", e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java new file mode 100644 index 00000000..745afb8e --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java @@ -0,0 +1,2 @@ +/** Auth utilities. */ +package com.samples.a2a.client.util; diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json b/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json new file mode 100644 index 00000000..8ed695b1 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json @@ -0,0 +1,9 @@ +{ + "realm": "quarkus", + "auth-server-url": "http://localhost:11001/", + "resource": "quarkus-app", + "credentials": { + "secret": "secret" + }, + "ssl-required": "external" +} diff --git a/samples/java/agents/magic_8_ball_security/pom.xml b/samples/java/agents/magic_8_ball_security/pom.xml new file mode 100644 index 00000000..e23a1557 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + + com.samples.a2a + agents-parent + 0.1.0 + + + magic-8-ball-security + pom + + + server + client + + diff --git a/samples/java/agents/magic_8_ball_security/server/.env.example b/samples/java/agents/magic_8_ball_security/server/.env.example new file mode 100644 index 00000000..cb2fe891 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/.env.example @@ -0,0 +1 @@ +QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here diff --git a/samples/java/agents/magic_8_ball_security/server/pom.xml b/samples/java/agents/magic_8_ball_security/server/pom.xml new file mode 100644 index 00000000..796b4608 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + com.samples.a2a + magic-8-ball-security + 0.1.0 + + + magic-8-ball-security-server + Magic 8-Ball Security Agent Server + A2A Magic 8-Ball Security Agent Server Implementation + + + + io.github.a2asdk + a2a-java-sdk-reference-jsonrpc + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-rest + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-grpc + ${io.a2a.sdk.version} + + + jakarta.enterprise + jakarta.enterprise.cdi-api + ${jakarta.enterprise.cdi-api.version} + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-security + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ai-gemini + ${quarkus.langchain4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.quarkus + quarkus-maven-plugin + + + + diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java new file mode 100644 index 00000000..acef32c3 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java @@ -0,0 +1,41 @@ +package com.samples.a2a; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; + +/** Magic 8 Ball fortune-telling agent. */ +@RegisterAiService(tools = Magic8BallTools.class) +@ApplicationScoped +public interface Magic8BallAgent { + + /** + * Answers questions using the mystical powers of the Magic 8 Ball. + * + * @param memoryId unique identifier for this conversation + * @param question the users' question + * @return the Magic 8 Ball's response + */ + @SystemMessage( + """ + You shake a Magic 8 Ball to answer questions. + The only thing you do is shake the Magic 8 Ball to answer + the user's question and then discuss the response. + When you are asked to answer a question, you must call the + shakeMagic8Ball tool with the user's question. + You should never rely on the previous history for Magic 8 Ball + responses. Call the shakeMagic8Ball tool for each question. + You should never shake the Magic 8 Ball on your own. + You must always call the tool. + When you are asked a question, you should always make the following + function call: + 1. You should first call the shakeMagic8Ball tool to get the response. + Wait for the function response. + 2. After you get the function response, relay the response to the user. + You should not rely on the previous history for Magic 8 Ball responses. + """) + String answerQuestion(@MemoryId String memoryId, + @UserMessage String question); +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java new file mode 100644 index 00000000..5bb5decf --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java @@ -0,0 +1,96 @@ +package com.samples.a2a; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import io.a2a.spec.ClientCredentialsOAuthFlow; +import io.a2a.spec.OAuth2SecurityScheme; +import io.a2a.spec.OAuthFlows; +import io.a2a.spec.TransportProtocol; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Map; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** Producer for Magic 8 Ball agent card configuration. */ +@ApplicationScoped +public final class Magic8BallAgentCardProducer { + + /** The HTTP port for the agent service. */ + @Inject + @ConfigProperty(name = "quarkus.http.port") + private int httpPort; + + /** The HTTP port for Keycloak. */ + @Inject + @ConfigProperty(name = "quarkus.keycloak.devservices.port") + private int keycloakPort; + + /** + * Produces the agent card for the Magic 8 Ball agent. + * + * @return the configured agent card + */ + @Produces + @PublicAgentCard + public AgentCard agentCard() { + ClientCredentialsOAuthFlow clientCredentialsOAuthFlow = new ClientCredentialsOAuthFlow( + null, + Map.of("openid", "openid", "profile", "profile"), + "http://localhost:" + keycloakPort + "/realms/quarkus/protocol/openid-connect/token"); + OAuth2SecurityScheme securityScheme = new OAuth2SecurityScheme.Builder() + .flows(new OAuthFlows.Builder().clientCredentials(clientCredentialsOAuthFlow).build()) + .build(); + + return new AgentCard.Builder() + .name("Magic 8 Ball Agent") + .description( + "A mystical fortune-telling agent that answers your yes/no " + + "questions by asking the all-knowing Magic 8 Ball oracle.") + .preferredTransport(TransportProtocol.JSONRPC.asString()) + .url("http://localhost:" + httpPort) + .version("1.0.0") + .documentationUrl("http://example.com/docs") + .capabilities( + new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(false) + .stateTransitionHistory(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .security(List.of(Map.of(OAuth2SecurityScheme.OAUTH2, + List.of("profile")))) + .securitySchemes(Map.of(OAuth2SecurityScheme.OAUTH2, securityScheme)) + .skills( + List.of( + new AgentSkill.Builder() + .id("magic_8_ball") + .name("Magic 8 Ball Fortune Teller") + .description("Uses a Magic 8 Ball to answer" + + " yes/no questions") + .tags(List.of("fortune", "magic-8-ball", "oracle")) + .examples( + List.of( + "Should I deploy this code on Friday?", + "Will my tests pass?", + "Is this a good idea?")) + .build())) + .protocolVersion("0.3.0") + .additionalInterfaces( + List.of( + new AgentInterface( + TransportProtocol.JSONRPC.asString(), + "http://localhost:" + httpPort), + new AgentInterface( + TransportProtocol.HTTP_JSON.asString(), + "http://localhost:" + httpPort), + new AgentInterface(TransportProtocol.GRPC.asString(), + "localhost:" + httpPort))) + .build(); + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java new file mode 100644 index 00000000..3711128c --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java @@ -0,0 +1,118 @@ +package com.samples.a2a; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.Task; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import java.util.UUID; + +/** Producer for Magic 8 Ball agent executor. */ +@ApplicationScoped +public final class Magic8BallAgentExecutorProducer { + + /** The Magic 8 Ball agent instance. */ + @Inject private Magic8BallAgent magic8BallAgent; + + /** + * Produces the agent executor for the Magic 8 Ball agent. + * + * @return the configured agent executor + */ + @Produces + public AgentExecutor agentExecutor() { + return new Magic8BallAgentExecutor(magic8BallAgent); + } + + /** Magic 8 Ball agent executor implementation. */ + private static class Magic8BallAgentExecutor implements AgentExecutor { + + /** The Magic 8 Ball agent instance. */ + private final Magic8BallAgent agent; + + /** + * Constructor for Magic8BallAgentExecutor. + * + * @param magic8BallAgentInstance the Magic 8 Ball agent instance + */ + Magic8BallAgentExecutor(final Magic8BallAgent magic8BallAgentInstance) { + this.agent = magic8BallAgentInstance; + } + + @Override + public void execute(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + + // mark the task as submitted and start working on it + if (context.getTask() == null) { + updater.submit(); + } + updater.startWork(); + + // extract the text from the message + final String question = extractTextFromMessage(context.getMessage()); + + // Generate a unique memory ID for this request for fresh chat memory + final String memoryId = UUID.randomUUID().toString(); + System.out.println( + "=== EXECUTOR === Using memory ID: " + + memoryId + " for question: " + question); + + // call the Magic 8 Ball agent with the question + final String response = agent.answerQuestion(memoryId, question); + + // create the response part + final TextPart responsePart = new TextPart(response, null); + final List> parts = List.of(responsePart); + + // add the response as an artifact and complete the task + updater.addArtifact(parts, null, null, null); + updater.complete(); + } + + private String extractTextFromMessage(final Message message) { + final StringBuilder textBuilder = new StringBuilder(); + if (message.getParts() != null) { + for (final Part part : message.getParts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + @Override + public void cancel(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final Task task = context.getTask(); + + if (task.getStatus().state() == TaskState.CANCELED) { + // task already cancelled + throw new TaskNotCancelableError(); + } + + if (task.getStatus().state() == TaskState.COMPLETED) { + // task already completed + throw new TaskNotCancelableError(); + } + + // cancel the task + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java new file mode 100644 index 00000000..3e5be00a --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java @@ -0,0 +1,59 @@ +package com.samples.a2a; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ThreadLocalRandom; + +/** Service class that provides Magic 8 Ball fortune-telling functionality. */ +@ApplicationScoped +public class Magic8BallTools { + + /** All possible Magic 8 Ball responses. */ + private static final String[] RESPONSES = { + // Positive responses (10) + "It is certain", + "It is decidedly so", + "Without a doubt", + "Yes definitely", + "You may rely on it", + "As I see it, yes", + "Most likely", + "Outlook good", + "Yes", + "Signs point to yes", + + // Negative responses (5) + "Don't count on it", + "My reply is no", + "My sources say no", + "Outlook not so good", + "Very doubtful", + + // Non-committal responses (5) + "Better not tell you now", + "Cannot predict now", + "Concentrate and ask again", + "Ask again later", + "Reply hazy, try again" + }; + + /** + * Get the response from the Magic 8 Ball. + * + * @param question the user's question + * @return A random Magic 8 Ball response + */ + @Tool("Get the response to the user's question from the Magic 8 Ball") + public String shakeMagic8Ball(final String question) { + int index = ThreadLocalRandom.current().nextInt(RESPONSES.length); + String response = RESPONSES[index]; + System.out.println( + "=== TOOL CALLED === Question: " + + question + + ", Index: " + + index + + ", Response: " + + response); + return response; + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java new file mode 100644 index 00000000..7079e6f4 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java @@ -0,0 +1,2 @@ +/** Magic 8 Ball package. */ +package com.samples.a2a; diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties b/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties new file mode 100644 index 00000000..d1ac5123 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# Use the same port for gRPC and HTTP +quarkus.grpc.server.use-separate-server=false +quarkus.http.port=11000 +quarkus.langchain4j.ai.gemini.timeout=42543 +quarkus.keycloak.devservices.port=11001 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash + diff --git a/samples/java/agents/pom.xml b/samples/java/agents/pom.xml new file mode 100644 index 00000000..faf5b293 --- /dev/null +++ b/samples/java/agents/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + com.samples.a2a + agents-parent + 0.1.0 + pom + + + content_editor + content_writer + dice_agent_multi_transport + magic_8_ball_security + weather_mcp + + + + 17 + 17 + UTF-8 + + + 0.3.0.Beta2 + + + 4.1.0 + 3.26.1 + 1.3.1 + 4.31.1 + + + + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + + + diff --git a/samples/java/agents/weather_mcp/pom.xml b/samples/java/agents/weather_mcp/pom.xml index 7d4664c7..44cdf755 100644 --- a/samples/java/agents/weather_mcp/pom.xml +++ b/samples/java/agents/weather_mcp/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - weather - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Beta1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + weather @@ -66,25 +48,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - From 27a0a11d3c7653a6c6e66890b5d6c15ed9319de9 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 24 Oct 2025 14:24:40 -0400 Subject: [PATCH 11/14] fix: Fix the path to the Weather Agent in the weather_and_airbnb_planner README and update the URL for the UI (#393) # Description Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). --- samples/python/hosts/weather_and_airbnb_planner/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/python/hosts/weather_and_airbnb_planner/README.md b/samples/python/hosts/weather_and_airbnb_planner/README.md index e75f1219..f293638d 100644 --- a/samples/python/hosts/weather_and_airbnb_planner/README.md +++ b/samples/python/hosts/weather_and_airbnb_planner/README.md @@ -136,7 +136,7 @@ Run the airbnb agent server: Open a new terminal and run the weather agent: ```bash - cd samples/multi_language/python_and_java_multiagent/weather_agent + cd samples/java/agents/weather_mcp mvn quarkus:dev ``` @@ -152,7 +152,7 @@ Open a new terminal and run the host agent server: ## 5. Test using the UI -From your browser, navigate to . +From your browser, navigate to . Here are example questions: From 8684c27f2070daf14326f66a2d08cd15f9b31fce Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Tue, 28 Oct 2025 11:24:12 -0400 Subject: [PATCH 12/14] Update the a2a-java samples to A2A Java SDK 0.3.0.Final (#395) # Description Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-samples/blob/main/CONTRIBUTING.md). --- .../java/com/samples/a2a/client/TestClientRunner.java | 6 +++--- .../java/com/samples/a2a/client/TestClientRunner.java | 10 +++++----- samples/java/agents/pom.xml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java index 44f7f6e7..a945106d 100644 --- a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -1,7 +1,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Final //DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 //DEPS io.grpc:grpc-netty-shaded:1.69.1 //SOURCES TestClient.java diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java index 096d6655..7ee5a208 100644 --- a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -1,9 +1,9 @@ /// usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-rest:0.3.0.Beta2 -//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-spi:0.3.0.Beta2 +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-rest:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-spi:0.3.0.Final //DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 //DEPS io.grpc:grpc-netty-shaded:1.69.1 //DEPS org.keycloak:keycloak-authz-client:25.0.1 diff --git a/samples/java/agents/pom.xml b/samples/java/agents/pom.xml index faf5b293..90ccf5a5 100644 --- a/samples/java/agents/pom.xml +++ b/samples/java/agents/pom.xml @@ -23,7 +23,7 @@ UTF-8 - 0.3.0.Beta2 + 0.3.0.Final 4.1.0 From eb3885fd019096d1abf092d476e7f5454d252c66 Mon Sep 17 00:00:00 2001 From: Andrey Bragin Date: Tue, 28 Oct 2025 16:24:54 +0100 Subject: [PATCH 13/14] Add Koog Framework Examples (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Add Koog Framework Examples to Samples This PR introduces comprehensive examples demonstrating A2A protocol implementation using [Koog](https://koog.ai/), JetBrains' open-source agentic framework for building enterprise-ready AI agents, targeting JVM backend, Android, iOS, JS, and WasmJS. ## What's Added Two progressive examples showing different A2A communication patterns: ### 1. Simple Joke Agent (`simplejoke`) - Basic message-based A2A communication - Direct request-response pattern using `sendMessage()` - Demonstrates minimal A2A server setup with AgentCard and message storage ### 2. Advanced Joke Agent (`advancedjoke`) - Full task-based A2A workflow implementation - Graph-based agent architecture using Koog's `GraphAIAgent` - Complete task lifecycle: Submitted → Working → InputRequired → Completed - Interactive clarifications via InputRequired state - Streaming task events and artifact delivery - Structured LLM outputs with type-safe parsing ## Key Features Demonstrated - **A2A Protocol Integration**: Both simple message-based and advanced task-based workflows - **Graph-based Agent Design**: Maintainable, visual agent logic using nodes and edges - **Koog's Prompt DSL**: Type-safe prompt building with automatic context management ## Why Koog? Adds diversity to A2A samples by showcasing: - JVM/Kotlin ecosystem representation - Type-safe agent development with compile-time guarantees - Multi-platform deployment options beyond Node.js/Python Each example includes detailed inline documentation and runnable Gradle tasks for immediate testing. --- .editorconfig | 36 ++ samples/java/koog/.gitignore | 3 + samples/java/koog/README.md | 114 +++++ samples/java/koog/build.gradle.kts | 43 ++ samples/java/koog/gradle.properties | 7 + samples/java/koog/gradle/libs.versions.toml | 29 ++ .../koog/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + samples/java/koog/gradlew | 251 +++++++++++ samples/java/koog/gradlew.bat | 94 ++++ samples/java/koog/settings.gradle.kts | 15 + .../ai/koog/example/advancedjoke/Client.kt | 160 +++++++ .../advancedjoke/JokeWriterAgentExecutor.kt | 425 ++++++++++++++++++ .../ai/koog/example/advancedjoke/Server.kt | 87 ++++ .../ai/koog/example/simplejoke/Client.kt | 94 ++++ .../ai/koog/example/simplejoke/Server.kt | 85 ++++ .../simplejoke/SimpleJokeAgentExecutor.kt | 79 ++++ .../java/koog/src/main/resources/logback.xml | 11 + 18 files changed, 1540 insertions(+) create mode 100644 .editorconfig create mode 100644 samples/java/koog/.gitignore create mode 100644 samples/java/koog/README.md create mode 100644 samples/java/koog/build.gradle.kts create mode 100644 samples/java/koog/gradle.properties create mode 100644 samples/java/koog/gradle/libs.versions.toml create mode 100644 samples/java/koog/gradle/wrapper/gradle-wrapper.jar create mode 100644 samples/java/koog/gradle/wrapper/gradle-wrapper.properties create mode 100755 samples/java/koog/gradlew create mode 100644 samples/java/koog/gradlew.bat create mode 100644 samples/java/koog/settings.gradle.kts create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt create mode 100644 samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt create mode 100644 samples/java/koog/src/main/resources/logback.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..3050c14e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +root = true + +[*.{kt,kts}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 120 + +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL + +# Disable wildcard imports entirely +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset + +ktlint_code_style = ktlint_official +ktlint_standard_annotation = disabled +ktlint_standard_class-naming = disabled +ktlint_standard_class-signature = disabled +ktlint_standard_filename = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_if-else-bracing = enabled +ktlint_standard_if-else-wrapping = enabled +ktlint_standard_no-consecutive-comments = disabled +ktlint_standard_no-single-line-block-comment = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_try-catch-finally-spacing = enabled +ktlint_standard_backing-property-naming = disabled + +[**/build/**/*] +ktlint = disabled \ No newline at end of file diff --git a/samples/java/koog/.gitignore b/samples/java/koog/.gitignore new file mode 100644 index 00000000..00a5ece3 --- /dev/null +++ b/samples/java/koog/.gitignore @@ -0,0 +1,3 @@ +/.gradle +/build +/.kotlin diff --git a/samples/java/koog/README.md b/samples/java/koog/README.md new file mode 100644 index 00000000..b3031946 --- /dev/null +++ b/samples/java/koog/README.md @@ -0,0 +1,114 @@ +# Agent-to-Agent (A2A) with Koog Framework Examples + +This project demonstrates how to build A2A-enabled agents using [Koog](https://github.com/JetBrains/koog), the official JetBrains' framework for building predictable, +fault-tolerant, and enterprise-ready AI agents, targeting JVM backend, Android, iOS, JS, and WasmJS. + +## What is Koog? + +Koog is JetBrains' open-source agentic framework that empowers developers to build AI agents using Kotlin. It provides: + +- **Graph-based agent architecture**: Define agent behavior as a graph of nodes and edges with type-safe inputs and outputs, + making complex workflows easier to understand and maintain +- **Multi-platform support**: Deploy agents across JVM, Android, native iOS, JS, and WasmJS using Kotlin Multiplatform +- **Fault tolerance**: Built-in retry mechanisms and agent state persistence for reliable execution, allowing to recover + crashed agents even on another machine. +- **Prompt DSL**: Clean, type-safe DSL for building LLM prompts and automatically managing conversation context +- **Enterprise integrations**: Works seamlessly with Spring Boot, Ktor, and other JVM frameworks +- **Advanced Observability**: Built-in integrations with enterprise observability tools like Langfuse and W&B Weave via OpenTelemetry +- **A2A protocol support**: Built-in support for Agent-to-Agent communication via the A2A protocol + +Learn more at [koog.ai](https://koog.ai/) + +## Prerequisites + +- JDK 17 or higher +- Set `GOOGLE_API_KEY` environment variable (or configure other LLM providers in the code) + +## Examples + +### Simple Joke Agent: [simplejoke](./src/main/kotlin/ai/koog/example/simplejoke) + +A basic example demonstrating message-based A2A communication without task workflows. + +**What it demonstrates:** +- Creating an `AgentExecutor` that wraps LLM calls using Koog's prompt DSL +- Setting up an A2A server with an `AgentCard` that describes agent capabilities +- Managing conversation context with message storage +- Simple request-response pattern using `sendMessage()` + +**Run:** +```bash +# Terminal 1: Start server (port 9998) +./gradlew runExampleSimpleJokeServer + +# Terminal 2: Run client +./gradlew runExampleSimpleJokeClient +``` + +### Advanced Joke Agent: [advancedjoke](./src/main/kotlin/ai/koog/example/advancedjoke) + +A sophisticated example showcasing task-based A2A workflows using Koog's graph-based agent architecture. + +**What it demonstrates:** +- **Graph-based agent design**: Uses Koog's `GraphAIAgent` with nodes and edges to create a maintainable workflow +- **Task lifecycle management**: Full A2A task states (Submitted → Working → InputRequired → Completed) +- **Interactive clarification**: Agent can request additional information using the InputRequired state +- **Structured LLM outputs**: Uses sealed interfaces with `nodeLLMRequestStructured` for type-safe agent decisions +- **Artifact delivery**: Returns final results as A2A artifacts +- **Streaming events**: Sends real-time task updates via `sendTaskEvent()` + +**Run:** +```bash +# Terminal 1: Start server (port 9999) +./gradlew runExampleAdvancedJokeServer + +# Terminal 2: Run client +./gradlew runExampleAdvancedJokeClient +``` + +## Key Patterns & Koog Concepts + +### A2A Communication Patterns + +**Simple Agent:** `sendMessage()` → single response +**Advanced Agent:** `sendMessageStreaming()` → Flow of events (Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent) + +**Task States:** Submitted → Working → InputRequired (optional) → Completed + +### Koog Framework Concepts Used + +**AgentExecutor**: The entry point for A2A requests. Receives the request context and event processor for sending responses. + +**GraphAIAgent**: Koog's graph-based agent implementation. Define your agent logic as nodes (processing steps) connected by edges (transitions). + +**Prompt DSL**: Type-safe Kotlin DSL for building prompts: +```kotlin +prompt("joke-generation") { + system { +"You are a helpful assistant" } + user { +"Tell me a joke" } +} +``` + +**MultiLLMPromptExecutor**: Unified interface for executing prompts across different LLM providers (OpenAI, Anthropic, Google, etc.). + +**nodeLLMRequestStructured**: Creates a graph node that calls the LLM and parses the response into a structured Kotlin data class using the `@LLMDescription` annotation. + +**A2AAgentServer plugin**: Koog plugin that integrates A2A functionality into your GraphAIAgent, providing access to message storage, task storage, and event processors. + +### Getting Started with Koog + +To build your own A2A agent with Koog: + +1. **Add Koog dependencies** (see [build.gradle.kts](./build.gradle.kts)) +2. **Create an AgentExecutor** to handle incoming A2A requests +3. **Define an AgentCard** describing your agent's capabilities +4. **Set up the A2A server** with HTTP transport +5. **For simple agents**: Use prompt executor directly with message storage +6. **For complex agents**: Use GraphAIAgent with the A2AAgentServer plugin + +See the code comments in `JokeWriterAgentExecutor.kt` for detailed implementation guidance. + +## Learn More + +- [Koog GitHub Repository](https://github.com/JetBrains/koog) +- [Koog Documentation](https://koog.ai/) diff --git a/samples/java/koog/build.gradle.kts b/samples/java/koog/build.gradle.kts new file mode 100644 index 00000000..15fb8683 --- /dev/null +++ b/samples/java/koog/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ktlint) +} + +dependencies { + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.kotlinx.coroutines.bom)) + + implementation(libs.koog.agents) + implementation(libs.koog.agents.features.a2a.server) + implementation(libs.koog.agents.features.a2a.client) + implementation(libs.koog.a2a.transport.server.jsonrpc.http) + implementation(libs.koog.a2a.transport.client.jsonrpc.http) + + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.core) + + implementation(libs.ktor.server.cio) + + runtimeOnly(libs.logback.classic) +} + +fun registerRunExampleTask( + name: String, + mainClassName: String, +) = tasks.register(name) { + doFirst { + standardInput = System.`in` + standardOutput = System.out + } + + mainClass.set(mainClassName) + classpath = sourceSets["main"].runtimeClasspath +} +// Simple joke generation +registerRunExampleTask("runExampleSimpleJokeServer", "ai.koog.example.simplejoke.ServerKt") +registerRunExampleTask("runExampleSimpleJokeClient", "ai.koog.example.simplejoke.ClientKt") + +// Advanced joke generation +registerRunExampleTask("runExampleAdvancedJokeServer", "ai.koog.example.advancedjoke.ServerKt") +registerRunExampleTask("runExampleAdvancedJokeClient", "ai.koog.example.advancedjoke.ClientKt") diff --git a/samples/java/koog/gradle.properties b/samples/java/koog/gradle.properties new file mode 100644 index 00000000..4db6c1cf --- /dev/null +++ b/samples/java/koog/gradle.properties @@ -0,0 +1,7 @@ +#Kotlin +kotlin.code.style=official + +#Gradle +org.gradle.jvmargs=-Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/samples/java/koog/gradle/libs.versions.toml b/samples/java/koog/gradle/libs.versions.toml new file mode 100644 index 00000000..1150725e --- /dev/null +++ b/samples/java/koog/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +kotlin = "2.2.20" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +kotlinx-serialization = "1.8.1" +ktor3 = "3.2.2" +koog = "0.5.0" +logback = "1.5.13" +oshai-logging = "7.0.7" +ktlint = "13.1.0" + +[libraries] +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor3" } +koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } +koog-agents-features-a2a-server = { module = "ai.koog:agents-features-a2a-server", version.ref = "koog" } +koog-agents-features-a2a-client = { module = "ai.koog:agents-features-a2a-client", version.ref = "koog" } +koog-a2a-transport-server-jsonrpc-http = { module = "ai.koog:a2a-transport-server-jsonrpc-http", version.ref = "koog" } +koog-a2a-transport-client-jsonrpc-http = { module = "ai.koog:a2a-transport-client-jsonrpc-http", version.ref = "koog" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +oshai-kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "oshai-logging" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/samples/java/koog/gradle/wrapper/gradle-wrapper.jar b/samples/java/koog/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$(cd -P "${APP_HOME:-./}" >/dev/null && printf '%s\n' "$PWD") || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn() { + echo "$*" +} >&2 + +die() { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$(uname)" in #( +CYGWIN*) cygwin=true ;; #( +Darwin*) darwin=true ;; #( +MSYS* | MINGW*) msys=true ;; #( +NONSTOP*) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ]; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop"; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$(ulimit -H -n) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) : ;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys"; then + APP_HOME=$(cygpath --path --mixed "$APP_HOME") + CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") + + JAVACMD=$(cygpath --unix "$JAVACMD") + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg; do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) + t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] + ;; #( + *) false ;; + esac + then + arg=$(cygpath --path --ignore --mixed "$arg") + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1; then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/java/koog/gradlew.bat b/samples/java/koog/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/samples/java/koog/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/java/koog/settings.gradle.kts b/samples/java/koog/settings.gradle.kts new file mode 100644 index 00000000..248587cf --- /dev/null +++ b/samples/java/koog/settings.gradle.kts @@ -0,0 +1,15 @@ +rootProject.name = "koog" + +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + google() + } +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt new file mode 100644 index 00000000..3cdbdcde --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt @@ -0,0 +1,160 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package ai.koog.example.advancedjoke + +import ai.koog.a2a.client.A2AClient +import ai.koog.a2a.client.UrlAgentCardResolver +import ai.koog.a2a.model.Artifact +import ai.koog.a2a.model.Message +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.Task +import ai.koog.a2a.model.TaskArtifactUpdateEvent +import ai.koog.a2a.model.TaskState +import ai.koog.a2a.model.TaskStatusUpdateEvent +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.transport.Request +import ai.koog.a2a.transport.client.jsonrpc.http.HttpJSONRPCClientTransport +import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +private const val CYAN = "\u001B[36m" +private const val YELLOW = "\u001B[33m" +private const val MAGENTA = "\u001B[35m" +private const val GREEN = "\u001B[32m" +private const val RED = "\u001B[31m" +private const val BLUE = "\u001B[34m" +private const val RESET = "\u001B[0m" + +private val json = Json { prettyPrint = true } + +@OptIn(ExperimentalUuidApi::class) +suspend fun main() { + println("\n${YELLOW}Starting Advanced Joke Generator A2A Client$RESET\n") + + val transport = HttpJSONRPCClientTransport(url = "http://localhost:9999${ADVANCED_JOKE_AGENT_PATH}") + val agentCardResolver = + UrlAgentCardResolver(baseUrl = "http://localhost:9999", path = ADVANCED_JOKE_AGENT_CARD_PATH) + val client = A2AClient(transport = transport, agentCardResolver = agentCardResolver) + + client.connect() + val agentCard = client.cachedAgentCard() + println("${YELLOW}Connected: ${agentCard.name}$RESET\n") + + if (agentCard.capabilities.streaming != true) { + println("${RED}Error: Streaming not supported$RESET") + transport.close() + return + } + + println("${CYAN}Context ID:$RESET") + val contextId = readln() + println() + + var currentTaskId: String? = null + val artifacts = mutableMapOf() + + while (true) { + println("${CYAN}Request (/q to quit):$RESET") + val request = readln() + println() + + if (request == "/q") break + + val message = + Message( + messageId = Uuid.random().toString(), + role = Role.User, + parts = listOf(TextPart(request)), + contextId = contextId, + taskId = currentTaskId, + ) + + try { + client.sendMessageStreaming(Request(MessageSendParams(message = message))).collect { response -> + val event = response.data + println("$BLUE[${event.kind}]$RESET") + println("${json.encodeToString(event)}\n") + + when (event) { + is Task -> { + currentTaskId = event.id + event.artifacts?.forEach { artifacts[it.artifactId] = it } + } + + is Message -> { + val textContent = event.parts.filterIsInstance().joinToString("\n") { it.text } + if (textContent.isNotBlank()) { + println("${MAGENTA}Message:$RESET\n$textContent\n") + } + } + + is TaskStatusUpdateEvent -> { + when (event.status.state) { + TaskState.InputRequired -> { + val question = + event.status.message + ?.parts + ?.filterIsInstance() + ?.joinToString("\n") { it.text } + if (!question.isNullOrBlank()) { + println("${MAGENTA}Question:$RESET\n$question\n") + } + } + + TaskState.Completed -> { + if (artifacts.isNotEmpty()) { + println("$GREEN=== Artifacts ===$RESET") + artifacts.values.forEach { artifact -> + val content = + artifact.parts + .filterIsInstance() + .joinToString("\n") { it.text } + if (content.isNotBlank()) { + println("$GREEN[${artifact.artifactId}]$RESET\n$content\n") + } + } + } + if (event.final) { + currentTaskId = null + artifacts.clear() + } + } + + TaskState.Failed, TaskState.Canceled, TaskState.Rejected -> { + if (event.final) { + currentTaskId = null + artifacts.clear() + } + } + + else -> {} + } + } + + is TaskArtifactUpdateEvent -> { + if (event.append == true) { + val existing = artifacts[event.artifact.artifactId] + if (existing != null) { + artifacts[event.artifact.artifactId] = + existing.copy( + parts = existing.parts + event.artifact.parts, + ) + } else { + artifacts[event.artifact.artifactId] = event.artifact + } + } else { + artifacts[event.artifact.artifactId] = event.artifact + } + } + } + } + } catch (e: Exception) { + println("${RED}Error: ${e.message}$RESET\n") + } + } + + println("${YELLOW}Done$RESET") + transport.close() +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt new file mode 100644 index 00000000..27aca820 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt @@ -0,0 +1,425 @@ +package ai.koog.example.advancedjoke + +import ai.koog.a2a.exceptions.A2AUnsupportedOperationException +import ai.koog.a2a.model.Artifact +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.Task +import ai.koog.a2a.model.TaskArtifactUpdateEvent +import ai.koog.a2a.model.TaskState +import ai.koog.a2a.model.TaskStatus +import ai.koog.a2a.model.TaskStatusUpdateEvent +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.server.agent.AgentExecutor +import ai.koog.a2a.server.session.RequestContext +import ai.koog.a2a.server.session.SessionEventProcessor +import ai.koog.agents.a2a.core.A2AMessage +import ai.koog.agents.a2a.core.toKoogMessage +import ai.koog.agents.a2a.server.feature.A2AAgentServer +import ai.koog.agents.a2a.server.feature.withA2AAgentServer +import ai.koog.agents.core.agent.GraphAIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.agent.context.agentInput +import ai.koog.agents.core.dsl.builder.forwardTo +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeLLMRequestStructured +import ai.koog.agents.core.dsl.extension.onIsInstance +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.core.tools.annotations.LLMDescription +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.google.GoogleLLMClient +import ai.koog.prompt.executor.clients.google.GoogleModels +import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor +import ai.koog.prompt.executor.model.PromptExecutor +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.message.Message +import ai.koog.prompt.text.text +import ai.koog.prompt.xml.xml +import kotlinx.datetime.Clock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.reflect.typeOf +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * An advanced A2A agent that demonstrates: + * - Task-based conversation flow with state management + * - Interactive clarification questions (InputRequired state) + * - Structured output via sealed interfaces + * - Artifact delivery for final results + */ +class JokeWriterAgentExecutor : AgentExecutor { + private val promptExecutor = + MultiLLMPromptExecutor( +// LLMProvider.OpenAI to OpenAILLMClient(System.getenv("OPENAI_API_KEY")), +// LLMProvider.Anthropic to AnthropicLLMClient(System.getenv("ANTHROPIC_API_KEY")), + LLMProvider.Google to GoogleLLMClient(System.getenv("GOOGLE_API_KEY")), + ) + + @OptIn(ExperimentalUuidApi::class) + override suspend fun execute( + context: RequestContext, + eventProcessor: SessionEventProcessor, + ) { + val agent = jokeWriterAgent(promptExecutor, context, eventProcessor) + agent.run(context.params.message) + } +} + +private fun jokeWriterAgent( + promptExecutor: PromptExecutor, + context: RequestContext, + eventProcessor: SessionEventProcessor, +): GraphAIAgent { + val agentConfig = + AIAgentConfig( + prompt = + prompt("joke-generation") { + system { + +"You are a very funny sarcastic assistant. You must help users generate funny jokes." + +( + "When asked for something else, sarcastically decline the request because you can only" + + " assist with jokes." + ) + } + }, + model = GoogleModels.Gemini2_5Flash, + maxAgentIterations = 20, + ) + + return GraphAIAgent( + inputType = typeOf(), + outputType = typeOf(), + promptExecutor = promptExecutor, + strategy = jokeWriterStrategy(), + agentConfig = agentConfig, + toolRegistry = ToolRegistry.EMPTY, + ) { + install(A2AAgentServer) { + this.context = context + this.eventProcessor = eventProcessor + } + } +} + +@OptIn(ExperimentalUuidApi::class) +private fun jokeWriterStrategy() = + strategy("joke-writer") { + // Node: Load conversation history from message storage + val setupMessageContext by node { userInput -> + if (!userInput.referenceTaskIds.isNullOrEmpty()) { + throw A2AUnsupportedOperationException( + "This agent doesn't understand task references in referenceTaskIds yet." + ) + } + + // Load current context messages + val contextMessages: List = + withA2AAgentServer { + context.messageStorage.getAll() + } + + // Update the prompt with the current context messages + llm.writeSession { + updatePrompt { + messages(contextMessages.map { it.toKoogMessage() }) + } + } + + userInput + } + + // Node: Load existing task (if continuing) or prepare for new task creation + val setupTaskContext by node { userInput -> + // Check if the message continues the task that already exists + val currentTask: Task? = + withA2AAgentServer { + context.task?.id?.let { id -> + // Load task with full conversation history to continue working on it + context.taskStorage.get(id, historyLength = null) + } + } + + currentTask?.let { task -> + val currentTaskMessages = + (task.history.orEmpty() + listOfNotNull(task.status.message) + userInput) + .map { it.toKoogMessage() } + + llm.writeSession { + updatePrompt { + user { + +"There's an ongoing task, the next messages contain conversation history for this task" + } + + messages(currentTaskMessages) + } + } + } + + // If task exists then the message belongs to the task, send event to update the task. + // Otherwise, put it in general message storage for the current context. + withA2AAgentServer { + if (currentTask != null) { + val updateEvent = + TaskStatusUpdateEvent( + taskId = currentTask.id, + contextId = currentTask.contextId, + status = + TaskStatus( + state = TaskState.Working, + message = userInput, + timestamp = Clock.System.now(), + ), + final = false, + ) + + eventProcessor.sendTaskEvent(updateEvent) + } else { + context.messageStorage.save(userInput) + } + } + + currentTask + } + + // Node: Ask LLM to classify if this is a joke request or something else + val classifyNewRequest by nodeLLMRequestStructured() + + // Node: Send a polite decline message if the request is not about jokes + val respondFallbackMessage by node { classification -> + withA2AAgentServer { + val message = + A2AMessage( + messageId = Uuid.random().toString(), + role = Role.Agent, + parts = + listOf( + TextPart(classification.response), + ), + contextId = context.contextId, + taskId = context.taskId, + ) + + // Store reply in message storage to preserve context + context.messageStorage.save(message) + // Reply with message + eventProcessor.sendMessage(message) + } + } + + // Node: Create a new task for the joke request + val createTask by node { + val userInput = agentInput() + + withA2AAgentServer { + val task = + Task( + id = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.Submitted, + message = userInput, + timestamp = Clock.System.now(), + ), + ) + + eventProcessor.sendTaskEvent(task) + } + } + + // Node: Ask LLM to classify joke details (or request clarification) + val classifyJokeRequest by nodeLLMRequestStructured() + + // Node: Generate the actual joke based on classified parameters + val generateJoke by node { request -> + llm.writeSession { + updatePrompt { + user { + +text { + +"Generate a joke based on the following user request:" + xml { + tag("subject") { + +request.subject + } + tag("targetAudience") { + +request.targetAudience + } + tag("isSwearingAllowed") { + +request.isSwearingAllowed.toString() + } + } + } + } + } + + val message = requestLLMWithoutTools() + message as? Message.Assistant ?: throw IllegalStateException("Unexpected message type: $message") + } + } + + // Node: Send InputRequired event to ask the user for more information + val askMoreInfo by node { clarification -> + withA2AAgentServer { + val taskUpdate = + TaskStatusUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.InputRequired, + message = + A2AMessage( + role = Role.Agent, + parts = + listOf( + TextPart(clarification.question), + ), + messageId = Uuid.random().toString(), + taskId = context.taskId, + contextId = context.contextId, + ), + timestamp = Clock.System.now(), + ), + final = true, + ) + + eventProcessor.sendTaskEvent(taskUpdate) + } + } + + // Node: Send the joke as an artifact and mark task as completed + val respondWithJoke by node { jokeMessage -> + withA2AAgentServer { + val artifactUpdate = + TaskArtifactUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + artifact = + Artifact( + artifactId = "joke", + parts = + listOf( + TextPart(jokeMessage.content), + ), + ), + ) + + eventProcessor.sendTaskEvent(artifactUpdate) + + val taskStatusUpdate = + TaskStatusUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.Completed, + ), + final = true, + ) + + eventProcessor.sendTaskEvent(taskStatusUpdate) + } + } + + // --- Graph Flow Definition --- + + // Always start by loading context and checking for existing tasks + nodeStart then setupMessageContext then setupTaskContext + + // If no task exists, classify whether this is a joke request + edge( + setupTaskContext forwardTo classifyNewRequest + onCondition { task -> task == null } + transformed { agentInput().content() }, + ) + // If task exists, continue processing the joke request + edge( + setupTaskContext forwardTo classifyJokeRequest + onCondition { task -> task != null } + transformed { agentInput().content() }, + ) + + // New request classification: If not a joke request, decline politely + edge( + classifyNewRequest forwardTo respondFallbackMessage + transformed { it.getOrThrow().structure } + onCondition { !it.isJokeRequest }, + ) + // New request classification: If joke request, create a task + edge( + classifyNewRequest forwardTo createTask + transformed { it.getOrThrow().structure } + onCondition { it.isJokeRequest }, + ) + + edge(respondFallbackMessage forwardTo nodeFinish) + + // After creating task, classify the joke details + edge( + createTask forwardTo classifyJokeRequest + transformed { agentInput().content() }, + ) + + // Joke classification: Ask for clarification if needed + edge( + classifyJokeRequest forwardTo askMoreInfo + transformed { it.getOrThrow().structure } + onIsInstance JokeRequestClassification.NeedsClarification::class, + ) + // Joke classification: Generate joke if we have all details + edge( + classifyJokeRequest forwardTo generateJoke + transformed { it.getOrThrow().structure } + onIsInstance JokeRequestClassification.Ready::class, + ) + + // After asking for info, wait for user response (finish this iteration) + edge(askMoreInfo forwardTo nodeFinish) + + // After generating joke, send it as an artifact + edge(generateJoke forwardTo respondWithJoke) + edge(respondWithJoke forwardTo nodeFinish) + } + +private fun A2AMessage.content(): String = parts.filterIsInstance().joinToString(separator = "\n") { it.text } + +// --- Structured Output Models --- + +@Serializable +@LLMDescription("Initial incoming user message classification, to determine if this is a joke request or not.") +private data class UserRequestClassification( + @property:LLMDescription("Whether the incoming message is a joke request or not") + val isJokeRequest: Boolean, + @property:LLMDescription( + "In case the message is not a joke request, polite reply to the user that the agent cannot assist." + + "Default is empty", + ) + val response: String = "", +) + +@LLMDescription("The classification of the joke request") +@Serializable +@SerialName("JokeRequestClassification") +private sealed interface JokeRequestClassification { + @Serializable + @SerialName("NeedsClarification") + @LLMDescription("The joke request needs clarification") + data class NeedsClarification( + @property:LLMDescription("The question that needs clarification") + val question: String, + ) : JokeRequestClassification + + @LLMDescription("The joke request is ready to be processed") + @Serializable + @SerialName("Ready") + data class Ready( + @property:LLMDescription("The joke subject") + val subject: String, + @property:LLMDescription("The joke target audience") + val targetAudience: String, + @property:LLMDescription("Whether the swearing is allowed in the joke") + val isSwearingAllowed: Boolean, + ) : JokeRequestClassification +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt new file mode 100644 index 00000000..6adc29a7 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt @@ -0,0 +1,87 @@ +package ai.koog.example.advancedjoke + +import ai.koog.a2a.model.AgentCapabilities +import ai.koog.a2a.model.AgentCard +import ai.koog.a2a.model.AgentInterface +import ai.koog.a2a.model.AgentSkill +import ai.koog.a2a.model.TransportProtocol +import ai.koog.a2a.server.A2AServer +import ai.koog.a2a.transport.server.jsonrpc.http.HttpJSONRPCServerTransport +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.server.cio.CIO + +private val logger = KotlinLogging.logger {} + +const val ADVANCED_JOKE_AGENT_PATH = "/advanced-joke-agent" +const val ADVANCED_JOKE_AGENT_CARD_PATH = "$ADVANCED_JOKE_AGENT_PATH/agent-card.json" + +suspend fun main() { + logger.info { "Starting Advanced Joke A2A Agent on http://localhost:9999" } + + // Create agent card with capabilities - this agent supports streaming and tasks + val agentCard = + AgentCard( + protocolVersion = "0.3.0", + name = "Advanced Joke Generator", + description = + "A sophisticated AI agent that generates jokes with clarifying questions and structured task flow", + version = "1.0.0", + url = "http://localhost:9999$ADVANCED_JOKE_AGENT_PATH", + preferredTransport = TransportProtocol.JSONRPC, + additionalInterfaces = + listOf( + AgentInterface( + url = "http://localhost:9999$ADVANCED_JOKE_AGENT_PATH", + transport = TransportProtocol.JSONRPC, + ), + ), + capabilities = + AgentCapabilities( + streaming = true, // Supports streaming responses + pushNotifications = false, + stateTransitionHistory = false, + ), + defaultInputModes = listOf("text"), + defaultOutputModes = listOf("text"), + skills = + listOf( + AgentSkill( + id = "advanced_joke_generation", + name = "Advanced Joke Generation", + description = + "Generates humorous jokes with interactive clarification and customization options", + examples = + listOf( + "Tell me a joke about programming", + "Generate a funny joke for teenagers", + "Make me laugh with a dad joke about cats", + ), + tags = listOf("humor", "jokes", "entertainment", "interactive"), + ), + ), + supportsAuthenticatedExtendedCard = false, + ) + + // Create agent executor + val agentExecutor = JokeWriterAgentExecutor() + + // Create A2A server + val a2aServer = + A2AServer( + agentExecutor = agentExecutor, + agentCard = agentCard, + ) + + // Create and start server transport + val serverTransport = HttpJSONRPCServerTransport(a2aServer) + + logger.info { "Advanced Joke Generator Agent ready at http://localhost:9999/$ADVANCED_JOKE_AGENT_PATH" } + serverTransport.start( + engineFactory = CIO, + port = 9999, + path = ADVANCED_JOKE_AGENT_PATH, + wait = true, // Block until server stops + agentCard = agentCard, + agentCardPath = ADVANCED_JOKE_AGENT_CARD_PATH, + ) +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt new file mode 100644 index 00000000..32bdf03e --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt @@ -0,0 +1,94 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package ai.koog.example.simplejoke + +import ai.koog.a2a.client.A2AClient +import ai.koog.a2a.client.UrlAgentCardResolver +import ai.koog.a2a.model.Message +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.transport.Request +import ai.koog.a2a.transport.client.jsonrpc.http.HttpJSONRPCClientTransport +import ai.koog.agents.a2a.core.toKoogMessage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +private const val BRIGHT_CYAN = "\u001B[1;36m" +private const val YELLOW = "\u001B[33m" +private const val BRIGHT_MAGENTA = "\u001B[1;35m" +private const val RED = "\u001B[31m" +private const val RESET = "\u001B[0m" + +@OptIn(ExperimentalUuidApi::class) +suspend fun main() { + println() + println("${YELLOW}Starting Joke Generator A2A Client$RESET\n") + + // Set up the HTTP JSON-RPC transport + val transport = + HttpJSONRPCClientTransport( + url = "http://localhost:9998${JOKE_GENERATOR_AGENT_PATH}", + ) + + // Set up the agent card resolver + val agentCardResolver = + UrlAgentCardResolver( + baseUrl = "http://localhost:9998", + path = JOKE_GENERATOR_AGENT_CARD_PATH, + ) + + // Create the A2A client + val client = + A2AClient( + transport = transport, + agentCardResolver = agentCardResolver, + ) + + // Connect and fetch agent card + client.connect() + val agentCard = client.cachedAgentCard() + println("${YELLOW}Connected to agent:$RESET\n${agentCard.name} (${agentCard.description})\n") + + // Read context ID + println("${BRIGHT_CYAN}Context ID (which chat to start/continue):$RESET") + val contextId = readln() + println() + + // Start chat loop + while (true) { + println("${BRIGHT_CYAN}Request (/q to quit):$RESET") + val request = readln() + println() + + if (request == "/q") { + break + } + + val message = + Message( + messageId = Uuid.random().toString(), + role = Role.User, + parts = listOf(TextPart(request)), + contextId = contextId, + ) + + val response = + client.sendMessage( + Request(MessageSendParams(message = message)), + ) + + val replyMessage = response.data as? Message + if (replyMessage != null) { + val reply = replyMessage.toKoogMessage().content + println("${BRIGHT_MAGENTA}Agent response:${RESET}\n$reply\n") + } else { + println("${RED}Error: Unexpected response type from agent.$RESET\n") + } + } + + println("${RED}Conversation complete!$RESET") + + // Clean up + transport.close() +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt new file mode 100644 index 00000000..9aa837c5 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt @@ -0,0 +1,85 @@ +package ai.koog.example.simplejoke + +import ai.koog.a2a.model.AgentCapabilities +import ai.koog.a2a.model.AgentCard +import ai.koog.a2a.model.AgentInterface +import ai.koog.a2a.model.AgentSkill +import ai.koog.a2a.model.TransportProtocol +import ai.koog.a2a.server.A2AServer +import ai.koog.a2a.transport.server.jsonrpc.http.HttpJSONRPCServerTransport +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.server.cio.CIO + +private val logger = KotlinLogging.logger {} + +const val JOKE_GENERATOR_AGENT_PATH = "/joke-generator-agent" +const val JOKE_GENERATOR_AGENT_CARD_PATH = "$JOKE_GENERATOR_AGENT_PATH/agent-card.json" + +suspend fun main() { + logger.info { "Starting Joke A2A Agent on http://localhost:9998" } + + // Create agent card with capabilities + val agentCard = + AgentCard( + protocolVersion = "0.3.0", + name = "Joke Generator", + description = "A helpful AI agent that generates jokes based on user requests", + version = "1.0.0", + url = "http://localhost:9998$JOKE_GENERATOR_AGENT_PATH", + preferredTransport = TransportProtocol.JSONRPC, + additionalInterfaces = + listOf( + AgentInterface( + url = "http://localhost:9998$JOKE_GENERATOR_AGENT_PATH", + transport = TransportProtocol.JSONRPC, + ), + ), + capabilities = + AgentCapabilities( + streaming = false, + pushNotifications = false, + stateTransitionHistory = false, + ), + defaultInputModes = listOf("text"), + defaultOutputModes = listOf("text"), + skills = + listOf( + AgentSkill( + id = "joke_generation", + name = "Joke Generation", + description = "Generates humorous jokes on various topics", + examples = + listOf( + "Tell me a joke", + "Generate a funny joke about programming", + "Make me laugh with a dad joke", + ), + tags = listOf("humor", "jokes", "entertainment"), + ), + ), + supportsAuthenticatedExtendedCard = false, + ) + + // Create agent executor + val agentExecutor = SimpleJokeAgentExecutor() + + // Create A2A server + val a2aServer = + A2AServer( + agentExecutor = agentExecutor, + agentCard = agentCard, + ) + + // Create and start server transport + val serverTransport = HttpJSONRPCServerTransport(a2aServer) + + logger.info { "Joke Generator Agent ready at http://localhost:9998/$JOKE_GENERATOR_AGENT_PATH" } + serverTransport.start( + engineFactory = CIO, + port = 9998, + path = JOKE_GENERATOR_AGENT_PATH, + wait = true, // Block until server stops + agentCard = agentCard, + agentCardPath = JOKE_GENERATOR_AGENT_CARD_PATH, + ) +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt new file mode 100644 index 00000000..eb688ddb --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt @@ -0,0 +1,79 @@ +package ai.koog.example.simplejoke + +import ai.koog.a2a.exceptions.A2AUnsupportedOperationException +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.server.agent.AgentExecutor +import ai.koog.a2a.server.session.RequestContext +import ai.koog.a2a.server.session.SessionEventProcessor +import ai.koog.agents.a2a.core.MessageA2AMetadata +import ai.koog.agents.a2a.core.toA2AMessage +import ai.koog.agents.a2a.core.toKoogMessage +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.google.GoogleLLMClient +import ai.koog.prompt.executor.clients.google.GoogleModels +import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.message.Message +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * This is a simple example of an agent executor that wraps LLM calls using prompt executor to generate jokes. + */ +class SimpleJokeAgentExecutor : AgentExecutor { + private val promptExecutor = + MultiLLMPromptExecutor( +// LLMProvider.OpenAI to OpenAILLMClient(System.getenv("OPENAI_API_KEY")), +// LLMProvider.Anthropic to AnthropicLLMClient(System.getenv("ANTHROPIC_API_KEY")), + LLMProvider.Google to GoogleLLMClient(System.getenv("GOOGLE_API_KEY")), + ) + + @OptIn(ExperimentalUuidApi::class) + override suspend fun execute( + context: RequestContext, + eventProcessor: SessionEventProcessor, + ) { + val userMessage = context.params.message + + if (context.task != null || !userMessage.referenceTaskIds.isNullOrEmpty()) { + throw A2AUnsupportedOperationException("This agent doesn't support tasks") + } + + // Save incoming message to the current context + context.messageStorage.save(userMessage) + + // Load all messages from the current context + val contextMessages = context.messageStorage.getAll().map { it.toKoogMessage() } + + val prompt = + prompt("joke-generation") { + system { + +"You are an assistant helping user to generate jokes" + } + + // Append current message context + messages(contextMessages) + } + + // Get a response from the LLM + val responseMessage = + promptExecutor + .execute(prompt, GoogleModels.Gemini2_5Flash) + .single() + .let { message -> + message as? Message.Assistant ?: throw IllegalStateException("Unexpected message type: $message") + }.toA2AMessage( + a2aMetadata = + MessageA2AMetadata( + messageId = Uuid.random().toString(), + contextId = context.contextId, + ), + ) + + // Save the response to the current context + context.messageStorage.save(responseMessage) + + // Reply with message + eventProcessor.sendMessage(responseMessage) + } +} diff --git a/samples/java/koog/src/main/resources/logback.xml b/samples/java/koog/src/main/resources/logback.xml new file mode 100644 index 00000000..24a99c37 --- /dev/null +++ b/samples/java/koog/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + From 97bd415be0d81c3b785b61fa8361895cf7fdaa12 Mon Sep 17 00:00:00 2001 From: idris_babay_cgi Date: Mon, 3 Nov 2025 15:25:10 +0100 Subject: [PATCH 14/14] update dependencies and README for a functioning demo --- samples/python/agents/github-agent/README.md | 2 +- samples/python/agents/github-agent/pyproject.toml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/python/agents/github-agent/README.md b/samples/python/agents/github-agent/README.md index d9e0ce1f..805f582b 100644 --- a/samples/python/agents/github-agent/README.md +++ b/samples/python/agents/github-agent/README.md @@ -96,7 +96,7 @@ git clone https://github.com/a2aproject/a2a-samples.git cd a2a-samples/samples/python/hosts/cli/ # run cli -uv run . http://localhost:10007 +uv run . --agent http://localhost:10007 ``` This will start an interactive CLI that connects to your GitHub agent server. diff --git a/samples/python/agents/github-agent/pyproject.toml b/samples/python/agents/github-agent/pyproject.toml index c0d6dc72..92ad81dc 100644 --- a/samples/python/agents/github-agent/pyproject.toml +++ b/samples/python/agents/github-agent/pyproject.toml @@ -5,7 +5,7 @@ description = "A2A GitHub agent" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk>=0.3.0", + "a2a-sdk[http-server]>=0.3.0", "click>=8.1.8", "dotenv>=0.9.9", "httpx>=0.28.1", @@ -15,6 +15,7 @@ dependencies = [ "uvicorn>=0.34.2", "pygithub>=2.5.0", "requests>=2.31.0", + "fastapi>=0.121.0", ] [tool.hatch.build.targets.wheel]