diff --git a/crates/loopal-agent-hub/src/agent_registry/completion.rs b/crates/loopal-agent-hub/src/agent_registry/completion.rs index 4a2e4203..d04a4fd8 100644 --- a/crates/loopal-agent-hub/src/agent_registry/completion.rs +++ b/crates/loopal-agent-hub/src/agent_registry/completion.rs @@ -70,13 +70,16 @@ impl AgentRegistry { } else { result.to_string() }; - let content = format!("\n{body}\n"); - // Source carries the child's local view; uplink SNAT stamps the - // origin hub when this completion is delivered to a remote parent. + // Source carries the child's local view (uplink SNAT stamps the origin + // hub on cross-hub delivery). The `` wrapper is rebuilt at + // LLM projection time — the envelope body stays raw so observers render + // it structurally. let envelope = Envelope::new( - MessageSource::Agent(QualifiedAddress::local(child_name)), + MessageSource::AgentResult { + child: QualifiedAddress::local(child_name), + }, parent.clone(), - content, + body, ); Some((tx, envelope)) } @@ -118,7 +121,8 @@ impl AgentRegistry { .iter() .filter(|c| { self.agents.get(c.as_str()).is_some_and(|a| { - a.info.lifecycle == AgentLifecycle::Running && !a.state.is_shadow() // shadows are remote, can't interrupt locally + a.info.lifecycle == AgentLifecycle::Running && !a.state.is_shadow() + // shadows are remote, can't interrupt locally }) }) .cloned() diff --git a/crates/loopal-agent-hub/src/finish.rs b/crates/loopal-agent-hub/src/finish.rs index c4689c13..4c8acdcc 100644 --- a/crates/loopal-agent-hub/src/finish.rs +++ b/crates/loopal-agent-hub/src/finish.rs @@ -53,13 +53,12 @@ pub async fn finish_and_deliver( && parent.is_remote() && let Some(ul) = uplink { - let content = format!("\n{output_text}\n"); - // Use Agent(local(child)) so uplink SNAT stamps the origin hub. - // System("agent-completed") cannot carry hub info — see review #3. let envelope = Envelope::new( - MessageSource::Agent(QualifiedAddress::local(name)), + MessageSource::AgentResult { + child: QualifiedAddress::local(name), + }, parent.clone(), - content, + output_text, ); if let Err(e) = ul.route(&envelope).await { tracing::warn!(agent = %name, parent = %parent, error = %e, diff --git a/crates/loopal-agent-hub/src/uplink.rs b/crates/loopal-agent-hub/src/uplink.rs index c5d61f74..50cdd47c 100644 --- a/crates/loopal-agent-hub/src/uplink.rs +++ b/crates/loopal-agent-hub/src/uplink.rs @@ -114,12 +114,13 @@ pub async fn handle_reverse_requests( let ok = if let Ok(env) = serde_json::from_value::(params) { - // Remote agent completions arrive with the agent-result - // marker in content. Detect by content (not source tag) - // so it works with the typed Agent source after SNAT. - if let Some(child) = extract_agent_result_name(&env) { + // Remote agent completions arrive as a typed AgentResult + // source (set by the origin hub, survives SNAT). The + // child name is the bare agent segment. + if let loopal_protocol::MessageSource::AgentResult { child } = &env.source { let output = env.content.text.clone(); - crate::finish::deliver_cross_hub_completion(&hub, &child, output).await; + crate::finish::deliver_cross_hub_completion(&hub, &child.agent, output) + .await; } // Defense in depth: target should be local at this point // (MetaHub router consumed the next-hop hub via DNAT). @@ -169,11 +170,3 @@ pub async fn handle_reverse_requests( } tracing::warn!(hub = %hub_name, "MetaHub reverse handler ended"); } - -/// Extract child agent name from `` envelope. -fn extract_agent_result_name(env: &loopal_protocol::Envelope) -> Option { - let text = &env.content.text; - let start = text.find("")); - assert!(env.content.text.contains("ok")); + // Body is raw now — the wrapper is a projection concern. + assert_eq!(env.content.text, "ok"); } /// Cross-hub spawn: a child registered with a qualified `hub/agent` parent diff --git a/crates/loopal-meta-hub/tests/suite/spawn_completion_test.rs b/crates/loopal-meta-hub/tests/suite/spawn_completion_test.rs index 14cda941..242960f2 100644 --- a/crates/loopal-meta-hub/tests/suite/spawn_completion_test.rs +++ b/crates/loopal-meta-hub/tests/suite/spawn_completion_test.rs @@ -111,12 +111,16 @@ async fn completion_delivery_to_remote_parent() { let params = match &msg { Incoming::Request { params, .. } | Incoming::Notification { params, .. } => params, }; - let text = params - .get("content") - .and_then(|c| c.get("text")) - .and_then(|t| t.as_str()) - .unwrap_or(""); - if text.contains("agent-result") && text.contains("child-worker") { + // Completion is now a typed AgentResult source carrying the child + // name; the body is the raw output, not a wrapped marker. + let is_result = params + .get("source") + .and_then(|s| s.get("AgentResult")) + .and_then(|r| r.get("child")) + .and_then(|c| c.get("agent")) + .and_then(|a| a.as_str()) + .is_some_and(|name| name == "child-worker"); + if is_result { return true; } } diff --git a/crates/loopal-protocol/src/envelope.rs b/crates/loopal-protocol/src/envelope.rs index 6f75dc91..54d38546 100644 --- a/crates/loopal-protocol/src/envelope.rs +++ b/crates/loopal-protocol/src/envelope.rs @@ -13,6 +13,9 @@ use crate::user_content::UserContent; pub enum MessageSource { Human, Agent(QualifiedAddress), + AgentResult { + child: QualifiedAddress, + }, Channel { channel: String, from: QualifiedAddress, @@ -27,6 +30,7 @@ impl MessageSource { match self { Self::Human => "human".to_string(), Self::Agent(addr) => addr.to_string(), + Self::AgentResult { child } => child.to_string(), Self::Channel { from, .. } => from.to_string(), Self::Scheduled => "scheduled".to_string(), Self::System(kind) => format!("system:{kind}"), @@ -67,7 +71,11 @@ impl MessageSource { pub fn is_task_boundary(&self) -> bool { matches!( self, - Self::Human | Self::Scheduled | Self::Agent(_) | Self::Channel { .. } + Self::Human + | Self::Scheduled + | Self::Agent(_) + | Self::AgentResult { .. } + | Self::Channel { .. } ) } @@ -76,6 +84,7 @@ impl MessageSource { pub fn prepend_hub(&mut self, self_hub: &str) { match self { Self::Agent(addr) => addr.prepend_hub(self_hub.to_string()), + Self::AgentResult { child } => child.prepend_hub(self_hub.to_string()), Self::Channel { from, .. } => from.prepend_hub(self_hub.to_string()), _ => {} } @@ -87,6 +96,7 @@ impl MessageSource { pub fn prepend_hub_if_local(&mut self, self_hub: &str) { match self { Self::Agent(addr) => addr.prepend_hub_if_local(self_hub.to_string()), + Self::AgentResult { child } => child.prepend_hub_if_local(self_hub.to_string()), Self::Channel { from, .. } => from.prepend_hub_if_local(self_hub.to_string()), _ => {} } diff --git a/crates/loopal-protocol/tests/suite/envelope_test.rs b/crates/loopal-protocol/tests/suite/envelope_test.rs index f9bde0a3..cb38c188 100644 --- a/crates/loopal-protocol/tests/suite/envelope_test.rs +++ b/crates/loopal-protocol/tests/suite/envelope_test.rs @@ -248,3 +248,61 @@ fn test_non_human_sources_are_not_optimistically_rendered() { .is_optimistically_rendered() ); } + +#[test] +fn test_agent_result_label_uses_child_address() { + let local = MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + }; + assert_eq!(local.label(), "worker"); + let remote = MessageSource::AgentResult { + child: QualifiedAddress::remote(["hub-A"], "worker"), + }; + assert_eq!(remote.label(), "hub-A/worker"); +} + +#[test] +fn test_agent_result_is_task_boundary() { + assert!( + MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + } + .is_task_boundary() + ); +} + +#[test] +fn test_agent_result_not_optimistically_rendered_nor_ephemeral() { + let src = MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + }; + assert!(!src.is_optimistically_rendered()); + assert!(!src.is_ephemeral_in_history()); + assert!(!src.wakes_suspended_session()); +} + +#[test] +fn test_agent_result_snat_stamps_child_hub() { + let mut env = Envelope::new( + MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + }, + "hub-A/parent", + "done", + ); + env.apply_snat("hub-B"); + let MessageSource::AgentResult { child } = &env.source else { + panic!("expected AgentResult source"); + }; + assert_eq!(child, &QualifiedAddress::remote(["hub-B"], "worker")); +} + +#[test] +fn test_agent_result_serde_roundtrip() { + let src = MessageSource::AgentResult { + child: QualifiedAddress::remote(["hub-A"], "worker"), + }; + let json = serde_json::to_string(&src).unwrap(); + let back: MessageSource = serde_json::from_str(&json).unwrap(); + assert_eq!(src, back); +} diff --git a/crates/loopal-provider-api/src/lib.rs b/crates/loopal-provider-api/src/lib.rs index 31addb21..413a42f4 100644 --- a/crates/loopal-provider-api/src/lib.rs +++ b/crates/loopal-provider-api/src/lib.rs @@ -17,7 +17,7 @@ pub use resolver::ProviderResolver; pub use thinking::*; pub use wire::{ ContentBlock, ImageSource, Message, MessageOrigin, MessageRole, normalize_messages, - project_turn_to_messages, project_turns_to_messages, + project_turn_to_messages, project_turns_to_messages, trigger_llm_text, }; // --------------------------------------------------------------------------- diff --git a/crates/loopal-provider-api/src/wire/mod.rs b/crates/loopal-provider-api/src/wire/mod.rs index cc2b7425..b3c30133 100644 --- a/crates/loopal-provider-api/src/wire/mod.rs +++ b/crates/loopal-provider-api/src/wire/mod.rs @@ -6,4 +6,4 @@ pub mod turn_projection; pub use message::{ContentBlock, ImageSource, Message, MessageRole}; pub use normalize::normalize_messages; pub use origin::MessageOrigin; -pub use turn_projection::{project_turn_to_messages, project_turns_to_messages}; +pub use turn_projection::{project_turn_to_messages, project_turns_to_messages, trigger_llm_text}; diff --git a/crates/loopal-provider-api/src/wire/turn_projection/mod.rs b/crates/loopal-provider-api/src/wire/turn_projection/mod.rs index 4d183855..bca77f86 100644 --- a/crates/loopal-provider-api/src/wire/turn_projection/mod.rs +++ b/crates/loopal-provider-api/src/wire/turn_projection/mod.rs @@ -1,8 +1,11 @@ mod blocks; mod compaction; +mod prefix; mod step; mod trigger; +pub use self::prefix::trigger_llm_text; + use loopal_turn::{Turn, TurnStep}; use self::step::project_step; diff --git a/crates/loopal-provider-api/src/wire/turn_projection/prefix.rs b/crates/loopal-provider-api/src/wire/turn_projection/prefix.rs new file mode 100644 index 00000000..e9ad253c --- /dev/null +++ b/crates/loopal-provider-api/src/wire/turn_projection/prefix.rs @@ -0,0 +1,66 @@ +use loopal_turn::TurnTrigger; + +/// SSOT for the LLM-facing text of a turn trigger: applies the source prefix +/// (`[scheduled]`, `[from: ...]`) or `` marker. Returns `None` +/// for triggers that produce no user message (`Resume`). Images on +/// `UserInput` are structural and handled by the caller — this is text only. +pub fn trigger_llm_text(trigger: &TurnTrigger) -> Option { + match trigger { + TurnTrigger::UserInput { content, .. } => Some(content.clone()), + TurnTrigger::Cron { content, .. } => Some(format!("[scheduled] {content}")), + TurnTrigger::Agent { from, content, .. } => Some(format!("[from: {from}] {content}")), + TurnTrigger::AgentResult { from, content, .. } => Some(format!( + "\n{content}\n" + )), + TurnTrigger::Channel { + channel, + from, + content, + .. + } => Some(format!("[from: #{channel}/{from}] {content}")), + TurnTrigger::GoalContinuation { content, .. } => Some(content.clone()), + TurnTrigger::BackgroundHook { content, .. } => Some(content.clone()), + TurnTrigger::Resume => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent_result(from: &str, content: &str) -> TurnTrigger { + TurnTrigger::AgentResult { + envelope_id: String::new(), + from: from.into(), + content: content.into(), + } + } + + #[test] + fn agent_result_wraps_in_marker() { + assert_eq!( + trigger_llm_text(&agent_result("worker", "done")).unwrap(), + "\ndone\n" + ); + } + + #[test] + fn cron_and_agent_carry_source_prefix() { + let cron = TurnTrigger::Cron { + envelope_id: String::new(), + content: "tick".into(), + }; + assert_eq!(trigger_llm_text(&cron).unwrap(), "[scheduled] tick"); + let agent = TurnTrigger::Agent { + envelope_id: String::new(), + from: "hub/w".into(), + content: "hi".into(), + }; + assert_eq!(trigger_llm_text(&agent).unwrap(), "[from: hub/w] hi"); + } + + #[test] + fn resume_produces_no_text() { + assert_eq!(trigger_llm_text(&TurnTrigger::Resume), None); + } +} diff --git a/crates/loopal-provider-api/src/wire/turn_projection/trigger.rs b/crates/loopal-provider-api/src/wire/turn_projection/trigger.rs index 055ee2da..9508c5df 100644 --- a/crates/loopal-provider-api/src/wire/turn_projection/trigger.rs +++ b/crates/loopal-provider-api/src/wire/turn_projection/trigger.rs @@ -2,49 +2,41 @@ use loopal_turn::TurnTrigger; use super::super::message::{ContentBlock, ImageSource, Message, MessageRole}; use super::super::origin::MessageOrigin; +use super::prefix::trigger_llm_text; pub(super) fn project_trigger(trigger: &TurnTrigger) -> Option { - match trigger { - TurnTrigger::UserInput { - content, images, .. - } => Some(text_user_with_images( + // UserInput carries images — project structurally, not via text prefix. + if let TurnTrigger::UserInput { + content, images, .. + } = trigger + { + return Some(text_user_with_images( content, images, Some(MessageOrigin::Human), - )), - TurnTrigger::Cron { content, .. } => Some(text_user( - &format!("[scheduled] {content}"), - Some(MessageOrigin::Scheduled), - )), - TurnTrigger::Agent { from, content, .. } => Some(text_user( - &format!("[from: {from}] {content}"), + )); + } + let text = trigger_llm_text(trigger)?; + Some(text_user(&text, trigger_origin(trigger))) +} + +fn trigger_origin(trigger: &TurnTrigger) -> Option { + match trigger { + TurnTrigger::UserInput { .. } => Some(MessageOrigin::Human), + TurnTrigger::Cron { .. } => Some(MessageOrigin::Scheduled), + TurnTrigger::Agent { from, .. } | TurnTrigger::AgentResult { from, .. } => { Some(MessageOrigin::Agent { label: from.clone(), - }), - )), - TurnTrigger::Channel { - channel, - from, - content, - .. - } => Some(text_user( - &format!("[from: #{channel}/{from}] {content}"), - Some(MessageOrigin::Channel { - name: channel.clone(), - from: from.clone(), - }), - )), - TurnTrigger::GoalContinuation { content, .. } => { - Some(text_user(content, Some(MessageOrigin::GoalContinuation))) + }) } - TurnTrigger::BackgroundHook { - hook_kind, content, .. - } => Some(text_user( - content, - Some(MessageOrigin::Other { - label: hook_kind.clone(), - }), - )), + TurnTrigger::Channel { channel, from, .. } => Some(MessageOrigin::Channel { + name: channel.clone(), + from: from.clone(), + }), + TurnTrigger::GoalContinuation { .. } => Some(MessageOrigin::GoalContinuation), + TurnTrigger::BackgroundHook { hook_kind, .. } => Some(MessageOrigin::Other { + label: hook_kind.clone(), + }), TurnTrigger::Resume => None, } } diff --git a/crates/loopal-provider-api/tests/suite/turn_projection_test.rs b/crates/loopal-provider-api/tests/suite/turn_projection_test.rs index 6c1c98a4..54f22919 100644 --- a/crates/loopal-provider-api/tests/suite/turn_projection_test.rs +++ b/crates/loopal-provider-api/tests/suite/turn_projection_test.rs @@ -489,3 +489,24 @@ fn cancelled_item_produces_error_tool_result() { assert_eq!(block.0, "Cancelled"); assert!(block.1); } + +#[test] +fn agent_result_trigger_rewraps_in_agent_result_marker() { + let t = turn_with( + TurnTrigger::AgentResult { + envelope_id: "env-r".into(), + from: "worker".into(), + content: "found 3 bugs".into(), + }, + vec![], + ); + let msgs = project_turn_to_messages(&t); + assert_eq!( + msgs[0].text_content(), + "\nfound 3 bugs\n" + ); + assert!(matches!( + &msgs[0].origin, + Some(MessageOrigin::Agent { label }) if label == "worker" + )); +} diff --git a/crates/loopal-provider/src/anthropic/request_turns/mod.rs b/crates/loopal-provider/src/anthropic/request_turns/mod.rs index 5cd31567..ee556eca 100644 --- a/crates/loopal-provider/src/anthropic/request_turns/mod.rs +++ b/crates/loopal-provider/src/anthropic/request_turns/mod.rs @@ -105,22 +105,7 @@ fn push_turn(out: &mut Vec, turn: &Turn) { } fn trigger_user(trigger: &TurnTrigger) -> Option { - match trigger { - TurnTrigger::UserInput { content, .. } => Some(text_user(content)), - TurnTrigger::Cron { content, .. } => Some(text_user(&format!("[scheduled] {content}"))), - TurnTrigger::Agent { from, content, .. } => { - Some(text_user(&format!("[from: {from}] {content}"))) - } - TurnTrigger::Channel { - channel, - from, - content, - .. - } => Some(text_user(&format!("[from: #{channel}/{from}] {content}"))), - TurnTrigger::GoalContinuation { content, .. } => Some(text_user(content)), - TurnTrigger::BackgroundHook { content, .. } => Some(text_user(content)), - TurnTrigger::Resume => None, - } + loopal_provider_api::trigger_llm_text(trigger).map(|text| text_user(&text)) } fn push_step(out: &mut Vec, step: &TurnStep) { diff --git a/crates/loopal-runtime/src/agent_loop/degeneration_detector.rs b/crates/loopal-runtime/src/agent_loop/degeneration_detector.rs index c3670b3f..1a76d5da 100644 --- a/crates/loopal-runtime/src/agent_loop/degeneration_detector.rs +++ b/crates/loopal-runtime/src/agent_loop/degeneration_detector.rs @@ -73,13 +73,12 @@ impl Governance for DegenerationDetector { } fn on_envelope_received(&mut self, source: &MessageSource) { - // External signal (human/cron/peer-agent) clears the silenced flag - // so a fresh degeneration window can re-emit. Without this, an - // /unsuspend followed by relapse would be silent. - if matches!( - source, - MessageSource::Human | MessageSource::Scheduled | MessageSource::Channel { .. } - ) { + // A fresh external task boundary (human/cron/peer-agent/agent-result/ + // channel) clears the silenced flag so a new degeneration window can + // re-emit. Reuses MessageSource::is_task_boundary so the boundary set + // stays single-sourced — the previous hand-rolled list silently + // dropped peer-agent envelopes the comment claimed to cover. + if source.is_task_boundary() { self.silenced_until_progress = false; } } @@ -197,4 +196,23 @@ mod tests { d.on_envelope_received(&MessageSource::System("goal_continuation".into())); assert!(matches!(d.on_after_turn(&r, &h), PostTurnAction::None)); } + + #[test] + fn peer_agent_and_agent_result_envelopes_clear_silence() { + use loopal_protocol::QualifiedAddress; + for src in [ + MessageSource::Agent(QualifiedAddress::local("worker")), + MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + }, + ] { + let mut d = DegenerationDetector::new(50, 3, 600); + let mut h = TurnHistory::with_capacity(100); + push_barren(&mut h, 3, 7); + assert_degen(&mut d, &h); + d.on_envelope_received(&src); + // Silence lifted → the next barren window re-triggers. + assert_degen(&mut d, &h); + } + } } diff --git a/crates/loopal-runtime/src/agent_loop/turn_trigger_map.rs b/crates/loopal-runtime/src/agent_loop/turn_trigger_map.rs index 87557b19..cd48d0cd 100644 --- a/crates/loopal-runtime/src/agent_loop/turn_trigger_map.rs +++ b/crates/loopal-runtime/src/agent_loop/turn_trigger_map.rs @@ -28,6 +28,15 @@ pub fn envelope_to_trigger(env: &Envelope) -> TurnTrigger { from: addr.to_string(), content, }, + MessageSource::AgentResult { child } => TurnTrigger::AgentResult { + envelope_id, + // reason: bare agent name (not child.to_string()) — the + // marker must stay hub-agnostic. After + // uplink SNAT `child` carries a hub path; to_string() would leak + // it into the marker the parent LLM reads. + from: child.agent.clone(), + content, + }, MessageSource::Channel { channel, from } => TurnTrigger::Channel { envelope_id, channel: channel.clone(), @@ -52,3 +61,29 @@ pub fn envelope_to_trigger(env: &Envelope) -> TurnTrigger { } } } + +#[cfg(test)] +mod tests { + use super::*; + use loopal_protocol::QualifiedAddress; + + #[test] + fn agent_result_source_maps_to_bare_child_name() { + let env = Envelope::new( + MessageSource::AgentResult { + child: QualifiedAddress::remote(["hub-a"], "worker"), + }, + "parent", + "done", + ); + match envelope_to_trigger(&env) { + TurnTrigger::AgentResult { from, content, .. } => { + // Hub path is stripped: marker stays hub-agnostic even for + // cross-hub completions whose source was SNAT-stamped. + assert_eq!(from, "worker"); + assert_eq!(content, "done"); + } + other => panic!("expected AgentResult trigger, got {other:?}"), + } + } +} diff --git a/crates/loopal-runtime/tests/agent_loop/e2e_event_waiters.rs b/crates/loopal-runtime/tests/agent_loop/e2e_event_waiters.rs index ad43d9a2..cc5f0f8c 100644 --- a/crates/loopal-runtime/tests/agent_loop/e2e_event_waiters.rs +++ b/crates/loopal-runtime/tests/agent_loop/e2e_event_waiters.rs @@ -180,6 +180,7 @@ fn turn_text_summary(turn: &loopal_turn::Turn) -> String { TurnTrigger::UserInput { content, .. } | TurnTrigger::Cron { content, .. } | TurnTrigger::Agent { content, .. } + | TurnTrigger::AgentResult { content, .. } | TurnTrigger::Channel { content, .. } | TurnTrigger::GoalContinuation { content, .. } | TurnTrigger::BackgroundHook { content, .. } => content.clone(), diff --git a/crates/loopal-tui/src/views/progress/message_lines.rs b/crates/loopal-tui/src/views/progress/message_lines.rs index b36104bf..38c22531 100644 --- a/crates/loopal-tui/src/views/progress/message_lines.rs +++ b/crates/loopal-tui/src/views/progress/message_lines.rs @@ -71,6 +71,7 @@ fn render_user(lines: &mut Vec>, msg: &SessionMessage, width: u16) fn render_inbox_origin(lines: &mut Vec>, origin: &InboxOrigin, width: u16) { let label = match &origin.source { MessageSource::Agent(addr) => format!("📨 from {addr}"), + MessageSource::AgentResult { child } => format!("✅ result from {child}"), MessageSource::Scheduled => "⏰ scheduled".to_string(), MessageSource::Channel { channel, from } => format!("📡 #{channel}/{from}"), MessageSource::System(kind) => format!("⚙ system:{kind}"), diff --git a/crates/loopal-tui/tests/suite/inbox_render_test.rs b/crates/loopal-tui/tests/suite/inbox_render_test.rs index 757c8d40..71f354ed 100644 --- a/crates/loopal-tui/tests/suite/inbox_render_test.rs +++ b/crates/loopal-tui/tests/suite/inbox_render_test.rs @@ -95,3 +95,18 @@ fn test_qualified_remote_agent_address_renders_in_label() { let text = flat(&message_to_lines(&m, 80)); assert!(text.contains("📨 from hub-A/alpha")); } + +#[test] +fn test_agent_result_inbox_origin_renders_result_label() { + let m = user_with_inbox( + "found 3 bugs", + MessageSource::AgentResult { + child: QualifiedAddress::local("worker"), + }, + None, + ); + let lines = message_to_lines(&m, 80); + let text = flat(&lines); + assert!(text.contains("✅ result from worker")); + assert!(text.contains("found 3 bugs")); +} diff --git a/crates/loopal-turn/src/turn.rs b/crates/loopal-turn/src/turn.rs index 3816f5de..0a9d5c76 100644 --- a/crates/loopal-turn/src/turn.rs +++ b/crates/loopal-turn/src/turn.rs @@ -94,6 +94,16 @@ pub enum TurnTrigger { from: String, content: String, }, + /// A spawned child agent's completion result routed back to its parent. + /// `from` is the bare child name; projection re-wraps `content` in the + /// `` marker the LLM expects. Unlike `Agent`, + /// this deliberately omits the `[from: ...]` prefix — the marker already + /// identifies the source, so the prefix would be redundant double-labeling. + AgentResult { + envelope_id: String, + from: String, + content: String, + }, /// Routed via a named channel. Channel { envelope_id: String,