A multi-agent system powered by Google ADK with an AG-UI compatible API server and Textual-based terminal client.
brew update
brew tap elicollinson/solenoid
brew install solenoidThen run:
solenoidSee Development section below for building from source with Poetry.
- Multi-Agent Architecture: Hierarchical agent system with specialized agents for different tasks
- AG-UI Protocol: Standards-compliant streaming API with Server-Sent Events (SSE)
- Textual TUI Client: Modern terminal-based chat interface with real-time streaming
- Local Code Execution: Secure WASM sandbox for Python execution with Pygal charting
- Web Research: Brave Search integration for real-time web queries
- MCP Support: Model Context Protocol for extensible tool integration (stdio and HTTP servers)
- Local Memory System: SQLite + FTS5 + sqlite-vec for hybrid semantic/keyword search with BGE reranking
- Configurable Models: Support for Ollama models via LiteLLM with automatic model pulling
- Customizable Prompts: All agent prompts configurable via YAML
- In-App Settings Editor: Edit configuration via
/settingscommand with YAML validation - Slash Commands: Extensible command system for quick actions (
/settings,/help,/clear)
This project uses poetry for dependency management:
# Install dependencies (creates the virtual environment)
poetry installStart both backend and frontend with a single command:
poetry run local-agentThis launches the FastAPI backend silently in the background and opens the Textual TUI in your terminal.
For development or debugging, run the server and client separately:
# Terminal 1: Start the AG-UI API server
poetry run uvicorn app.server.main:app --port 8000
# Terminal 2: Start the terminal client
poetry run terminal-appThe client connects to http://localhost:8000/api/agent by default.
+------------------------------------------------------------------+
| TUI Client |
| (app/ui/agent_app.py) |
| Textual-based terminal interface |
+-----------------------------+------------------------------------+
| AG-UI SSE Stream
v
+------------------------------------------------------------------+
| FastAPI Server |
| (app/server/main.py) |
| AG-UI Protocol endpoint: /api/agent |
+-----------------------------+------------------------------------+
|
v
+------------------------------------------------------------------+
| Agent Hierarchy |
| |
| user_proxy_agent (gateway) |
| +-- prime_agent (router) |
| +-- planning_agent (coordinator) |
| +-- code_executor_agent (WASM Python sandbox) |
| +-- chart_generator_agent (Pygal visualizations) |
| +-- research_agent (Web search + retrieval) |
| +-- mcp_agent (MCP tools integration) |
| +-- generic_executor_agent (General knowledge tasks) |
+------------------------------------------------------------------+
main_bundled.py # Bundled entry point (backend + frontend)
app/
├── __init__.py
├── main.py # TUI-only entry point
├── server/
│ ├── main.py # FastAPI AG-UI server
│ └── manager.py # Backend server lifecycle manager
├── ui/
│ ├── agent_app.py # Textual TUI application
│ ├── agui/ # AG-UI protocol client
│ │ ├── client.py # SSE stream client
│ │ └── types.py # Event type definitions
│ ├── chat_input/ # Input widget (with slash command support)
│ ├── message_list/ # Message display widget
│ └── settings/ # Settings editor UI
│ └── screen.py # Modal settings screen
├── settings/ # Settings management module
│ ├── validator.py # Extensible YAML validation
│ └── manager.py # Settings load/save operations
├── agent/
│ ├── config.py # Settings loader
│ ├── prime_agent/
│ │ ├── agent.py # Prime agent (router)
│ │ └── user_proxy.py # User proxy agent (gateway)
│ ├── planning_agent/
│ │ ├── agent.py # Planning coordinator
│ │ └── generic_executor.py
│ ├── code_executor_agent/ # WASM Python executor
│ ├── chart_generator_agent/ # Pygal chart generation
│ ├── research_agent/ # Web search agent
│ ├── mcp_agent/ # MCP tools agent
│ ├── memory/
│ │ ├── adk_sqlite_memory.py # ADK memory service
│ │ ├── embeddings.py # Nomic embeddings
│ │ ├── search.py # Hybrid search
│ │ └── rerank.py # BGE reranking
│ ├── search/
│ │ ├── universal_search.py # Brave Search
│ │ └── web_retrieval.py # Page content fetching
│ ├── local_execution/
│ │ ├── wasm_engine.py # Wasmtime runtime
│ │ └── adk_wrapper.py # ADK code executor
│ ├── models/
│ │ └── factory.py # LiteLLM model factory
│ └── ollama/
│ └── ollama_app.py # Ollama server management
└── resources/
└── python-wasi/ # Python WASM runtime
| Agent | Role | Capabilities |
|---|---|---|
user_proxy_agent |
Gateway | Receives user requests, delegates to prime_agent, validates responses |
prime_agent |
Router | Decides whether to answer directly or delegate to planning_agent |
planning_agent |
Coordinator | Creates execution plans, delegates to specialist agents |
code_executor_agent |
Code execution | Runs Python in WASM sandbox, math calculations |
chart_generator_agent |
Visualization | Creates Pygal charts (bar, line, pie, scatter, etc.) |
research_agent |
Web research | Searches the web, retrieves page content |
mcp_agent |
Tool integration | Uses MCP servers for documentation lookup, file operations |
generic_executor_agent |
General tasks | Writing, summaries, explanations, general knowledge |
All configuration is managed through app_settings.yaml in the project root.
models:
default:
name: "ministral-3:8b"
provider: "ollama_chat"
context_length: 128000
agent:
name: "ministral-3:8b"
context_length: 128000
extractor:
name: "ministral-3:8b"Model Roles:
default: Fallback model for unspecified rolesagent: Used by all agent roles (requires function calling support)extractor: Used for memory extraction
Model Requirements:
- Models used for the
agentrole must support function calling (tool use) - Recommended:
ministral-3:8b,qwen3:8b,llama3.1, or similar function-calling capable models - Uses Ollama model names from the Ollama library
Automatic Model Pulling: If a configured model is not found in your local Ollama instance, the application automatically attempts to pull it when the agent starts.
search:
provider: "brave"
brave_search_api_key: "YOUR_BRAVE_API_KEY"The research_agent uses Brave Search for web queries. Get an API key from Brave Search API.
MCP servers are configured in app_settings.yaml:
mcp_servers:
# stdio-based server (local process)
filesystem:
command: "npx"
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- "./"
# HTTP-based server (remote)
context7:
type: "http"
url: "https://mcp.context7.com/mcp"
headers:
CONTEXT7_API_KEY: "your-api-key"Supported Server Types:
stdio: Launches a local process (default, requirescommandandargs)http: Connects to a remote HTTP server (requiresurl, optionalheaders)
When the agent starts, it initializes the configured MCP servers and adds their tools to the mcp_agent's toolset. This allows the agent to use these tools seamlessly during conversations.
For example, with the filesystem server configured above, the agent can use tools like list_directory and read_file to interact with your local files.
All agent instructions are configurable in app_settings.yaml:
agent_prompts:
user_proxy_agent: |
You are the User Proxy, the gateway between the user and the agent system...
prime_agent: |
You are the Prime Agent, the intelligent router...
planning_agent: |
You are the Chief Planner...
code_executor_agent: |
You are a Python Code Executor Agent operating in a secure WASM sandbox...
chart_generator_agent: |
You are a Python Chart Generator Agent specializing in Pygal visualizations...
research_agent: |
You are the Research Specialist...
mcp_agent: |
You are an MCP tools specialist...
generic_executor_agent: |
You are the Generic Executor Agent...This allows you to customize agent behavior without modifying code.
The application includes an in-app settings editor accessible via the /settings command. This provides a safe way to modify configuration without directly editing YAML files.
- Type
/settingsin the chat input - A modal screen appears with available configuration sections
- Use arrow keys to navigate between sections
- Press
Enterto edit a section - Modify the YAML in the text editor
- Press
Ctrl+Sor clickSaveto validate and save changes - Press
Escapeor clickBackto return to section list - Press
Escapeagain or clickCloseto exit settings
Editor Keyboard Shortcuts:
| Key | Action |
|---|---|
Ctrl+S |
Save current section |
Escape |
Go back / Close |
Enter |
Select section to edit |
| Section | Description |
|---|---|
models |
Model configuration (defaults and per-agent overrides in models.agents) |
search |
Web search provider and API keys |
mcp_servers |
MCP server connections (stdio and HTTP) |
agent_prompts |
System prompts for each agent |
Changes are validated before saving. The validator checks:
- YAML Syntax: Ensures valid YAML formatting
- Structure: Validates types match expected schema
- Section-specific rules: Custom validation per section type
If validation fails, an error message is displayed and the editor remains open for corrections.
After successfully saving settings, the application prompts you to restart the backend server. This ensures your changes take effect immediately.
Restart Dialog Options:
- Restart Now: Stops the backend, clears caches, and starts a fresh server instance
- Later: Saves settings but leaves the current backend running (manual restart required)
What happens during restart:
- The current uvicorn server is gracefully stopped
- Settings caches are cleared
- A new server instance starts with updated configuration
- The application waits for the health check to pass
- Status updates are shown throughout the process
Note: If running the frontend and backend separately (not in bundled mode), the restart prompt will indicate that manual restart is required.
The settings system is extensible. To add validation for a new section or customize existing validation:
from app.settings.validator import SettingsValidator, ValidationResult, ValidationError
def validate_my_section(value: any, reference: any) -> ValidationResult:
"""Custom validator for 'my_section' settings."""
errors = []
if not isinstance(value, dict):
errors.append(ValidationError("", "Must be a mapping"))
return ValidationResult(is_valid=False, errors=errors)
# Add your validation logic
if 'required_field' not in value:
errors.append(ValidationError("required_field", "This field is required"))
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
parsed_value=value
)
# Register the validator
SettingsValidator.register_validator('my_section', validate_my_section)To add a new slash command to the TUI:
- Edit
app/ui/agent_app.py - Add a case to the
_handle_commandmethod:
def _handle_command(self, command: str, args: str) -> None:
feed = self.query_one(MessageList)
if command == "settings":
self._open_settings()
elif command == "mycommand":
self._handle_my_command(args)
# ... other commands
else:
feed.add_system_message(f"Unknown command: /{command}")
def _handle_my_command(self, args: str) -> None:
"""Handle the /mycommand slash command."""
# Your command logic here
pass- Update the help text in
_show_help()to document your command
| Key | Action |
|---|---|
Enter |
Send message |
Ctrl+J / Shift+Enter |
Insert newline |
Ctrl+C |
Quit application |
Ctrl+L |
Clear message feed |
Escape |
Close settings / Go back |
The TUI supports slash commands for quick actions:
| Command | Description |
|---|---|
/settings |
Open the settings editor |
/clear |
Clear the chat history |
/help |
Show available commands |
| Endpoint | Method | Description |
|---|---|---|
/api/agent |
POST | AG-UI agent run endpoint (SSE stream) |
/ |
GET | API information and agent hierarchy |
/health |
GET | Health check |
/docs |
GET | OpenAPI documentation |
curl -X POST http://localhost:8000/api/agent \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What is 15 factorial?"}]}'from app.ui.agent_app import AgentApp
# Connect to a different backend
app = AgentApp(
base_url="http://other-host:3000",
endpoint="/api/agent"
)
app.run()The agent automatically remembers context across conversations using a local memory system.
- Injection: When a user sends a message, relevant memories are retrieved and injected into the prompt
- Extraction: When a final response is generated, an LLM extracts key facts, preferences, and events
- Storage: Memories are embedded via Ollama (
nomic-embed-text) and stored in SQLite with vector search
| Component | Purpose |
|---|---|
| SQLite + FTS5 | Keyword search |
| sqlite-vec | Vector similarity search (256-dim embeddings) |
| BGE Reranker | Cross-encoder reranking for relevance |
embeddings:
provider: ollama
host: http://localhost:11434
model: nomic-embed-textThe embedding model is pulled automatically on first run. Memories persist in memories.db across restarts.
Python code runs in a secure WASM sandbox:
- Runtime: Wasmtime with Python 3.13 WASI
- Available Libraries: Python standard library + Pygal
- Output: Captured via stdout (print statements)
- Charts: Rendered to SVG files
Example capabilities:
- Mathematical calculations
- Data processing with standard library
- Chart generation (bar, line, pie, scatter, histogram, radar)
Run agent evaluation tests:
poetry run python tests/eval/run_eval.py --runs 5This executes test cases from tests/eval/agent_test_cases.csv and generates reports in tests/eval/eval_results/.
You can package Solenoid as a standalone executable using PyInstaller:
# Install PyInstaller in your poetry environment
poetry add --group dev pyinstaller
# Build the binary using the spec file
poetry run pyinstaller solenoid.specThe executable will be created at dist/solenoid. This binary replicates the behavior of poetry run local-agent and can be distributed without requiring Python or Poetry to be installed.
Note: The binary will be large (~500MB+) due to bundled ML dependencies (transformers, torch, sentence-transformers). Build time is also significant due to dependency collection.
- Python 3.11+
- Poetry (for dependency management)
- Ollama (for local LLM inference)
google-adk- Google Agent Development Kitag-ui-adk- AG-UI protocol adapter for ADKtextual- Terminal UI frameworkfastapi+uvicorn- API serverlitellm- LLM provider abstractionsqlite-vec- Vector search extensionsentence-transformers- BGE rerankerwasmtime- WASM runtime
poetry run pytestCreate a new tool using ADK's FunctionTool:
from google.adk.tools.function_tool import FunctionTool
def my_custom_tool(param: str) -> str:
"""Tool description for the agent."""
return f"Result: {param}"
custom_tool = FunctionTool(func=my_custom_tool)- Create a new directory under
app/agent/ - Define the agent in
agent.py:
from google.adk.agents import Agent
from app.agent.models.factory import get_model
from app.agent.config import get_agent_prompt
agent = Agent(
name="my_agent",
model=get_model("agent"),
instruction=get_agent_prompt("my_agent"),
tools=[...],
)- Add the prompt to
app_settings.yamlunderagent_prompts - Register with the planning_agent's sub_agents list in
app/agent/planning_agent/agent.py
- Built with Google ADK
- AG-UI Protocol from AG-UI
- Terminal UI with Textual
- Local inference with Ollama
- Vector search with sqlite-vec
- Embeddings from Nomic AI
This is a demonstration project for building multi-agent systems with local LLM inference.