Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion crates/loopal-agent-hub/src/dispatch/spawn_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Hub>>,
params: Value,
Expand Down Expand Up @@ -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"));
}
}
5 changes: 4 additions & 1 deletion crates/loopal-agent/src/tools/collaboration/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
20 changes: 17 additions & 3 deletions crates/loopal-runtime/src/agent_loop/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use super::turn_context::TurnContext;
impl AgentLoopRunner {
pub(super) async fn run_loop(&mut self) -> Result<AgentOutput> {
let mut last_output = String::new();
let mut last_error: Option<String> = 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
Expand Down Expand Up @@ -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,
})
}

Expand Down
29 changes: 29 additions & 0 deletions crates/loopal-runtime/tests/agent_loop/run_test.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]
Expand Down
Loading