The LLMProtocolAdapter is a generic adapter that wraps any LLMProtocol implementation and makes it compatible with the A2A (Agent-to-Agent) protocol. This adapter provides a bridge between the SwiftAgentKit's LLM interface and the A2A server infrastructure.
Related
ForStatefulLLM,QueuedLLM, and the full three-layer state model (runtime / per-call / agentic), see LLM state and observation.
ℹ️ Logging tip
The adapter now emits detailed debug information (agentic iterations, tool execution, streaming progress) throughSwiftAgentKitLogging. Bootstrap once prior to creating the adapter:import Logging import SwiftAgentKit SwiftAgentKitLogging.bootstrap( logger: Logger(label: "com.example.llm"), level: .info )Any
The LLMProtocolAdapter allows you to:
- Wrap any LLM that implements the
LLMProtocolinterface - Use that LLM with A2A servers and clients
- Maintain conversation history and context
- Support both synchronous and streaming responses
- Configure LLM parameters like temperature, max tokens, etc.
- Generic Design: Works with any LLM that implements
LLMProtocol - A2A Compatibility: Full integration with the A2A protocol
- Conversation History: Maintains context across multiple messages
- Streaming Support: Real-time streaming responses
- Runtime State Visibility: Observe reasoning/generating/completion transitions on the shared LLM instance
- Image Generation: Automatic detection and support for image generation requests
- Configurable: Customizable parameters and system prompts
- Error Handling: Graceful error handling and recovery
LLMProtocol now exposes:
currentState: LLMRuntimeStatestateUpdates: AsyncStream<LLMRuntimeState>
If your model implementation does not override these properties, defaults are still provided (.idle(.ready) and a single-value stream), so existing conformers remain source-compatible.
For end-to-end runtime state transitions without modifying your existing LLM, wrap it with StatefulLLM:
import SwiftAgentKit
import SwiftAgentKitAdapters
let rawLLM = MyCustomLLM(model: "my-model")
let trackedLLM = StatefulLLM(baseLLM: rawLLM)
let adapter = LLMProtocolAdapter(
llm: trackedLLM,
model: "my-model"
)
Task {
for await state in trackedLLM.stateUpdates {
print("LLM state:", state)
}
}| Layer | Type | Where to observe |
|---|---|---|
| Instance (shared LLM) | LLMRuntimeState |
llm.stateUpdates / StatefulLLM |
Per-call (one send / stream / generateImage) |
LLMRequestState |
StatefulLLM.requestStateUpdates, QueuedLLM.requestStateUpdates |
| Agentic (full tool loop until final answer) | AgenticLoopState |
LLMProtocolAdapter.agenticLoopUpdates, SwiftAgentKitOrchestrator.agenticLoopUpdates |
LLMRuntimeState describes the shared LLM instance (idle vs generating). For each send / stream / generateImage invocation, observe LLMRequestState:
| API | What you get |
|---|---|
StatefulLLM.requestStateUpdates |
active, generation sub-phases (generating, streaming when applicable), completed / failed — no queued |
QueuedLLM.requestStateUpdates |
Same timeline plus queued while waiting for a queue slot (compose QueuedLLM(base: StatefulLLM(base: provider)) for both) |
For LLMProtocolAdapter, each agentic run is keyed by AgenticLoopID.a2a(taskId:contextId:). Subscribe to adapter.agenticLoopUpdates (AsyncStream<(AgenticLoopID, AgenticLoopState)>) for coarse progress: started, llmCall(iteration:), waitingForToolExecution, executingTools, betweenIterations, completed, failed, maxIterationsReached.
For SwiftAgentKitOrchestrator, the id is AgenticLoopID.orchestratorSession(UUID) for one top-level updateConversation (including recursive tool continuations). Use await orchestrator.agenticLoopUpdates. See SwiftAgentKitOrchestrator.
Agentic “waiting for tool result” happens between LLM calls in the adapter/orchestrator loop; it is not a single LLMRequestState value. queued remains per-call only (see below).
LLMProtocolAdapter and SwiftAgentKitOrchestrator take any LLMProtocol; they do not surface per-request phases on the protocol itself. If you wrap the base LLM with QueuedLLM, FIFO wait appears only on QueuedLLM.requestStateUpdates, not on the shared stateUpdates stream.
queued is not an agentic-loop enum case. Queue contention is per send / stream / generateImage. agenticLoopUpdates (adapter / orchestrator) answers “where are we in the tool loop?”; requestStateUpdates from the QueuedLLM you injected answers “is this call blocked before it reaches the inner LLM?”
How to tell the agentic turn is stalled because the request is still queued:
- Subscribe to both
agenticLoopUpdates(fromLLMProtocolAdapterorSwiftAgentKitOrchestrator) andrequestStateUpdateson the sameQueuedLLMinstance you pass into the adapter or orchestrator (e.g.QueuedLLM(baseLLM: StatefulLLM(baseLLM: provider))). You must keep a reference to that wrapper; the adapter does not forward per-request phases. - Correlate by timeline: Instrumentation publishes an agentic LLM-bound state (e.g.
llmCall(iteration:)) immediately before theawait llm.send/streamthat goes throughQueuedLLM. While that call is blocked on the FIFO,QueuedLLMemits(LLMRequestID, .queued)for that invocation, thenactive(and later phases) after a slot is acquired. So: agentic state shows an in-flight LLM iteration and the latest per-request state for the current underlying call is.queued⇒ the loop is waiting because the request is still in the FIFO queue (not yet executing on the base LLM). - Optional later: Stronger correlation (e.g. pairing agentic and request IDs in TaskLocals or docs) if you need to disambiguate overlapping sessions; not required for a single in-flight turn.
For SwiftAgentKitOrchestrator, the same applies: observe agenticLoopUpdates on the orchestrator and QueuedLLM.requestStateUpdates on the wrapper you supply as the LLM.
import SwiftAgentKit
import SwiftAgentKitA2A
import SwiftAgentKitAdapters
// Create your LLM implementation
let myLLM = MyCustomLLM(model: "my-model")
// Create the adapter with DynamicPrompt
var prompt = DynamicPrompt(template: "You are a helpful assistant.")
let adapter = LLMProtocolAdapter(
llm: myLLM,
model: "my-model",
maxTokens: 1000,
temperature: 0.7,
systemPrompt: prompt
)
// Use with A2A server
let server = A2AServer(port: 4245, adapter: adapter)
try await server.start()The LLMProtocolAdapter.Configuration struct includes model parameters, agent card fields, maxAgenticIterations, and toolCallTimeout (seconds, default 300). The latter bounds each ToolProvider.executeTool call via withToolCallTimeout so a hung tool does not stall the agentic loop; timeouts surface as tool-role errors for the model. For the full member list, see the struct in source.
// Not exhaustive — see Sources/SwiftAgentKitAdapters/Adapters/LLMProtocolAdapter.swift
public struct Configuration: Sendable {
public let model: String
public let maxTokens: Int?
public let temperature: Double?
public let topP: Double?
public let systemPrompt: DynamicPrompt?
public let additionalParameters: JSON?
public let maxAgenticIterations: Int
public let toolCallTimeout: TimeInterval // default 300
// … agentName, agentDescription, cardCapabilities, skills, input/output modes, etc.
}For simpler use cases, you can use the convenience initializer:
var prompt = DynamicPrompt(template: "You are a helpful assistant.")
let adapter = LLMProtocolAdapter(
llm: myLLM,
model: "my-model",
maxTokens: 1000,
temperature: 0.7,
systemPrompt: prompt
)You can use DynamicPrompt to create system prompts with replaceable tokens:
var prompt = DynamicPrompt(template: "You are {{role}} assistant. Your expertise is in {{domain}}.")
prompt["role"] = "helpful"
prompt["domain"] = "software development"
let adapter = LLMProtocolAdapter(
llm: myLLM,
model: "my-model",
systemPrompt: prompt
)
// The prompt will be rendered as: "You are helpful assistant. Your expertise is in software development."To use the LLMProtocolAdapter, your LLM must implement the LLMProtocol interface:
struct MyCustomLLM: LLMProtocol {
let model: String
let logger: Logger
init(model: String) {
self.model = model
self.logger = Logger(label: "MyCustomLLM")
}
func getModelName() -> String {
return model
}
func getCapabilities() -> [LLMCapability] {
return [.completion, .tools, .imageGeneration] // Include .imageGeneration if supported
}
func send(_ messages: [Message], config: LLMRequestConfig) async throws -> LLMResponse {
// Your LLM implementation here
let response = "Response from my custom LLM"
return LLMResponse(content: response)
}
func stream(_ messages: [Message], config: LLMRequestConfig) -> AsyncThrowingStream<StreamResult<LLMResponse, LLMResponse>, Error> {
// Your streaming implementation here
return AsyncThrowingStream { continuation in
// Stream implementation
}
}
// Optional: Implement image generation if your LLM supports it
func generateImage(_ config: ImageGenerationRequestConfig) async throws -> ImageGenerationResponse {
// Your image generation implementation here
// Return ImageGenerationResponse with URLs to generated images
}
}The LLMProtocolAdapter can be combined with the tool-aware architecture:
// Create base adapter
let baseAdapter = LLMProtocolAdapter(llm: myLLM)
// Create tool-aware adapter
let toolAwareAdapter = ToolAwareAdapter(
baseAdapter: baseAdapter,
toolManager: toolManager
)
// Use with A2A server
let server = A2AServer(port: 4245, adapter: toolAwareAdapter)The adapter automatically maintains conversation history:
// First message
let message1 = A2AMessage(
role: "user",
parts: [.text(text: "My name is Alice")],
messageId: UUID().uuidString
)
// Second message (includes context from first)
let message2 = A2AMessage(
role: "user",
parts: [.text(text: "What's my name?")],
messageId: UUID().uuidString
)
// The LLM will have context from the previous messageHere's a complete example showing how to create and use an LLMProtocolAdapter:
import Foundation
import SwiftAgentKit
import SwiftAgentKitA2A
import SwiftAgentKitAdapters
import Logging
// Custom LLM implementation
struct ExampleLLM: LLMProtocol {
let model: String
let logger: Logger
init(model: String = "example-llm") {
self.model = model
self.logger = Logger(label: "ExampleLLM")
}
func getModelName() -> String {
return model
}
func getCapabilities() -> [LLMCapability] {
return [.completion, .tools]
}
func send(_ messages: [Message], config: LLMRequestConfig) async throws -> LLMResponse {
let lastUserMessage = messages.last { $0.role == .user }?.content ?? "Hello"
let response = "Response to: '\(lastUserMessage)'"
return LLMResponse(
content: response,
metadata: LLMMetadata(
promptTokens: 10,
completionTokens: response.count / 4,
totalTokens: 10 + (response.count / 4),
finishReason: "stop"
)
)
}
func stream(_ messages: [Message], config: LLMRequestConfig) -> AsyncThrowingStream<LLMResponse, Error> {
return AsyncThrowingStream { continuation in
Task {
let response = try await send(messages, config: config)
let words = response.content.components(separatedBy: " ")
for (index, word) in words.enumerated() {
let isComplete = index == words.count - 1
let chunk = LLMResponse(
content: word + (isComplete ? "" : " "),
isComplete: isComplete
)
continuation.yield(chunk)
if !isComplete {
try await Task.sleep(nanoseconds: 100_000_000)
}
}
continuation.finish()
}
}
}
}
// Main application
@main
struct ExampleApp {
static func main() async throws {
// Set up logging
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardOutput(label: label)
handler.logLevel = .info
return handler
}
// Create LLM and adapter
let exampleLLM = ExampleLLM(model: "example-llm-v1")
var prompt = DynamicPrompt(template: "You are a helpful assistant.")
let adapter = LLMProtocolAdapter(
llm: exampleLLM,
model: "example-llm-v1",
maxTokens: 1000,
temperature: 0.7,
systemPrompt: prompt
)
// Create and start A2A server
let server = A2AServer(port: 4245, adapter: adapter)
try await server.start()
print("Server running on http://localhost:4245")
// Keep server running
try await Task.sleep(nanoseconds: UInt64.max)
}
}The LLMProtocolAdapter automatically detects and handles image generation requests when:
- LLM supports image generation: The LLM's
getCapabilities()includes.imageGeneration - Client accepts image output: The request's
acceptedOutputModesincludes image MIME types (e.g.,"image/png","image/jpeg","image/*") - Message contains a prompt: The message has text content to use as the image generation prompt
The adapter uses A2A-compliant detection by checking the acceptedOutputModes field in MessageSendConfiguration. This means any standard A2A client can request image generation by simply specifying image output modes.
// Client sends a request accepting image output
let config = MessageSendConfiguration(
acceptedOutputModes: ["image/png", "text/plain"] // Client accepts images
)
let params = MessageSendParams(
message: A2AMessage(
role: "user",
parts: [.text(text: "Generate a beautiful sunset over mountains")],
messageId: UUID().uuidString
),
configuration: config
)
// Optional: Pass additional parameters via metadata
let paramsWithOptions = MessageSendParams(
message: message,
configuration: config,
metadata: try JSON([
"n": 2, // Generate 2 images
"size": "512x512" // Image size
])
)When image generation is detected and the LLM supports it:
- The adapter calls
llm.generateImage(config)instead ofllm.send() - Generated images are returned as artifacts with
A2AMessagePart.fileparts - Each image URL is wrapped in a separate artifact
- Artifacts include MIME type metadata and creation timestamps
If the client requests images but the LLM doesn't support image generation:
- The adapter gracefully falls back to text generation
- No error is thrown - the request is handled as a normal text request
- This ensures compatibility with LLMs that don't support image generation
To add image generation support to your custom LLM:
struct MyImageGeneratingLLM: LLMProtocol {
// ... other methods ...
func getCapabilities() -> [LLMCapability] {
return [.completion, .tools, .imageGeneration] // Add .imageGeneration
}
func generateImage(_ config: ImageGenerationRequestConfig) async throws -> ImageGenerationResponse {
// Your image generation logic here
// Generate images based on config.prompt, config.image, etc.
// Save generated images to filesystem and return URLs
let imageURLs = try await generateImagesAndSaveToDisk(config)
return ImageGenerationResponse(
images: imageURLs,
createdAt: Date(),
metadata: LLMMetadata(totalTokens: 100)
)
}
}The LLMProtocolAdapter complements the existing adapters in SwiftAgentKitAdapters:
- OpenAIAdapter: For OpenAI GPT models (also supports DALL-E image generation)
- AnthropicAdapter: For Anthropic Claude models
- GeminiAdapter: For Google Gemini models
- LLMProtocolAdapter: For any custom LLM implementation
This allows you to use the same A2A infrastructure with both commercial LLM providers and your own custom implementations.
Both LLMProtocolAdapter and OpenAIAdapter support image generation using the same A2A-compliant detection mechanism:
- LLMProtocolAdapter: Automatically supports image generation if the wrapped LLM implements
generateImage() - OpenAIAdapter: Directly supports DALL-E image generation via OpenAI's API
Both adapters use the same detection logic (checking acceptedOutputModes) and return images as file-based artifacts, ensuring a consistent experience across different LLM providers.
The adapter provides comprehensive error handling for image generation:
- Invalid Parameters: Invalid
n(not 1-10) orsizevalues are automatically clamped to valid ranges with warnings logged - Prompt Length: Prompts exceeding 1000 characters log warnings (LLM may truncate)
- LLM Errors: Errors from the underlying LLM's
generateImage()method are properly propagated - No Images Generated: If LLM returns no images, throws
LLMError.imageGenerationError(.noImagesGenerated)
Generated images are saved to filesystem URLs returned by the LLM's generateImage() method. The adapter:
- Creates artifacts with file URLs pointing to the generated images
- Relies on the LLM implementation to manage file storage and cleanup
- Does not automatically delete generated images (LLM implementation responsibility)
For production use, ensure your LLM implementation handles file cleanup appropriately.
Image generation requests bypass tool handling - they are direct operations that don't require tool execution. When using ToolAwareAdapter:
- Image generation requests are detected and handled before tool processing
- Tools are not available during image generation (by design)
- This ensures image generation is fast and direct without agentic loops
The adapter includes comprehensive error handling:
- LLM Errors: Errors from the underlying LLM are properly propagated
- A2A Protocol Errors: Protocol-specific errors are handled gracefully
- Network Errors: Network-related issues are caught and reported
- Task State Management: Failed tasks are properly marked in the A2A task store
- Memory Usage: The adapter maintains conversation history in memory
- Streaming: Real-time streaming with minimal latency
- Concurrency: Fully async/await compatible
- Resource Management: Proper cleanup of resources
- Model Selection: Choose appropriate model parameters for your use case
- System Prompts: Use clear, specific system prompts to guide LLM behavior
- Error Handling: Always handle potential errors from the LLM
- Resource Cleanup: Ensure proper cleanup when shutting down servers
- Monitoring: Monitor token usage and response times
- Testing: Test with various input types and conversation lengths
- Compilation Errors: Ensure your LLM implements all required
LLMProtocolmethods - Runtime Errors: Check that your LLM handles edge cases properly
- Performance Issues: Monitor token usage and consider caching strategies
- Memory Leaks: Ensure proper cleanup of streaming resources
Enable debug logging to troubleshoot issues:
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardOutput(label: label)
handler.logLevel = .debug // Set to debug level
return handler
}public struct LLMProtocolAdapter: AgentAdapter {
public init(llm: LLMProtocol, configuration: Configuration)
public init(llm: LLMProtocol, model: String?, maxTokens: Int?, temperature: Double?, topP: Double?, systemPrompt: DynamicPrompt?, additionalParameters: JSON?)
public var agentName: String
public var agentDescription: String
public var cardCapabilities: AgentCard.AgentCapabilities
public var skills: [AgentCard.AgentSkill]
public var defaultInputModes: [String]
public var defaultOutputModes: [String]
public func handleSend(_ params: MessageSendParams, store: TaskStore) async throws -> A2ATask
public func handleStream(_ params: MessageSendParams, store: TaskStore, eventSink: @escaping (Encodable) -> Void) async throws
}public struct Configuration: Sendable {
public let model: String
public let maxTokens: Int?
public let temperature: Double?
public let topP: Double?
public let systemPrompt: DynamicPrompt?
public let additionalParameters: JSON?
public init(model: String, maxTokens: Int?, temperature: Double?, topP: Double?, systemPrompt: DynamicPrompt?, additionalParameters: JSON?)
}