-
Notifications
You must be signed in to change notification settings - Fork 0
The Brain
shelldon has no model of its own. A Raspberry Pi Zero 2W (416 MB RAM, aarch64) cannot host a usable LLM, so every "thought" is a network call to a remote provider — GLM via Z.ai, native Anthropic Claude, or any OpenAI-compatible endpoint. The component that makes those calls is the broker, and it is deliberately the only part of the system allowed to hold a credential or touch the network.
This page documents the broker: its role as the egress and safety boundary, the provider abstraction, the ordered chain, automatic fallback, retry, the credential vault, native function-calling, and what happens when every provider is down.
Related pages: Architecture · How a Turn Works · The Bus · Self-Coding
All source lives under shelldon/broker/:
shelldon/broker/
├── __init__.py # "sole credential holder + model/tool egress"
├── provider.py # the LLMProvider seam + the two error types
├── anthropic_provider.py # Anthropic-format adapter (GLM via Z.ai + native Claude)
├── openai_provider.py # OpenAI-compatible adapter (Ollama / OpenAI / OpenRouter / …)
├── chain.py # config-driven ordered chain builder
├── broker.py # retry (handle_job) + fallback (handle_job_chain)
├── service.py # the bus-client loop (run_broker)
└── vault.py # the broker-only secret-surfacing gate
The architecture rule (AD-2) is blunt: the broker is a separate process and the only holder of credentials and the only code path out to a model or tool. Nothing else in the system — not the core, not the worker, not a plugin — ever holds an API key or opens a socket to a provider.
This is enforced three ways, two of them mechanical:
- Process separation. The broker runs as its own process and is the privileged actor (the service uid). Other actors talk to it only over the bus.
-
Credentials never travel on the bus. A
Jobenvelope carries the prompt and nothing credential-shaped. The broker injects the key internally when it constructs the provider. There is no key field on anyJob,Completion, orEnvelope. -
An import-linter rule keeps
core/LLM-free (AD-1). The[tool.importlinter]"core is LLM-free" contract inpyproject.tomlforbidscore/from importinganthropic,openai,google,litellm,zhipuai, orollama. CI runsuv run lint-imports; if core ever imported a provider SDK the build fails. The provider SDKs are imported only underbroker/.
The practical payoff: a bug, a prompt-injection, or a rogue self-written tool in the untrusted "brain" path cannot read your keys or call the network on its own — it has to go through the broker, which is small, auditable, and does no pet-domain parsing.
Every provider, regardless of vendor SDK, sits behind one tiny interface defined in shelldon/broker/provider.py:
@runtime_checkable
class LLMProvider(Protocol):
name: str # audit label (chain preset name, e.g. "glm") — never a credential
async def complete(self, prompt: str) -> str: ...
async def complete_with_tools(
self, messages: list[Message], tools: list[ToolDefinition]
) -> Completion: ...Two failure types accompany it, and they are the load-bearing contract of the whole brain:
class TransientProviderError(Exception):
"""Retryable — timeout, rate-limit, or a 5xx."""
class PermanentProviderError(Exception):
"""Non-retryable — bad request, auth, or any other 4xx."""The rule is absolute: an adapter maps its SDK's exceptions to exactly one of these two types and never lets a raw vendor exception escape. The broker's retry and fallback logic keys only on TransientProviderError / PermanentProviderError, so it stays testable with fakes and never needs to know a vendor SDK exists. A new adapter that leaked a raw SDK exception would break fallback.
There are exactly two adapters, grouped by wire format, not by vendor.
shelldon/broker/anthropic_provider.py::AnthropicProvider wraps anthropic.AsyncAnthropic. The same code serves two very different backends — the only difference is config:
-
GLM (the default) — point
base_urlat Z.ai's Anthropic-compatible endpoint (https://api.z.ai/api/anthropic) with a GLM key and a GLM model id. -
Native Claude — leave
base_urlat the SDK default (api.anthropic.com) with an Anthropic key and a Claude model.
Error mapping (the canonical template every adapter copies):
except (APITimeoutError, APIConnectionError, RateLimitError, InternalServerError) as exc:
raise TransientProviderError(type(exc).__name__) from exc
except anthropic.APIStatusError as exc:
if exc.status_code >= 500:
raise TransientProviderError(type(exc).__name__) from exc
raise PermanentProviderError(f"status {exc.status_code}") from excNote type(exc).__name__, not str(exc). SDK exception messages can embed request headers or keys, and that text would otherwise cross the bus inside Completion.error. Only the exception type name is surfaced; the full detail stays in the chained __cause__ for broker-local logs. A reply with no text and no tool calls raises PermanentProviderError("provider returned no text") — a no-text reply is a failed turn, not a silent empty success.
shelldon/broker/openai_provider.py::OpenAIProvider wraps openai.AsyncOpenAI. One adapter, many endpoints — base_url is the only thing that changes between Ollama-on-the-LAN, OpenAI, OpenRouter, Groq, Cerebras, NVIDIA, Mistral, GitHub Models, and Gemini (via its OpenAI-compatible endpoint). The error mapping mirrors the Anthropic adapter exactly, so fallback keys on the same two types.
Ollama is the one quirk: it ignores the API key, but the SDK still requires a non-empty one, so the chain passes a placeholder ("ollama").
shelldon/broker/chain.py::build_chain(env) reads one config variable — PROVIDER_CHAIN — and produces an ordered list[LLMProvider]. It is a comma-separated list of preset names, tried left to right, default "glm":
PROVIDER_CHAIN="glm,ollama,gemini"Each preset name maps to (adapter class, base_url, key env var, model env var). Most presets are OpenAI-compatible and live as data rows in _OPENAI_COMPAT:
_OPENAI_COMPAT = {
"openai": (None, "OPENAI_API_KEY", "OPENAI_MODEL"),
"openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "OPENROUTER_MODEL"),
"groq": ("https://api.groq.com/openai/v1", "GROQ_API_KEY", "GROQ_MODEL"),
"cerebras": ("https://api.cerebras.ai/v1", "CEREBRAS_API_KEY", "CEREBRAS_MODEL"),
"nvidia": ("https://integrate.api.nvidia.com/v1","NVIDIA_API_KEY", "NVIDIA_MODEL"),
"mistral": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "MISTRAL_MODEL"),
"github": ("https://models.github.ai/inference","GITHUB_TOKEN", "GITHUB_MODEL"),
"gemini": ("https://generativelanguage.googleapis.com/v1beta/openai/",
"GEMINI_API_KEY", "GEMINI_MODEL"),
}glm, claude, and ollama have small dedicated builders for their non-standard config. Reordering or extending the chain is a config line, never a code change — adding a new OpenAI-compatible provider is one new row in that dict.
Credentials resolve here, and only here, from the broker's own environment. The builders hand the resolved key to the adapters, which are pure (config in, no env reads). The credential's life is: env var → builder → adapter constructor → the vendor SDK client. It never enters a Job, Completion, or Envelope.
build_chain fails fast. An unknown preset name, an empty PROVIDER_CHAIN, or a preset whose required key/model env var is missing raises at startup — a misconfigured chain must never start silently degraded. Blank entries ("glm,,openai") and duplicate presets ("glm,glm", which would just waste a fallback slot on the same provider) are dropped with a warning rather than swallowed silently.
shelldon/broker/broker.py::handle_job(job, provider) is the per-provider unit. It calls the provider, and on a TransientProviderError it retries exactly once after a small backoff:
_MAX_ATTEMPTS = 2 # first attempt + one retry
_RETRY_BACKOFF_S = 0.5 # pause before the retry so we don't hammer a rate-limited endpointThe outcomes:
- success →
Completion(ok=True, payload=text) - transient error, then success on retry → success
- transient error twice →
Completion(ok=False, ...) -
PermanentProviderError→Completion(ok=False, ...)immediately, no retry (a 4xx won't fix itself in 0.5 s) - any unexpected exception → caught and converted to
Completion(ok=False, error="unexpected provider error: <Type>")— never an exception across the bus
The backoff constant is module-level so tests can zero it; the suite never does a real wall-clock wait.
shelldon/broker/broker.py::handle_job_chain(job, chain) wraps handle_job and is the single thing that makes a dead provider not kill your turn:
async def handle_job_chain(job, chain):
completion = Completion(ok=False, error="empty provider chain")
for fallbacks, provider in enumerate(chain):
completion = await handle_job(job, provider)
if completion.ok:
log.info("turn answered by provider %r (after %d fallback(s))", provider.name, fallbacks)
return completion
log.warning("provider %r failed, advancing: %s", provider.name, completion.error)
return completionBehavior:
-
First success wins. As soon as a provider returns
ok=True, thatCompletionis returned and the rest of the chain is never touched. -
Advance on any failure. The loop keys only on
completion.ok— it advances on a transient-exhausted failure and on a permanent 4xx. If provider A has a bad key or a wrong model id, provider B might still answer. The goal is a completed turn. - Sequential, not parallel. Providers are tried in order, one at a time. There is no "race for first answer" (that would multiply spend).
-
Audit trail without secrets. On success the answering provider is logged by its preset
name("glm","ollama") and the number of fallbacks. Every failure is logged at WARNING with its error text. Thenameis a config label, never a credential, so this audit record is safe. -
Exhaustion returns the last failure
Completion. When every provider failed, the last failure is returned. This terminal failure is the handoff to graceful degradation below.
shelldon/broker/service.py::run_broker(socket_path, chain) is the broker's whole runtime. It connects to the bus hub as Actor.BROKER, then loops: read a JOB envelope, run it through handle_job_chain, and write the resulting Completion back. Two details matter:
-
The broker returns a
Completionto the WORKER, not aResultto core. The broker is a pure egress boundary — it produces raw model output (text plus any normalized tool calls) and does no pet-domain parsing. The worker is what interprets the reply, runs the tool loop, and emits theResultthat core consumes. Theturn_idis echoed so the worker/core fence can correlate the answer to the question. -
It is built to survive. A malformed frame is skipped; a framing error or a vanished hub ends the current connection cleanly. The initial connect is timeout-bounded (
_CONNECT_TIMEOUT_S = 5.0) so a hung hub can't block forever, andrun_broker(reconnect=True)(the default) wraps connect→serve in a backoff-and-retry loop so a transient hub drop or restart doesn't kill the broker permanently. A deliberate shutdown cancels the task and the loop exits cleanly — cancellation always wins over the reconnect loop.
See How a Turn Works for where this sits in a full turn.
The whole point of the chain is the v1 pain it fixes: a single GLM 500 or a full network outage used to kill a turn or freeze the pet. Now, when handle_job_chain exhausts every provider, it returns a terminal failure Completion. Core (not the broker) turns that into a degrade:
- Core's
_degrade()(shelldon/core/runtime.py) sendsDEGRADE_TEXT— a short "…can't think right now…" reply — over the outbound channel, and pushes aFACE_DEGRADED("cant-think") expression to the E-Ink display. - The same path fires on a turn timeout, so an offline outage that returns a fast failure degrades promptly — it does not wait out the full turn timeout.
- The process keeps running. The bus loop, core, and arbiter all stay alive, ready for the next message. A full outage makes shelldon quiet, not frozen or crashed.
Recovery is automatic and unlatched. There is no "degraded mode" flag anywhere, no circuit breaker, no health-check timer. After a degrade, the arbiter releases its slot and the turn fence clears. The next message starts a fresh turn that re-attempts the whole chain from the top. The instant any provider answers again, the next turn completes normally with real model text. Degradation is structural — a property of each turn being an independent re-attempt — not a feature with state to manage.
When a Job carries tools, handle_job routes to provider.complete_with_tools(messages, tools) instead of the plain complete(prompt) path. This is how shelldon's self-written tools get invoked (see Self-Coding). The two wire formats express tool use very differently, so each adapter normalizes the provider's native format into one closed contract — a Completion carrying ToolCall structs:
-
Anthropic uses
tool_usecontent blocks; tool results ride back on auserturn astool_resultblocks.normalize_anthropic_responsejoins text blocks intopayloadand turns eachtool_useblock into aToolCall. -
OpenAI uses a
tool_callsarray on the assistant message, with arguments as a JSON string; tool results come back asrole="tool"messages keyed bytool_call_id.normalize_openai_responseparses eachargumentsstring into a dict. Malformed model-emitted JSON is mapped toPermanentProviderError(it's bad-request-shaped, not retryable).
Because both adapters emit the same normalized Completion, the worker's tool loop is provider-agnostic — it never sees an Anthropic block or an OpenAI tool_calls array. The error taxonomy is shared too: complete_with_tools raises the same TransientProviderError / PermanentProviderError, so retry and fallback work identically for tool-use turns.
shelldon/broker/vault.py is a related but distinct egress authority. Where the chain holds provider credentials, the vault is the broker-only gate for surfacing the owner's secrets (e.g. a stored value the pet might be asked to recall). Because the broker is the privileged actor, it (and only it) holds the authorized path to read vault/. The worker — the untrusted brain — has no vault-read API, and on a dropped uid it has no OS permission to read the files either.
surface_vault(memory_root, key) is the sole authorized read. It is a read-and-authorize gate, not an LLM-output parser: it gates on a conservative single-segment key (ASCII word chars and - only, via _SAFE_KEY_RE), which forbids path separators and .. by construction so a constructed path can never escape vault/. A rejected key, a missing file, or a corrupt/unreadable file all return None rather than raising into the egress path.
| Concern | Mechanism | File |
|---|---|---|
| Sole credential holder + sole egress | separate process, no keys on the bus, import-linter |
broker/ (AD-2/AD-1) |
| Vendor-agnostic interface |
LLMProvider Protocol + two error types |
provider.py |
| Two wire formats | Anthropic-format / OpenAI-compatible adapters |
anthropic_provider.py, openai_provider.py
|
| Ordered, config-driven chain |
PROVIDER_CHAIN → build_chain
|
chain.py |
| Retry once on transient error | handle_job |
broker.py |
| Automatic fallback through the chain | handle_job_chain |
broker.py |
| Survive hub drops | timeout + reconnect loop | service.py |
| Degrade to reflex when all fail | terminal failure Completion → core _degrade()
|
service.py → core/runtime.py
|
| Native tool use, normalized |
complete_with_tools + normalize_*_response
|
adapters |
| Owner-secret surfacing | broker-only authorized read | vault.py |
shelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes