Skip to main content

harn_vm/orchestration/
handoffs.rs

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}