diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 09118af2..872f098d 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -199,6 +199,12 @@ pub async fn build_router( "/api/agents/{id}/start", axum::routing::post(routes::restart_agent), ) + .route( + // Issue #890 — alias so dashboards and external orchestrators can + // wake an inactive agent via a verb that matches the agent_activate tool. + "/api/agents/{id}/activate", + axum::routing::post(routes::restart_agent), + ) .route( "/api/agents/{id}/message", axum::routing::post(routes::send_message), diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index a09fc153..9808f461 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -3482,6 +3482,44 @@ impl OpenFangKernel { )) } + /// Activate (wake up) an inactive agent — flips Suspended/Crashed/Created + /// state back to Running so it can receive messages and process events again. + /// + /// Returns the agent's name on success. `Terminated` agents cannot be + /// activated (they have been removed from the registry). `Running` agents + /// are a no-op (returns name, last_active is refreshed). + /// + /// See issue #890 — allows an orchestrator agent to wake other agents. + pub fn activate_agent(&self, agent_id: AgentId) -> KernelResult { + let entry = self.registry.get(agent_id).ok_or_else(|| { + KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string())) + })?; + + if entry.state == AgentState::Terminated { + return Err(KernelError::OpenFang(OpenFangError::Internal(format!( + "Agent {} is Terminated and cannot be activated", + entry.name + )))); + } + + let was_state = entry.state; + let name = entry.name.clone(); + drop(entry); + + self.registry + .set_state(agent_id, AgentState::Running) + .map_err(KernelError::OpenFang)?; + + info!( + agent = %name, + id = %agent_id, + previous_state = ?was_state, + "Agent activated" + ); + + Ok(name) + } + /// Kill an agent. pub fn kill_agent(&self, agent_id: AgentId) -> KernelResult<()> { let entry = self @@ -6823,6 +6861,19 @@ impl KernelHandle for OpenFangKernel { OpenFangKernel::kill_agent(self, id).map_err(|e| format!("Kill failed: {e}")) } + fn activate_agent(&self, agent_id: &str) -> Result { + // Accept UUID or human-readable name. + let id: AgentId = match agent_id.parse() { + Ok(id) => id, + Err(_) => self + .registry + .find_by_name(agent_id) + .map(|e| e.id) + .ok_or_else(|| format!("Agent not found: {agent_id}"))?, + }; + OpenFangKernel::activate_agent(self, id).map_err(|e| format!("Activate failed: {e}")) + } + fn memory_store(&self, key: &str, value: serde_json::Value) -> Result<(), String> { let agent_id = shared_memory_agent_id(); self.memory @@ -7987,6 +8038,132 @@ mod tests { kernel.shutdown(); } + // ---------------------------------------------------------------------- + // Issue #890: activate_agent — wake up inactive agents + // ---------------------------------------------------------------------- + + #[test] + fn test_activate_agent_wakes_suspended_and_crashed() { + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-kernel-activate-test"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + ..KernelConfig::default() + }; + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + + // Suspended agent: should flip to Running. + let suspended = register_test_agent(&kernel, "sleepy"); + kernel + .registry + .set_state(suspended, AgentState::Suspended) + .unwrap(); + let name = kernel + .activate_agent(suspended) + .expect("activate suspended agent"); + assert_eq!(name, "sleepy"); + assert_eq!( + kernel.registry.get(suspended).unwrap().state, + AgentState::Running + ); + + // Crashed agent: should also flip to Running. + let crashed = register_test_agent(&kernel, "broken"); + kernel + .registry + .set_state(crashed, AgentState::Crashed) + .unwrap(); + kernel.activate_agent(crashed).expect("activate crashed"); + assert_eq!( + kernel.registry.get(crashed).unwrap().state, + AgentState::Running + ); + + // Created (never-started) agent: should also flip to Running. + let created = register_test_agent(&kernel, "freshly-baked"); + kernel + .registry + .set_state(created, AgentState::Created) + .unwrap(); + kernel.activate_agent(created).expect("activate created"); + assert_eq!( + kernel.registry.get(created).unwrap().state, + AgentState::Running + ); + + // Already-running agent: idempotent, stays Running, no error. + kernel.activate_agent(crashed).expect("idempotent activate"); + assert_eq!( + kernel.registry.get(crashed).unwrap().state, + AgentState::Running + ); + + // Terminated agent: rejected. + let dead = register_test_agent(&kernel, "zombie"); + kernel + .registry + .set_state(dead, AgentState::Terminated) + .unwrap(); + assert!( + kernel.activate_agent(dead).is_err(), + "Terminated agents must not be revivable" + ); + + // Unknown agent ID: rejected. + assert!(kernel.activate_agent(AgentId::new()).is_err()); + + kernel.shutdown(); + } + + #[test] + fn test_activate_agent_handle_accepts_name_and_uuid() { + use openfang_runtime::kernel_handle::KernelHandle; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-kernel-activate-handle-test"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + ..KernelConfig::default() + }; + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + + let agent = register_test_agent(&kernel, "worker"); + kernel + .registry + .set_state(agent, AgentState::Suspended) + .unwrap(); + + // Wake by name. + let name = KernelHandle::activate_agent(&kernel, "worker").expect("activate by name"); + assert_eq!(name, "worker"); + assert_eq!( + kernel.registry.get(agent).unwrap().state, + AgentState::Running + ); + + // Put it back to sleep then wake by UUID string. + kernel + .registry + .set_state(agent, AgentState::Suspended) + .unwrap(); + KernelHandle::activate_agent(&kernel, &agent.to_string()).expect("activate by uuid"); + assert_eq!( + kernel.registry.get(agent).unwrap().state, + AgentState::Running + ); + + // Unknown name returns Err. + assert!(KernelHandle::activate_agent(&kernel, "ghost").is_err()); + + kernel.shutdown(); + } + // ---------------------------------------------------------------------- // Issue #1069: sanitize_cron_job_name + shared-memory schedule migration // ---------------------------------------------------------------------- diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index e3e1b763..ec57efe1 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -43,6 +43,15 @@ pub trait KernelHandle: Send + Sync { /// Kill an agent by ID. fn kill_agent(&self, agent_id: &str) -> Result<(), String>; + /// Activate (wake up) an inactive agent by ID, flipping its state to Running. + /// Used by orchestrator agents to dispatch work to currently inactive agents + /// (Suspended, Crashed, or never-started). Terminated agents cannot be revived. + /// Returns the agent's name on success. + fn activate_agent(&self, agent_id: &str) -> Result { + let _ = agent_id; + Err("Agent activation not available".to_string()) + } + /// Store a value in shared memory (cross-agent accessible). fn memory_store(&self, key: &str, value: serde_json::Value) -> Result<(), String>; diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index 6f745965..dd28ae39 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -519,7 +519,7 @@ pub fn tool_category(name: &str) -> &'static str { "memory_store" | "memory_recall" | "memory_delete" | "memory_list" => "Memory", - "agent_send" | "agent_spawn" | "agent_list" | "agent_kill" => "Agents", + "agent_send" | "agent_spawn" | "agent_list" | "agent_kill" | "agent_activate" => "Agents", "image_describe" | "image_generate" | "audio_transcribe" | "tts_speak" => "Media", @@ -581,6 +581,7 @@ pub fn tool_hint(name: &str) -> &'static str { "agent_spawn" => "create a new agent", "agent_list" => "list running agents", "agent_kill" => "terminate an agent", + "agent_activate" => "wake up an inactive agent so it can receive work", // Media "image_describe" => "describe an image", diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 426637a7..0f3958d2 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -299,6 +299,7 @@ pub async fn execute_tool( "agent_spawn" => tool_agent_spawn(input, kernel, caller_agent_id).await, "agent_list" => tool_agent_list(kernel), "agent_kill" => tool_agent_kill(input, kernel), + "agent_activate" => tool_agent_activate(input, kernel), // Shared memory tools "memory_store" => tool_memory_store(input, kernel), @@ -694,6 +695,24 @@ pub fn builtin_tool_definitions() -> Vec { "required": ["agent_id"] }), }, + ToolDefinition { + name: "agent_activate".to_string(), + description: "Activate (wake up) an inactive agent so it can receive messages \ + and process events. Use this when agent_list shows an agent in a \ + Suspended, Crashed, or Created state and you want to delegate work \ + to it via agent_send. Terminated agents cannot be revived." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The target agent's UUID or human-readable name" + } + }, + "required": ["agent_id"] + }), + }, // --- Shared memory tools --- ToolDefinition { name: "memory_store".to_string(), @@ -1696,6 +1715,20 @@ fn tool_agent_kill( Ok(format!("Agent {agent_id} killed successfully.")) } +fn tool_agent_activate( + input: &serde_json::Value, + kernel: Option<&Arc>, +) -> Result { + let kh = require_kernel(kernel)?; + let agent_id = input["agent_id"] + .as_str() + .ok_or("Missing 'agent_id' parameter")?; + let name = kh.activate_agent(agent_id)?; + Ok(format!( + "Agent '{name}' activated. It is now Running and ready to receive messages." + )) +} + // --------------------------------------------------------------------------- // Shared memory tools // --------------------------------------------------------------------------- @@ -3420,6 +3453,8 @@ mod tests { assert!(names.contains(&"agent_spawn")); assert!(names.contains(&"agent_list")); assert!(names.contains(&"agent_kill")); + // Issue #890 — wake up inactive agents + assert!(names.contains(&"agent_activate")); assert!(names.contains(&"memory_store")); assert!(names.contains(&"memory_recall")); // 6 collaboration tools