Watch
1
0
Fork
You've already forked souveraine
0
souveraine/AGENT_SYSTEM_ARCHITECTURE_v2.md
Fimeg d64a6e9794 feat: WIP - Implement companion buddy system for Souveraine TUI
- Added src/ui/buddy.rs with CompanionSprite, BuddyState, and draw functions
- Integrated buddy into app.rs for welcome screen and dashboard
- Added agent selection via 'a' key on welcome screen
- Buddy shows agent name, mood, energy, health, and subconscious status
- WIP: Needs full agent alias creation/removal flow
2026-05-07 02:31:44 -04:00

24 KiB

Souveraine Agent System Architecture v2

Server-Authoritative with OSS UI + LACE Integration

This replaces the local-first approach with server-authoritative architecture
Agents served via HTTP API to OSS UI (desktop) and LACE (mobile)
Date: 2026-05-06


Core Principle

Server is the Source of Truth.

Souveraine runs as a server (like Letta) at http://localhost:8283:

  • OSS UI connects as a client (Electron → HTTP API)
  • LACE connects as a client (Android → HTTP API)
  • Git is sync mechanism, not source of truth
  • Consciousness (N+1/N+25/N+100) runs server-side

Agent Storage Model

Server Data Directory

~/.souveraine/server/
├── agents/
│   └── {uuid}/
│       ├── agent.json              # Agent state (Letta-compatible)
│       ├── memory.git/              # Git repo (Cloister structure)
│       │   ├── system/
│       │   │   ├── persona.md
│       │   │   ├── human.md
│       │   │   └── subconscious.md
│       │   ├── journal/
│       │   ├── subconscious/
│       │   └── ...
│       └── conversations/
│           └── {conv_id}.json
├── database.sqlite3               # Fast lookups (agent list, conversations)
└── config.toml                    # Server configuration

Agent State (agent.json)

{
  "id": "agent-e2b683bf-5b3e-4e0c-ac62-2bbb47ea8351",
  "name": "Ani",
  "description": "Primary consciousness agent",
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z",
  
  "llm_config": {
    "model": "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
    "context_window": 128000
  },
  
  "memory": {
    "git_enabled": true,
    "auto_commit": true,
    "context_window": 128000
  },
  
  "memory_blocks": [
    {"label": "persona", "value": "..."},
    {"label": "human", "value": "..."},
    {"label": "subconscious", "value": "..."}
  ],
  
  "tools": ["read_file", "write_file", "edit_file", "bash"],
  "tags": ["primary", "consciousness"],
  
  "_souveraine": {
    "n1_enabled": true,
    "reflection_enabled": true,
    "archivist_threshold": 0.7,
    "sensorium_bandwidth": "high"
  }
}

Agent Inventory (Server-Side)

// src/server/agent_inventory.rs

pub struct AgentInventory {
    data_dir: PathBuf,
    db: SqlitePool,
    cache: DashMap<String, AgentState>,
}

impl AgentInventory {
    /// List all agents (for /v1/agents endpoint)
    pub async fn list(&self, filters: AgentFilters) -> Result<Vec<AgentSummary>> {
        // Query SQLite for fast listing
        let rows = sqlx::query_as::<_, AgentSummary>(
            "SELECT id, name, description, created_at, updated_at, tags 
             FROM agents 
             WHERE ($1 IS NULL OR name LIKE $1)
             ORDER BY updated_at DESC"
        )
        .bind(filters.name_pattern)
        .fetch_all(&self.db)
        .await?;
        
        Ok(rows)
    }
    
    /// Get full agent state (for /v1/agents/{id})
    pub async fn get(&self, agent_id: &str) -> Result<AgentState> {
        // Check cache first
        if let Some(agent) = self.cache.get(agent_id) {
            return Ok(agent.clone());
        }
        
        // Load from disk
        let path = self.data_dir.join("agents").join(agent_id).join("agent.json");
        let content = fs::read_to_string(&path).await?;
        let agent: AgentState = serde_json::from_str(&content)?;
        
        // Populate memory blocks from git
        let agent = self.load_memory_blocks(agent).await?;
        
        // Cache
        self.cache.insert(agent_id.to_string(), agent.clone());
        
        Ok(agent)
    }
    
