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,