daat-locus 0.1.1

A long-running local agent runtime with memory, workflows, apps, and sleep-time self-improvement.
use serde_json::to_string_pretty;
use tokio::sync::watch::Sender;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter;

use crate::{
    context_budget::RequestBudgetBreakdown,
    daat_locus_paths::daat_locus_paths,
    dashboard::DashboardState,
    reasoning::runtime::{
        AgentContentPart, AgentMessage, AgentTurnItem, AgentTurnRequest, AgentTurnStreamResult,
        render_assistant_tool_call_protocol_dump,
    },
};

#[derive(Clone, Copy)]
pub enum RuntimeStatusLevel {
    Debug,
    Info,
    Warn,
    Error,
}

/// Initialize file logging and return the guard that must be held until process exit.
pub async fn init_logging() -> WorkerGuard {
    let log_dir = daat_locus_paths().await.logs_dir();
    if let Err(err) = tokio::fs::create_dir_all(&log_dir).await {
        eprintln!(
            "failed to create log directory {}: {err}",
            log_dir.display()
        );
        // Fall back to stderr and return a /dev/null appender guard.
        let (non_blocking, guard) = tracing_appender::non_blocking(std::io::sink());
        let _ = tracing_subscriber::fmt()
            .with_env_filter(EnvFilter::new("warn"))
            .with_ansi(false)
            .with_writer(non_blocking)
            .try_init();
        return guard;
    }

    let file_appender = tracing_appender::rolling::never(&log_dir, "daat-locus.log");
    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);

    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

    if let Err(err) = tracing_subscriber::fmt()
        .with_env_filter(env_filter)
        .with_ansi(false)
        .with_target(true)
        .with_thread_ids(true)
        .with_writer(non_blocking)
        .try_init()
    {
        // Global subscriber was already set, which should not normally happen; fall back to stderr.
        eprintln!("init_logging: tracing subscriber already set, falling back to stderr: {err}");
        let (nb_stderr, guard_stderr) = tracing_appender::non_blocking(std::io::stderr());
        let _ = tracing_subscriber::fmt()
            .with_env_filter(EnvFilter::new("daat_locus=info,warn"))
            .with_ansi(false)
            .with_writer(nb_stderr)
            .try_init();
        return guard_stderr;
    }

    guard
}

pub fn set_runtime_status(
    tx: Option<&Sender<DashboardState>>,
    level: RuntimeStatusLevel,
    message: impl Into<String>,
) {
    let message = message.into();
    match level {
        RuntimeStatusLevel::Debug => tracing::debug!("{message}"),
        RuntimeStatusLevel::Info => tracing::info!("{message}"),
        RuntimeStatusLevel::Warn => tracing::warn!("{message}"),
        RuntimeStatusLevel::Error => tracing::error!("{message}"),
    }
    if let Some(tx) = tx {
        tx.send_modify(|state| state.runtime_status = Some(message.clone()));
    }
}

pub fn clear_runtime_status(tx: Option<&Sender<DashboardState>>) {
    if let Some(tx) = tx {
        tx.send_modify(|state| state.runtime_status = None);
    }
}

pub async fn write_current_turn_messages_dump(
    request: &AgentTurnRequest,
    budget: &RequestBudgetBreakdown,
    model_name: Option<&str>,
) {
    let body = render_current_turn_messages_dump(request, budget, model_name);
    write_current_turn_log_file("current_turn_messages.txt", body).await;
}

pub async fn write_current_turn_response_dump(response: &AgentTurnStreamResult, attempt: usize) {
    let body = render_current_turn_response_dump(response, attempt);
    write_current_turn_log_file("current_turn_response.txt", body).await;
}

pub async fn write_current_turn_response_error_dump(error: &str, attempt: usize, will_retry: bool) {
    let mut lines = vec![
        "status=error".to_string(),
        format!("attempt={attempt}"),
        format!("will_retry={will_retry}"),
        String::new(),
        "error:".to_string(),
        error.to_string(),
    ];
    lines.push(String::new());
    lines.push("note=contents reflect the latest LLM request outcome for this turn".to_string());
    write_current_turn_log_file("current_turn_response.txt", lines.join("\n")).await;
}

