diff --git a/crates/loopal-agent-hub/src/dispatch/spawn_routing.rs b/crates/loopal-agent-hub/src/dispatch/spawn_routing.rs index b2567d46..39b7c22a 100644 --- a/crates/loopal-agent-hub/src/dispatch/spawn_routing.rs +++ b/crates/loopal-agent-hub/src/dispatch/spawn_routing.rs @@ -34,11 +34,33 @@ pub async fn handle_spawn_agent( "'target_hub' cannot contain '/' (cross-hub address encoding), got: {target}" )); } - return super::cross_hub_forward::forward_cross_hub_spawn(hub, params, from_agent).await; + let own_hub = hub + .lock() + .await + .uplink + .as_ref() + .map(|u| u.hub_name().to_string()); + if !is_self_target(own_hub.as_deref(), target) { + return super::cross_hub_forward::forward_cross_hub_spawn(hub, params, from_agent) + .await; + } + let mut local_params = params; + if let Some(obj) = local_params.as_object_mut() { + obj.remove("target_hub"); + } + return spawn_local(hub, local_params, from_agent).await; } spawn_local(hub, params, from_agent).await } +// reason: a hub targeting itself would pre-register a shadow then route back +// through MetaHub into its own registry, colliding as "already registered" and +// orphaning a forked process. Self-target must spawn locally — same registry, +// no MetaHub round-trip. +fn is_self_target(own_hub: Option<&str>, target: &str) -> bool { + own_hub == Some(target) +} + async fn spawn_local( hub: &Arc>, params: Value, @@ -149,3 +171,23 @@ pub(super) async fn spawn_via_manager( info!(agent = %name, %agent_id, "spawn done"); Ok(json!({"agent_id": agent_id, "name": name})) } + +#[cfg(test)] +mod tests { + use super::is_self_target; + + #[test] + fn own_hub_equals_target_is_self() { + assert!(is_self_target(Some("hub-a"), "hub-a")); + } + + #[test] + fn different_hub_is_not_self() { + assert!(!is_self_target(Some("hub-a"), "hub-b")); + } + + #[test] + fn no_uplink_is_never_self() { + assert!(!is_self_target(None, "hub-a")); + } +} diff --git a/crates/loopal-agent/src/tools/collaboration/agent.rs b/crates/loopal-agent/src/tools/collaboration/agent.rs index 16e0d7a5..f4eb34df 100644 --- a/crates/loopal-agent/src/tools/collaboration/agent.rs +++ b/crates/loopal-agent/src/tools/collaboration/agent.rs @@ -29,7 +29,10 @@ impl Tool for AgentTool { "prompt": { "type": "string" }, "name": { "type": "string" }, "subagent_type": { "type": "string" }, - "model": { "type": "string" }, + "model": { + "type": "string", + "description": "Omit to inherit the parent agent's model. Only set to override, and only to a model the target hub actually has — a cross-hub spawn forwards this name verbatim, so an unsupported model fails with 'Model not found'." + }, "target_hub": { "type": "string", "description": "Spawn on a remote hub in the cluster (e.g. 'hub-b'). Requires MetaHub connection." diff --git a/crates/loopal-runtime/src/agent_loop/run.rs b/crates/loopal-runtime/src/agent_loop/run.rs index 4e92cb72..e6288c5b 100644 --- a/crates/loopal-runtime/src/agent_loop/run.rs +++ b/crates/loopal-runtime/src/agent_loop/run.rs @@ -15,6 +15,7 @@ use super::turn_context::TurnContext; impl AgentLoopRunner { pub(super) async fn run_loop(&mut self) -> Result { let mut last_output = String::new(); + let mut last_error: Option = None; let mut server_block_retry = false; let mut context_overflow_retry = false; // Need user input whenever the last message isn't User — covers empty @@ -108,16 +109,29 @@ impl AgentLoopRunner { continue; } error!(error = %e, "LLM request failed"); - self.transition_error(LoopalError::to_string(&e)).await?; + let msg = LoopalError::to_string(&e); + self.transition_error(msg.clone()).await?; + last_error = Some(msg); + // reason: an ephemeral agent has no UI/parent to retry, so an + // unrecovered turn error must terminate the loop with the real + // error — not fall through to "idle, exiting" which would report + // a successful empty result and hide the failure from the caller. + if matches!(self.params.config.lifecycle, LifecycleMode::Ephemeral) { + break; + } } } server_block_retry = false; context_overflow_retry = false; } + let (result, terminate_reason) = match last_error { + Some(err) if last_output.is_empty() => (err, TerminateReason::Error), + _ => (last_output, TerminateReason::Goal), + }; Ok(AgentOutput { - result: last_output, - terminate_reason: TerminateReason::Goal, + result, + terminate_reason, }) } diff --git a/crates/loopal-runtime/tests/agent_loop/run_test.rs b/crates/loopal-runtime/tests/agent_loop/run_test.rs index 80a589c8..0a8ea6f3 100644 --- a/crates/loopal-runtime/tests/agent_loop/run_test.rs +++ b/crates/loopal-runtime/tests/agent_loop/run_test.rs @@ -1,6 +1,7 @@ use loopal_error::{LoopalError, TerminateReason}; use loopal_protocol::AgentEventPayload; use loopal_provider_api::{StopReason, StreamChunk}; +use loopal_runtime::agent_loop::LifecycleMode; use super::mock_provider::{ make_interactive_multi_runner, make_multi_runner, make_runner_with_mock_provider, @@ -134,6 +135,34 @@ async fn test_prompt_driven_error_exits_cleanly() { ); } +/// Reproduces the cross-hub failure: a spawned (ephemeral) agent whose model +/// has no provider on this hub. resolve_provider errors on the first LLM call; +/// the loop must surface that error as the result + TerminateReason::Error +/// instead of returning an empty, falsely-successful Goal completion. +#[tokio::test] +async fn test_ephemeral_unresolved_model_propagates_error_to_result() { + let calls = vec![vec![ + Ok(StreamChunk::Text { + text: "unused".into(), + }), + Ok(StreamChunk::Done { + stop_reason: StopReason::EndTurn, + }), + ]]; + let (mut runner, _event_rx) = make_multi_runner(calls); + runner.params.config.lifecycle = LifecycleMode::Ephemeral; + runner.params.config.router = + loopal_provider_api::ModelRouter::new("unknown-model-xyz".to_string()); + + let output = runner.run().await.unwrap(); + assert_eq!(output.terminate_reason, TerminateReason::Error); + assert!( + !output.result.is_empty() && output.result.contains("unknown-model-xyz"), + "real provider error must reach result (caller-visible), got: {:?}", + output.result + ); +} + /// Authoritative `Running` event is emitted before any `Stream`, so the /// TUI status bar can flip before the first LLM byte arrives. #[tokio::test]