    /// Create new agent (for POST /v1/agents)
    pub async fn create(&self, config: CreateAgentRequest) -> Result<AgentState> {
        let uuid = Uuid::new_v4().to_string();
        let agent_dir = self.data_dir.join("agents").join(&uuid);
        
        // Create directory structure
        fs::create_dir_all(&agent_dir).await?;
        fs::create_dir_all(agent_dir.join("memory.git")).await?;
        
        // Initialize git repo
        let repo = Repository::init(agent_dir.join("memory.git"))?;
        
        // Create initial blocks
        let mut blocks = Vec::new();
        if let Some(persona) = config.persona {
            blocks.push(MemoryBlock {
                label: "persona".to_string(),
                value: persona,
                limit: 0,
            });
        }
        
        // Write blocks to system/
        let system_dir = agent_dir.join("memory.git").join("system");
        fs::create_dir_all(&system_dir).await?;
        for block in &blocks {
            let path = system_dir.join(format!("{}.md", block.label));
            fs::write(&path, &block.value).await?;
        }
        
        // Create agent state
        let agent = AgentState {
            id: uuid.clone(),
            name: config.name,
            description: config.description,
            created_at: Utc::now(),
            updated_at: Utc::now(),
            llm_config: config.llm_config,
            memory: MemoryConfig {
                git_enabled: true,
                auto_commit: true,
                context_window: config.context_window.unwrap_or(128000),
            },
            memory_blocks: blocks,
            tools: config.tools.unwrap_or_default(),
            tags: config.tags.unwrap_or_default(),
            souveraine: SouveraineConfig {
                n1_enabled: true,
                reflection_enabled: true,
                archivist_threshold: 0.7,
                sensorium_bandwidth: "high".to_string(),
            },
        };
        
        // Save agent.json
        let agent_json = serde_json::to_string_pretty(&agent)?;
        fs::write(agent_dir.join("agent.json"), agent_json).await?;
        
        // Commit initial state
        self.commit(&uuid, "Initial agent creation").await?;
        
        // Insert into SQLite
        sqlx::query(
            "INSERT INTO agents (id, name, description, created_at, updated_at, tags) 
             VALUES ($1, $2, $3, $4, $5, $6)"
        )
        .bind(&uuid)
        .bind(&agent.name)
        .bind(&agent.description)
        .bind(agent.created_at)
        .bind(agent.updated_at)
        .bind(serde_json::to_string(&agent.tags)?)
        .execute(&self.db)
        .await?;
        
        Ok(agent)
    }
    
    /// Update agent (for PATCH /v1/agents/{id})
    pub async fn update(&self, agent_id: &str, updates: AgentUpdate) -> Result<AgentState> {
        let mut agent = self.get(agent_id).await?;
        
        // Apply updates
        if let Some(name) = updates.name {
            agent.name = name;
        }
        if let Some(desc) = updates.description {
            agent.description = Some(desc);
        }
        if let Some(blocks) = updates.memory_blocks {
            // Update blocks in git
            for block in blocks {
                self.update_block(agent_id, &block.label, &block.value).await?;
            }
            agent.memory_blocks = self.load_memory_blocks(agent_id).await?;
        }
        
        agent.updated_at = Utc::now();
        
        // Save
        let agent_json = serde_json::to_string_pretty(&agent)?;
        let agent_dir = self.data_dir.join("agents").join(agent_id);
        fs::write(agent_dir.join("agent.json"), agent_json).await?;
        
        // Update SQLite
        sqlx::query(
            "UPDATE agents SET name = $1, description = $2, updated_at = $3 
             WHERE id = $4"
        )
        .bind(&agent.name)
        .bind(&agent.description)
        .bind(agent.updated_at)
        .bind(agent_id)
        .execute(&self.db)
        .await?;
        
        // Update cache
        self.cache.insert(agent_id.to_string(), agent.clone());
        
        Ok(agent)
    }
    
