Skip to content
Get Started

③ Answers

Agent Types

DeepAgent and BaseAgent — who runs the tool loop, what each does with a turn, and how to configure their providers, prompts and tools

Two agent runtimes ship, and the difference between them is not size — it is who runs the tool loop. Both read the same tool registry and answer on the same POST /api/chat.

DeepAgent — you run the engineBaseAgent — your model consumes ours
Tool loop runshere, in your deploymentin the LLM provider, against /mcp over HTTP
HarnessLangChain create_agent + the deepagents middleware stacknone, on purpose
Virtual filesystemyes, PostgreSQL-backedno
Code executioneval — in-process JavaScript REPLno
Agent Skillsyes, through SkillsMiddlewareno
Chartsa fenced vis-chart block the client rendersnone — the consuming client brings its own
Providers keyPROVIDERS_DEEPPROVIDERS_BASE
Use it whenthis is the default — pick it unless you have a reason not toyou are targeting Claude Desktop, Cursor, ChatGPT Apps or any MCP client

DeepAgent is the workhorse: file reading, computation and multi-step tool use all live there. BaseAgent is deliberately the thinnest possible derivation over the MCP tool surface, so its capability matches exactly what an external MCP client gets: anything BaseAgent cannot do unaided, an outside MCP client cannot do either.

Agents are discovered the same way tools are — by scanning directories at startup.

  1. Each directory in AGENT_DIRS is scanned for .py files subdirectories and names starting with _ are skipped
  2. A class becomes an agent by defining generate_response its name minus a trailing Agent is the agent name — DeepAgentDeep
  3. If it also defines load_llm_clients, that runs now with PROVIDERS_{NAME} as its input, building one client per provider entry
  4. An agent with zero clients is not offered so leaving PROVIDERS_BASE empty is how you switch BaseAgent off
  5. GET /api/models returns the surviving agent/provider pairs and POST /api/chat takes agent and provider as separate fields
Terminal window
curl http://localhost:18080/api/models
response
{"success":true,"code":0,"msg":"ok","data":["Base/gemini-2.5-flash","Deep/claude-sonnet","Deep/gemini-3.5-flash"]}

The client therefore chooses both halves. Omit provider and DeepAgent falls back to DEFAULT_PROVIDER_DEEP, or to gemini-3.5-flash when that is unset; BaseAgent falls back to gemini-2.5-flash. An agent with zero usable clients is not offered at all, so emptying PROVIDERS_BASE is how you switch BaseAgent off.

DeepAgent builds a deepagents agent per turn out of four things: the discovered tools, a system prompt, a PostgreSQL-backed filesystem and a middleware stack.

With an authenticated user, the backend is a CompositeBackend of five mounts. The deepagents-native file tools — ls, read_file, write_file, edit_file, glob, grep — operate on it as if it were a disk:

MountScopeAccessHolds
(default)this sessionread/writescratch space for the turn
/memories/cross-sessionread/writenotes the agent chooses to keep
/uploads/this sessionread-onlythe files attached to this request
/library/cross-sessionread-onlythe user’s earlier parsed files
/skills/packagedread-onlythe Agent Skills from SKILL_DIRS — the agent must never edit its own skills

/uploads/ and /library/ are mirrored from the th_files table as pointers — no bytes are copied into PostgreSQL. Parsed text is inlined so grep works; the raw bytes are surfaced multimodally when the model reads the file. An anonymous call gets an in-memory StateBackend instead, so nothing persists.

Six middlewares wrap a DeepAgent turn, in the order they are applied:

  1. ToolFaultMiddleware Outermost, so a tool that raises is contained instead of ending the turn.
  2. InvalidToolCallRepairMiddleware A tool call whose JSON never parsed is repaired rather than dropped.
  3. ModelCallLimitMiddleware The real per-turn budget: MODEL_CALL_LIMIT model calls (default 50), then the turn ends gracefully. RECURSION_LIMIT is a raw LangGraph ceiling kept only as a backstop.
  4. CodeInterpreterMiddleware From langchain-quickjs: a persistent, in-process JavaScript REPL exposed as eval. If the package is missing the middleware is skipped with a warning and the turn runs without it.
  5. SkillsMiddleware Injects every skill's frontmatter into the prompt at startup and serves the full SKILL.md through the /skills/ mount only when a task calls for it. Skipped for anonymous sessions, which have no mount to read from.
  6. UniversalPromptCachingMiddleware(ttl="5m") Last, so its decision wins. Marks the prompt cacheable on providers that support it and is ignored on those that do not.

Two things deepagents would otherwise contribute are deliberately not present:

  • No task subagent. Streaming a subagent run delays every event until it finishes, and here task would only ever be a no-op self-clone. It is disabled through a registered harness profile rather than through DISALLOWED_TOOLS_DEEP.
  • No write_todos. deepagents 0.7 dropped TodoListMiddleware from its default stack, and DeepAgent does not add it back.

The delete file tool is also excluded by name: the PostgreSQL filesystem backend does not implement it, so it is not offered.

BaseAgent has no LangChain agent loop, no middleware and no virtual filesystem. It renders a prompt, hands our MCP server to the provider, and streams the result — the tool loop belongs to the provider.

