Skip to content
EpistasisLabPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

Motoro

A headless agentic platform: the Sense-Reason-Plan-Act runtime, the pattern engine, the LLM bridge, MCP integration, memory, and the persistence beneath them.

Core ships no FastAPI application, no routers, and no UI. A product owns its HTTP layer, its frontend, and its own domain tables, and depends on core for the machinery. ASAREE builds on it today; ecoxai is planned.

See docs/DESIGN.md for the architectural boundary and the rationale behind it — why core has no user model, why it owns a separate database, how patterns and memory and MCP registration are designed, and the real bugs that shaped each of those decisions.

Features

Area What core gives you
Agent runtime Sense → Reason → Plan → Act over durable agents and runs
Pattern engine Plugin patterns that hook the phase boundaries
LLM bridge Structured output across five provider paths
Output contracts A universal envelope, optionally extracted into typed fields
Memory Working, episodic and semantic behind one service
MCP Client, registry, persisted servers, bundled servers
Agent Skills SKILL.md with three-level progressive disclosure
Security Injection fencing, SSRF guard, allowlists, encryption at rest
Observability OTel traces and metrics, per-model cost tracking
Persistence Core's own Postgres schema and migration chain

Agent runtime

  • create_agent provisions once — agents are durable resources
  • create_run and execute_run are separate, so a product can run inline or hand execution to a worker
  • fail_run closes out a run whose process died; it's a no-op on a run already in a terminal status, so a stale-run sweep can't clobber a live loop
  • Runs carry heartbeats for that sweep to key on

Pattern engine

  • Patterns are plugin classes registered by slug — reason_act and single_agent_baseline ship built in
  • Composition resolves hook ordering, detects conflicts, validates dependencies
  • The class is the source of truth for metadata, so pattern_config is validated and pattern_params defaulted at agent-creation time — not after the model has been billed

LLM bridge

  • Multi-provider structured output via litellm + Instructor
  • Anthropic (direct, Microsoft Foundry, or Bedrock), OpenAI, OpenRouter, and any self-hosted OpenAI-compatible server
  • model_capabilities decides per model whether to send temperature or the adaptive-thinking effort dial, so call sites don't have to know
  • Structured-output retries, per-call timeouts, equal-jitter backoff

Output contracts

  • Every run yields a universal envelope, assembled from run data with no extra LLM call
  • An agent declaring an output_contract also gets one extraction pass into typed fields
  • Every contracted field is optional and inference is forbidden — a failed extraction degrades to None with a caveat rather than killing the run

Memory

  • Working — Redis, intra-run, sliding-window or LLM-summarization eviction, guarded by a per-run lock so concurrent writers can't double-summarize
  • Episodic — per-run summaries that carry learning across runs
  • Semantic — pgvector, embedding-based recall
  • All three behind one MemoryService

MCP

  • Client over stdio, HTTP and SSE; registry for concurrent connections; tool adapters that expose MCP tools to the Act phase
  • Server configs persist — every process after the first reconnects from the table, not from code
  • Subprocess environments are allowlisted, so credentials don't leak into untrusted servers
  • Bundled and ready to register: an OKF (Open Knowledge Format) server that mediates a knowledge bundle instead of exposing the filesystem — reads return pre-parsed, filtered JSON; writes enforce the spec's structural rules

Agent Skills

  • SKILL.md documents with YAML frontmatter, stored as rows
  • Three disclosure levels honoured end to end: the index always in context, the body loaded on request as a tool result, bundled reference files read only when the body points at one
  • A skill can be ingested as its whole directory, not just its SKILL.md

Security

  • Prompt-injection fencing that delimits user-controlled text as data
  • SSRF guard for every user-supplied outbound URL
  • MCP command allowlist
  • Fernet encryption for secrets at rest
  • Credential scrubbing over error messages and logs

Observability

  • OTel tracing: a run is a trace; each phase, LLM call and tool call is a span
  • Error-aware sampling force-keeps failed spans the ratio sampler would drop
  • Metrics via Prometheus pull or OTLP push, under a configurable prefix so two products don't collide in one registry
  • Per-model cost tracking with configurable overrides and a litellm fallback