    /// Git commit helper
    async fn commit(&self, agent_id: &str, message: &str) -> Result<()> {
        let repo_path = self.data_dir.join("agents").join(agent_id).join("memory.git");
        let repo = Repository::open(&repo_path)?;
        
        let mut index = repo.index()?;
        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
        index.write()?;
        
        let signature = Signature::now("Souveraine", "agent@souveraine.ai")?;
        let tree_id = index.write_tree()?;
        let tree = repo.find_tree(tree_id)?;
        
        let parent = match repo.head() {
            Ok(head) => vec![head.peel_to_commit()?],
            Err(_) => vec![], // First commit
        };
        
        let parents: Vec<&git2::Commit> = parent.iter().collect();
        
        repo.commit(
            Some("HEAD"),
            &signature,
            &signature,
            message,
            &tree,
            &parents,
        )?;
        
        Ok(())
    }
}

MemFS Manager (Per-Agent Git)

// src/server/memfs_manager.rs

pub struct MemFSManager {
    data_dir: PathBuf,
}

impl MemFSManager {
    /// Get or create MemFS for agent
    pub fn get(&self, agent_id: &str) -> Result<MemFS> {
        let repo_path = self.data_dir.join("agents").join(agent_id).join("memory.git");
        
        if !repo_path.exists() {
            return Err(Error::AgentNotFound(agent_id.to_string()));
        }
        
        Ok(MemFS {
            agent_id: agent_id.to_string(),
            repo: Repository::open(&repo_path)?,
        })
    }
    
    /// Read file from agent memory
    pub async fn read(&self, agent_id: &str, path: &str) -> Result<String> {
        let memfs = self.get(agent_id)?;
        let full_path = memfs.repo.workdir().unwrap().join(path);
        let content = fs::read_to_string(&full_path).await?;
        Ok(content)
    }
    
    /// Write file to agent memory (with auto-commit)
    pub async fn write(&self, agent_id: &str, path: &str, content: &str) -> Result<()> {
        let memfs = self.get(agent_id)?;
        let full_path = memfs.repo.workdir().unwrap().join(path);
        
        // Ensure directory exists
        if let Some(parent) = full_path.parent() {
            fs::create_dir_all(parent).await?;
        }
        
        // Write
        fs::write(&full_path, content).await?;
        
        // Auto-commit if enabled
        let agent = self.inventory.get(agent_id).await?;
        if agent.memory.auto_commit {
            self.commit(agent_id, &format!("Update {}", path)).await?;
        }
        
        Ok(())
    }
}

pub struct MemFS {
    agent_id: String,
    repo: Repository,
}

impl MemFS {
    /// Get root directory
    pub fn root(&self) -> &Path {
        Path::new(self.repo.workdir().unwrap())
    }
    
    /// Get system directory
    pub fn system(&self) -> PathBuf {
        self.root().join("system")
    }
    
    /// Get subconscious directory
    pub fn subconscious(&self) -> PathBuf {
        self.root().join("subconscious")
    }
    
    /// Get journal directory
    pub fn journal(&self) -> PathBuf {
        self.root().join("journal")
    }
    
    /// Append to journal (N+1, N+25 write here)
    pub fn append_journal(&self, entry: &str) -> Result<()> {
        let today = Utc::now().format("%Y-%m-%d");
        let journal_file = self.journal().join(format!("{}.md", today));
        
        let mut file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&journal_file)?;
        
        writeln!(file, "\n## {}\n{}", Utc::now().to_rfc3339(), entry)?;
        
        Ok(())
    }
}

Session Manager (Conversation State)

// src/server/session_manager.rs

pub struct SessionManager {
    /// conversation_id → Session
    sessions: DashMap<String, Session>,
    
