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 #[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
795pub 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}