Persistence

  • Core owns its own PostgreSQL database and Alembic chain, independent of the product's
  • python -m motoro.migrations upgrade is a deploy step; every migration is safe to re-run over an existing schema
  • Worker helpers keep long-running processes resilient to dropped connections

Layout

src/motoro/
  config.py           CoreSettings + configure() + the settings proxy
  runner.py           create_agent/create_run/execute_run/fail_run — the public lifecycle
  models/             SQLAlchemy models; models/base.py owns the shared Base
  schemas/            Pydantic contracts
  services/           LLM bridge, cost, retry, scrubbing, memory service, MCP registration, encryption
  mcp/                MCP client, registry, tool adapters (transport only)
  mcp_servers/        bundled, ready-to-register MCP servers that ship with core itself
  memory/             working (Redis), episodic + semantic (pgvector), embedding
  engine/             the SRPA loop, and later the pattern engine
  migrations/         Alembic environment + versions for core's own database
  worker/             long-running worker helpers (e.g. DB connection resilience)
  observability/      tracing + metrics
  security/           prompt-injection fencing (isolation is undecided), MCP command allowlist, SSRF guard
tests/

Installing

[project]
dependencies = ["motoro"]

[tool.uv.sources]
motoro = { git = "https://github.com/EpistasisLab/motoro.git", tag = "v0.2.0" }