async fn write_current_turn_log_file(file_name: &str, body: String) {
    let paths = daat_locus_paths().await;
    let log_dir = paths.logs_dir();
    if let Err(err) = tokio::fs::create_dir_all(&log_dir).await {
        tracing::warn!(
            "failed to create log directory for current turn dump {}: {err}",
            log_dir.display()
        );
        return;
    }

    let dump_path = paths.logs_file(file_name);
    if let Err(err) = tokio::fs::write(&dump_path, body).await {
        tracing::warn!(
            "failed to write current turn dump {}: {err}",
            dump_path.display()
        );
    }
}

fn render_current_turn_messages_dump(
    request: &AgentTurnRequest,
    budget: &RequestBudgetBreakdown,
    model_name: Option<&str>,
) -> String {
    let mut lines = vec![
        format!(
            "model={}",
            model_name
                .filter(|value| !value.trim().is_empty())
                .unwrap_or("unknown")
        ),
        format!("message_count={}", request.messages.len()),
        format!("tool_count={}", request.tools.len()),
    ];
    lines.extend(budget.summary_lines());

    if !request.tools.is_empty() {
        lines.push(String::new());
        lines.push("== Tools ==".to_string());
        for tool in &request.tools {
            lines.push(format!("- {}", tool.name));
        }
    }

    for (index, message) in request.messages.iter().enumerate() {
        lines.push(String::new());
        lines.push(format!("== Message {} ==", index + 1));
        lines.extend(render_agent_message_dump(message));
    }

    lines.join("\n")
}

fn render_current_turn_response_dump(response: &AgentTurnStreamResult, attempt: usize) -> String {
    let mut lines = vec![
        "status=ok".to_string(),
        format!("attempt={attempt}"),
        format!("item_count={}", response.items.len()),
        format!(
            "raw_stream_follow_up={}",
            if response.raw_stream_follow_up {
                "true"
            } else {
                "false"
            }
        ),
        format!(
            "last_assistant_message={}",
            response
                .last_assistant_message
                .as_deref()
                .filter(|value| !value.trim().is_empty())
                .unwrap_or("none")
        ),
    ];

    for (index, item) in response.items.iter().enumerate() {
        lines.push(String::new());
        lines.push(format!("== Item {} ==", index + 1));
        lines.extend(render_agent_turn_item_dump(item));
    }

    lines.join("\n")
}

fn render_agent_message_dump(message: &AgentMessage) -> Vec<String> {
    match message {
        AgentMessage::System { content } => {
            vec![
                "role=system".to_string(),
                "content:".to_string(),
                content.clone(),
            ]
        }
        AgentMessage::User { content } => {
            let mut lines = vec![
                "role=user".to_string(),
                "content:".to_string(),
                content.as_text().to_string(),
            ];
            for (index, part) in content.parts().iter().enumerate() {
                match part {
                    AgentContentPart::Text { text } => {
                        lines.push(format!("part[{index}]=text chars={}", text.chars().count()));
                    }
                    AgentContentPart::Image {
                        path,
                        media_type,
                        description,
                    } => {
                        lines.push(format!(
                            "part[{index}]=image media_type={media_type} path={path} description={}",
                            description.as_deref().unwrap_or("")
                        ));
                    }
                }
            }
            lines
        }
        AgentMessage::Assistant { content } => {
            vec![
                "role=assistant".to_string(),
                "content:".to_string(),
                content.clone(),
            ]
        }
        AgentMessage::AssistantToolCallProtocol {
            content,
            reasoning_content,
            calls,
        } => render_assistant_tool_call_protocol_dump(
            content.as_deref(),
            reasoning_content.as_deref(),
            calls,
        ),
        AgentMessage::Tool {
            tool_call_id,
            name,
            content,
        } => vec![
            "role=tool".to_string(),
            format!("tool_call_id={tool_call_id}"),
            format!("name={name}"),
            "content:".to_string(),
            content.clone(),
        ],
    }
}

fn render_agent_turn_item_dump(item: &AgentTurnItem) -> Vec<String> {
    match item {
        AgentTurnItem::AssistantMessage { content } => vec![
            "kind=assistant_message".to_string(),
            "content:".to_string(),
            content.clone(),
        ],
        AgentTurnItem::ToolCall { call } => vec![
            "kind=tool_call".to_string(),
            format!("id={}", call.id),
            format!("name={}", call.name),
            "arguments:".to_string(),
            to_string_pretty(&call.arguments).unwrap_or_else(|_| call.arguments.to_string()),
        ],
    }
}