The admission rule: BaseAgent clients speak Responses-style APIs only, because the whole point is handing our MCP server to someone else’s agent loop. A provider that offers only Chat Completions is consumed through DeepAgent’s LangChain stack instead — a local function-call loop here would just duplicate it.

ProviderClassDefault modelServer-side MCPStateful
OpenAI ResponsesOpenAIResponsesClientgpt-5-nanoyesyes
Gemini InteractionsGeminiClientgemini-2.5-flashlocal function fallbackyes
DeepSeek ResponsesDeepSeekResponsesClientdeepseek-v4-flashno — function and server-side web_search onlyno
DashScope ResponsesDashScopeClientqwen3.5-flashno — its MCP accepts server_protocol: "sse", ours is streamable HTTPyes

Two more consequences worth knowing before you choose it:

  • It trims history to the last five user turns by default (user_message_threshold).
  • Its provider entries carry no llm_type. The client class is picked from the prefix of the provider keygemini…, gpt…, deepseek…, qwen…/dashscope…. A key matching no known prefix silently gets no client, and the provider then disappears from /api/models.

The same question produces a different artefact depending on which agent answered.

DeepAgentBaseAgent
Produced bythe model writing a fenced vis-chart block of pure-data JSON inlinenothing
Rendered bythe web client, interactively, from the JSON in that blockthe consuming MCP client’s own visualization
Server round-tripnone — no tool call, no PNGn/a

The tools themselves are documented in Built-in Tools.

Each agent reads its own PROVIDERS_ key, suffixed with the agent name in upper case:

config.localdb.yaml
PROVIDERS_DEEP:
gemini-3.5-flash:
llm_type: google-genai
api_key: GOOGLE_API_KEY
model: gemini-3.5-flash
temperature: 1.0
claude-sonnet:
llm_type: openai
api_key: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
model: anthropic/claude-sonnet-4.6
temperature: 0.1
qwen-vl:
llm_type: openai
api_key: DASHSCOPE_API_KEY
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-vl-max
supports_pdf: true
supports_image: true

The key of each entry is the provider name the client asks for. Inside it:

llm_type string default: openai

Which LangChain integration to initialise — google-genai or openai. Anything reached over an OpenAI-compatible endpoint (OpenRouter, DashScope, Volcengine) is openai plus a base_url.

api_key string

The name of the key that holds the secret — not the secret. See the warning below.

base_url string

Endpoint for OpenAI-compatible providers. A literal URL works; a name that resolves to one in the environment or config is also accepted.

model string required

The upstream model id. An entry without it is skipped at startup with a warning.

temperature number

Passed straight through, as is any other key not listed here — include_thoughts, thinking_level and friends reach the LangChain constructor unchanged.

supports_pdf boolean

Declare only for OpenAI-compatible endpoints, whose capabilities the engine cannot infer. true sends PDFs as a native file block; unset serves them as pre-extracted text.

supports_image boolean

Same, for images.

api_key: GOOGLE_API_KEY
$GOOGLE_API_KEY, else config key GOOGLE_API_KEY
api_key: sk-abc123…
looked up as a name, found nothing

PROMPTS_{NAME} is a list of Jinja2 template paths. Each entry may pin the key the template is registered under with a path@key suffix; without one, the key is the file name minus .jinja:

config.yaml
PROMPTS_DEEP:
- agent/prompts/deep.jinja

DeepAgent selects a template by the prompt_name sent with the request, falling back to the first one configured. A per-user prompt stored through the user-prompt API takes precedence over both.

The same two keys as everywhere else, suffixed with the agent name in upper case:

KeyEffect
ALLOWED_TOOLS_{NAME}whitelist — when set, only these tools are offered
DISALLOWED_TOOLS_{NAME}blacklist — applied after the whitelist, so it always wins

The shipped config.yaml leaves every one of them empty, which means “all discovered tools”. It also carries ALLOWED_TOOLS_RTC / DISALLOWED_TOOLS_RTC / PROMPTS_RTC placeholders left behind by a removed agent — harmless, and a reminder that the suffix is just an agent name.

AGENT_DIRS is scanned the same way MCP_TOOL_DIRS is. It ships with one entry — the packaged mirobody/agent — so add your own directory and list it first:

config.{env}.yaml
AGENT_DIRS:
- agents # yours, scanned first
- mirobody/agent # the packaged default

The contract is small: one class per agent, an async generator called generate_response, and — if the agent has providers — a load_llm_clients that turns its PROVIDERS_ entry into clients. Subclassing DeepAgent inherits both.

agents/triage_agent.py
from typing import Any, AsyncGenerator
from mirobody.agent.deep_agent import DeepAgent
class TriageAgent(DeepAgent):
"""Registers as agent name "Triage"; configured with PROVIDERS_TRIAGE,
ALLOWED_TOOLS_TRIAGE, DISALLOWED_TOOLS_TRIAGE and PROMPTS_TRIAGE."""
async def generate_response(
self, user_id: str, messages: list[Any], **kwargs
) -> AsyncGenerator[dict[str, Any], None]:
async for event in super().generate_response(user_id, messages, **kwargs):
yield event