Skip to main content

harn_vm/orchestration/
handoffs.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5
6use super::{
7    current_mutation_session, new_id, now_rfc3339, ArtifactRecord, CapabilityPolicy, EffectRecord,
8    RunRecord,
9};
10
11const HANDOFF_TYPE: &str = "handoff_artifact";
12const HANDOFF_ARTIFACT_KIND: &str = "handoff";
13const RUN_RECEIPT_LINK_KIND: &str = "run_receipt";
14const DEFAULT_HANDOFF_KIND: &str = "handoff";
15
16thread_local! {
17    static HANDOFF_ROUTES: RefCell<Vec<HandoffRouteConfig>> = const { RefCell::new(Vec::new()) };
18}
19
20#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(default)]
22pub struct HandoffTargetRecord {
23    pub kind: String,
24    pub id: Option<String>,
25    pub label: Option<String>,
26    pub uri: Option<String>,
27}
28
29impl HandoffTargetRecord {
30    pub fn normalize(mut self) -> Self {
31        self.kind = normalize_target_kind(&self.kind);
32        if self
33            .id
34            .as_deref()
35            .is_some_and(|value| value.trim().is_empty())
36        {
37            self.id = None;
38        }
39        if self
40            .label
41            .as_deref()
42            .is_some_and(|value| value.trim().is_empty())
43        {
44            self.label = None;
45        }
46        if self
47            .uri
48            .as_deref()
49            .is_some_and(|value| value.trim().is_empty())
50        {
51            self.uri = None;
52        }
53        self
54    }
55
56    pub fn display_name(&self) -> String {
57        self.label
58            .clone()
59            .or_else(|| self.id.clone())
60            .unwrap_or_else(|| "unknown".to_string())
61    }
62}
63
64#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
65#[serde(default)]
66pub struct HandoffRouteTargetConfig {
67    pub id: Option<String>,
68    pub target: String,
69    pub when: Option<String>,
70    pub transport: Option<String>,
71    pub allow_cleartext: Option<bool>,
72    pub metadata: BTreeMap<String, serde_json::Value>,
73}
74
75impl HandoffRouteTargetConfig {
76    pub fn normalize(mut self) -> Self {
77        if self
78            .id
79            .as_deref()
80            .is_some_and(|value| value.trim().is_empty())
81        {
82            self.id = None;
83        }
84        self.target = self.target.trim().to_string();
85        self.when = self
86            .when
87            .map(|value| value.trim().to_string())
88            .filter(|value| !value.is_empty());
89        self.transport = self
90            .transport
91            .map(|value| value.trim().to_string())
92            .filter(|value| !value.is_empty());
93        self
94    }
95}
96
97#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
98#[serde(default)]
99pub struct HandoffRouteConfig {
100    pub id: Option<String>,
101    pub kind: String,
102    pub from: String,
103    #[serde(alias = "routes")]
104    pub route: Vec<HandoffRouteTargetConfig>,
105    pub metadata: BTreeMap<String, serde_json::Value>,
106}
107
108impl HandoffRouteConfig {
109    pub fn normalize(mut self) -> Self {
110        if self
111            .id
112            .as_deref()
113            .is_some_and(|value| value.trim().is_empty())
114        {
115            self.id = None;
116        }
117        self.kind = normalize_handoff_kind(&self.kind);
118        self.from = self.from.trim().to_string();
119        self.route = self
120            .route
121            .into_iter()
122            .map(HandoffRouteTargetConfig::normalize)
123            .collect();
124        self
125    }
126}
127
128#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
129#[serde(default)]
130pub struct HandoffRouteDecisionRecord {
131    pub route_id: Option<String>,
132    pub route_index: Option<u64>,
133    pub target_index: Option<u64>,
134    pub handoff_id: Option<String>,
135    pub handoff_kind: String,
136    pub source_persona: String,
137    pub target: String,
138    pub target_persona_or_human: HandoffTargetRecord,
139    pub matched_when: String,
140    pub selected_at: String,
141    pub dispatch_kind: String,
142    pub dispatch_status: Option<String>,
143    pub dispatch_receipt: Option<serde_json::Value>,
144    pub metadata: BTreeMap<String, serde_json::Value>,
145}
146
147impl HandoffRouteDecisionRecord {
148    pub fn normalize(mut self) -> Self {
149        self.handoff_id = self
150            .handoff_id
151            .map(|value| value.trim().to_string())
152            .filter(|value| !value.is_empty());
153        self.handoff_kind = normalize_handoff_kind(&self.handoff_kind);
154        self.source_persona = self.source_persona.trim().to_string();
155        self.target = self.target.trim().to_string();
156        self.target_persona_or_human = self.target_persona_or_human.normalize();
157        self.matched_when = self.matched_when.trim().to_string();
158        if self.matched_when.is_empty() {
159            self.matched_when = "always".to_string();
160        }
161        self.selected_at = self.selected_at.trim().to_string();
162        if self.selected_at.is_empty() {
163            self.selected_at = now_rfc3339();
164        }
165        self.dispatch_kind = normalize_target_kind(&self.dispatch_kind);
166        self.dispatch_status = self
167            .dispatch_status
168            .map(|value| value.trim().to_string())
169            .filter(|value| !value.is_empty());
170        self
171    }
172}
173
174#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(default)]
176pub struct HandoffEvidenceRefRecord {
177    pub artifact_id: Option<String>,
178    pub kind: Option<String>,
179    pub label: Option<String>,
180    pub path: Option<String>,
181    pub uri: Option<String>,
182}
183
184#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
185#[serde(default)]
186pub struct HandoffBudgetRemainingRecord {
187    pub tokens: Option<i64>,
188    pub tool_calls: Option<i64>,
189    pub dollars: Option<f64>,
190}
191
192#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(default)]
194pub struct HandoffDeadlineCheckbackRecord {
195    pub deadline: Option<String>,
196    pub checkback_at: Option<String>,
197}
198
199#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(default)]
201pub struct HandoffReceiptLinkRecord {
202    pub kind: String,
203    pub label: Option<String>,
204    pub run_id: Option<String>,
205    pub artifact_id: Option<String>,
206    pub path: Option<String>,
207    pub href: Option<String>,
208}
209
210impl HandoffReceiptLinkRecord {
211    pub fn normalize(mut self) -> Self {
212        if self.kind.trim().is_empty() {
213            self.kind = RUN_RECEIPT_LINK_KIND.to_string();
214        }
215        if self
216            .label
217            .as_deref()
218            .is_some_and(|value| value.trim().is_empty())
219        {
220            self.label = None;
221        }
222        if self
223            .run_id
224            .as_deref()
225            .is_some_and(|value| value.trim().is_empty())
226        {
227            self.run_id = None;
228        }
229        if self
230            .artifact_id
231            .as_deref()
232            .is_some_and(|value| value.trim().is_empty())
233        {
234            self.artifact_id = None;
235        }
236        if self
237            .path
238            .as_deref()
239            .is_some_and(|value| value.trim().is_empty())
240        {
241            self.path = None;
242        }
243        if self
244            .href
245            .as_deref()
246            .is_some_and(|value| value.trim().is_empty())
247        {
248            self.href = None;
249        }
250        self
251    }
252}
253
254#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
255#[serde(default)]
256pub struct HandoffArtifact {
257    #[serde(rename = "_type")]
258    pub type_name: String,
259    pub kind: String,
260    pub id: String,
261    pub parent_run_id: Option<String>,
262    pub source_persona: String,
263    pub target_persona_or_human: HandoffTargetRecord,
264    pub task: String,
265    pub reason: String,
266    pub evidence_refs: Vec<HandoffEvidenceRefRecord>,
267    pub files_or_entities_touched: Vec<String>,
268    pub open_questions: Vec<String>,
269    pub blocked_on: Vec<String>,
270    pub requested_capabilities: Vec<String>,
271    pub allowed_side_effects: Vec<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub policy_override: Option<CapabilityPolicy>,
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub reminder_propagation: Vec<crate::llm::helpers::SystemReminder>,
276    /// Typed effect set computed at child-spawn time from the spawn
277    /// config's capability declarations + transitive `harn graph --json`
278    /// analysis. Empty when the handoff predates effect tracking or the
279    /// producer has no analyzable entrypoint. Enforcement of the
280    /// parent-⊆-child relation lives in E5.4 (`HARN-CAP-301`); the
281    /// receipt-chain inclusion proof lives in E5.5
282    /// (`opentrustgraph/v0.1`).
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub effects: Vec<EffectRecord>,
285    pub budget_remaining: Option<HandoffBudgetRemainingRecord>,
286    pub deadline_checkback: Option<HandoffDeadlineCheckbackRecord>,
287    pub confidence: Option<f64>,
288    pub receipt_links: Vec<HandoffReceiptLinkRecord>,
289    pub route_decision: Option<HandoffRouteDecisionRecord>,
290    pub created_at: String,
291    pub metadata: BTreeMap<String, serde_json::Value>,
292}
293
294impl HandoffArtifact {
295    pub fn normalize(mut self) -> Self {
296        if self.type_name.is_empty() {
297            self.type_name = HANDOFF_TYPE.to_string();
298        }
299        self.kind = normalize_handoff_kind(&self.kind);
300        if self.id.is_empty() {
301            self.id = new_id("handoff");
302        }
303        if self.created_at.is_empty() {
304            self.created_at = now_rfc3339();
305        }
306        if self.parent_run_id.is_none() {
307            self.parent_run_id = current_mutation_session().and_then(|session| session.run_id);
308        }
309        self.source_persona = self.source_persona.trim().to_string();
310        self.task = self.task.trim().to_string();
311        self.reason = self.reason.trim().to_string();
312        self.target_persona_or_human = self.target_persona_or_human.normalize();
313        self.files_or_entities_touched = normalize_string_list(self.files_or_entities_touched);
314        self.open_questions = normalize_string_list(self.open_questions);
315        self.blocked_on = normalize_string_list(self.blocked_on);
316        self.requested_capabilities = normalize_string_list(self.requested_capabilities);
317        self.allowed_side_effects = normalize_string_list(self.allowed_side_effects);
318        self.receipt_links = self
319            .receipt_links
320            .into_iter()
321            .map(HandoffReceiptLinkRecord::normalize)
322            .collect();
323        self.route_decision = self
324            .route_decision
325            .map(HandoffRouteDecisionRecord::normalize);
326        self.confidence = self.confidence.map(|value| value.clamp(0.0, 1.0));
327        self
328    }
329}
330
331pub fn install_handoff_routes(routes: Vec<HandoffRouteConfig>) {
332    HANDOFF_ROUTES.with(|installed| {
333        *installed.borrow_mut() = routes
334            .into_iter()
335            .map(HandoffRouteConfig::normalize)
336            .collect();
337    });
338}
339
340pub fn snapshot_handoff_routes() -> Vec<HandoffRouteConfig> {
341    HANDOFF_ROUTES.with(|installed| installed.borrow().clone())
342}
343
344fn normalize_string_list(values: Vec<String>) -> Vec<String> {
345    let mut seen = BTreeSet::new();
346    values
347        .into_iter()
348        .map(|value| value.trim().to_string())
349        .filter(|value| !value.is_empty() && seen.insert(value.clone()))
350        .collect()
351}
352
353fn normalize_target_kind(kind: &str) -> String {
354    match kind.trim() {
355        "human" => "human".to_string(),
356        "persona" => "persona".to_string(),
357        "a2a" | "external_a2a" => "a2a".to_string(),
358        "worker" | "queue" => "worker".to_string(),
359        _ => "persona".to_string(),
360    }
361}
362
363fn normalize_handoff_kind(kind: &str) -> String {
364    let kind = kind.trim();
365    if kind.is_empty() {
366        DEFAULT_HANDOFF_KIND.to_string()
367    } else {
368        kind.to_string()
369    }
370}
371
372pub fn normalize_handoff_artifact_json(
373    value: serde_json::Value,
374) -> Result<HandoffArtifact, String> {
375    let handoff: HandoffArtifact =
376        serde_json::from_value(value).map_err(|error| format!("handoff parse error: {error}"))?;
377    let handoff = handoff.normalize();
378    if handoff.source_persona.is_empty() {
379        return Err("handoff source_persona is required".to_string());
380    }
381    if handoff.target_persona_or_human.display_name() == "unknown" {
382        return Err("handoff target_persona_or_human is required".to_string());
383    }
384    if handoff.task.is_empty() {
385        return Err("handoff task is required".to_string());
386    }
387    if handoff.reason.is_empty() {
388        return Err("handoff reason is required".to_string());
389    }
390    if let Some(decision) = handoff.route_decision.as_ref() {
391        if decision.target_persona_or_human.display_name() == "unknown" {
392            return Err("handoff route_decision target is required".to_string());
393        }
394    }
395    Ok(handoff)
396}
397
398pub fn handoff_from_json_value(value: &serde_json::Value) -> Option<HandoffArtifact> {
399    let object = value.as_object()?;
400    if object.get("_type").and_then(|value| value.as_str()) == Some(HANDOFF_TYPE)
401        || (object.contains_key("source_persona")
402            && object.contains_key("target_persona_or_human")
403            && object.contains_key("task"))
404    {
405        return normalize_handoff_artifact_json(value.clone()).ok();
406    }
407    if object.get("_type").and_then(|value| value.as_str()) == Some("artifact")
408        || object.get("kind").and_then(|value| value.as_str()) == Some(HANDOFF_ARTIFACT_KIND)
409    {
410        return object
411            .get("data")
412            .and_then(handoff_from_json_value)
413            .or_else(|| normalize_handoff_artifact_json(value.clone()).ok());
414    }
415    if object.get("_type").and_then(|value| value.as_str()) == Some("agent_state_handoff") {
416        return object
417            .get("handoff")
418            .and_then(handoff_from_json_value)
419            .or_else(|| object.get("summary").and_then(handoff_from_json_value));
420    }
421    None
422}
423
424pub fn extract_handoff_from_artifact(artifact: &ArtifactRecord) -> Option<HandoffArtifact> {
425    if artifact.kind != HANDOFF_ARTIFACT_KIND {
426        return None;
427    }
428    artifact.data.as_ref().and_then(handoff_from_json_value)
429}
430
431pub fn extract_handoffs_from_json_value(value: &serde_json::Value) -> Vec<HandoffArtifact> {
432    fn collect(value: &serde_json::Value, out: &mut Vec<HandoffArtifact>) {
433        if let Some(handoff) = handoff_from_json_value(value) {
434            out.push(handoff);
435        }
436        let Some(object) = value.as_object() else {
437            return;
438        };
439        for key in ["handoffs", "artifacts"] {
440            if let Some(items) = object.get(key).and_then(|value| value.as_array()) {
441                for item in items {
442                    collect(item, out);
443                }
444            }
445        }
446        for key in ["run", "result"] {
447            if let Some(nested) = object.get(key) {
448                collect(nested, out);
449            }
450        }
451    }
452
453    let mut handoffs = Vec::new();
454    collect(value, &mut handoffs);
455    dedup_handoffs(handoffs)
456}
457
458fn dedup_handoffs(handoffs: Vec<HandoffArtifact>) -> Vec<HandoffArtifact> {
459    let mut by_id = BTreeMap::new();
460    for handoff in handoffs {
461        by_id
462            .entry(handoff.id.clone())
463            .and_modify(|existing: &mut HandoffArtifact| {
464                *existing = merge_handoffs(existing.clone(), handoff.clone())
465            })
466            .or_insert(handoff);
467    }
468    by_id.into_values().collect()
469}
470
471fn merge_receipt_links(
472    left: Vec<HandoffReceiptLinkRecord>,
473    right: Vec<HandoffReceiptLinkRecord>,
474) -> Vec<HandoffReceiptLinkRecord> {
475    let mut seen = BTreeSet::new();
476    left.into_iter()
477        .chain(right)
478        .map(HandoffReceiptLinkRecord::normalize)
479        .filter(|link| {
480            seen.insert((
481                link.kind.clone(),
482                link.run_id.clone(),
483                link.artifact_id.clone(),
484                link.path.clone(),
485                link.href.clone(),
486            ))
487        })
488        .collect()
489}
490
491fn merge_handoffs(mut left: HandoffArtifact, right: HandoffArtifact) -> HandoffArtifact {
492    if left.parent_run_id.is_none() {
493        left.parent_run_id = right.parent_run_id;
494    }
495    if left.source_persona.is_empty() {
496        left.source_persona = right.source_persona;
497    }
498    if left.target_persona_or_human.display_name() == "unknown" {
499        left.target_persona_or_human = right.target_persona_or_human;
500    }
501    if left.task.is_empty() {
502        left.task = right.task;
503    }
504    if left.reason.is_empty() {
505        left.reason = right.reason;
506    }
507    if left.evidence_refs.is_empty() {
508        left.evidence_refs = right.evidence_refs;
509    }
510    if left.files_or_entities_touched.is_empty() {
511        left.files_or_entities_touched = right.files_or_entities_touched;
512    }
513    if left.open_questions.is_empty() {
514        left.open_questions = right.open_questions;
515    }
516    if left.blocked_on.is_empty() {
517        left.blocked_on = right.blocked_on;
518    }
519    if left.requested_capabilities.is_empty() {
520        left.requested_capabilities = right.requested_capabilities;
521    }
522    if left.allowed_side_effects.is_empty() {
523        left.allowed_side_effects = right.allowed_side_effects;
524    }
525    if left.policy_override.is_none() {
526        left.policy_override = right.policy_override;
527    }
528    if left.reminder_propagation.is_empty() {
529        left.reminder_propagation = right.reminder_propagation;
530    }
531    if left.effects.is_empty() {
532        left.effects = right.effects;
533    }
534    if left.budget_remaining.is_none() {
535        left.budget_remaining = right.budget_remaining;
536    }
537    if left.deadline_checkback.is_none() {
538        left.deadline_checkback = right.deadline_checkback;
539    }
540    if left.confidence.is_none() {
541        left.confidence = right.confidence;
542    }
543    if left.route_decision.is_none() {
544        left.route_decision = right.route_decision;
545    }
546    left.receipt_links = merge_receipt_links(left.receipt_links, right.receipt_links);
547    for (key, value) in right.metadata {
548        left.metadata.entry(key).or_insert(value);
549    }
550    left
551}
552
553pub fn handoff_context_text(handoff: &HandoffArtifact) -> String {
554    let mut lines = vec![
555        format!("<kind>{}</kind>", handoff.kind),
556        format!(
557            "<source_persona>{}</source_persona>",
558            handoff.source_persona
559        ),
560        format!(
561            "<target kind=\"{}\">{}</target>",
562            handoff.target_persona_or_human.kind,
563            handoff.target_persona_or_human.display_name()
564        ),
565        format!("<task>{}</task>", handoff.task),
566        format!("<reason>{}</reason>", handoff.reason),
567    ];
568    append_list_section(
569        &mut lines,
570        "files_or_entities_touched",
571        &handoff.files_or_entities_touched,
572    );
573    append_list_section(&mut lines, "open_questions", &handoff.open_questions);
574    append_list_section(&mut lines, "blocked_on", &handoff.blocked_on);
575    append_list_section(
576        &mut lines,
577        "requested_capabilities",
578        &handoff.requested_capabilities,
579    );
580    append_list_section(
581        &mut lines,
582        "allowed_side_effects",
583        &handoff.allowed_side_effects,
584    );
585    if !handoff.evidence_refs.is_empty() {
586        lines.push("<evidence_refs>".to_string());
587        for evidence in &handoff.evidence_refs {
588            let mut parts = Vec::new();
589            if let Some(label) = evidence.label.as_ref() {
590                parts.push(label.clone());
591            }
592            if let Some(artifact_id) = evidence.artifact_id.as_ref() {
593                parts.push(format!("artifact_id={artifact_id}"));
594            }
595            if let Some(path) = evidence.path.as_ref() {
596                parts.push(format!("path={path}"));
597            }
598            if let Some(uri) = evidence.uri.as_ref() {
599                parts.push(format!("uri={uri}"));
600            }
601            if let Some(kind) = evidence.kind.as_ref() {
602                parts.push(format!("kind={kind}"));
603            }
604            lines.push(format!("- {}", parts.join(" | ")));
605        }
606        lines.push("</evidence_refs>".to_string());
607    }
608    if let Some(budget) = handoff.budget_remaining.as_ref() {
609        lines.push(format!(
610            "<budget_remaining tokens=\"{}\" tool_calls=\"{}\" dollars=\"{}\" />",
611            budget
612                .tokens
613                .map(|value| value.to_string())
614                .unwrap_or_default(),
615            budget
616                .tool_calls
617                .map(|value| value.to_string())
618                .unwrap_or_default(),
619            budget
620                .dollars
621                .map(|value| format!("{value:.4}"))
622                .unwrap_or_default(),
623        ));
624    }
625    if let Some(deadline) = handoff.deadline_checkback.as_ref() {
626        lines.push(format!(
627            "<deadline_checkback deadline=\"{}\" checkback_at=\"{}\" />",
628            deadline.deadline.clone().unwrap_or_default(),
629            deadline.checkback_at.clone().unwrap_or_default(),
630        ));
631    }
632    if let Some(confidence) = handoff.confidence {
633        lines.push(format!("<confidence>{confidence:.2}</confidence>"));
634    }
635    if let Some(decision) = handoff.route_decision.as_ref() {
636        lines.push(format!(
637            "<route_decision target=\"{}\" when=\"{}\" dispatch=\"{}\" selected_at=\"{}\" />",
638            decision.target, decision.matched_when, decision.dispatch_kind, decision.selected_at
639        ));
640    }
641    format!("<handoff>\n{}\n</handoff>", lines.join("\n"))
642}
643
644fn append_list_section(lines: &mut Vec<String>, label: &str, items: &[String]) {
645    if items.is_empty() {
646        return;
647    }
648    lines.push(format!("<{label}>"));
649    for item in items {
650        lines.push(format!("- {item}"));
651    }
652    lines.push(format!("</{label}>"));
653}
654
655fn handoff_target_label(handoff: &HandoffArtifact) -> String {
656    handoff.target_persona_or_human.display_name()
657}
658
659fn handoff_metadata(handoff: &HandoffArtifact) -> BTreeMap<String, serde_json::Value> {
660    BTreeMap::from([
661        ("handoff_id".to_string(), serde_json::json!(handoff.id)),
662        ("handoff_kind".to_string(), serde_json::json!(handoff.kind)),
663        (
664            "target_kind".to_string(),
665            serde_json::json!(handoff.target_persona_or_human.kind),
666        ),
667        (
668            "target_label".to_string(),
669            serde_json::json!(handoff_target_label(handoff)),
670        ),
671    ])
672}
673
674pub fn handoff_artifact_record(
675    handoff: &HandoffArtifact,
676    existing: Option<&ArtifactRecord>,
677) -> ArtifactRecord {
678    let mut metadata = existing
679        .map(|artifact| artifact.metadata.clone())
680        .unwrap_or_default();
681    metadata.extend(handoff_metadata(handoff));
682    ArtifactRecord {
683        type_name: "artifact".to_string(),
684        id: existing
685            .map(|artifact| artifact.id.clone())
686            .unwrap_or_else(|| format!("artifact_{}", handoff.id)),
687        kind: HANDOFF_ARTIFACT_KIND.to_string(),
688        title: existing
689            .and_then(|artifact| artifact.title.clone())
690            .or_else(|| Some(format!("Handoff to {}", handoff_target_label(handoff)))),
691        text: Some(handoff_context_text(handoff)),
692        data: Some(serde_json::to_value(handoff).unwrap_or(serde_json::Value::Null)),
693        source: existing
694            .and_then(|artifact| artifact.source.clone())
695            .or_else(|| Some(handoff.source_persona.clone())),
696        created_at: existing
697            .map(|artifact| artifact.created_at.clone())
698            .unwrap_or_else(now_rfc3339),
699        freshness: existing
700            .and_then(|artifact| artifact.freshness.clone())
701            .or_else(|| Some("fresh".to_string())),
702        priority: existing.and_then(|artifact| artifact.priority).or(Some(85)),
703        lineage: existing
704            .map(|artifact| artifact.lineage.clone())
705            .unwrap_or_default(),
706        relevance: handoff.confidence.or(Some(1.0)),
707        estimated_tokens: None,
708        stage: existing.and_then(|artifact| artifact.stage.clone()),
709        metadata,
710    }
711    .normalize()
712}
713
714fn receipt_link_for_run(run: &RunRecord) -> HandoffReceiptLinkRecord {
715    HandoffReceiptLinkRecord {
716        kind: RUN_RECEIPT_LINK_KIND.to_string(),
717        label: run
718            .workflow_name
719            .clone()
720            .or_else(|| Some(run.workflow_id.clone())),
721        run_id: Some(run.id.clone()),
722        artifact_id: None,
723        path: run.persisted_path.clone(),
724        href: None,
725    }
726    .normalize()
727}
728
729fn sync_handoff_receipt_links(handoff: &mut HandoffArtifact, run: &RunRecord) {
730    if handoff.parent_run_id.is_none() {
731        handoff.parent_run_id = Some(run.id.clone());
732    }
733    handoff.receipt_links = merge_receipt_links(
734        std::mem::take(&mut handoff.receipt_links),
735        vec![receipt_link_for_run(run)],
736    );
737}
738
739fn artifact_handoff_id(artifact: &ArtifactRecord) -> Option<String> {
740    if artifact.kind != HANDOFF_ARTIFACT_KIND {
741        return None;
742    }
743    artifact
744        .metadata
745        .get("handoff_id")
746        .and_then(|value| value.as_str())
747        .map(str::to_string)
748        .or_else(|| {
749            artifact
750                .data
751                .as_ref()
752                .and_then(|value| value.get("id"))
753                .and_then(|value| value.as_str())
754                .map(str::to_string)
755        })
756}
757
758pub fn sync_run_handoffs(run: &mut RunRecord) {
759    let mut by_id = BTreeMap::new();
760    for handoff in std::mem::take(&mut run.handoffs) {
761        by_id.insert(handoff.id.clone(), handoff.normalize());
762    }
763    for artifact in &run.artifacts {
764        if let Some(handoff) = extract_handoff_from_artifact(artifact) {
765            by_id
766                .entry(handoff.id.clone())
767                .and_modify(|existing| {
768                    *existing = merge_handoffs(existing.clone(), handoff.clone())
769                })
770                .or_insert(handoff);
771        }
772    }
773
774    let mut artifact_index_by_handoff_id = BTreeMap::new();
775    for (index, artifact) in run.artifacts.iter().enumerate() {
776        if let Some(handoff_id) = artifact_handoff_id(artifact) {
777            artifact_index_by_handoff_id.insert(handoff_id, index);
778        }
779    }
780
781    let mut handoffs = by_id.into_values().collect::<Vec<_>>();
782    handoffs.sort_by(|left, right| left.created_at.cmp(&right.created_at));
783    for handoff in &mut handoffs {
784        sync_handoff_receipt_links(handoff, run);
785        if let Some(index) = artifact_index_by_handoff_id.get(&handoff.id).copied() {
786            let existing = run.artifacts[index].clone();
787            run.artifacts[index] = handoff_artifact_record(handoff, Some(&existing));
788        } else {
789            run.artifacts.push(handoff_artifact_record(handoff, None));
790        }
791    }
792    run.handoffs = handoffs;
793}
794
795/// Compute the effect set for a spawn-time handoff and attach it to the
796/// envelope. Mirrors what `agent_spawn` / `sub_agent_run` do when a child
797/// entrypoint module is statically known: the effect set is derived from
798/// the child's source via the same capability analysis backing
799/// `harn graph --json` and clamped to the spawn-config ceiling.
800///
801/// Pre-existing `effects` are preserved when the producer has already
802/// populated them; otherwise the computed set is installed. Empty source
803/// is a no-op so callers can route through this helper unconditionally.
804pub fn attach_spawn_handoff_effects(
805    handoff: &mut HandoffArtifact,
806    entrypoint_source: &str,
807    ceiling: Option<&CapabilityPolicy>,
808) {
809    if !handoff.effects.is_empty() {
810        return;
811    }
812    if entrypoint_source.trim().is_empty() {
813        return;
814    }
815    handoff.effects = crate::orchestration::compute_handoff_effects(entrypoint_source, ceiling);
816}
817
818#[cfg(test)]
819mod spawn_effect_tests {
820    use super::*;
821    use crate::orchestration::{
822        attach_spawn_handoff_effects, CapabilityPolicy, EffectKind, EffectRecord, EffectScope,
823        HandoffTargetRecord,
824    };
825
826    fn spawn_handoff(source_persona: &str) -> HandoffArtifact {
827        HandoffArtifact {
828            source_persona: source_persona.to_string(),
829            target_persona_or_human: HandoffTargetRecord {
830                kind: "persona".to_string(),
831                label: Some("research-worker".to_string()),
832                ..Default::default()
833            },
834            task: "summarize the page".to_string(),
835            reason: "needs network reach".to_string(),
836            ..Default::default()
837        }
838        .normalize()
839    }
840
841    #[test]
842    fn spawn_with_harness_net_child_attaches_net_effect() {
843        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test/api") }"#;
844        let mut handoff = spawn_handoff("planner");
845        attach_spawn_handoff_effects(&mut handoff, source, None);
846        assert!(
847            handoff
848                .effects
849                .iter()
850                .any(|effect| matches!(effect.kind, EffectKind::Net)),
851            "expected Net effect on spawn handoff, got {:?}",
852            handoff.effects
853        );
854    }
855
856    #[test]
857    fn spawn_ceiling_clamps_to_allowed_capabilities() {
858        let source = r#"fn main(harness: Harness) {
859            harness.net.get("https://example.test")
860            harness.fs.read_file("/tmp/input")
861        }"#;
862        let mut ceiling = CapabilityPolicy::default();
863        ceiling
864            .capabilities
865            .insert("workspace".to_string(), vec!["read_text".to_string()]);
866        let mut handoff = spawn_handoff("planner");
867        attach_spawn_handoff_effects(&mut handoff, source, Some(&ceiling));
868
869        assert!(
870            handoff
871                .effects
872                .iter()
873                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
874            "ceiling should have dropped Net effect, got {:?}",
875            handoff.effects
876        );
877        assert!(
878            handoff
879                .effects
880                .iter()
881                .any(|effect| matches!(effect.kind, EffectKind::Fs)),
882            "ceiling should have kept Fs read, got {:?}",
883            handoff.effects
884        );
885    }
886
887    #[test]
888    fn spawn_handoff_effects_round_trip_via_serde() {
889        let mut handoff = spawn_handoff("planner");
890        handoff.effects.push(
891            EffectRecord::new(EffectKind::Net, EffectScope::Write)
892                .with_resource("https://api.example/v1/research"),
893        );
894        handoff.effects.push(EffectRecord::new(
895            EffectKind::Llm {
896                provider: Some("anthropic".to_string()),
897                model: Some("claude-3-7-sonnet".to_string()),
898            },
899            EffectScope::Write,
900        ));
901
902        let encoded = serde_json::to_string(&handoff).expect("encode");
903        let decoded: HandoffArtifact = serde_json::from_str(&encoded).expect("decode");
904        assert_eq!(decoded.effects, handoff.effects);
905    }
906
907    #[test]
908    fn attach_is_no_op_when_handoff_already_has_effects() {
909        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
910        let mut handoff = spawn_handoff("planner");
911        let preset = EffectRecord::new(
912            EffectKind::Persona {
913                id: "auditor".to_string(),
914            },
915            EffectScope::Observe,
916        );
917        handoff.effects.push(preset.clone());
918        attach_spawn_handoff_effects(&mut handoff, source, None);
919        assert_eq!(handoff.effects, vec![preset]);
920    }
921
922    #[test]
923    fn attach_is_no_op_when_source_is_empty() {
924        let mut handoff = spawn_handoff("planner");
925        attach_spawn_handoff_effects(&mut handoff, "", None);
926        assert!(handoff.effects.is_empty());
927    }
928}