Research: Choose Your Backend¶
Feature: 023-choose-your-backend Date: 2026-03-15 Status: Complete
R1: Google ADK Python SDK API Surface¶
Decision: Use google.adk.agents.Agent (alias for LlmAgent) with Runner for invocation and InMemorySessionService for session management.
Rationale: ADK's Runner.run_async() returns an AsyncGenerator[Event] which maps cleanly to HoloDeck's ExecutionResult. The InMemorySessionService handles multi-turn state without external dependencies. Tools can be plain callables (auto-wrapped as FunctionTool) or McpToolset instances.
Key API Mapping:
- Agent creation: LlmAgent(name, model, instruction, tools, generate_content_config)
- Invocation: runner.run_async(user_id, session_id, new_message) → AsyncGenerator[Event]
- Response extraction: event.is_final_response(), event.content.parts, event.get_function_calls(), event.usage_metadata
- Streaming: RunConfig(streaming_mode=StreamingMode.SSE) → partial events
- MCP tools: McpToolset(connection_params=StdioConnectionParams(command, args))
- Multi-model: For provider: google, model name passes directly (e.g., "gemini-2.5-flash"). For cross-provider (backend: google_adk + other provider), HoloDeck constructs the LiteLLM prefix internally from model.provider (e.g., provider: openai + name: gpt-4o → "openai/gpt-4o").
Alternatives Considered: - Direct Gemini API (google-genai): Too low-level, no tool loop, no MCP support - LangChain Google integration: Adds unnecessary dependency layer
R2: Microsoft Agent Framework API Surface¶
Decision: Use BaseChatClient.as_agent() pattern with provider-specific client classes (OpenAIChatClient, AzureOpenAIChatClient, AnthropicClient, OllamaChatClient).
Rationale: AF's agent.run() returns AgentResponse (non-streaming) or ResponseStream[AgentResponseUpdate, AgentResponse] (streaming), mapping directly to ExecutionResult. The @tool decorator and FunctionTool class handle function tools. MCP tools are first-class via MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool.
Key API Mapping:
- Client creation: Selected based on model.provider (e.g., provider: openai → OpenAIChatClient(api_key, model))
- Agent creation: client.as_agent(name, instructions, tools, default_options, context_providers)
- Invocation: await agent.run(message) → AgentResponse
- Response extraction: result.text, result.usage_details, result.messages (for tool calls)
- Streaming: agent.run(message, stream=True) → ResponseStream
- Sessions: agent.create_session(), pass to agent.run(message, session=session)
- MCP: MCPStdioTool(name, command, args), requires async with agent: context manager
Alternatives Considered: - Semantic Kernel (already used): SK is the existing backend; AF provides a different runtime with middleware/compaction features - AutoGen: Not protocol-driven, harder to adapt
R3: Embedding Service Abstraction¶
Decision: Create an EmbeddingService protocol with embed_batch(texts) -> list[list[float]] method. Single implementation: LiteLLMEmbeddingAdapter wrapping litellm.aembedding(). Replaces all SK TextEmbedding classes entirely.
Rationale: The current tool_initializer.py hard-depends on SK's OpenAITextEmbedding, AzureTextEmbedding, and OllamaTextEmbedding. Research confirmed:
- ADK has no native embedding support (confirmed via PyPI and ADK docs)
- AF (agent-framework-core) has no confirmed embedding support in the core package (OpenAIEmbeddingClient is unverified)
- SK is planned for deprecation
LiteLLM (litellm.aembedding()) provides:
1. 20+ embedding providers (OpenAI, Azure, Gemini, Cohere, Vertex, Bedrock, HuggingFace, Ollama, etc.)
2. Unified OpenAI-compatible async API
3. Already a transitive dependency of google-adk
4. MIT licensed, actively maintained (v1.82.2)
Protocol Design:
@runtime_checkable
class EmbeddingService(Protocol):
async def embed_batch(self, texts: list[str]) -> list[list[float]]: ...
@property
def dimensions(self) -> int: ...
@property
def model_id(self) -> str: ...
Single Adapter — LiteLLMEmbeddingAdapter:
- Maps existing embedding_provider YAML config to LiteLLM params:
- provider: openai + name: text-embedding-3-small → model="text-embedding-3-small"
- provider: azure_openai + name: my-deployment → model="azure/my-deployment", api_base=endpoint
- provider: ollama + name: nomic-embed-text → model="ollama/nomic-embed-text", api_base=endpoint
- api_key, endpoint → passed through as api_key, api_base
- api_version → passed through for Azure
What is removed:
- All SK TextEmbedding imports from tool_initializer.py
- No SKEmbeddingAdapter (SK embedding classes no longer used)
- No AFEmbeddingAdapter / af_embedding_adapter.py (AF has no confirmed embedding API)
Alternatives Considered: - Keep SK embedding classes as default adapter: Rejected (SK is being deprecated; adds unnecessary dependency) - Per-backend embedding adapters (SK, AF, ADK): Rejected (neither ADK nor AF has confirmed embedding support; multiple adapters for no benefit) - No protocol, call LiteLLM inline: Rejected (protocol enables testability via mocking)
R4: Tool Adapter Patterns Per Backend¶
Decision: Create per-backend tool adapter modules (adk_tool_adapters.py, af_tool_adapters.py) following the existing tool_adapters.py (Claude) pattern.
Tool Type Mapping:
| HoloDeck Tool | ADK Adaptation | AF Adaptation |
|---|---|---|
| VectorstoreTool | Plain async callable wrapping instance.search() |
FunctionTool(func=search_fn) |
| HierarchicalDocumentTool | Plain async callable wrapping instance.search() |
FunctionTool(func=search_fn) |
| FunctionTool | Load Python function, pass directly (ADK auto-wraps) | FunctionTool(func=loaded_fn) or @tool |
| MCPTool (stdio) | McpToolset(StdioConnectionParams(command, args)) |
MCPStdioTool(name, command, args) |
| MCPTool (sse/http) | McpToolset(SseConnectionParams(url)) / McpToolset(StreamableHTTPConnectionParams(url)) |
MCPStreamableHTTPTool(name, url) |
| MCPTool (websocket) | McpToolset(SseConnectionParams(url)) (ADK may not have native WS) |
MCPWebsocketTool(name, url) |
| SkillTool | Native skill runtime: ADK agent composition with skill instructions + filtered parent tools | Native skill runtime: AF agent delegation with skill instructions + filtered parent tools |
Alternatives Considered: - Single universal adapter module: Rejected (each backend has different tool APIs; separate modules are cleaner) - Wrapping all tools as MCP servers: Over-engineering; function tools don't need MCP overhead
R5: Provider Client Selection (Agent Framework)¶
Decision: AF reads model.provider directly to select the appropriate client class. No auto-detection or model name prefix matching needed.
Provider-to-Client Mapping:
model.provider |
AF Client Class |
|---|---|
openai |
OpenAIChatClient |
azure_openai |
AzureOpenAIChatClient |
anthropic |
AnthropicClient |
ollama |
OllamaChatClient |
When model.provider is set, the AF backend directly instantiates the corresponding client. No sub_provider field is needed.
Note: provider: google is not supported by AF — no Google/Gemini client exists in the Agent Framework. Backend/provider compatibility validation (see R8) prevents this combination.
Alternatives Considered:
- Model name prefix matching with sub_provider override: Rejected — model.provider already contains the information; prefix matching adds fragile heuristics
- Always require explicit client specification: Rejected — model.provider is always present and unambiguous
R6: Dependency Versioning Strategy¶
Decision: Pin to specific tested versions in optional dependency groups. Label backends as "preview" in documentation.
Pinned Versions:
- google-adk: Pin to latest stable or RC at time of implementation (e.g., google-adk>=1.2.0,<2.0.0)
- agent-framework-core: Pin to ==1.0.0rc4 (exact RC pin until GA)
- Provider-specific AF packages: agent-framework-anthropic, agent-framework-ollama as optional sub-extras
pyproject.toml additions:
[project.optional-dependencies]
google-adk = ["google-adk>=1.2.0,<2.0.0"]
agent-framework = [
"agent-framework-core==1.0.0rc4",
]
agent-framework-anthropic = [
"agent-framework-core==1.0.0rc4",
"agent-framework-anthropic==1.0.0rc4",
]
agent-framework-ollama = [
"agent-framework-core==1.0.0rc4",
"agent-framework-ollama==1.0.0rc4",
]
Note: Version ranges above are placeholders. Exact package names and available versions must be validated as a Phase 0 prerequisite task before implementation begins. See plan.md for the validation task.
Alternatives Considered: - Wait for GA: Blocks feature indefinitely; RC APIs are stable enough for preview - Unpinned ranges: Risk of silent breakage from RC API changes
R7: SkillTool Design (Replacing PromptTool)¶
Decision: Replace the unimplemented PromptTool (type: prompt) with SkillTool (type: skill) following the Agent Skills specification. Skills use each backend's native skill/sub-agent runtime.
Rationale: PromptTool was defined in the Pydantic schema but never implemented — no backend initializes, adapts, or executes prompt tools. The concept (Jinja2 template → LLM call) conflated template rendering with agentic invocation. The Agent Skills spec provides a cleaner model: skills are sub-agent invocations with their own instructions and scoped tool access, running on the parent's backend via its native runtime.
Key Design Decisions:
- Hybrid form: Inline (instructions in agent.yaml) for simple skills; file-based (path to skill directory with SKILL.md) for complex skills with scripts/references/assets
- Tool access: Skills reference parent tools by name via allowed_tools in agent.yaml. None = no tool access. SKILL.md's allowed-tools frontmatter is for the backend's native tool permissions and is NOT merged.
- Backend inheritance: Skills run on the same backend as the parent agent. No cross-backend skill invocation. Each backend uses its native skill/sub-agent runtime.
- SKILL.md parsing: Each backend (ADK, AF, Claude) handles SKILL.md content natively via its own skill runtime. HoloDeck only needs to parse the YAML frontmatter (via existing PyYAML dependency) for config-time validation (name, description required fields). No dedicated skill_loader.py needed.
- No model override: Skills inherit the parent agent's model entirely. No model field on SkillTool.
- Name format: Follows Agent Skills spec — lowercase alphanumeric + hyphens, 1-64 chars. This is a different pattern from other tool types which use ^[0-9A-Za-z_]+$.
- SK excluded: SK backend is planned for deprecation and receives no SkillTool support.
Alternatives Considered:
- Keep PromptTool and implement it: Rejected — Jinja2 template rendering is too limited; skills are a strictly better abstraction
- Skills as a separate top-level config (not in tools): Rejected — skills are invoked as tools by the agent, so they belong in the tools list
- Allow skills to define their own tools (not just reference parent tools): Rejected — creates tool lifecycle complexity; skills should compose from the parent's tool set
R8: Backend/Provider Decoupling¶
Decision: Introduce a separate BackendEnum (semantic_kernel, claude, google_adk, agent_framework) alongside the existing ProviderEnum. Add an optional top-level backend field to agent configuration. When omitted, the backend is auto-detected from model.provider.
Rationale: The original design overloaded model.provider with two independent concerns:
1. Provider — which LLM API serves the model (authentication, API format)
2. Backend — which agent runtime executes the agent (tool handling, sessions, orchestration)
This prevented valid combinations like "run ADK with an OpenAI model" or "run Agent Framework with an Anthropic model."
Default Routing Table:
| Provider | Default Backend | Rationale |
|---|---|---|
openai |
agent_framework |
AF is the recommended OpenAI runtime; SK planned for deprecation |
azure_openai |
agent_framework |
AF has native Azure OpenAI client support |
anthropic |
claude |
Claude Agent SDK is the native Anthropic runtime |
ollama |
claude |
Claude SDK supports Ollama models natively |
google |
google_adk |
ADK is the native Google/Gemini runtime |
Breaking Change: openai, azure_openai, and ollama previously defaulted to semantic_kernel. Users who depend on SK behavior must add backend: semantic_kernel explicitly. Migration path: add backend: semantic_kernel to existing agent.yaml files that use these providers and rely on SK-specific behavior.
Backend/Provider Compatibility Matrix:
| Backend | Compatible Providers |
|---|---|
semantic_kernel |
openai, azure_openai, ollama |
claude |
anthropic |
google_adk |
google, openai, azure_openai, anthropic, ollama (via LiteLLM) |
agent_framework |
openai, azure_openai, anthropic, ollama |
Alternatives Rejected:
- Nested model.backend field: Rejected — backend is an agent-level concern (runtime), not a model-level concern (API)
- Required backend field: Rejected — auto-detection provides good defaults for the common case; explicit field is for advanced cross-provider scenarios
- Provider-as-backend (status quo): Rejected — prevents cross-provider backend selection and creates artificial provider values that don't represent LLM APIs