A provider-agnostic orchestrator that runs a team of LLM agents as real
MCP clients on top of m3-memory's orchestration primitives. Each agent
is a config entry, not a code change — add or remove agents by editing
team.yaml.
This is the v2 design. The orchestrator is a polling shell; every notification triggers a multi-turn dispatch loop where the LLM can call m3-memory tools mid-turn (read context, write results, mark tasks complete). The orchestrator is no longer "just a wake signal" — it is a full MCP host that brokers between the LLM and the in-process catalog of 44 m3-memory tools.
- Reads
team.yaml, registers every listed agent withagent_register. - Polls each agent's
notificationsqueue on a tick. - For each unread
task_assignedorhandoffnotification, hands off todispatch.dispatch():- builds an OpenAI-shape
toolslist from that agent's allowlist, - calls the agent's provider with tool calling enabled,
- executes any tool calls in-process through
mcp_tool_catalog, - appends results to the message history,
- loops until the model emits no more tool calls (or a bound is hit).
- builds an OpenAI-shape
- Acks the notification on terminal (except transient 5xx exhaustion, which is left on the queue for the next tick).
- Prints a one-line summary per dispatch:
[agent] notif=N terminal=success turns=2 tool_calls=5 elapsed=1.2s
The orchestrator never knows or cares how many agents you have or which
providers they use — that's all in team.yaml.
pip install -e .
m3-team init team.yaml # writes a starter file
# start LM Studio at http://localhost:1234, load any tool-calling model
m3-team check # validates yaml + flags missing env vars
m3-team run # starts pollingIn another terminal, queue work via any MCP client connected to m3-memory (Claude Code, Gemini CLI, etc.):
task_create("Summarize the README", created_by="you")
task_assign(<task_id>, "local-agent")
The local agent picks it up on the next tick, reads the file via m3-memory tools, writes a summary back to memory, and marks the task complete.
Set the API keys for the providers you actually use (only the ones
referenced in team.yaml need to exist):
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export GEMINI_API_KEY=...
export GROK_API_KEY=...Then either via the installed CLI:
m3-team run examples/multi-agent-team/team.yaml…or directly:
cd examples/multi-agent-team
python orchestrator.py team.yamlEdit team.yaml and add a new entry under agents:
- name: my-new-agent
provider: openai # must match a key in `providers`
model: gpt-4.1-mini # whatever the provider exposes today
role: helper
capabilities: [summarize, draft]
tools: # optional — restrict what the agent can call
allow: [memory_search, memory_get, memory_write, task_update]
system_prompt: |
You are a fast helper for short summarization tasks.Restart the orchestrator. The new agent is automatically registered with m3-memory and starts receiving its share of notifications.
If tools.allow is omitted, the agent gets the catalog default
allowlist — every m3-memory tool except destructive ones
(memory_delete, gdpr_export, gdpr_forget, chroma_sync,
memory_maintenance, memory_set_retention, memory_export,
memory_import, agent_offline). Use tools.deny to subtract
individual tools after allow.
If the new provider speaks the OpenAI chat completions format (Grok, Groq, Perplexity, OpenRouter, Together, LM Studio, vLLM, Ollama, etc.), just add a YAML block:
providers:
openrouter:
format: openai_compat
base_url: https://openrouter.ai/api/v1/chat/completions
api_key_env: OPENROUTER_API_KEYThen point an agent at it: provider: openrouter. No code changes.
If the provider has a wire format the orchestrator doesn't know yet, add
a new format value here and a matching branch in
dispatch._call_provider_with_retry(). The translation logic itself
lives in bin/agent_protocol.py — extend AgentProtocol if you need a
brand-new request/response shape.
The orchestrator and dispatch loop only use these m3-memory entry points:
agent_register_impl— at startupnotifications_*— to discover and ack worktask_get_impl— to read task details before dispatchingtask_update_impl— to mark a task failed on bounded-failure terminalsmcp_tool_catalog.execute_tool— to run any tool the agent calls during a turn (this is the new piece — the LLM is a full MCP client, not a one-shot text producer)
Everything else (handoffs between agents, task state transitions, the
audit log in memory_history, memory_write results, allowlist
enforcement, agent_id injection) is driven by the agents themselves
through their MCP tool calls — but those calls are now executed
in-process by the orchestrator instead of being just suggestions in
returned text.
Every dispatch runs under a strict per-call budget. Defaults live in
team.yaml > orchestrator.dispatch_limits:
dispatch_limits:
max_turns: 8 # provider round-trips per dispatch
max_tool_calls: 24 # total tool calls per dispatch
max_seconds: 120 # wall clock per dispatch
max_tokens_per_call: 4096 # provider max_tokens per request
provider_retries: 3 # exponential backoff on 429 / 5xxPlus three implicit guarantees that don't have knobs:
- Loop detection: 4 identical (tool_name, args) calls in a row
trips a
loop_detectedterminal. - Provider retry: 4xx is terminal immediately; 429, 5xx, Anthropic
529, and Gemini
RESOURCE_EXHAUSTEDretry with exponential backoff (0.5s → 1s → 2s, capped at 4s). - Agent identity injection: 6 tools (
memory_write,agent_heartbeat,agent_offline,memory_inbox,notifications_poll,notifications_ack_all) haveagent_idinjected from the dispatch loop's known agent name. The LLM cannot spoof as another agent.
Bounded failures (max_turns, tool_call_budget, timeout,
loop_detected, provider_error_4xx) mark the task as failed with
the terminal name in metadata. Transient provider_error_5xx_exhausted
leaves the task untouched and the notification on the queue, so the
next tick retries.
orchestrator:
poll_interval_seconds: 5
max_iterations: 0 # 0 = forever; N to stop after N idle ticks
max_concurrent_dispatches: 4 # how many notifications run in parallel per tick
notification_kinds: # which kinds wake an agent
- task_assigned
- handoff
dispatch_limits:
# ...see aboveAdd task_completed to notification_kinds if you want planners to
react to subtask completions automatically.