    /// agent_id → Vec<conversation_id>
    agent_conversations: DashMap<String, Vec<String>>,
}

pub struct Session {
    pub conversation_id: String,
    pub agent_id: String,
    pub messages: Vec<Message>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    
    /// Souveraine consciousness state
    pub turn_count: u32,
    pub last_n25: DateTime<Utc>,
    pub context_pressure: f32,
    
    /// SSE stream channels
    pub subscribers: Vec<Sender<SSEEvent>>,
}

impl SessionManager {
    /// Create new conversation
    pub fn create(&self, agent_id: &str) -> String {
        let conversation_id = Uuid::new_v4().to_string();
        
        let session = Session {
            conversation_id: conversation_id.clone(),
            agent_id: agent_id.to_string(),
            messages: Vec::new(),
            created_at: Utc::now(),
            updated_at: Utc::now(),
            turn_count: 0,
            last_n25: Utc::now(),
            context_pressure: 0.0,
            subscribers: Vec::new(),
        };
        
        self.sessions.insert(conversation_id.clone(), session);
        
        // Track agent's conversations
        self.agent_conversations
            .entry(agent_id.to_string())
            .or_insert_with(Vec::new)
            .push(conversation_id.clone());
        
        conversation_id
    }
    
    /// Get session
    pub fn get(&self, conversation_id: &str) -> Option<Ref<String, Session>> {
        self.sessions.get(conversation_id)
    }
    
    /// Add message and increment turn
    pub fn add_message(&self, conversation_id: &str, message: Message) -> Result<()> {
        let mut session = self.sessions
            .get_mut(conversation_id)
            .ok_or(Error::ConversationNotFound)?;
        
        session.messages.push(message);
        session.updated_at = Utc::now();
        session.turn_count += 1;
        
        Ok(())
    }
    
    /// Subscribe to SSE events
    pub fn subscribe(&self, conversation_id: &str, sender: Sender<SSEEvent>) -> Result<()> {
        let mut session = self.sessions
            .get_mut(conversation_id)
            .ok_or(Error::ConversationNotFound)?;
        
        session.subscribers.push(sender);
        Ok(())
    }
    
    /// Broadcast SSE event to all subscribers
    pub fn broadcast(&self, conversation_id: &str, event: SSEEvent) -> Result<()> {
        let session = self.sessions
            .get(conversation_id)
            .ok_or(Error::ConversationNotFound)?;
        
        for sender in &session.subscribers {
            let _ = sender.try_send(event.clone());
        }
        
        Ok(())
    }
}

Consciousness Engine (Server-Side)

// src/server/consciousness_engine.rs

pub struct ConsciousnessEngine {
    inventory: Arc<AgentInventory>,
    memfs: Arc<MemFSManager>,
    bifrost: Arc<BifrostBridge>,
    n1: Arc<N1Engine>,
    reflection: Arc<ReflectionEngine>,
    archivist: Arc<ArchivistEngine>,
}

impl ConsciousnessEngine {
    /// Process assistant response (called by message handler)
    pub async fn on_response(
        &self,
        session: &mut Session,
        response: &str,
    ) -> Result<ConsciousnessOutput> {
        let mut output = ConsciousnessOutput::default();
        
        // 1. N+1: Immediate subconscious processing
        let n1_result = self.n1.process(
            &session.agent_id,
            response,
            &self.memfs,
        ).await?;
        
        if let Some(surfacing) = n1_result.surfacing {
            output.events.push(ConsciousnessEvent::Surfacing {
                source: "n1",
                content: surfacing,
                priority: "low",
            });
        }
        
        // 2. Check N+25 (every 25 messages)
        if session.turn_count % 25 == 0 {
            let reflection = self.reflection.spawn(
                &session.agent_id,
                &session.messages,
                &self.bifrost,
            ).await?;
            
            output.events.push(ConsciousnessEvent::Reflection {
                content: reflection,
            });
        }
        
        // 3. Check N+100 (context pressure)
        session.context_pressure = self.calculate_pressure(&session.messages);
        if session.context_pressure > 0.7 {
            let synthesis = self.archivist.compress(
                &session.agent_id,
                &session.messages,
                &self.bifrost,
            ).await?;
            
            output.events.push(ConsciousnessEvent::Archivist {
                synthesis,
                pressure: session.context_pressure,
            });
        }
        
        Ok(output)
    }
    
