1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::{current_mutation_session, new_id, now_rfc3339, ArtifactRecord, RunRecord};
6
7const HANDOFF_TYPE: &str = "handoff_artifact";
8const HANDOFF_ARTIFACT_KIND: &str = "handoff";
9const RUN_RECEIPT_LINK_KIND: &str = "run_receipt";
10
11#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(default)]
13pub struct HandoffTargetRecord {
14 pub kind: String,
15 pub id: Option<String>,
16 pub label: Option<String>,
17}
18
19impl HandoffTargetRecord {
20 pub fn normalize(mut self) -> Self {
21 self.kind = normalize_target_kind(&self.kind);
22 if self
23 .id
24 .as_deref()
25 .is_some_and(|value| value.trim().is_empty())
26 {
27 self.id = None;
28 }
29 if self
30 .label
31 .as_deref()
32 .is_some_and(|value| value.trim().is_empty())
33 {
34 self.label = None;
35 }
36 self
37 }
38
39 pub fn display_name(&self) -> String {
40 self.label
41 .clone()
42 .or_else(|| self.id.clone())
43 .unwrap_or_else(|| "unknown".to_string())
44 }
45}
46
47#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(default)]
49pub struct HandoffEvidenceRefRecord {
50 pub artifact_id: Option<String>,
51 pub kind: Option<String>,
52 pub label: Option<String>,
53 pub path: Option<String>,
54 pub uri: Option<String>,
55}
56
57#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
58#[serde(default)]
59pub struct HandoffBudgetRemainingRecord {
60 pub tokens: Option<i64>,
61 pub tool_calls: Option<i64>,
62 pub dollars: Option<f64>,
63}
64
65#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(default)]
67pub struct HandoffDeadlineCheckbackRecord {
68 pub deadline: Option<String>,
69 pub checkback_at: Option<String>,
70}
71
72#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(default)]
74pub struct HandoffReceiptLinkRecord {
75 pub kind: String,
76 pub label: Option<String>,
77 pub run_id: Option<String>,
78 pub artifact_id: Option<String>,
79 pub path: Option<String>,
80 pub href: Option<String>,
81}
82
83impl HandoffReceiptLinkRecord {
84 pub fn normalize(mut self) -> Self {
85 if self.kind.trim().is_empty() {
86 self.kind = RUN_RECEIPT_LINK_KIND.to_string();
87 }
88 if self
89 .label
90 .as_deref()
91 .is_some_and(|value| value.trim().is_empty())
92 {
93 self.label = None;
94 }
95 if self
96 .run_id
97 .as_deref()
98 .is_some_and(|value| value.trim().is_empty())
99 {
100 self.run_id = None;
101 }
102 if self
103 .artifact_id
104 .as_deref()
105 .is_some_and(|value| value.trim().is_empty())
106 {
107 self.artifact_id = None;
108 }
109 if self
110 .path
111 .as_deref()
112 .is_some_and(|value| value.trim().is_empty())
113 {
114 self.path = None;
115 }
116 if self
117 .href
118 .as_deref()
119 .is_some_and(|value| value.trim().is_empty())
120 {
121 self.href = None;
122 }
123 self
124 }
125}
126
127#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
128#[serde(default)]
129pub struct HandoffArtifact {
130 #[serde(rename = "_type")]
131 pub type_name: String,
132 pub id: String,
133 pub parent_run_id: Option<String>,
134 pub source_persona: String,
135 pub target_persona_or_human: HandoffTargetRecord,
136 pub task: String,
137 pub reason: String,
138 pub evidence_refs: Vec<HandoffEvidenceRefRecord>,
139 pub files_or_entities_touched: Vec<String>,
140 pub open_questions: Vec<String>,
141 pub blocked_on: Vec<String>,
142 pub requested_capabilities: Vec<String>,
143 pub allowed_side_effects: Vec<String>,
144 pub budget_remaining: Option<HandoffBudgetRemainingRecord>,
145 pub deadline_checkback: Option<HandoffDeadlineCheckbackRecord>,
146 pub confidence: Option<f64>,
147 pub receipt_links: Vec<HandoffReceiptLinkRecord>,
148 pub created_at: String,
149 pub metadata: BTreeMap<String, serde_json::Value>,
150}
151
152impl HandoffArtifact {
153 pub fn normalize(mut self) -> Self {
154 if self.type_name.is_empty() {
155 self.type_name = HANDOFF_TYPE.to_string();
156 }
157 if self.id.is_empty() {
158 self.id = new_id("handoff");
159 }
160 if self.created_at.is_empty() {
161 self.created_at = now_rfc3339();
162 }
163 if self.parent_run_id.is_none() {
164 self.parent_run_id = current_mutation_session().and_then(|session| session.run_id);
165 }
166 self.source_persona = self.source_persona.trim().to_string();
167 self.task = self.task.trim().to_string();
168 self.reason = self.reason.trim().to_string();
169 self.target_persona_or_human = self.target_persona_or_human.normalize();
170 self.files_or_entities_touched = normalize_string_list(self.files_or_entities_touched);
171 self.open_questions = normalize_string_list(self.open_questions);
172 self.blocked_on = normalize_string_list(self.blocked_on);
173 self.requested_capabilities = normalize_string_list(self.requested_capabilities);
174 self.allowed_side_effects = normalize_string_list(self.allowed_side_effects);
175 self.receipt_links = self
176 .receipt_links
177 .into_iter()
178 .map(HandoffReceiptLinkRecord::normalize)
179 .collect();
180 self.confidence = self.confidence.map(|value| value.clamp(0.0, 1.0));
181 self
182 }
183}
184
185fn normalize_string_list(values: Vec<String>) -> Vec<String> {
186 let mut seen = BTreeSet::new();
187 values
188 .into_iter()
189 .map(|value| value.trim().to_string())
190 .filter(|value| !value.is_empty() && seen.insert(value.clone()))
191 .collect()
192}
193
194fn normalize_target_kind(kind: &str) -> String {
195 match kind.trim() {
196 "human" => "human".to_string(),
197 "persona" => "persona".to_string(),
198 _ => "persona".to_string(),
199 }
200}
201
202pub fn normalize_handoff_artifact_json(
203 value: serde_json::Value,
204) -> Result<HandoffArtifact, String> {
205 let handoff: HandoffArtifact =
206 serde_json::from_value(value).map_err(|error| format!("handoff parse error: {error}"))?;
207 let handoff = handoff.normalize();
208 if handoff.source_persona.is_empty() {
209 return Err("handoff source_persona is required".to_string());
210 }
211 if handoff.target_persona_or_human.display_name() == "unknown" {
212 return Err("handoff target_persona_or_human is required".to_string());
213 }
214 if handoff.task.is_empty() {
215 return Err("handoff task is required".to_string());
216 }
217 if handoff.reason.is_empty() {
218 return Err("handoff reason is required".to_string());
219 }
220 Ok(handoff)
221}
222
223pub fn handoff_from_json_value(value: &serde_json::Value) -> Option<HandoffArtifact> {
224 let object = value.as_object()?;
225 if object.get("_type").and_then(|value| value.as_str()) == Some(HANDOFF_TYPE)
226 || (object.contains_key("source_persona")
227 && object.contains_key("target_persona_or_human")
228 && object.contains_key("task"))
229 {
230 return normalize_handoff_artifact_json(value.clone()).ok();
231 }
232 if object.get("_type").and_then(|value| value.as_str()) == Some("artifact")
233 || object.get("kind").and_then(|value| value.as_str()) == Some(HANDOFF_ARTIFACT_KIND)
234 {
235 return object
236 .get("data")
237 .and_then(handoff_from_json_value)
238 .or_else(|| normalize_handoff_artifact_json(value.clone()).ok());
239 }
240 if object.get("_type").and_then(|value| value.as_str()) == Some("agent_state_handoff") {
241 return object
242 .get("handoff")
243 .and_then(handoff_from_json_value)
244 .or_else(|| object.get("summary").and_then(handoff_from_json_value));
245 }
246 None
247}
248
249pub fn extract_handoff_from_artifact(artifact: &ArtifactRecord) -> Option<HandoffArtifact> {
250 if artifact.kind != HANDOFF_ARTIFACT_KIND {
251 return None;
252 }
253 artifact.data.as_ref().and_then(handoff_from_json_value)
254}
255
256pub fn extract_handoffs_from_json_value(value: &serde_json::Value) -> Vec<HandoffArtifact> {
257 fn collect(value: &serde_json::Value, out: &mut Vec<HandoffArtifact>) {
258 if let Some(handoff) = handoff_from_json_value(value) {
259 out.push(handoff);
260 }
261 let Some(object) = value.as_object() else {
262 return;
263 };
264 for key in ["handoffs", "artifacts"] {
265 if let Some(items) = object.get(key).and_then(|value| value.as_array()) {
266 for item in items {
267 collect(item, out);
268 }
269 }
270 }
271 for key in ["run", "result"] {
272 if let Some(nested) = object.get(key) {
273 collect(nested, out);
274 }
275 }
276 }
277
278 let mut handoffs = Vec::new();
279 collect(value, &mut handoffs);
280 dedup_handoffs(handoffs)
281}
282
283fn dedup_handoffs(handoffs: Vec<HandoffArtifact>) -> Vec<HandoffArtifact> {
284 let mut by_id = BTreeMap::new();
285 for handoff in handoffs {
286 by_id
287 .entry(handoff.id.clone())
288 .and_modify(|existing: &mut HandoffArtifact| {
289 *existing = merge_handoffs(existing.clone(), handoff.clone())
290 })
291 .or_insert(handoff);
292 }
293 by_id.into_values().collect()
294}
295
296fn merge_receipt_links(
297 left: Vec<HandoffReceiptLinkRecord>,
298 right: Vec<HandoffReceiptLinkRecord>,
299) -> Vec<HandoffReceiptLinkRecord> {
300 let mut seen = BTreeSet::new();
301 left.into_iter()
302 .chain(right)
303 .map(HandoffReceiptLinkRecord::normalize)
304 .filter(|link| {
305 seen.insert((
306 link.kind.clone(),
307 link.run_id.clone(),
308 link.artifact_id.clone(),
309 link.path.clone(),
310 link.href.clone(),
311 ))
312 })
313 .collect()
314}
315
316fn merge_handoffs(mut left: HandoffArtifact, right: HandoffArtifact) -> HandoffArtifact {
317 if left.parent_run_id.is_none() {
318 left.parent_run_id = right.parent_run_id;
319 }
320 if left.source_persona.is_empty() {
321 left.source_persona = right.source_persona;
322 }
323 if left.target_persona_or_human.display_name() == "unknown" {
324 left.target_persona_or_human = right.target_persona_or_human;
325 }
326 if left.task.is_empty() {
327 left.task = right.task;
328 }
329 if left.reason.is_empty() {
330 left.reason = right.reason;
331 }
332 if left.evidence_refs.is_empty() {
333 left.evidence_refs = right.evidence_refs;
334 }
335 if left.files_or_entities_touched.is_empty() {
336 left.files_or_entities_touched = right.files_or_entities_touched;
337 }
338 if left.open_questions.is_empty() {
339 left.open_questions = right.open_questions;
340 }
341 if left.blocked_on.is_empty() {
342 left.blocked_on = right.blocked_on;
343 }
344 if left.requested_capabilities.is_empty() {
345 left.requested_capabilities = right.requested_capabilities;
346 }
347 if left.allowed_side_effects.is_empty() {
348 left.allowed_side_effects = right.allowed_side_effects;
349 }
350 if left.budget_remaining.is_none() {
351 left.budget_remaining = right.budget_remaining;
352 }
353 if left.deadline_checkback.is_none() {
354 left.deadline_checkback = right.deadline_checkback;
355 }
356 if left.confidence.is_none() {
357 left.confidence = right.confidence;
358 }
359 left.receipt_links = merge_receipt_links(left.receipt_links, right.receipt_links);
360 for (key, value) in right.metadata {
361 left.metadata.entry(key).or_insert(value);
362 }
363 left
364}
365
366pub fn handoff_context_text(handoff: &HandoffArtifact) -> String {
367 let mut lines = vec![
368 format!(
369 "<source_persona>{}</source_persona>",
370 handoff.source_persona
371 ),
372 format!(
373 "<target kind=\"{}\">{}</target>",
374 handoff.target_persona_or_human.kind,
375 handoff.target_persona_or_human.display_name()
376 ),
377 format!("<task>{}</task>", handoff.task),
378 format!("<reason>{}</reason>", handoff.reason),
379 ];
380 append_list_section(
381 &mut lines,
382 "files_or_entities_touched",
383 &handoff.files_or_entities_touched,
384 );
385 append_list_section(&mut lines, "open_questions", &handoff.open_questions);
386 append_list_section(&mut lines, "blocked_on", &handoff.blocked_on);
387 append_list_section(
388 &mut lines,
389 "requested_capabilities",
390 &handoff.requested_capabilities,
391 );
392 append_list_section(
393 &mut lines,
394 "allowed_side_effects",
395 &handoff.allowed_side_effects,
396 );
397 if !handoff.evidence_refs.is_empty() {
398 lines.push("<evidence_refs>".to_string());
399 for evidence in &handoff.evidence_refs {
400 let mut parts = Vec::new();
401 if let Some(label) = evidence.label.as_ref() {
402 parts.push(label.clone());
403 }
404 if let Some(artifact_id) = evidence.artifact_id.as_ref() {
405 parts.push(format!("artifact_id={artifact_id}"));
406 }
407 if let Some(path) = evidence.path.as_ref() {
408 parts.push(format!("path={path}"));
409 }
410 if let Some(uri) = evidence.uri.as_ref() {
411 parts.push(format!("uri={uri}"));
412 }
413 if let Some(kind) = evidence.kind.as_ref() {
414 parts.push(format!("kind={kind}"));
415 }
416 lines.push(format!("- {}", parts.join(" | ")));
417 }
418 lines.push("</evidence_refs>".to_string());
419 }
420 if let Some(budget) = handoff.budget_remaining.as_ref() {
421 lines.push(format!(
422 "<budget_remaining tokens=\"{}\" tool_calls=\"{}\" dollars=\"{}\" />",
423 budget
424 .tokens
425 .map(|value| value.to_string())
426 .unwrap_or_default(),
427 budget
428 .tool_calls
429 .map(|value| value.to_string())
430 .unwrap_or_default(),
431 budget
432 .dollars
433 .map(|value| format!("{value:.4}"))
434 .unwrap_or_default(),
435 ));
436 }
437 if let Some(deadline) = handoff.deadline_checkback.as_ref() {
438 lines.push(format!(
439 "<deadline_checkback deadline=\"{}\" checkback_at=\"{}\" />",
440 deadline.deadline.clone().unwrap_or_default(),
441 deadline.checkback_at.clone().unwrap_or_default(),
442 ));
443 }
444 if let Some(confidence) = handoff.confidence {
445 lines.push(format!("<confidence>{confidence:.2}</confidence>"));
446 }
447 format!("<handoff>\n{}\n</handoff>", lines.join("\n"))
448}
449
450fn append_list_section(lines: &mut Vec<String>, label: &str, items: &[String]) {
451 if items.is_empty() {
452 return;
453 }
454 lines.push(format!("<{label}>"));
455 for item in items {
456 lines.push(format!("- {item}"));
457 }
458 lines.push(format!("</{label}>"));
459}
460
461fn handoff_target_label(handoff: &HandoffArtifact) -> String {
462 handoff.target_persona_or_human.display_name()
463}
464
465fn handoff_metadata(handoff: &HandoffArtifact) -> BTreeMap<String, serde_json::Value> {
466 BTreeMap::from([
467 ("handoff_id".to_string(), serde_json::json!(handoff.id)),
468 (
469 "target_kind".to_string(),
470 serde_json::json!(handoff.target_persona_or_human.kind),
471 ),
472 (
473 "target_label".to_string(),
474 serde_json::json!(handoff_target_label(handoff)),
475 ),
476 ])
477}
478
479pub fn handoff_artifact_record(
480 handoff: &HandoffArtifact,
481 existing: Option<&ArtifactRecord>,
482) -> ArtifactRecord {
483 let mut metadata = existing
484 .map(|artifact| artifact.metadata.clone())
485 .unwrap_or_default();
486 metadata.extend(handoff_metadata(handoff));
487 ArtifactRecord {
488 type_name: "artifact".to_string(),
489 id: existing
490 .map(|artifact| artifact.id.clone())
491 .unwrap_or_else(|| format!("artifact_{}", handoff.id)),
492 kind: HANDOFF_ARTIFACT_KIND.to_string(),
493 title: existing
494 .and_then(|artifact| artifact.title.clone())
495 .or_else(|| Some(format!("Handoff to {}", handoff_target_label(handoff)))),
496 text: Some(handoff_context_text(handoff)),
497 data: Some(serde_json::to_value(handoff).unwrap_or(serde_json::Value::Null)),
498 source: existing
499 .and_then(|artifact| artifact.source.clone())
500 .or_else(|| Some(handoff.source_persona.clone())),
501 created_at: existing
502 .map(|artifact| artifact.created_at.clone())
503 .unwrap_or_else(now_rfc3339),
504 freshness: existing
505 .and_then(|artifact| artifact.freshness.clone())
506 .or_else(|| Some("fresh".to_string())),
507 priority: existing.and_then(|artifact| artifact.priority).or(Some(85)),
508 lineage: existing
509 .map(|artifact| artifact.lineage.clone())
510 .unwrap_or_default(),
511 relevance: handoff.confidence.or(Some(1.0)),
512 estimated_tokens: None,
513 stage: existing.and_then(|artifact| artifact.stage.clone()),
514 metadata,
515 }
516 .normalize()
517}
518
519fn receipt_link_for_run(run: &RunRecord) -> HandoffReceiptLinkRecord {
520 HandoffReceiptLinkRecord {
521 kind: RUN_RECEIPT_LINK_KIND.to_string(),
522 label: run
523 .workflow_name
524 .clone()
525 .or_else(|| Some(run.workflow_id.clone())),
526 run_id: Some(run.id.clone()),
527 artifact_id: None,
528 path: run.persisted_path.clone(),
529 href: None,
530 }
531 .normalize()
532}
533
534fn sync_handoff_receipt_links(handoff: &mut HandoffArtifact, run: &RunRecord) {
535 if handoff.parent_run_id.is_none() {
536 handoff.parent_run_id = Some(run.id.clone());
537 }
538 handoff.receipt_links = merge_receipt_links(
539 std::mem::take(&mut handoff.receipt_links),
540 vec![receipt_link_for_run(run)],
541 );
542}
543
544fn artifact_handoff_id(artifact: &ArtifactRecord) -> Option<String> {
545 if artifact.kind != HANDOFF_ARTIFACT_KIND {
546 return None;
547 }
548 artifact
549 .metadata
550 .get("handoff_id")
551 .and_then(|value| value.as_str())
552 .map(str::to_string)
553 .or_else(|| {
554 artifact
555 .data
556 .as_ref()
557 .and_then(|value| value.get("id"))
558 .and_then(|value| value.as_str())
559 .map(str::to_string)
560 })
561}
562
563pub fn sync_run_handoffs(run: &mut RunRecord) {
564 let mut by_id = BTreeMap::new();
565 for handoff in std::mem::take(&mut run.handoffs) {
566 by_id.insert(handoff.id.clone(), handoff.normalize());
567 }
568 for artifact in &run.artifacts {
569 if let Some(handoff) = extract_handoff_from_artifact(artifact) {
570 by_id
571 .entry(handoff.id.clone())
572 .and_modify(|existing| {
573 *existing = merge_handoffs(existing.clone(), handoff.clone())
574 })
575 .or_insert(handoff);
576 }
577 }
578
579 let mut artifact_index_by_handoff_id = BTreeMap::new();
580 for (index, artifact) in run.artifacts.iter().enumerate() {
581 if let Some(handoff_id) = artifact_handoff_id(artifact) {
582 artifact_index_by_handoff_id.insert(handoff_id, index);
583 }
584 }
585
586 let mut handoffs = by_id.into_values().collect::<Vec<_>>();
587 handoffs.sort_by(|left, right| left.created_at.cmp(&right.created_at));
588 for handoff in &mut handoffs {
589 sync_handoff_receipt_links(handoff, run);
590 if let Some(index) = artifact_index_by_handoff_id.get(&handoff.id).copied() {
591 let existing = run.artifacts[index].clone();
592 run.artifacts[index] = handoff_artifact_record(handoff, Some(&existing));
593 } else {
594 run.artifacts.push(handoff_artifact_record(handoff, None));
595 }
596 }
597 run.handoffs = handoffs;
598}