Skip to content

Psychonauts

Each psychonaut is a GenServer (Agent.Server). The phase field drives transitions via handle_continue(:enter_phase, state):

:idle --> :streaming --> :executing_tools --> :steering_check --> :streaming (loop)
\-> :steering_check --> :done (no tool calls, task complete)
\-> :error (max turns exceeded)

Phase transitions are automatic — each phase completes and triggers the next via {:noreply, state, {:continue, :enter_phase}}. The agent loops until:

  • The LLM responds without tool calls (task complete -> :done)
  • Turn limit exceeded (max_turns, default 50 -> :error)
  • Recursion depth exceeded (max_depth, default 5 -> :done)
  • Conversation exceeds 200 messages -> :done

The complete runtime state for a psychonaut:

%Annihilation.Agent.State{
id: "agent_abc123", # Unique agent identifier
identity: %AgentIdentity{}, # Name, description, model
model: "claude-sonnet-4-20250514", # LLM model
provider: Annihilation.Agent.Provider.Anthropic, # Provider module
system_prompt: "You are...", # System prompt
tools: [Shell, FileRead], # Available tool modules
messages: [%Message{}, ...], # Full conversation history
current_deltas: nil, # Delta accumulator during streaming
phase: :idle, # Current phase
error_reason: nil, # Error details when phase == :error
bead_id: nil, # Current bead being worked on
burst_id: nil, # Current burst ID
session_id: nil, # Session for JSONL logging
pipeline_stage: nil, # Current pipeline stage index
recursion_depth: 0, # Current recursion depth
max_depth: 5, # Max allowed recursion depth
max_turns: 50, # Max LLM turns before forced stop
parent_id: nil, # Parent agent ID (if recursive child)
turn_count: 0, # Current turn count
config: %{}, # Provider-specific config (api_key, etc.)
inherited_leases: [], # File leases from parent (read-only)
lease_permissions: :full, # :full or :read_only
active_leases: [] # Currently held file leases
}

The Provider behaviour abstracts LLM APIs. The system never sees provider-specific formats.

@callback stream(messages, tools, config) :: {:ok, Enumerable.t()} | {:error, term()}
@callback format_messages(messages) :: [map()]
@callback format_tools(tools) :: [map()]

The built-in Provider.Anthropic communicates with the Anthropic Messages API using HTTP/2 streaming via Mint:

  • Connects via Mint.HTTP.connect(:https, "api.anthropic.com", 443)
  • Builds Stream.resource/3 for lazy delta processing
  • SSE parser (SSEParser) converts raw bytes into Delta structs
  • DeltaAccumulator collects deltas into complete Message structs
  • System prompt extracted from messages and passed as separate system parameter
  • Tool calls formatted as tool_use content blocks

Config keys: :api_key, :model (default claude-sonnet-4-20250514), :max_tokens (default 8192), :system, :temperature.

Provider.Mock returns scripted responses for testing. No network calls.

Every tool implements Annihilation.Agent.Tool:

@callback name() :: String.t()
@callback description() :: String.t()
@callback parameters_schema() :: map() # JSON Schema
@callback execute(args :: map(), context :: map()) :: {:ok, term()} | {:error, String.t()}

The context map contains agent_id, bead_id, burst_id, project_root, and tool_call_id.

Agent.Tool.Registry is an ETS-based GenServer that maps tool names to modules. On startup, it auto-registers all built-in tools. Tools are resolved at call time from the agent’s tools list.

Tool errors are never fatal. They become %ToolResult{is_error: true} and are sent back to the LLM, which self-corrects:

  • Parameter validation failure -> error result with schema info
  • Tool crash -> rescued -> error result with exception message
  • Tool not found -> error result listing available tools
  • Invalid JSON arguments -> error result with raw arguments
ToolModuleDescription
shellTool.ShellExecute shell commands (integrates with CommandGuard + TraumaGuard)
file_readTool.FileReadRead file contents
file_writeTool.FileWriteWrite/edit files (integrates with LeaseManager)
ask_userTool.AskUserReach for the Tether with a question
echoTool.EchoReturn input as output (testing/debugging)
peek_fileTool.PeekFileQuick file preview (first/last N lines)
peek_dirTool.PeekDirDirectory listing with metadata
recurseTool.RecurseSpawn a single recursive sub-agent
recurse_fan_outTool.RecurseFanOutConcurrent fan-out with optional reduce
search_sessionsTool.SearchSessionsFTS5 search over past session transcripts
search_skillsTool.SearchSkillsFind relevant skills from the catalog
create_skillTool.CreateSkillAdd a skill to the catalog
check_messagesTool.CheckMessagesRead inter-agent mailbox
list_psychonautsTool.ListPsychonautsList active psychonaut agents
whoisTool.WhoisLook up agent details by ID
set_pipelineTool.SetPipelineOverride pipeline for current bead
create_pipelineTool.CreatePipelineCreate a new pipeline definition
read_diaryTool.ReadDiaryRead diary entries from past bursts
propose_playbook_deltaTool.ProposePlaybookDeltaPropose changes to playbook rules

Psychonauts can spawn child agents for focused subtasks via Agent.Recurse:

Agent.Recurse.call(parent_state, context, query, opts)
# -> {:ok, result_text} | {:error, reason}

The parent blocks until the child completes, crashes, or times out (default 5 minutes). Children inherit the parent’s bead, session, burst context, and file leases (as read-only).

Agent.Recurse.fan_out(parent_state, tasks, opts)
# tasks = [%{task: "analyze X", context: "..."}, ...]
# -> {:ok, %{result: text, metadata: %{total_partitions: N, ...}}}

Spawns N children concurrently (max concurrency: 5 by default). Supports an optional :reduce_prompt that spawns a synthesis agent to combine results.

  • Depth limit: max_depth (default 5). At max depth, recurse and recurse_fan_out tools are removed from the child’s tool list.
  • Semaphore: RecursionSemaphore limits global concurrent children to 16. Uses a waiter queue when at capacity.
  • Timeout: Per-child timeout (default 5 minutes). Timed-out children are terminated.
  • Lease inheritance: Children inherit parent’s file leases as :read_only. They cannot acquire exclusive leases.

Child lifecycle events (child_spawned, child_completed, child_failed) are logged to the parent’s session JSONL for full audit trail.

Psychonauts can communicate via Agent.Mailbox, an ETS-based message router:

# Send a direct message
Mailbox.send_message(from_id, to_id, "subject", "body", priority: :high)
# Broadcast to all active agents
Mailbox.broadcast_message(from_id, "subject", "body")
# Check inbox (via check_messages tool)
Mailbox.get_unread(agent_id)

Messages have priority ordering (:high > :normal) and statuses (:unread -> :read -> :acknowledged). Event notifications are broadcast on "agent:mail:#{id}" for real-time awareness.