Standalone WFO search agent for deployment. Exposes the orchestration search agent via AG-UI, A2A, and MCP protocols.
cp .env.example .env
# Edit .env with your DATABASE_URI and LLM settings
uv sync
uv run uvicorn orchestrator_agent.app:app --port 8080| Path | Protocol | Description |
|---|---|---|
POST /agui |
AG-UI | SSE streaming for frontend |
POST / |
A2A | Agent-to-agent JSON-RPC (message/send, message/stream) |
GET /.well-known/agent.json |
A2A | Agent card discovery |
GET /.well-known/agent-card.json |
A2A | Agent card discovery (alias) |
/mcp |
MCP | Model Context Protocol tools |
GET /health |
REST | Health check |
docker compose up --build# Install demo dependencies (included in the default dev group)
uv sync
# AG-UI: stream a search query
uv run demos/agui_client.py "find active subscriptions"
# AG-UI: follow-up on the same thread
uv run demos/agui_client.py "export them" <thread-id>
# MCP: run all smoke tests (search, aggregate, ask)
uv run demos/mcp_client.py
# MCP: single tool
uv run demos/mcp_client.py search "active subscriptions"
# A2A: delegate to the agent via CrewAI
uv run demos/a2a_client.py <subscription-uuid>| Variable | Default | Description |
|---|---|---|
DATABASE_URI |
(required) | PostgreSQL connection URI for the WFO database |
WFO_CORE_MCP_URL |
http://localhost:8080/mcp |
URL of orchestrator-core's MCP server (serves the domain tools the agent calls) |
BASE_URL |
http://localhost:8080 |
Public URL of this agent service |
AGENT_MODEL |
openai:gpt-4o |
LLM model in provider:model format |
AGENT_API_BASE |
(none) | Custom base URL for the LLM provider (OpenAI-compatible) or Azure endpoint |
AGENT_API_KEY |
(none) | API key for the LLM provider |
AGENT_API_VERSION |
(none) | API version for Azure OpenAI (e.g. 2024-12-01-preview) |
AGENT_DOMAIN_CONTEXT |
(empty) | Optional free-text domain knowledge appended to the agent system prompt (e.g. identifier conventions and their filter fields). Empty disables the section |
OAUTH2_ACTIVE |
true |
Enable OIDC authentication on incoming requests (via oauth2_lib) |
OAUTH2_OUTBOUND_ACTIVE |
(unset) | Enable OAuth2 client-credentials auth on outgoing requests to orchestrator-core. When unset, follows OAUTH2_ACTIVE; set to true/false to control outbound auth independently of incoming auth |
OIDC_BASE_URL |
(none) | Base URL of the OIDC provider (required when OAUTH2_ACTIVE=true) |
OIDC_CONF_URL |
(none) | OIDC discovery document URL (required when OAUTH2_ACTIVE=true) |
OAUTH2_RESOURCE_SERVER_ID |
(none) | OAuth2 client ID / resource server ID (required when OAUTH2_ACTIVE=true) |
OAUTH2_RESOURCE_SERVER_SECRET |
(none) | OAuth2 client secret / resource server secret (required when OAUTH2_ACTIVE=true) |
OAUTH2_TOKEN_URL |
(none) | OAuth2 token endpoint for outgoing client-credentials requests |
LANGFUSE_ENABLED |
false |
Enable Langfuse OpenTelemetry tracing. Requires the langfuse extra and the LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST environment variables |
By default the agent uses the standard OpenAI API with the OPENAI_API_KEY environment variable. To use a custom OpenAI-compatible endpoint, set AGENT_API_BASE and/or AGENT_API_KEY:
# Local Ollama
AGENT_MODEL=openai:llama3
AGENT_API_BASE=http://localhost:11434/v1
# LiteLLM proxy or other OpenAI-compatible endpoint
AGENT_MODEL=openai:gpt-4o
AGENT_API_BASE=https://my-proxy.example.com/v1
AGENT_API_KEY=sk-custom-key
# Azure OpenAI
AGENT_MODEL=azure:gpt-4o
AGENT_API_BASE=https://my-resource.openai.azure.com/
AGENT_API_KEY=azure-api-key
AGENT_API_VERSION=2024-12-01-previewThe azure: prefix on AGENT_MODEL (or setting AGENT_API_VERSION) selects the Azure provider automatically. When none of AGENT_API_BASE, AGENT_API_KEY, or AGENT_API_VERSION is set, AGENT_MODEL is passed directly to pydantic-ai as a model string (existing behavior).
When a user references an entity by a concrete identifier — a customer name, a subscription id, or a code/number such as IS4443, 4433, or id 1234 — the search skill is guided to extract that token and use it as a high-signal search key: it discovers the matching field and filters with like (substring/typo-tolerant), and only falls back to plain ranking when no field clearly matches.
The agent also picks a retriever per query:
- HYBRID (semantic + fuzzy keyword) — for identifier/code/name-centric lookups.
- SEMANTIC — for descriptive or sentence-like queries.
- FUZZY — for exact tokens, and whenever embeddings are unavailable.
SEMANTIC and HYBRID require embeddings (configured in orchestrator-core via EMBEDDING_API_ENABLED). When embeddings are disabled, the agent automatically uses FUZZY and the prompt stops offering the embedding-based options — so the feature degrades safely with no configuration change.
Use AGENT_DOMAIN_CONTEXT to teach the agent deployment-specific conventions that it cannot infer, for example:
AGENT_DOMAIN_CONTEXT="Circuit codes look like IS#### and map to the imsCircuitId field — filter with like. Customer references are 8-digit numbers — field customerId."This text is injected verbatim as a ## Domain Knowledge section appended to the agent system prompt (so every capability sees it); leaving it empty omits the section entirely.
This repo is a thin MCP client. The agent is a plain pydantic-ai Agent configured with capabilities (capabilities/). The full orchestrator-core MCP toolset (WFO_CORE_MCP_URL) is passed to the Agent; each plugin declares the tools it owns via tools:. When a plugin is deferred, the cross-cutting DeferredToolGate hides its owned tools (and defer_loading hides its instructions) until the model calls load_capability — so the model can't bypass a multi-step skill by calling its tool without the guidance. Tools owned by no plugin (the filtering tools, any new server tool) are always available, so they appear automatically. The search infrastructure (query engine, filters, retrievers, indexing, DB models) lives in orchestrator-core.
Request ──► AG-UI adapter ──► async with Agent: ──► run_stream_events()
──► A2A adapter ──► (full MCP toolset; gate hides deferred plugins' tools)
──► MCP adapter ──► └─► MCPToolset ──► orchestrator-core /mcp
Capabilities are always-on (defer_loading=False). The domain capabilities — search, aggregate, entity (details/lookup), export — are plugins: authored Markdown files loaded at startup (see Plugins below). Each plugin becomes one pydantic-ai capability that bundles its instructions and, when it declares artifact:, the matching behaviour — mapping its tools' JSON results into QueryArtifact / DataArtifact / ExportArtifact metadata for the AG-UI/A2A transport (capabilities/behavior/). Two hooks remain cross-cutting (single invariants across all tools, not plugin-owned): FilterPathGuard enforces that any filters/group_by call is preceded by discover_filter_paths (paths are DB-specific and must not be guessed), and ProcessHistory does sliding-window history trimming. Grouped aggregations and search results are rendered deterministically in code (a Mermaid chart / Markdown table carried as a RenderedBlock) and injected into the answer, so they appear even on text-only clients. MCP tools require an open session, so every run happens inside async with agent:.
Each domain capability is a plugin — a single Markdown file with YAML frontmatter under
capabilities/plugins/:
---
id: search
description: Find subscriptions, products, workflows…
a2a_tags: [search, query, fuzzy, semantic]
examples: [Find all active subscriptions]
defer_loading: false # required: always-on (false) or load on demand (true)
tools: [SEARCH_TOOL] # tools this plugin OWNS (constants from tool_names.py)
artifact: query # map results to an artifact (query/data/export); omit = instructions-only
---
# Searching
…determine the entity_type, then run the search…The body is the prompt, used verbatim — no template language, no substitution, no includes. How to use the tools (filtering, operator choice, discovery order) lives in the MCP tool descriptions on orchestrator-core, not here.
- Prompts describe intent and do not name MCP tools. The model binds "run the search" → the
searchtool from the tool's own description; the plugin owns exactly the action tool it needs, so the choice is unambiguous, andFilterPathGuardenforces the discover-before-filter order. This is the "fat tools, thin prompts" model — verified live (a search query still callsdiscover_filter_paths→search). It keeps prompts free of tool names without any substitution machinery. - A plugin owns the tools it lists in
tools:(constants fromtool_names.py, resolved to live names byowned_tool_names). Ownership is not about prompt references — it drives artifact mapping (a tool's results → the declaredartifact:) andDeferredToolGate(those tools hide with the plugin when deferred). A typo'd constant fails loud at startup. The constants are verified against the live MCP server at startup (verify_tool_contract), so the code and the server can't drift apart silently.
The agent-level system prompt is not a plugin — so it lives at
capabilities/system_prompt.md, beside plugins/ rather than inside it; the operator's
AGENT_DOMAIN_CONTEXT is appended to it (so it reaches every capability, not just search).
Files prefixed _ are never loaded as plugins.
Adding a plugin: drop a <id>.md file with frontmatter + body into the built-in plugins/
directory and restart — a fork, like any other behaviour change. Each plugin projects to an A2A
AgentSkill (when advertise: true) and to one pydantic-ai capability.
Artifact behaviour is declared, not coded. A plugin maps its tool results to a rich artifact by
declaring artifact: query (or data/export) in frontmatter — the values are the ArtifactType
enum, so a bad one fails at load. The loader binds it to a shared builder function
(ARTIFACT_BUILDERS in capabilities/behavior/), carried by one PluginCapability. So a new plugin
that returns a standard result needs no code — it can declare artifact: query and get the
table/chart mapping for free. A plugin with no
artifact: is instructions-only. PluginCapability handles ownership filtering (by the plugin's
tools:) and artifact attachment; the builders (query_artifact/data_artifact/export_artifact)
are the reusable, testable units. Cross-cutting hooks (FilterPathGuard, history trimming, the
DeferredToolGate) are not plugins — they live in capabilities/hooks.py because they're single
invariants across all tools.
(A genuinely new artifact type with bespoke rendering — e.g. a custom Mermaid graph for a future
tool — adds a value to ArtifactType and a builder in ARTIFACT_BUILDERS, or eventually ships as
co-located plugin code; the declarative binding covers reuse of the standard types.)
defer_loading. Frontmatter defer_loading: true makes a capability load on demand: its
instructions stay hidden (pydantic-ai's load_capability) and DeferredToolGate hides its owned
tools until the model loads it — so the tool and its guidance are revealed together by one
load_capability, and the model can't call the tool without the instructions (verified live: a
deferred search makes the model load_capability→discover_filter_paths→search instead of
guessing). pydantic-ai's defer alone hides only instructions, not toolset tools — the gate closes
that gap, keying on the same tools: ownership. All built-ins are false (always-on). The seam
exists for when the capability set grows large enough that on-demand loading is worth the routing.
The A2A adapter uses a2a-sdk server primitives (AgentExecutor, DefaultRequestHandler, A2AFastAPIApplication). The SDK handles JSON-RPC routing, SSE streaming, task lifecycle, and agent card serving. The adapter implements a single WFOAgentExecutor.execute() method that drives the pydantic-ai event stream and publishes A2A events via TaskUpdater. The AgentCard.skills list is projected from the advertised capability specs (skills_from_specs), keeping the advertised skills in sync with the configured capabilities.