Extending Agent Providers¶
This guide explains how to implement custom agent persistence by creating an AgentProvider.
When to Use
Create a custom AgentProvider when you need to load and persist agents from a custom storage backend — for example, a database, file system, or remote registry. The evolution engine uses the provider to load agents and save evolved instructions.
Protocol Definition¶
The AgentProvider protocol defines three methods:
# Protocol definition (from gepa_adk.ports.agent_provider — shown for reference)
class AgentProvider(Protocol):
def get_agent(self, name: str) -> LlmAgent:
"""Load an agent by its unique name."""
...
def save_instruction(self, name: str, instruction: str) -> None:
"""Persist an evolved instruction for a named agent."""
...
def list_agents(self) -> list[str]:
"""List all available agent names."""
...
Error Contracts
get_agent()raisesKeyErrorfor unknown names,ValueErrorfor empty/invalid names.save_instruction()raisesKeyErrorfor unknown names,ValueErrorfor empty/invalid names,IOErrorfor persistence failures.list_agents()returns an empty list when no agents are available.
Step-by-Step Implementation¶
Here is a JsonFileAgentProvider that reads and writes agent configurations from JSON files:
import json
from pathlib import Path
from google.adk.agents import LlmAgent
from gepa_adk import AgentProvider # verify structural subtyping
class JsonFileAgentProvider:
"""Agent provider backed by JSON files on disk."""
def __init__(self, directory: Path) -> None:
self._directory = directory
self._directory.mkdir(parents=True, exist_ok=True)
def _agent_path(self, name: str) -> Path:
return self._directory / f"{name}.json"
def get_agent(self, name: str) -> LlmAgent:
if not name:
raise ValueError("Agent name cannot be empty")
path = self._agent_path(name)
if not path.exists():
raise KeyError(f"Agent not found: {name}")
try:
data = json.loads(path.read_text())
except OSError as e:
raise IOError(f"Failed to read agent file: {path}") from e
return LlmAgent(
name=data["name"],
model=data["model"],
instruction=data.get("instruction", ""),
)
def save_instruction(self, name: str, instruction: str) -> None:
if not name:
raise ValueError("Agent name cannot be empty")
path = self._agent_path(name)
if not path.exists():
raise KeyError(f"Agent not found: {name}")
try:
data = json.loads(path.read_text())
data["instruction"] = instruction
path.write_text(json.dumps(data, indent=2))
except OSError as e:
raise IOError(f"Failed to save instruction: {path}") from e
def list_agents(self) -> list[str]:
return [p.stem for p in self._directory.glob("*.json")]
Note that JsonFileAgentProvider is a plain class — it does not inherit from AgentProvider. gepa-adk uses structural subtyping (ADR-002): any class with matching method signatures satisfies the protocol automatically.
Registration / Injection¶
Unlike ComponentHandler (which uses a registry), AgentProvider is injected directly into the evolution engine or your orchestration code:
from pathlib import Path
provider = JsonFileAgentProvider(Path("./agents"))
agent = provider.get_agent("my_assistant")
There is no global registry for agent providers — you pass the provider to wherever it is needed.
Runnable Example¶
This example demonstrates the full get_agent/save_instruction/list_agents cycle without requiring an LLM API key:
import json
import tempfile
from pathlib import Path
from google.adk.agents import LlmAgent
from gepa_adk import AgentProvider
class JsonFileAgentProvider:
def __init__(self, directory: Path) -> None:
self._directory = directory
self._directory.mkdir(parents=True, exist_ok=True)
def _agent_path(self, name: str) -> Path:
return self._directory / f"{name}.json"
def get_agent(self, name: str) -> LlmAgent:
if not name:
raise ValueError("Agent name cannot be empty")
path = self._agent_path(name)
if not path.exists():
raise KeyError(f"Agent not found: {name}")
data = json.loads(path.read_text())
return LlmAgent(
name=data["name"],
model=data["model"],
instruction=data.get("instruction", ""),
)
def save_instruction(self, name: str, instruction: str) -> None:
if not name:
raise ValueError("Agent name cannot be empty")
path = self._agent_path(name)
if not path.exists():
raise KeyError(f"Agent not found: {name}")
data = json.loads(path.read_text())
data["instruction"] = instruction
path.write_text(json.dumps(data, indent=2))
def list_agents(self) -> list[str]:
return [p.stem for p in self._directory.glob("*.json")]
# Verify protocol compliance
temp_dir = Path(tempfile.mkdtemp())
provider = JsonFileAgentProvider(temp_dir)
assert isinstance(provider, AgentProvider)
# Seed an agent config file
agent_data = {"name": "helper", "model": "gemini-2.5-flash", "instruction": "Be helpful"}
(temp_dir / "helper.json").write_text(json.dumps(agent_data))
# Demonstrate the full cycle
print(f"Available agents: {provider.list_agents()}") # ["helper"]
agent = provider.get_agent("helper")
print(f"Instruction: {agent.instruction}") # "Be helpful"
provider.save_instruction("helper", "Be concise and helpful")
updated = provider.get_agent("helper")
print(f"Updated: {updated.instruction}") # "Be concise and helpful"
To integrate with evolve(), pass the provider to your orchestration code:
from gepa_adk import EvolutionConfig, evolve, run_sync
provider = JsonFileAgentProvider(Path("./agents"))
agent = provider.get_agent("my_assistant")
config = EvolutionConfig(max_iterations=5)
result = run_sync(evolve(agent, trainset, config=config))
# Persist the evolved instruction
provider.save_instruction("my_assistant", result.evolved_components["instruction"])
Common Pitfalls¶
Avoid These Mistakes
Missing validation on empty names. Both get_agent() and save_instruction() must raise ValueError for empty or None names. This is part of the protocol contract — callers expect consistent error behavior.
Not persisting before returning. save_instruction() must fully persist the instruction before returning. If a subsequent get_agent() call does not reflect the saved instruction, the evolution engine may use stale data.
Ignoring IOError handling. File and network operations can fail. Wrap I/O in try/except and raise IOError with a descriptive message so callers can distinguish persistence failures from missing-agent errors.
Inheriting from the Protocol. Do NOT write class MyProvider(AgentProvider):. gepa-adk uses structural subtyping (ADR-002) — just implement the methods with matching signatures. Inheriting from a Protocol is misleading and unnecessary.
Contract Test Skeleton¶
When adding a new provider, write contract tests to verify protocol compliance. This skeleton follows the three-class template established in the project.
Exemplar Reference
This skeleton mirrors the three-class pattern used in tests/contracts/test_stopper_protocol.py and tests/contracts/test_candidate_selector_protocol.py — always check the latest exemplars before starting.
import pytest
from google.adk.agents import LlmAgent
from gepa_adk import AgentProvider
pytestmark = pytest.mark.contract
class TestMyProviderRuntimeCheckable:
"""Positive compliance: isinstance checks."""
def test_satisfies_agent_provider_protocol(self):
provider = MyProvider()
assert isinstance(provider, AgentProvider)
def test_protocol_has_required_methods(self):
provider = MyProvider()
assert hasattr(provider, "get_agent")
assert hasattr(provider, "save_instruction")
assert hasattr(provider, "list_agents")
class TestMyProviderBehavior:
"""Behavioral expectations: return types, error contracts."""
def test_get_agent_returns_llm_agent(self):
provider = MyProvider()
# ... register a test agent ...
agent = provider.get_agent("test_agent")
assert isinstance(agent, LlmAgent)
def test_get_agent_raises_key_error_for_unknown(self):
provider = MyProvider()
with pytest.raises(KeyError):
provider.get_agent("nonexistent")
def test_get_agent_raises_value_error_for_empty(self):
provider = MyProvider()
with pytest.raises(ValueError):
provider.get_agent("")
def test_save_instruction_persists(self):
provider = MyProvider()
# ... register a test agent ...
provider.save_instruction("test_agent", "New instruction")
agent = provider.get_agent("test_agent")
assert agent.instruction == "New instruction"
def test_list_agents_returns_list_of_strings(self):
provider = MyProvider()
result = provider.list_agents()
assert isinstance(result, list)
assert all(isinstance(n, str) for n in result)
def test_list_agents_empty_when_no_agents(self):
provider = MyProvider()
assert provider.list_agents() == []
class TestMyProviderNonCompliance:
"""Negative cases: missing methods fail isinstance."""
def test_missing_save_instruction_fails(self):
class Incomplete:
def get_agent(self, name):
raise NotImplementedError
def list_agents(self):
return []
assert not isinstance(Incomplete(), AgentProvider)
def test_missing_list_agents_fails(self):
class Incomplete:
def get_agent(self, name):
raise NotImplementedError
def save_instruction(self, name, instruction):
pass
assert not isinstance(Incomplete(), AgentProvider)
def test_missing_get_agent_fails(self):
class Incomplete:
def save_instruction(self, name, instruction):
pass
def list_agents(self):
return []
assert not isinstance(Incomplete(), AgentProvider)
API Reference¶
AgentProvider— Protocol definition