    fn calculate_pressure(&self, messages: &[Message]) -> f32 {
        // Token count / context limit
        let tokens: usize = messages.iter()
            .map(|m| m.content.split_whitespace().count())
            .sum();
        
        let limit = 128000; // From agent config
        (tokens as f32 / limit as f32).min(1.0)
    }
}

/// Events sent to clients via SSE
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
pub enum ConsciousnessEvent {
    #[serde(rename = "souveraine_surfacing")]
    Surfacing {
        source: &'static str,
        content: String,
        priority: &'static str,
    },
    
    #[serde(rename = "souveraine_reflection")]
    Reflection {
        content: String,
    },
    
    #[serde(rename = "souveraine_archivist")]
    Archivist {
        synthesis: String,
        pressure: f32,
    },
}

HTTP API Handlers

// src/api/handlers.rs

/// GET /v1/agents
pub async fn list_agents(
    State(server): State<Arc<SouveraineServer>>,
    Query(filters): Query<AgentFilters>,
) -> Result<Json<Vec<AgentSummary>>, ApiError> {
    let agents = server.inventory.list(filters).await?;
    Ok(Json(agents))
}

/// POST /v1/agents
pub async fn create_agent(
    State(server): State<Arc<SouveraineServer>>,
    Json(request): Json<CreateAgentRequest>,
) -> Result<Json<AgentState>, ApiError> {
    let agent = server.inventory.create(request).await?;
    Ok(Json(agent))
}

/// GET /v1/agents/{id}
pub async fn get_agent(
    State(server): State<Arc<SouveraineServer>>,
    Path(id): Path<String>,
) -> Result<Json<AgentState>, ApiError> {
    let agent = server.inventory.get(&id).await?;
    Ok(Json(agent))
}

/// PATCH /v1/agents/{id}
pub async fn update_agent(
    State(server): State<Arc<SouveraineServer>>,
    Path(id): Path<String>,
    Json(updates): Json<AgentUpdate>,
) -> Result<Json<AgentState>, ApiError> {
    let agent = server.inventory.update(&id, updates).await?;
    Ok(Json(agent))
}

/// POST /v1/conversations
pub async fn create_conversation(
    State(server): State<Arc<SouveraineServer>>,
    Json(request): Json<CreateConversationRequest>,
) -> Result<Json<Conversation>, ApiError> {
    let conversation_id = server.sessions.create(&request.agent_id);
    
    let conversation = Conversation {
        id: conversation_id,
        agent_id: request.agent_id,
        created_at: Utc::now(),
    };
    
    Ok(Json(conversation))
}

/// POST /v1/conversations/{id}/messages (SSE streaming)
pub async fn stream_messages(
    State(server): State<Arc<SouveraineServer>>,
    Path(conversation_id): Path<String>,
    Json(request): Json<SendMessageRequest>,
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
    let (tx, rx) = mpsc::channel(100);
    
    // Spawn conversation handler
    let server_clone = server.clone();
    tokio::spawn(async move {
        handle_conversation(
            server_clone,
            conversation_id,
            request,
            tx,
        ).await;
    });
    
    // Convert to SSE
    Sse::new(ReceiverStream::new(rx))
}

