1use std::collections::BTreeMap;
11
12use crate::WireFormat;
13
14const CODEX_SESSION_ID_PATH: &str = "x-codex-turn-metadata.session_id";
16const CODEX_THREAD_ID_PATH: &str = "x-codex-turn-metadata.thread_id";
17const CODEX_PARENT_THREAD_ID_PATH: &str = "x-codex-turn-metadata.parent_thread_id";
18const CODEX_TURN_ID_PATH: &str = "x-codex-turn-metadata.turn_id";
19const CODEX_SUBAGENT_KIND_PATH: &str = "x-codex-turn-metadata.subagent_kind";
20const CODEX_AGENT_ROLE_PATH: &str = "x-codex-turn-metadata.agent_role";
21const CODEX_TASK_ID_PATH: &str = "x-codex-turn-metadata.task_id";
22const CODEX_TASK_KIND_PATH: &str = "x-codex-turn-metadata.task_kind";
23
24const SWITCHYARD_SESSION_ID_HEADER: &str = "x-switchyard-session-id";
26const SWITCHYARD_AGENT_ID_HEADER: &str = "x-switchyard-agent-id";
27const SWITCHYARD_PARENT_AGENT_ID_HEADER: &str = "x-switchyard-parent-agent-id";
28const SWITCHYARD_IS_SUBAGENT_HEADER: &str = "x-switchyard-is-subagent";
29const SWITCHYARD_AGENT_KIND_HEADER: &str = "x-switchyard-agent-kind";
30const SWITCHYARD_AGENT_ROLE_HEADER: &str = "x-switchyard-agent-role";
31const SWITCHYARD_TASK_ID_HEADER: &str = "x-switchyard-task-id";
32const SWITCHYARD_TASK_KIND_HEADER: &str = "x-switchyard-task-kind";
33const SWITCHYARD_TURN_ID_HEADER: &str = "x-switchyard-turn-id";
34const SWITCHYARD_REQUEST_ID_HEADER: &str = "x-switchyard-request-id";
35const SWITCHYARD_SESSION_FINAL_HEADER: &str = "x-switchyard-session-final";
36
37const RELAY_SESSION_ID_HEADER: &str = "x-nemo-relay-session-id";
39const RELAY_SUBAGENT_ID_HEADER: &str = "x-nemo-relay-subagent-id";
40
41const DYNAMO_SESSION_ID_HEADER: &str = "x-dynamo-session-id";
43const DYNAMO_PARENT_SESSION_ID_HEADER: &str = "x-dynamo-parent-session-id";
44const DYNAMO_SESSION_FINAL_HEADER: &str = "x-dynamo-session-final";
45
46const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
48
49const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
51
52const CLAUDE_SESSION_ID_HEADER: &str = "x-claude-code-session-id";
54const CLAUDE_AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
55const CLAUDE_PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
56
57const OPENCODE_SESSION_ID_HEADER: &str = "x-session-id";
59
60const SESSION_ID_HEADER: &str = "session-id";
62const THREAD_ID_HEADER: &str = "thread-id";
63const TASK_ID_HEADER: &str = "x-task-id";
64const REQUEST_ID_HEADER: &str = "x-request-id";
65const CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id";
66
67const SUBAGENT_WORK_KINDS: &[&str] = &["collab_spawn", "review"];
71
72type HeaderConfig = [(&'static str, &'static [&'static str])];
75
76const HEADER_CONFIG: &HeaderConfig = &[
78 (
79 SWITCHYARD_SESSION_ID_HEADER,
80 &[
81 SWITCHYARD_SESSION_ID_HEADER,
82 CLAUDE_SESSION_ID_HEADER,
83 RELAY_SESSION_ID_HEADER,
84 OPENCODE_SESSION_ID_HEADER,
85 CODEX_SESSION_ID_PATH,
86 SESSION_ID_HEADER,
87 ],
88 ),
89 (
90 SWITCHYARD_AGENT_ID_HEADER,
91 &[
92 SWITCHYARD_AGENT_ID_HEADER,
93 CLAUDE_AGENT_ID_HEADER,
94 RELAY_SUBAGENT_ID_HEADER,
95 DYNAMO_SESSION_ID_HEADER,
96 CODEX_THREAD_ID_PATH,
97 THREAD_ID_HEADER,
98 ],
99 ),
100 (
101 SWITCHYARD_PARENT_AGENT_ID_HEADER,
102 &[
103 SWITCHYARD_PARENT_AGENT_ID_HEADER,
104 DYNAMO_PARENT_SESSION_ID_HEADER,
105 CODEX_PARENT_THREAD_ID_PATH,
106 CODEX_PARENT_THREAD_ID_HEADER,
107 ],
108 ),
109 (
110 SWITCHYARD_AGENT_KIND_HEADER,
111 &[
112 SWITCHYARD_AGENT_KIND_HEADER,
113 CODEX_SUBAGENT_KIND_PATH,
114 OPENAI_SUBAGENT_HEADER,
115 ],
116 ),
117 (
118 SWITCHYARD_AGENT_ROLE_HEADER,
119 &[SWITCHYARD_AGENT_ROLE_HEADER, CODEX_AGENT_ROLE_PATH],
120 ),
121 (
122 SWITCHYARD_TASK_ID_HEADER,
123 &[
124 SWITCHYARD_TASK_ID_HEADER,
125 CODEX_TASK_ID_PATH,
126 TASK_ID_HEADER,
127 ],
128 ),
129 (
130 SWITCHYARD_TASK_KIND_HEADER,
131 &[SWITCHYARD_TASK_KIND_HEADER, CODEX_TASK_KIND_PATH],
132 ),
133 (
134 SWITCHYARD_TURN_ID_HEADER,
135 &[SWITCHYARD_TURN_ID_HEADER, CODEX_TURN_ID_PATH],
136 ),
137 (
138 SWITCHYARD_REQUEST_ID_HEADER,
139 &[
140 SWITCHYARD_REQUEST_ID_HEADER,
141 REQUEST_ID_HEADER,
142 CLIENT_REQUEST_ID_HEADER,
143 ],
144 ),
145 (
146 SWITCHYARD_SESSION_FINAL_HEADER,
147 &[SWITCHYARD_SESSION_FINAL_HEADER, DYNAMO_SESSION_FINAL_HEADER],
148 ),
149];
150
151#[derive(Clone, Default)]
159pub struct Metadata {
160 pub session_id: Option<String>,
162 pub agent_id: Option<String>,
164 pub parent_agent_id: Option<String>,
166 pub is_subagent: bool,
168 pub is_delegated_work: bool,
173 pub agent_kind: Option<String>,
175 pub agent_role: Option<String>,
177 pub task_id: Option<String>,
179 pub task_kind: Option<String>,
181 pub turn_id: Option<String>,
183 pub session_final: Option<bool>,
186 pub correlation_id: Option<String>,
188 pub extra_metadata: Option<BTreeMap<String, String>>,
190 pub http_headers: Option<BTreeMap<String, String>>,
192 pub wire_format: Option<WireFormat>,
194}
195
196impl Metadata {
197 pub fn from_headers(headers: &BTreeMap<String, String>) -> Self {
208 let headers = &normalize_headers(headers);
209
210 let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
211
212 Metadata {
213 session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
214 agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
215 parent_agent_id,
216 is_subagent,
217 is_delegated_work,
218 agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
219 agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
220 task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
221 task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
222 turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
223 session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
224 .as_deref()
225 .and_then(parse_bool),
226 correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
227 ..Metadata::default()
228 }
229 }
230
231 pub fn is_subagent_work(&self) -> bool {
237 self.is_delegated_work
238 }
239}
240
241fn parse_sub_agent(headers: &BTreeMap<String, String>) -> (Option<String>, bool, bool) {
251 let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
252
253 let (claude_parent, claude_subagent) = claude_lineage(headers);
254
255 let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
258 .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
259
260 let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
263 .or_else(|| claude_parent.map(str::to_string));
264
265 let is_subagent = explicit.unwrap_or(claude_subagent || harness_kind.is_some());
266
267 let is_delegated_work = match explicit {
268 Some(false) => false,
269 Some(true) => harness_kind
270 .as_deref()
271 .map(|k| SUBAGENT_WORK_KINDS.contains(&k))
272 .unwrap_or(true),
273 None => {
274 claude_subagent
275 || harness_kind
276 .as_deref()
277 .is_some_and(|k| SUBAGENT_WORK_KINDS.contains(&k))
278 }
279 };
280
281 (parent, is_subagent, is_delegated_work)
282}
283
284fn claude_lineage(headers: &BTreeMap<String, String>) -> (Option<&str>, bool) {
291 let session = header(headers, CLAUDE_SESSION_ID_HEADER);
292 let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
293 let is_subagent = agent.is_some();
294 let parent = is_subagent
295 .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
296 .flatten();
297 (parent, is_subagent)
298}
299
300fn parse_bool(value: &str) -> Option<bool> {
302 match value.trim().to_ascii_lowercase().as_str() {
303 "1" | "true" | "yes" | "on" => Some(true),
304 "0" | "false" | "no" | "off" => Some(false),
305 _ => None,
306 }
307}
308
309fn normalize_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
311 let mut normalized = BTreeMap::new();
312 for (key, value) in headers {
313 let lower_key = key.to_ascii_lowercase();
314 let trimmed_value = value.trim();
315 if !normalized.contains_key(&lower_key) && !trimmed_value.is_empty() {
316 normalized.insert(lower_key, trimmed_value.to_string());
317 }
318 }
319
320 normalized
321}
322
323fn sy_header(headers: &BTreeMap<String, String>, key: &str) -> Option<String> {
329 let (_, paths) = HEADER_CONFIG
330 .iter()
331 .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
332 paths.iter().find_map(|path| resolve_path(headers, path))
333}
334
335fn resolve_path(headers: &BTreeMap<String, String>, path: &str) -> Option<String> {
337 let (header_name, nested) = match path.split_once('.') {
338 Some((name, rest)) => (name, Some(rest)),
339 None => (path, None),
340 };
341 let raw = headers.get(&header_name.to_ascii_lowercase())?;
342
343 let Some(nested) = nested else {
345 return Some(raw.clone());
346 };
347
348 let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
350 for segment in nested.split('.') {
351 current = current.as_object()?.get(segment)?.clone();
352 }
353
354 match current {
355 serde_json::Value::String(s) => Some(s),
356 serde_json::Value::Null => None,
357 leaf => Some(leaf.to_string()),
358 }
359}
360
361fn header<'a>(headers: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
362 let lower_key = key.to_ascii_lowercase();
363 headers.get(&lower_key).map(|s| s.as_str())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
372
373 #[test]
374 fn normalizes_codex_child_metadata() {
375 let headers = BTreeMap::from([(
376 CODEX_TURN_METADATA_HEADER.to_string(),
377 serde_json::json!({
378 "session_id": "root-session",
379 "thread_id": "child-agent",
380 "parent_thread_id": "root-agent",
381 "turn_id": "turn-7",
382 "subagent_kind": "collab_spawn",
383 })
384 .to_string(),
385 )]);
386
387 let metadata = Metadata::from_headers(&headers);
388 assert_eq!(metadata.session_id.as_deref(), Some("root-session"));
389 assert_eq!(metadata.agent_id.as_deref(), Some("child-agent"));
390 assert!(metadata.is_subagent);
391 assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-agent"));
392 }
393
394 #[test]
395 fn root_codex_metadata_is_not_inferred_as_a_subagent() {
396 let headers = BTreeMap::from([(
397 CODEX_TURN_METADATA_HEADER.to_string(),
398 serde_json::json!({
399 "session_id": "root-session",
400 "thread_id": "root-agent",
401 "turn_id": "turn-1",
402 })
403 .to_string(),
404 )]);
405
406 let metadata = Metadata::from_headers(&headers);
407 assert!(!metadata.is_subagent);
408 }
409
410 #[test]
411 fn codex_parent_thread_id_alone_is_not_a_subagent_signal() {
412 let headers = BTreeMap::from([(
416 CODEX_TURN_METADATA_HEADER.to_string(),
417 serde_json::json!({
418 "session_id": "root-session",
419 "thread_id": "child-thread",
420 "parent_thread_id": "root-thread",
421 "turn_id": "turn-3",
422 })
424 .to_string(),
425 )]);
426
427 let metadata = Metadata::from_headers(&headers);
428 assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-thread"));
430 assert!(!metadata.is_subagent);
432 assert!(!metadata.is_subagent_work());
433 }
434
435 #[test]
436 fn explicit_switchyard_subagent_flag_overrides_inference() {
437 let headers = BTreeMap::from([
438 ("x-switchyard-is-subagent".to_string(), "false".to_string()),
439 (
440 "x-switchyard-parent-agent-id".to_string(),
441 "parent".to_string(),
442 ),
443 ]);
444
445 let metadata = Metadata::from_headers(&headers);
446 assert!(!metadata.is_subagent);
447 }
448
449 #[test]
450 fn normalizes_claude_code_session_header() {
451 let headers = BTreeMap::from([(
454 "x-claude-code-session-id".to_string(),
455 "fb46caae-eac6-4f5f-83fd-8fc8f5743abb".to_string(),
456 )]);
457
458 let metadata = Metadata::from_headers(&headers);
459 assert_eq!(
460 metadata.session_id.as_deref(),
461 Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
462 );
463 }
464
465 #[test]
466 fn normalizes_relay_and_dynamo_child_headers() {
467 let headers = BTreeMap::from([
470 (
471 "x-nemo-relay-session-id".to_string(),
472 "relay-session".to_string(),
473 ),
474 (
475 "x-nemo-relay-subagent-id".to_string(),
476 "relay-child".to_string(),
477 ),
478 (
479 "x-dynamo-parent-session-id".to_string(),
480 "relay-parent".to_string(),
481 ),
482 ]);
483
484 let metadata = Metadata::from_headers(&headers);
485 assert_eq!(metadata.session_id.as_deref(), Some("relay-session"));
486 assert_eq!(metadata.agent_id.as_deref(), Some("relay-child"));
487 assert_eq!(metadata.parent_agent_id.as_deref(), Some("relay-parent"));
488 assert!(!metadata.is_subagent);
489 assert!(!metadata.is_subagent_work());
490 }
491
492 #[test]
493 fn claude_code_agent_lineage_marks_subagent_and_infers_parent() {
494 let metadata = Metadata::from_headers(&BTreeMap::from([
497 (
498 "x-claude-code-session-id".to_string(),
499 "claude-session".to_string(),
500 ),
501 (
502 "x-claude-code-agent-id".to_string(),
503 "claude-agent".to_string(),
504 ),
505 ]));
506 assert_eq!(metadata.session_id.as_deref(), Some("claude-session"));
507 assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent"));
508 assert!(metadata.is_subagent);
509 assert_eq!(metadata.parent_agent_id.as_deref(), Some("claude-session"));
510 }
511
512 #[test]
513 fn claude_code_agent_id_alone_marks_subagent() {
514 let metadata = Metadata::from_headers(&BTreeMap::from([(
517 "x-claude-code-agent-id".to_string(),
518 "claude-agent".to_string(),
519 )]));
520 assert!(metadata.is_subagent);
521 assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent"));
522 assert_eq!(metadata.parent_agent_id, None);
523 }
524
525 #[test]
526 fn explicit_claude_parent_agent_overrides_inferred_session() {
527 let metadata = Metadata::from_headers(&BTreeMap::from([
528 (
529 "x-claude-code-session-id".to_string(),
530 "claude-session".to_string(),
531 ),
532 (
533 "x-claude-code-agent-id".to_string(),
534 "claude-agent".to_string(),
535 ),
536 (
537 "x-claude-code-parent-agent-id".to_string(),
538 "claude-parent-agent".to_string(),
539 ),
540 ]));
541 assert_eq!(
542 metadata.parent_agent_id.as_deref(),
543 Some("claude-parent-agent")
544 );
545 }
546
547 #[test]
548 fn claude_root_agent_without_agent_id_is_not_a_subagent() {
549 let metadata = Metadata::from_headers(&BTreeMap::from([
552 (
553 "x-claude-code-session-id".to_string(),
554 "claude-session".to_string(),
555 ),
556 (
557 "x-claude-code-parent-agent-id".to_string(),
558 "claude-parent-agent".to_string(),
559 ),
560 ]));
561 assert_eq!(metadata.session_id.as_deref(), Some("claude-session"));
562 assert_eq!(metadata.agent_id, None);
563 assert!(!metadata.is_subagent);
564 assert_eq!(metadata.parent_agent_id, None);
565 }
566
567 #[test]
568 fn opencode_session_headers_are_correlation_only() {
569 let metadata = Metadata::from_headers(&BTreeMap::from([
572 ("x-session-id".to_string(), "opencode-run".to_string()),
573 (
574 "x-parent-session-id".to_string(),
575 "opencode-parent".to_string(),
576 ),
577 ]));
578 assert_eq!(metadata.session_id.as_deref(), Some("opencode-run"));
579 assert!(!metadata.is_subagent);
580 assert_eq!(metadata.parent_agent_id, None);
581 }
582
583 #[test]
584 fn opencode_parent_header_is_not_a_parent_agent_id_source() {
585 let metadata = Metadata::from_headers(&BTreeMap::from([
588 ("session-id".to_string(), "codex-run".to_string()),
589 (
590 "x-parent-session-id".to_string(),
591 "stray-parent".to_string(),
592 ),
593 ]));
594 assert_eq!(metadata.session_id.as_deref(), Some("codex-run"));
595 assert!(!metadata.is_subagent);
596 assert_eq!(metadata.parent_agent_id, None);
597 }
598
599 #[test]
600 fn dynamo_session_final_is_captured() {
601 let metadata = Metadata::from_headers(&BTreeMap::from([
602 ("x-dynamo-session-id".to_string(), "generic-run".to_string()),
603 (
604 "x-dynamo-parent-session-id".to_string(),
605 "generic-parent".to_string(),
606 ),
607 ("x-dynamo-session-final".to_string(), "true".to_string()),
608 ]));
609 assert_eq!(metadata.agent_id.as_deref(), Some("generic-run"));
610 assert_eq!(metadata.parent_agent_id.as_deref(), Some("generic-parent"));
611 assert_eq!(metadata.session_final, Some(true));
612
613 let not_final = Metadata::from_headers(&BTreeMap::from([
614 ("x-dynamo-session-id".to_string(), "generic-run".to_string()),
615 ("x-dynamo-session-final".to_string(), "false".to_string()),
616 ]));
617 assert_eq!(not_final.session_final, Some(false));
618 }
619
620 #[test]
621 fn sy_header_resolves_paths_in_order_and_descends_into_json() {
622 let headers = BTreeMap::from([(
624 CODEX_TURN_METADATA_HEADER.to_string(),
625 serde_json::json!({ "session_id": "codex-session" }).to_string(),
626 )]);
627 assert_eq!(
628 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
629 Some("codex-session")
630 );
631
632 let headers = BTreeMap::from([
634 (
635 SWITCHYARD_SESSION_ID_HEADER.to_string(),
636 "explicit".to_string(),
637 ),
638 (
639 CODEX_TURN_METADATA_HEADER.to_string(),
640 serde_json::json!({ "session_id": "codex-session" }).to_string(),
641 ),
642 ]);
643 assert_eq!(
644 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
645 Some("explicit")
646 );
647
648 assert_eq!(
650 sy_header(&BTreeMap::new(), SWITCHYARD_SESSION_ID_HEADER),
651 None
652 );
653 assert_eq!(sy_header(&headers, "x-not-a-field"), None);
654 }
655
656 #[test]
657 fn codex_session_header_is_case_insensitive() {
658 let metadata = Metadata::from_headers(&BTreeMap::from([(
659 "Session-ID".to_string(),
660 "codex-run".to_string(),
661 )]));
662 assert_eq!(metadata.session_id.as_deref(), Some("codex-run"));
663 }
664
665 #[test]
666 fn explicit_subagent_flag_decides_without_a_parent_header() {
667 let metadata = Metadata::from_headers(&BTreeMap::from([
670 ("x-switchyard-is-subagent".to_string(), "false".to_string()),
671 ("x-openai-subagent".to_string(), "review".to_string()),
672 ]));
673 assert!(!metadata.is_subagent);
674
675 let metadata = Metadata::from_headers(&BTreeMap::from([(
676 "x-switchyard-is-subagent".to_string(),
677 "true".to_string(),
678 )]));
679 assert!(metadata.is_subagent);
680 }
681
682 #[test]
683 fn operator_agent_kind_does_not_suppress_harness_subagent_routing() {
684 let with_openai = Metadata::from_headers(&BTreeMap::from([
687 ("x-openai-subagent".to_string(), "review".to_string()),
688 (
689 "x-switchyard-agent-kind".to_string(),
690 "researcher".to_string(),
691 ),
692 ]));
693 assert!(with_openai.is_subagent);
694 assert!(with_openai.is_subagent_work());
695
696 let with_explicit = Metadata::from_headers(&BTreeMap::from([
697 ("x-switchyard-is-subagent".to_string(), "true".to_string()),
698 (
699 "x-switchyard-agent-kind".to_string(),
700 "researcher".to_string(),
701 ),
702 ]));
703 assert!(with_explicit.is_subagent);
704 assert!(with_explicit.is_subagent_work());
705 }
706
707 #[test]
708 fn subagent_work_requires_a_delegated_work_kind_when_kinded() {
709 let claude_child = Metadata::from_headers(&BTreeMap::from([
711 ("x-claude-code-session-id".to_string(), "root".to_string()),
712 ("x-claude-code-agent-id".to_string(), "worker".to_string()),
713 ]));
714 assert!(claude_child.is_subagent_work());
715
716 let review = Metadata::from_headers(&BTreeMap::from([(
718 "x-openai-subagent".to_string(),
719 "review".to_string(),
720 )]));
721 assert!(review.is_subagent_work());
722
723 for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
726 let metadata = Metadata::from_headers(&BTreeMap::from([(
727 "x-openai-subagent".to_string(),
728 kind.to_string(),
729 )]));
730 assert!(metadata.is_subagent, "{kind} keeps the lineage fact");
731 assert!(!metadata.is_subagent_work(), "{kind} is not routed as work");
732 }
733
734 assert!(!Metadata::default().is_subagent_work());
736 }
737}