BL-008 v2 — Cortex Invocation from Persona Router
Date: 2026-04-12
Status: Draft — awaiting review
Touches: @cambium/cortex, @cambium/providers, deployments/obadiah/bridge/start-rhythms.ts
Purpose
Close the loop: a Discord message that matches a persona now gets a real response from that persona, powered by the full Cortex pipeline (intent decompose → capability resolve → response generation), with the reply posted as a threaded Discord message. V1 (BL-008 v1) captured routing decisions as memory marks. V2 makes the personas actually speak.
Design Decisions (locked by Joshua on 2026-04-12)
- LLM provider: Anthropic, API key auth (
ANTHROPIC_API_KEYenv var). Intelligent model routing: Haiku for triage (routing tiebreakers, heartbeat checks, lightweight classification), Sonnet for general persona work (composing replies, understanding context), Opus only for complex multi-step reasoning (explicitly requested tasks, deep analysis). Uses the existing@cambium/providersProviderRegistrywith capability-based lookup + cost ranking. - Reply surface: Same Discord channel, threaded reply.
- Multi-match disambiguation: LLM tiebreaker. One Haiku call to pick the most relevant persona. All matches still captured as memory marks (BL-008 v1 behavior preserved).
- Error handling: Errors logged to a dedicated store and fed back into the composting/learning loop. Failed invocations should become data, not silent losses.
Architecture
The invocation pipeline
Discord message arrives as signal
↓
Persona router matches N personas against domain_triggers (existing v1 code)
↓
For each match: emit memory.observation (existing v1 code — preserved)
↓
[NEW] If N > 1: make one Haiku LLM call to tiebreak
Prompt: "Given message '<content>' and these candidate personas: <list with descriptions>,
which single persona is the best fit? Return just the persona id."
This costs ~0.001¢ per tiebreak
↓
[NEW] Selected persona → invoke Cortex response generation
This is NOT the full Orchestrator.submitIntent pipeline.
Why: the full pipeline (intent decompose → DAG graph → multi-step workflow) is
designed for complex multi-step tasks. A Discord message response is simpler —
it's a single LLM call with persona context, memory recall, and the message.
The full Orchestrator pipeline is reserved for explicit multi-step intents
(e.g. "research the best restaurants in Berlin and send me a summary").
↓
[NEW] Response generation (one Sonnet call):
System prompt: persona's character description (from agents.yaml or personas/*.md)
Context: recall() results for the message's subject (from memory)
User message: the Discord message content
→ LLM generates a response in the persona's voice
↓
[NEW] Post response as threaded Discord reply via DiscordClient
↓
[NEW] Emit persona.responded signal (for downstream tracking, memory capture)
Why NOT the full Orchestrator
The Orchestrator.submitIntent() pipeline was designed for complex, multi-step task execution (decompose intent → build DAG → spawn agents → execute graph nodes → collect results). A Discord message response is a single-turn conversation: understand the message, recall relevant context, compose a reply in the persona’s voice, post it. Using the full Orchestrator for this would be massive overkill — like using a CNC machine to cut a sandwich.
The Orchestrator stays available for explicitly multi-step intents that arrive via other surfaces (CLI, Loom UI, scheduled workflows). Those are separate use cases from “Arnold replies to a Discord message about sleep.”
Model routing strategy
| Task type | Model | Rationale |
|---|---|---|
| Routing tiebreaker (which persona wins a multi-match) | Haiku | Simple classification, fast, cheap (~0.001¢) |
| Persona response composition | Sonnet | Good writing, good reasoning, cost-effective for conversational replies |
| Complex multi-step intent decomposition (via Orchestrator, future) | Opus | Needs deep reasoning about task graphs; worth the cost for complex work |
| Heartbeat checks, signal classification | Haiku | Trivial tasks, pure classification |
| Memory consolidation (nightly daily→marks) | Sonnet | Needs reading comprehension and judgment about what’s worth marking |
These are wired as provider_kind values in the ProviderRegistry. The brain registers three Anthropic providers at boot with different model IDs and capability sets. The existing BudgetEnforcer enforces per-workspace cost ceilings.
Persona context loading
When generating a response, the system loads:
1. Persona identity — from the agents.yaml persona entry (short description + domain_triggers + capabilities) OR from the richer /Obadiah/personas/<name>.md markdown profile if it exists
2. Memory recall — recall({ about: <inferred subject from message>, horizon: '7d', workspace_id }) returns substrate excerpts, recent marks, daily hits, compost learnings relevant to the topic
3. Conversation history — last N messages from the same Discord thread (if threaded) or channel (if top-level). N=5 for v1. Fetched via DiscordClient.
These three sources compose the LLM prompt’s system/context sections.
Error handling → composting
When a persona invocation fails at any stage (LLM call fails, tiebreaker fails, Discord post fails):
- Emit
cortex.invocation_failedsignal with payload:{ persona, message_id, error_message, stage (tiebreak | generate | post), signal_id } - A receptor catches this and writes to a new
invocation_errorsDrizzle table:id, workspace_id, persona, signal_id, stage, error_message, created_at, resolved_at, resolution - The error pattern also gets fed into Genesis composting — if a persona template consistently fails on a specific kind of message (e.g. “Arnold can’t handle messages about workout equipment”), that failure pattern becomes a perennial learning that influences future routing.
- Errors are visible via a new CLI command:
obadiah cortex errors [--unresolved] - No retry. V1 fails once and logs. The user can manually re-trigger if needed. Automatic retry with backoff is a v2 optimization.
Discord threading
When the DiscordClient posts a reply:
- If the original message was in a thread: reply in that thread.
- If the original message was top-level: create a new thread from the original message and post the reply there. This keeps the channel clean — persona replies don’t clutter the main channel flow.
- Thread name: "<persona_name> responding" (e.g. “Arnold responding”).
Data Model
New table: invocation_errors
invocation_errors
id text pk
workspace_id text not null
persona text not null -- persona id from agents.yaml
signal_id text not null -- the discord.message_created signal that triggered this
stage text not null -- 'tiebreak' | 'generate' | 'post'
error_message text not null
stack_trace text -- nullable
created_at timestamptz not null
resolved_at timestamptz -- null until manually resolved
resolution text -- 'fixed' | 'dismissed' | 'composted'
indexes:
- workspace_id
- (workspace_id, persona)
- resolved_at -- for filtering unresolved
New env vars needed
ANTHROPIC_API_KEY— Anthropic API key for LLM calls. Without this, persona responses are skipped (same graceful-no-op pattern asDISCORD_BOT_TOKEN).
Changes to spore.yaml
# New optional field — persona profile directory (richer markdown profiles)
paths:
persona_profiles: /Users/joshua/Projects/Obadiah/personas # optional; agents.yaml is always primary
What Ships in v1
PersonaInvokermodule atpackages/cortex/src/persona-invoker.ts— the function that takes a matched persona + a Discord signal and produces a response via one Sonnet LLM call- LLM tiebreaker at
packages/cortex/src/persona-tiebreaker.ts— one Haiku call to pick from multi-match - Provider boot — three Anthropic providers registered in start-rhythms.ts (haiku for triage, sonnet for general, opus for complex)
- Discord reply posting — extend DiscordClient with a
replyInThread(channelId, messageId, content)method - invocation_errors table + migration
- Error receptor — catches
cortex.invocation_failedsignals, writes to invocation_errors - Persona profile loading — reads the richer
/Obadiah/personas/*.mdif configured in spore - Memory recall integration — response generation uses
recall()for context - End-to-end integration test — emit a synthetic
discord.message_created, mock the LLM to return a fixed response, verify the reply is posted via the DiscordClient mock - Brain wiring —
start-rhythms.tsregisters the invoker, tiebreaker, error receptor, and providers - Graceful no-op — without
ANTHROPIC_API_KEY, persona invocation is skipped with a warning (same pattern asDISCORD_BOT_TOKEN)
Deferred
| Item | Why | Trigger |
|---|---|---|
| Full Orchestrator pipeline for multi-step intents | Discord responses are single-turn; Orchestrator is for complex DAGs | When a “research X and summarize” intent arrives via Discord and single-turn can’t serve it |
| Automatic retry with backoff | V1 fails once and logs | When error rate > 5% on a specific persona |
| Opus auto-escalation | V1 always uses Sonnet for responses; some questions deserve Opus | When Sonnet responses are observably worse on complex topics (tracked via composting feedback) |
| Conversation history beyond 5 messages | V1 fetches last 5 from Discord thread | When persona responses lose thread context visibly |
| OAuth for Anthropic | V1 uses API key; productization needs per-user OAuth | When Cambium ships to a second user |
Non-Goals
- Full Orchestrator invocation for Discord messages. Discord → persona → reply is single-turn. Multi-step tasks go through the Orchestrator separately.
- Autonomous action beyond replying. The persona replies in Discord. It does not autonomously take real-world actions (send emails, modify files, etc.) in response to a Discord message — that’s a separate, more dangerous feature with its own approval gates.
- Training / fine-tuning. Model routing is rules-based. We don’t train or fine-tune models.
Success Criteria
- A synthetic
discord.message_createdsignal with content “I slept terribly last night” → tiebreaker picks Arnold → Sonnet generates a response in Arnold’s voice → DiscordClient posts it as a threaded reply →persona.respondedsignal emitted → integration test passes - A multi-match message → tiebreaker makes one Haiku call → picks one persona → single reply (not multiple)
- A failed LLM call →
cortex.invocation_failedsignal → invocation_errors row → no crash, no retry, brain continues - Without
ANTHROPIC_API_KEY→ all persona invocations are skipped with a warning log, routing still works, memory marks still captured - Budget enforcement: if the workspace exceeds its cost ceiling, persona invocations are paused (existing BudgetEnforcer behavior) and the user sees a budget warning in logs