Core Framework (soothe)
The soothe package is the heart of the system — it provides the configuration model, the protocol abstractions that define every pluggable capability, the agent construction pipeline, and the execution runner. The daemon and SDK packages build on top of this foundation.
Source:
packages/soothe/src/soothe/
Architectural Tiers
Soothe separates concerns into three tiers, each with a strict contract:
| Tier | Responsibility | Key Class |
|---|---|---|
| CoreAgent | Pure agent execution — tools, subagents, middleware, LangGraph runtime | CoreAgent |
| SootheRunner | Goal-driven orchestration — planning, strange-loop iteration, thread management | SootheRunner |
| SootheDaemon | Daemon infrastructure — IPC, multi-transport, background scheduling | SootheDaemon |
CoreAgent knows nothing about goals or the daemon. SootheRunner wraps CoreAgent with protocol orchestration. SootheDaemon (documented in daemon-api.md) hosts SootheRunner as a long-running server.
This separation is intentional: CoreAgent can be used standalone for embedding in any async Python process, while SootheRunner adds the agentic loop and protocol pre/post-processing.
Configuration System
SootheConfig (packages/soothe/src/soothe/config/settings.py) is a Pydantic BaseSettings model that holds the entire agent configuration tree. It is the single entry point for all configuration — providers, models, agent behavior, protocols, persistence, observability, security, subagents, and MCP servers.
Key Design Decisions
- Environment variable interpolation: Every string value supports
${ENV_VAR}syntax, recursively expanded across the entire config tree (not just leaf values). Unresolved placeholders are left intact rather than raising. - Dual config source: Values can come from a YAML file (
from_yaml_file) or environment variables with theSOOTHE_prefix. Nested fields use__as separator — e.g.,SOOTHE_PROVIDERS__OPENAI__API_KEY. .envloading happens before imports: The daemon bootstrap callsbootstrap_dotenv()before any langchain imports so provider API keys are visible at import time.- Lazy LLM factory: The
_llm_factoryis lazily initialized to avoid paying provider import costs until a model is actually requested.
Model Routing
ModelRouter maps roles (not tasks) to provider:model strings: default, planner, subagent, embedding, classify. This indirection lets you swap the planner model without touching agent code. The resolve_model(role) and create_chat_model(role) methods bridge config to LangChain BaseChatModel instances.
The propagate_env() method exports resolved API keys into os.environ so downstream libraries (openai, anthropic SDKs) pick them up without explicit passing — important when MCP servers or subagents initialize their own clients.
Minimal Config Example
providers:
openai:
type: openai
api_key: ${OPENAI_API_KEY}
models:
default: openai:gpt-4o
planner: openai:gpt-4o
subagent: openai:gpt-4o-mini
Full field reference:
packages/soothe/src/soothe/config/models.py
Protocol Definitions
Protocols are runtime-agnostic typing.Protocol interfaces that define what a capability does, decoupled from how it’s implemented. The core package defines them; backends implement them; the runner resolves which implementation to use based on config.
Source:
packages/soothe/src/soothe/protocols/
| Protocol | Purpose | RFC | Backends |
|---|---|---|---|
MemoryProtocol |
Remember/recall agent memories with semantic search | RFC-303 | MemUMemory |
DurabilityProtocol |
Thread lifecycle: create, suspend, resume, archive, list | RFC-306 | SQLiteDurability, PostgreSQLDurability |
PlannerProtocol |
Goal decomposition into steps + completion assessment | RFC-304 | LLMPlanner |
PolicyProtocol |
Permission enforcement + child-agent permission narrowing | RFC-305 | ConfigDrivenPolicy |
VectorStoreProtocol |
Vector add/search/delete with metadata filtering | RFC-303 | PGVectorStore, SQLiteVecStore, WeaviateVectorStore |
AsyncPersistStore |
Async key-value persistence (bytes values) | RFC-302 | SQLitePersistStore, PostgreSQLPersistStore |
IdentityProtocol |
Token issuance, auth, external identity mapping | RFC-307 | (re-exported from SDK) |
Why Protocols, Not ABCs?
The protocols use typing.Protocol (structural subtyping) rather than abc.ABC (nominal subtyping). This means a third-party class can satisfy a protocol without importing it — critical for plugin authors who want zero hard dependencies on the core package. The SDK re-exports the same protocol definitions so plugin authors can type-check against them without pulling in daemon code.
Memory API
MemoryProtocol provides four operations: remember(item) stores a MemoryItem and returns its ID; recall(query, limit) retrieves items by semantic relevance; recall_by_tags(tags, limit) retrieves items matching all specified tags (AND logic); forget(item_id) removes an item. Each MemoryItem carries content, tags, importance (0.0–1.0), and arbitrary metadata. Memory is explicitly populated — there is no auto-memorization.
Agent Construction
Source:
packages/soothe/src/soothe/foundation/core/agent/
CoreAgent
CoreAgent is a thin typed wrapper around LangGraph’s CompiledStateGraph. It exposes protocol instances (memory, planner, policy) as typed properties and provides the astream() execution interface. It deliberately contains no goal infrastructure — that’s SootheRunner’s job.
A key design detail: CoreAgent can hold a twin execute graph (execute_graph) compiled without a checkpointer, used for ephemeral execute-phase streaming (IG-477). This avoids checkpoint pollution from the StrangeLoop’s ACT phase.
AgentBuilder & create_soothe_agent()
AgentBuilder encapsulates the complex construction pipeline: protocol resolution, middleware stack assembly, backend initialization, plugin loading, and MCP registry integration. The convenience function create_soothe_agent() is the standard entry point — it creates an AgentBuilder, resolves all protocols from config, and builds the agent.
The middleware stack (assembled in build_soothe_middleware_stack) is ordered:
- PolicyMiddleware — safety enforcement (runs first, can block)
- SystemPromptMiddleware — dynamic prompt injection based on classification
- WorkspaceContextMiddleware — binds thread to a workspace path
- SubagentContextMiddleware — injects context briefing for delegation
Minimal Agent Creation
from soothe.foundation.core.agent import create_soothe_agent
from soothe.config import SootheConfig
agent = create_soothe_agent(SootheConfig.from_yaml_file("config.yml"))
For custom protocol injection (e.g., a test-memory backend), use AgentBuilder directly with .with_memory(), .with_policy(), etc.
Agent Execution (SootheRunner)
Source:
packages/soothe/src/soothe/runner/
SootheRunner
SootheRunner wraps create_soothe_agent() with protocol pre/post-processing and the agentic loop. It is decomposed into four mixins:
| Mixin | Phase |
|---|---|
PhasesMixin |
Pre-stream: thread binding, policy setup, memory recall, plan bootstrap |
StrangeLoopMixin |
The Reason→Act iterative loop (RFC-201, RFC-220) |
AutopilotWorkerMixin |
Single-goal worker entry for daemon-dispatched goals (RFC-222) |
CheckpointMixin |
Progressive checkpointing, artifacts, reports (RFC-220) |
The runner’s astream() method yields StreamChunk objects — a normalized (namespace, mode, data) tuple that extends the raw LangGraph stream with soothe.* custom events for protocol observability. Namespaces include assistant (model output), tool (tool execution), and soothe (protocol-level events like memory recall or policy checks).
Gotcha: Thread ID vs Loop ID
There are two distinct identifiers that are easy to confuse:
thread_id— the LangGraph/durability checkpoint key (configurable.thread_id). This is what persists conversation state.loop_id— the StrangeLoop subscription scope for event streaming. A loop contains one or more threads.
The daemon’s command handlers receive loop_id on the wire but internally bind it to a checkpoint_thread_id before executing. See packages/soothe-daemon/src/soothe_daemon/server/commands.py for the binding logic.
Gotcha: Autopilot is Daemon-Owned
The legacy in-process autonomous multi-goal loop has been removed (RFC-222 Phase D). Autopilot goals are dispatched by the daemon and arrive through LoopRunRequest.autopilot_job, routing to the single-goal worker path. You cannot run autopilot from a bare SootheRunner — it requires the daemon’s goal dispatch infrastructure.
Backend Implementations
Backends are concrete protocol implementations. They live in packages/soothe/src/soothe/backends/ and are resolved by packages/soothe/src/soothe/runner/resolver/.
Resolution Pattern
The resolver functions (resolve_memory, resolve_durability, resolve_planner, resolve_policy, resolve_checkpointer) read config flags and instantiate the appropriate backend. Each is a simple factory — if the protocol is disabled in config, it returns None. This means a missing backend dependency (e.g., psycopg not installed) only matters if you actually enable that backend.
Backend Selection Guide
| Need | Backend | Config Flag |
|---|---|---|
| Local dev / single user | SQLiteDurability + SQLitePersistStore |
backend: sqlite |
| Production / multi-agent | PostgreSQLDurability + PostgreSQLPersistStore |
backend: postgresql |
| Semantic memory | MemUMemory |
memory.enabled: true |
| Vector search (local) | SQLiteVecStore |
vector_store.default: sqlite_vec |
| Vector search (production) | PGVectorStore |
vector_store.default: pgvector |
| Policy enforcement | ConfigDrivenPolicy |
policy.enabled: true |
Policy Profiles
ConfigDrivenPolicy supports named profiles (readonly, standard, privileged) with permission grants and deny_by_default toggles. The narrow_for_child() method is critical for subagent security — it derives a restricted permission set for a delegated subagent, ensuring a child agent never has more permissions than its parent.
See Also
- Daemon API — SootheDaemon server infrastructure
- SDK API — Client and plugin development
- Protocols Layer — Protocol specifications
- Configuration Guide — Full config reference
- RFC-000 System Design — Architecture RFCs