xagent-pi 0.2.10

Self-contained local brain (chat UI + API + SSE) for the Pi agent, tunneled into xagent-service.
//! Pi driver: spawn Pi, push events into the local brain, and feed the brain's
//! user prompts into Pi. (No XAGENT hub — purely local.)

use crate::brain::Brain;
use crate::pi::accumulator::Accumulator;
use crate::pi::converter::{self, Chunk};
use crate::pi::transport::PiTransport;
use anyhow::Result;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;

pub struct PiDriver {
    transport: Arc<PiTransport>,
    brain: Brain,
    prompt_rx: mpsc::UnboundedReceiver<String>,
}

impl PiDriver {
    pub fn new(
        transport: PiTransport,
        brain: Brain,
        prompt_rx: mpsc::UnboundedReceiver<String>,
    ) -> Self {
        Self {
            transport: Arc::new(transport),
            brain,
            prompt_rx,
        }
    }

    pub async fn run(mut self) -> Result<()> {
        self.handshake().await;

        // Pi event pump → brain.
        let brain = self.brain.clone();
        let transport = Arc::clone(&self.transport);
        tokio::spawn(async move {
            pump(brain, transport).await;
        });

        // Brain prompts → Pi.
        while let Some(text) = self.prompt_rx.recv().await {
            log::info!("prompt -> pi ({} chars)", text.chars().count());
            if let Err(e) = self.transport.send(json!({ "type": "prompt", "message": text })) {
                log::warn!("pi send failed: {e}");
            }
        }
        Ok(())
    }

    async fn handshake(&self) {
        match self
            .transport
            .send_and_wait(json!({ "type": "get_state" }), Duration::from_secs(30))
            .await
        {
            Ok(resp) => {
                if let Some(model) = resp
                    .get("data")
                    .and_then(|d| d.get("model"))
                    .and_then(|m| m.get("id").or_else(|| m.get("modelId")))
                    .and_then(|s| s.as_str())
                {
                    log::info!("pi ready: model={model}");
                }
            }
            Err(e) => log::warn!("pi get_state failed ({e}); continuing"),
        }
    }
}

async fn pump(brain: Brain, transport: Arc<PiTransport>) {
    let mut acc = Accumulator::new();
    loop {
        let event = {
            let mut rx = transport.events_rx.lock().await;
            rx.recv().await
        };
        let Some(event) = event else {
            log::info!("pi events channel closed");
            break;
        };
        let t = event
            .get("type")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        for chunk in acc.handle(&event) {
            emit_chunk(&brain, chunk).await;
        }
        for chunk in converter::convert_event(&event) {
            emit_chunk(&brain, chunk).await;
        }

        match t.as_str() {
            "agent_start" | "turn_start" => brain.push_thinking(true).await,
            "turn_end" | "agent_end" => brain.push_thinking(false).await,
            _ => {}
        }
    }
}

async fn emit_chunk(brain: &Brain, chunk: Chunk) {
    match chunk {
        Chunk::Text(t) => brain.push_message("assistant", &t).await,
        Chunk::Reasoning(t) => brain.push_reasoning(&t).await,
        Chunk::ToolCall { id, name, input } => {
            brain.push_tool(&id, &name, Some(input), None, false).await
        }
        Chunk::ToolResult {
            id,
            name,
            result,
            is_error,
        } => brain
            .push_tool(&id, &name, None, Some(result), is_error)
            .await,
    }
}