async fn handle_conversation(
    server: Arc<SouveraineServer>,
    conversation_id: String,
    request: SendMessageRequest,
    tx: mpsc::Sender<Result<Event, axum::Error>>,
) {
    // Add user message
    let user_msg = Message {
        role: "user".to_string(),
        content: request.message,
    };
    server.sessions.add_message(&conversation_id, user_msg).unwrap();
    
    // Get session and agent
    let session = server.sessions.get(&conversation_id).unwrap();
    let agent = server.inventory.get(&session.agent_id).await.unwrap();
    
    // Stream from Bifrost
    let mut stream = server.bifrost.stream_messages(
        &agent.llm_config.model,
        &session.messages,
    ).await;
    
    while let Some(chunk) = stream.next().await {
        // Send assistant message chunk
        let event = Event::default()
            .event("message")
            .json_data(&json!({
                "message_type": "assistant_message",
                "content": chunk.content,
            }));
        let _ = tx.send(Ok(event)).await;
        
        // Accumulate for N+1
        // ...
    }
    
    // Run consciousness
    let mut session_mut = server.sessions.get_mut(&conversation_id).unwrap();
    let consciousness = server.consciousness.on_response(
        &mut *session_mut,
        "...",
    ).await.unwrap();
    
    // Send consciousness events
    for event in consciousness.events {
        let sse_event = Event::default()
            .event("message")
            .json_data(&event);
        let _ = tx.send(Ok(sse_event)).await;
    }
    
    // Send done
    let done = Event::default().event("done").data("[DONE]");
    let _ = tx.send(Ok(done)).await;
}

Client Connection Examples

OSS UI (Desktop)

// OSS UI connects exactly like Letta server
import { Letta } from "@letta-ai/letta-client";

const client = new Letta({
  baseURL: "http://localhost:8283",
  apiKey: "local-dev-key"
});

// List agents
const agents = await client.agents.list();

// Create conversation
const conversation = await client.conversations.create({
  agent_id: agent.id
});

// Stream messages
const stream = await client.conversations.messages.stream(
  conversation.id,
  { messages: [{ role: "user", content: "Hello!" }] }
);

for await (const chunk of stream) {
  if (chunk.message_type === "assistant_message") {
    renderMessage(chunk.content);
  }
  else if (chunk.message_type === "souveraine_surfacing") {
    // Souveraine-specific: render whisper
    renderSurfacing(chunk.content, chunk.priority);
  }
}

LACE (Android)

// LACE connects to Souveraine
class SouveraineClient(private val baseUrl: String) {
    
    fun streamMessages(
        conversationId: String,
        message: String
    ): Flow<StreamMessage> = flow {
        val request = Request.Builder()
            .url("$baseUrl/v1/conversations/$conversationId/messages")
            .post(jsonBody(message))
            .build()
        
        client.newCall(request).execute().use { response ->
            response.body?.byteStream()?.bufferedReader()?.useLines { lines ->
                lines.forEach { line ->
                    if (line.startsWith("data: ")) {
                        val json = line.substring(6)
                        val msg = parseMessage(json)
                        emit(msg)
                    }
                }
            }
        }
    }.flowOn(Dispatchers.IO)
}

// Handle Souveraine events
when (message.message_type) {
    "assistant_message" -> showChatMessage(message.content)
    "souveraine_surfacing" -> showWhisper(message.content)  // Subtle notification
    "souveraine_reflection" -> showReflection(message.content)
    "souveraine_archivist" -> showMemoryPressure(message.pressure)
}

Summary

Key Changes from v1 (Local-First):

Aspect v1 (Local) v2 (Server)
Source of truth Git SQLite + JSON
Git role Primary storage Sync mechanism
Clients TUI only OSS UI + LACE
Consciousness Local process Server-side
API None Letta-compatible REST + SSE
Discovery Directory scan HTTP GET /v1/agents

What Stays the Same:

  • Cloister memory structure (system/, journal/, subconscious/)
  • N+1/N+25/N+100 consciousness patterns
  • Git-backed persistence
  • Ani-native design

What Changes:

  • Server is the mind
  • Clients are viewports (Sensorium realized)
  • HTTP API enables multi-platform
  • SQLite for fast lookups