Pin by tag (semver, against core's public surface), not a raw commit SHA. To cut a new release: land the change on main, confirm dependent products still work against it, then git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z.

That tag is the version: hatch-vcs derives both the package version and motoro.__version__ from it at build time, so there is no version string to edit here and none to forget. Tagging is the whole procedure.

from pydantic_settings import SettingsConfigDict
from motoro import CoreSettings, configure

class Settings(CoreSettings):
    model_config = SettingsConfigDict(env_prefix="ASAREE_", env_file=".env", extra="ignore")
    # product-only fields here

configure(Settings())   # before any core module reads a setting

Core's only package-root export is lifecycle; everything else is imported from its own module (settings, MCP client, patterns, memory, ...) — see docs/DESIGN.md for why there's no facade.

Using core in a product

A product does not run a script or shell out to a CLI — it imports the same functions directly into wherever its own request handling already lives (a FastAPI router, a Django view, a worker task, anything). This is the actual integration shape; the examples/ CLI walkthrough further down ("Running the examples") is for trying core out, not for a real product to run.

from motoro.runner import create_agent, create_run, execute_run
from motoro.mcp.registry import get_registry

# e.g. behind POST /agents — once; agents are durable resources
agent = await create_agent(
    name="my-agent", goal="...", system_prompt="...",
    model_config={"provider": "anthropic", "model": "claude-sonnet-5"},
    owner_id=user.id,
)

# e.g. behind POST /runs — per request
run = await create_run(agent_id=agent.id, user_input=user_input, owner_id=user.id)
await execute_run(run_id=run.id, registry=get_registry(), available_tools=[...])

create_run and execute_run are separate calls so a product can enqueue execution instead of blocking on it (create the run in the request, execute it in a worker) — calling both inline in the same request, like above, is just as valid; core has no opinion on which a product picks.

Per-run agent configuration

Use config_snapshot to run concurrent variants of one agent. It accepts a dict, AgentSnapshotUpdate, or AgentUpdate, with the same public field names as the agent schemas. Snapshot validation accepts empty names/goals and limit values allowed in stored agents; model and pattern validation remain unchanged. Omitted fields are captured from the agent when the run is created; each supplied field replaces that whole setting. Optional fields can be explicitly cleared with None.

run = await create_run(
    agent_id=agent.id,
    user_input=user_input,
    config_snapshot={
        "system_prompt": "Review the evidence carefully.",
        "model_config": {"provider": "openai", "model": "gpt-4o", "max_tokens": 2048},
        "pattern_config": {
            "execution_pattern": "reason_act",
            "pattern_params": {"reason_act": {"max_iterations": 6}},
        },
        "skill_config": {"skill_ids": [str(skill.id)]},
        "output_contract": {"fields": [{"name": "approved", "type": "bool"}]},
    },
)

Snapshots cover name, goal, description, system prompt, model, pattern, skills, output contract, memory, budget and duration. API keys are excluded from snapshots; credentials still resolve per call. Skill IDs are pinned, while skill bodies resolve at execution time. Tools remain per execution through execute_run(available_tools=...).

model_config_overrides still shallow-merges on top of the snapshot. A pinned pattern takes precedence over pattern_overrides; new callers should put their pattern in the snapshot. ReAct telemetry is stored in run.run_metadata["reason_act_state"], preserving other metadata, instead of writing runtime state into pattern_overrides.

Without a snapshot, the run reads the agent's settings when execution starts, then records them in run.config_snapshot for audit and subsequent executions. motoro.runner.effective_agent_config(run) centralizes this resolution and returns a validated AgentExecutionConfig from motoro.schemas.run_config, which accepts stored agent values without create-request constraints. Budget and duration remain product-enforced settings; the helper exposes their pinned values without requiring a product to reread the shared agent.

Same-named MCP tools

Pass the plain catalog from registry.get_all_tools() as available_tools. When two granted servers expose the same bare name, such as ping, Motoro derives unique provider function names from server.tool and maps calls back to the appropriate server. Tools with unique bare names keep those readable provider names. Text-based selection uses namespaced names for collisions too; planned steps must use the namespaced names shown in the prompt catalog when the bare name collides. Plan validation clears ambiguous bare names so Act can select an available tool explicitly.

Descriptors must retain server_id, server, tool_name and name. Legacy descriptors whose tool_name was rewritten to server.tool are also accepted. Dispatch still checks the resolved server and tool against the run's grants.

from motoro.runner import fail_run

# e.g. a stale-run sweep keyed on AgentRun.last_heartbeat_at
await fail_run(run_id=run.id, error="worker died mid-flight")

execute_run's own commit is the only thing that normally moves a run out of a non-terminal status. fail_run is the way in from outside that loop — for a run whose process died (killed worker, crashed host) with nothing left to close it out. It's a no-op on a run that's already COMPLETED, FAILED or CANCELLED, so a detector racing a slow-but-live execute_run can't clobber a status it already wrote.

Requirements

Core requires PostgreSQL and Redis. A product provisions both, points database_url at a database for core, and applies core's own schema as a deploy step:

python -m motoro.migrations upgrade --url "$MOTORO_DATABASE_URL"   # core's DB
alembic upgrade head                                                     # the product's own DB

The two databases and their migration chains are independent — no ordering constraint, no shared tables.

Setting a provider

ANTHROPIC_API_KEY=sk-ant-...
# or, for Anthropic on Microsoft Foundry — BOTH are required:
ANTHROPIC_FOUNDRY_API_KEY=...
ANTHROPIC_FOUNDRY_RESOURCE=my-resource
# or OpenRouter:
OPENROUTER_API_KEY=sk-or-...
# or a self-hosted OpenAI-compatible server (LM Studio, vLLM, llama.cpp, …) —
# LOCAL_LLM_API_BASE is required (there's no default host); LOCAL_LLM_API_KEY
# is optional since most such servers don't check one:
LOCAL_LLM_API_BASE=http://localhost:8000/v1

These are bare-name conventional variables, not prefixed by the product's own settings prefix. A product with its own credential store installs a resolver:

from motoro.services.credentials import set_credential_resolver
set_credential_resolver(my_resolver)   # e.g. ASAREE reads a per-user settings table

Model capabilities

Unknown models default to effort-only capabilities and send reasoning_effort="medium" without temperature. Known entries keep their existing controls and supported effort levels, including temperature-only GPT-4o, GPT-4o Mini, GPT-5 Chat, and Claude Haiku 4.5.

ASAREE should validate its live capability metadata with motoro.services.model_capabilities.ModelCapabilities.model_validate() and populate ModelConfig.resolved_capabilities (or the same field on PartialModelConfig at its agent/API boundary). Use that validated object to render the UI and carry it into execution:

from motoro.schemas.agent import ModelConfig
from motoro.services.llm_service import LLMService
from motoro.services.model_capabilities import ModelCapabilities, get_capabilities

# live_metadata is supplied by the product's capability discovery.
caps = ModelCapabilities.model_validate(live_metadata)
# Without live discovery: caps = get_capabilities("my-model")
config = ModelConfig(
    provider="openai",
    model="my-model",
    resolved_capabilities=caps,
    effort=None,  # effort-only requests use caps.default_effort
)
# Render controls from config.resolved_capabilities in ASAREE's UI.
text, record = await LLMService().complete_text(
    config, [{"role": "user", "content": "Hello"}]
)

Resolved capabilities override the registry, including conflicting known entries. When absent, execution calls get_capabilities() with the effective provider model string. The same decision applies to structured completions, text completions, native tool calls, tool selection, and their retries. The field survives JSON serialization and Motoro's agent/run execution path; each fallback_models entry carries its own capabilities independently. Supply metadata for the effective model, including any deployment mapping, and refresh or clear it when changing the model or provider.

Effort-capable metadata must declare nonempty, unique effort_levels and a default_effort from that list. Levels may be provider-specific strings. An explicit effort is checked at request construction; an unsupported value raises UnsupportedEffortError (a ValueError) before sending a request, without retrying or switching to a fallback. Temperature-only models reject configured effort too. Models supporting both controls retain temperature-first behavior. Models supporting neither send neither control.

For custom temperature-only models, pass resolved_capabilities=TEMPERATURE_ONLY_CAPABILITIES, imported from motoro.services.model_capabilities, or equivalent metadata:

{"supports_temperature": True, "supports_effort": False,
 "effort_levels": [], "default_effort": None}

The temperature=0.7 schema default remains available for temperature-capable models; effort-only requests omit it entirely.

Timeouts

llm_call_timeout_seconds (default 120) caps a single completion attempt, and hook_timeout_seconds (default 30) wraps one pattern-hook invocation around it — both are deployment-dependent, not a core concern, and both are plain CoreSettings fields a product overrides in its own subclass. Keep hook_timeout_seconds comfortably above llm_call_timeout_seconds: the abort error always names hook_timeout_seconds regardless of which of the two actually fired, so a run failing with a "Hook '...' timed out after 30s" message is often really the inner LLM-call timeout being too tight for a slow provider or a high reasoning-effort completion.

Development

cp .env.example .env
docker compose up -d                    # postgres :5453, redis :6381, schema applied

python -m venv .venv && .venv/bin/pip install -e '.[dev]'
set -a && . ./.env && set +a

.venv/bin/pytest
.venv/bin/ruff check src tests scripts examples
.venv/bin/mypy src
.venv/bin/lint-imports                  # core must never import a product

Running the examples

examples/ walks the same functions shown above from a bare CLI, laid out as the three phases a product genuinely has — useful for seeing core work end to end with no HTTP app around it, not something a real product runs.

# 1. DEPLOY STEP — once per release, before the app starts
docker compose up -d                    # the motoro-migrate service does this
# ...or, without Compose:
python -m motoro.migrations upgrade --url "$MOTORO_DATABASE_URL"

# 2. PROVISIONING — once; agents are durable resources
python examples/provision.py            # prints the agent id; idempotent by name

# 3. RUNTIME — per request
python examples/run.py --agent-id <id> --input "What is 17 * 23?"

# 3b. RUNTIME, with episodic memory
python examples/memory_run.py --input "My favourite programming language is Rust."
python examples/memory_run.py --input "What did I say my favourite language was?"

# 3c. RUNTIME, with a real MCP tool call
python examples/mcp_run.py --input "What is the secret code for alpha?" --trace

examples/settings.py holds the one thing all three share: a CoreSettings subclass with the product's env prefix. See docs/DESIGN.md for why migrations are a deploy step rather than run at app startup.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages