diff --git a/.gitignore b/.gitignore index b2668f19..78c0f28f 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index 8f216c56..2a6d2bc4 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -2,6 +2,7 @@ use crate::context::CliContext; use crate::output::Table; +use chrono::Utc; use colored::Colorize; use serde::Serialize; use std::collections::BTreeMap; @@ -18,6 +19,8 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho .map_err(|e| anyhow::anyhow!("Failed to list persisted agents: {}", e))?; let mut merged: BTreeMap = BTreeMap::new(); + + // Process live agents from the registry first for m in &agents_metadata { let status = format!("{:?}", m.state); let is_running = is_running_state(&status); @@ -28,23 +31,26 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho name: m.name.clone(), status, is_running, + uptime: None, // Live registry currently doesn't provide start time easily + provider: None, + model: None, description: m.description.clone(), }, ); } + // Merge in persisted agents for (_, entry) in persisted_agents { merged.entry(entry.id.clone()).or_insert_with(|| { - let status = if is_running_state(&entry.state) { - format!("{} (persisted)", entry.state) - } else { - entry.state - }; + let status = entry.state.clone(); AgentInfo { id: entry.id, name: entry.name, - status, - is_running: false, + status: status.clone(), + is_running: is_running_state(&status), + uptime: Some(format_duration(Utc::now() - entry.started_at)), + provider: entry.provider, + model: entry.model, description: entry.description, } }); @@ -84,6 +90,24 @@ pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyho Ok(()) } +/// Formats a duration into a human-readable string (e.g., "2h 15m", "45s"). +fn format_duration(duration: chrono::Duration) -> String { + let seconds = duration.num_seconds(); + if seconds <= 0 { + return "0s".to_string(); + } + if seconds < 60 { + format!("{}s", seconds) + } else if seconds < 3600 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else { + let hours = seconds / 3600; + let mins = (seconds % 3600) / 60; + format!("{}h {}m", hours, mins) + } +} + +/// Agent information for display purposes. #[derive(Debug, Clone, Serialize)] struct AgentInfo { id: String, @@ -91,10 +115,20 @@ struct AgentInfo { status: String, #[serde(skip_serializing)] is_running: bool, + uptime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, } +/// Checks if a status string represents a running or ready agent. fn is_running_state(status: &str) -> bool { - status == "Running" || status == "Ready" + let s = status.to_lowercase(); + matches!( + s.as_str(), + "running" | "ready" | "executing" | "initializing" | "paused" + ) } diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index 6a300c94..ba8a0e15 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -2,6 +2,7 @@ use crate::config::loader::ConfigLoader; use crate::context::{AgentConfigEntry, CliContext}; +use chrono::Utc; use colored::Colorize; /// Execute the `mofa agent start` command @@ -82,8 +83,12 @@ pub async fn run( id: agent_id.to_string(), name: agent_config.name.clone(), state: "Running".to_string(), + started_at: Utc::now(), + provider: None, // Could be extracted from agent_config in the future + model: None, // Could be extracted from agent_config in the future description: agent_config.description.clone(), }; + if let Err(e) = ctx.agent_store.save(agent_id, &entry) { let rollback_result = ctx.agent_registry.unregister(agent_id).await; match rollback_result { @@ -105,7 +110,11 @@ pub async fn run( } } - println!("{} Agent '{}' started", "✓".green(), agent_id); + println!( + "{} Agent '{}' started (state persisted)", + "√".green(), + agent_id + ); Ok(()) } diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs index e0fd8b8c..7edc8fe2 100644 --- a/crates/mofa-cli/src/context.rs +++ b/crates/mofa-cli/src/context.rs @@ -3,6 +3,7 @@ use crate::store::PersistedStore; use crate::utils::paths; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use mofa_foundation::agent::base::BaseAgent; use mofa_foundation::agent::components::tool::EchoTool; use mofa_foundation::agent::session::SessionManager; @@ -25,11 +26,24 @@ const BUILTIN_HTTP_PLUGIN_KIND: &str = "builtin:http"; const BUILTIN_ECHO_TOOL_KIND: &str = "builtin:echo"; const CLI_BASE_FACTORY_KIND: &str = "cli-base"; +/// Persistent entry for agent configuration and state metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfigEntry { + /// Unique identifier for the agent. pub id: String, + /// Human-readable name of the agent. pub name: String, + /// Last known execution state (e.g., "Running", "Ready"). pub state: String, + /// Timestamp when the agent was last started. + pub started_at: DateTime, + /// Optional AI provider used by the agent (e.g., "openai", "anthropic"). + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Optional AI model name (e.g., "gpt-4", "claude-3"). + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Optional description of the agent's purpose. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -340,6 +354,9 @@ mod tests { id: "persisted-agent".to_string(), name: "Persisted Agent".to_string(), state: "Running".to_string(), + started_at: Utc::now(), + provider: None, + model: None, description: None, }; ctx1.agent_store.save("persisted-agent", &entry).unwrap();