1use std::{collections::BTreeMap, str::FromStr as _};
11
12use crate::{ModelId, 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_THREAD_SOURCE_PATH: &str = "x-codex-turn-metadata.thread_source";
20const CODEX_SUBAGENT_KIND_PATH: &str = "x-codex-turn-metadata.subagent_kind";
21const CODEX_AGENT_ROLE_PATH: &str = "x-codex-turn-metadata.agent_role";
22const CODEX_TASK_ID_PATH: &str = "x-codex-turn-metadata.task_id";
23const CODEX_TASK_KIND_PATH: &str = "x-codex-turn-metadata.task_kind";
24
25const SWITCHYARD_SESSION_ID_HEADER: &str = "x-switchyard-session-id";
27const SWITCHYARD_AGENT_ID_HEADER: &str = "x-switchyard-agent-id";
28const SWITCHYARD_PARENT_AGENT_ID_HEADER: &str = "x-switchyard-parent-agent-id";
29const SWITCHYARD_IS_SUBAGENT_HEADER: &str = "x-switchyard-is-subagent";
30const SWITCHYARD_AGENT_KIND_HEADER: &str = "x-switchyard-agent-kind";
31const SWITCHYARD_AGENT_ROLE_HEADER: &str = "x-switchyard-agent-role";
32const SWITCHYARD_TASK_ID_HEADER: &str = "x-switchyard-task-id";
33const SWITCHYARD_TASK_KIND_HEADER: &str = "x-switchyard-task-kind";
34const SWITCHYARD_TURN_ID_HEADER: &str = "x-switchyard-turn-id";
35const SWITCHYARD_REQUEST_ID_HEADER: &str = "x-switchyard-request-id";
36const SWITCHYARD_SESSION_FINAL_HEADER: &str = "x-switchyard-session-final";
37
38const RELAY_SESSION_ID_HEADER: &str = "x-nemo-relay-session-id";
40const RELAY_SUBAGENT_ID_HEADER: &str = "x-nemo-relay-subagent-id";
41
42const DYNAMO_SESSION_ID_HEADER: &str = "x-dynamo-session-id";
44const DYNAMO_PARENT_SESSION_ID_HEADER: &str = "x-dynamo-parent-session-id";
45const DYNAMO_SESSION_FINAL_HEADER: &str = "x-dynamo-session-final";
46
47const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
49
50const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
52
53const CLAUDE_SESSION_ID_HEADER: &str = "x-claude-code-session-id";
55const CLAUDE_AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
56const CLAUDE_PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
57
58const OPENCODE_SESSION_ID_HEADER: &str = "x-session-id";
60
61const SESSION_ID_HEADER: &str = "session-id";
63const THREAD_ID_HEADER: &str = "thread-id";
64const TASK_ID_HEADER: &str = "x-task-id";
65const REQUEST_ID_HEADER: &str = "x-request-id";
66const CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id";
67
68const USER_AGENT_HEADER: &str = "user-agent";
70
71const CLAUDE_CHILD_IDENTITY_VERSION: [u64; 3] = [2, 1, 139];
74
75const SUBAGENT_WORK_KINDS: &[&str] = &["collab_spawn", "review"];
79
80type HeaderConfig = [(&'static str, &'static [&'static str])];
83
84const HEADER_CONFIG: &HeaderConfig = &[
86 (
87 SWITCHYARD_SESSION_ID_HEADER,
88 &[
89 SWITCHYARD_SESSION_ID_HEADER,
90 CLAUDE_SESSION_ID_HEADER,
91 RELAY_SESSION_ID_HEADER,
92 OPENCODE_SESSION_ID_HEADER,
93 CODEX_SESSION_ID_PATH,
94 SESSION_ID_HEADER,
95 ],
96 ),
97 (
98 SWITCHYARD_AGENT_ID_HEADER,
99 &[
100 SWITCHYARD_AGENT_ID_HEADER,
101 CLAUDE_AGENT_ID_HEADER,
102 RELAY_SUBAGENT_ID_HEADER,
103 DYNAMO_SESSION_ID_HEADER,
104 CODEX_THREAD_ID_PATH,
105 THREAD_ID_HEADER,
106 ],
107 ),
108 (
109 SWITCHYARD_PARENT_AGENT_ID_HEADER,
110 &[
111 SWITCHYARD_PARENT_AGENT_ID_HEADER,
112 DYNAMO_PARENT_SESSION_ID_HEADER,
113 CODEX_PARENT_THREAD_ID_PATH,
114 CODEX_PARENT_THREAD_ID_HEADER,
115 ],
116 ),
117 (
118 SWITCHYARD_AGENT_KIND_HEADER,
119 &[
120 SWITCHYARD_AGENT_KIND_HEADER,
121 CODEX_SUBAGENT_KIND_PATH,
122 OPENAI_SUBAGENT_HEADER,
123 ],
124 ),
125 (
126 SWITCHYARD_AGENT_ROLE_HEADER,
127 &[SWITCHYARD_AGENT_ROLE_HEADER, CODEX_AGENT_ROLE_PATH],
128 ),
129 (
130 SWITCHYARD_TASK_ID_HEADER,
131 &[
132 SWITCHYARD_TASK_ID_HEADER,
133 CODEX_TASK_ID_PATH,
134 TASK_ID_HEADER,
135 ],
136 ),
137 (
138 SWITCHYARD_TASK_KIND_HEADER,
139 &[SWITCHYARD_TASK_KIND_HEADER, CODEX_TASK_KIND_PATH],
140 ),
141 (
142 SWITCHYARD_TURN_ID_HEADER,
143 &[SWITCHYARD_TURN_ID_HEADER, CODEX_TURN_ID_PATH],
144 ),
145 (
146 SWITCHYARD_REQUEST_ID_HEADER,
147 &[
148 SWITCHYARD_REQUEST_ID_HEADER,
149 REQUEST_ID_HEADER,
150 CLIENT_REQUEST_ID_HEADER,
151 ],
152 ),
153 (
154 SWITCHYARD_SESSION_FINAL_HEADER,
155 &[SWITCHYARD_SESSION_FINAL_HEADER, DYNAMO_SESSION_FINAL_HEADER],
156 ),
157];
158
159#[derive(Clone, Default)]
167pub struct Metadata {
168 pub session_id: Option<String>,
170 pub agent_id: Option<String>,
172 pub parent_agent_id: Option<String>,
174 pub is_subagent: bool,
176 pub is_delegated_work: bool,
181 pub agent_kind: Option<String>,
183 pub agent_role: Option<String>,
185 pub task_id: Option<String>,
187 pub task_kind: Option<String>,
189 pub turn_id: Option<String>,
191 pub session_final: Option<bool>,
194 pub correlation_id: Option<String>,
196 pub subagent_identity_unsupported: bool,
200 pub served_model: Option<ModelId>,
202 pub extra_metadata: Option<BTreeMap<String, String>>,
204 pub http_headers: Option<http::HeaderMap>,
206 pub wire_format: Option<WireFormat>,
208}
209
210impl Metadata {
211 pub fn from_headers(headers: &http::HeaderMap) -> Self {
213 let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
214
215 Metadata {
216 session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
217 agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
218 parent_agent_id,
219 is_subagent,
220 is_delegated_work,
221 agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
222 agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
223 task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
224 task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
225 turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
226 session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
227 .as_deref()
228 .and_then(parse_bool),
229 correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
230 subagent_identity_unsupported: claude_lacks_child_identity(headers),
231 ..Metadata::default()
232 }
233 }
234
235 pub fn is_subagent_work(&self) -> bool {
241 self.is_delegated_work
242 }
243}
244
245fn parse_sub_agent(headers: &http::HeaderMap) -> (Option<String>, bool, bool) {
256 let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
257
258 let (claude_parent, claude_subagent) = claude_lineage(headers);
259
260 let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
263 .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
264
265 let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
268 .or_else(|| claude_parent.map(str::to_string));
269
270 let codex_child = parent.is_some()
274 && resolve_path(headers, CODEX_THREAD_SOURCE_PATH).as_deref() == Some("subagent");
275
276 let is_subagent = explicit.unwrap_or(claude_subagent || codex_child || harness_kind.is_some());
277
278 let is_delegated_work = match (explicit, harness_kind.as_deref()) {
282 (Some(false), _) => false,
283 (_, Some(kind)) => SUBAGENT_WORK_KINDS.contains(&kind),
284 (Some(true), None) => true,
285 (None, None) => claude_subagent || codex_child,
286 };
287
288 (parent, is_subagent, is_delegated_work)
289}
290
291fn claude_lineage(headers: &http::HeaderMap) -> (Option<&str>, bool) {
298 let session = header(headers, CLAUDE_SESSION_ID_HEADER);
299 let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
300 let is_subagent = agent.is_some();
301 let parent = is_subagent
302 .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
303 .flatten();
304 (parent, is_subagent)
305}
306
307fn claude_lacks_child_identity(headers: &http::HeaderMap) -> bool {
313 fn parse(user_agent: &str) -> Option<([u64; 3], bool)> {
314 let tagged = user_agent
315 .strip_prefix("claude-cli/")?
316 .split([' ', '+'])
317 .next()?;
318 let (version, prerelease) = match tagged.split_once('-') {
319 Some((version, _)) => (version, true),
320 None => (tagged, false),
321 };
322 let mut parts = version.split('.').map(|part| part.parse::<u64>().ok());
323 Some(([parts.next()??, parts.next()??, parts.next()??], prerelease))
324 }
325 header(headers, USER_AGENT_HEADER)
326 .and_then(parse)
327 .is_some_and(|(version, prerelease)| {
328 version < CLAUDE_CHILD_IDENTITY_VERSION
329 || (prerelease && version == CLAUDE_CHILD_IDENTITY_VERSION)
330 })
331}
332
333fn parse_bool(value: &str) -> Option<bool> {
335 match value.trim().to_ascii_lowercase().as_str() {
336 "1" | "true" | "yes" | "on" => Some(true),
337 "0" | "false" | "no" | "off" => Some(false),
338 _ => None,
339 }
340}
341
342fn sy_header(headers: &http::HeaderMap, key: &str) -> Option<String> {
348 let (_, paths) = HEADER_CONFIG
349 .iter()
350 .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
351 paths.iter().find_map(|path| resolve_path(headers, path))
352}
353
354fn resolve_path(headers: &http::HeaderMap, path: &str) -> Option<String> {
357 let (header_name, nested) = match path.split_once('.') {
358 Some((name, rest)) => (name, Some(rest)),
359 None => (path, None),
360 };
361 let raw = headers.get(header_name)?.to_str().ok().map(|s| s.trim())?;
362 if raw.is_empty() {
363 return None;
364 }
365
366 let Some(nested) = nested else {
368 return Some(raw.to_string());
369 };
370
371 let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
373 for segment in nested.split('.') {
374 current = current.as_object()?.get(segment)?.clone();
375 }
376
377 match current {
378 serde_json::Value::String(s) => {
379 let value = s.trim();
380 (!value.is_empty()).then(|| value.to_string())
381 }
382 serde_json::Value::Null => None,
383 leaf => Some(leaf.to_string()),
384 }
385}
386
387fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> {
388 headers
389 .get(key)
390 .and_then(|s| s.to_str().ok())
391 .map(str::trim)
392 .filter(|s| !s.is_empty())
393}
394
395pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap {
397 let mut m = http::HeaderMap::with_capacity(sl.len());
398 for (k, v) in sl {
399 m.insert(
400 http::HeaderName::from_str(k).unwrap(),
401 (*v).try_into().unwrap(),
402 );
403 }
404 m
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
413
414 fn metadata(headers: &[(&str, &str)]) -> Metadata {
415 Metadata::from_headers(&slice_to_header_map(headers))
416 }
417
418 #[test]
419 fn normalizes_codex_metadata_and_lineage() {
420 let child_body = serde_json::json!({
421 "session_id": "root-session",
422 "thread_id": "child-agent",
423 "parent_thread_id": "root-agent",
424 "turn_id": "turn-7",
425 "subagent_kind": "collab_spawn",
426 })
427 .to_string();
428 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
429 assert_eq!(child.session_id.as_deref(), Some("root-session"));
430 assert_eq!(child.agent_id.as_deref(), Some("child-agent"));
431 assert_eq!(child.parent_agent_id.as_deref(), Some("root-agent"));
432 assert!(child.is_subagent);
433
434 let root_body = serde_json::json!({
435 "session_id": "root-session",
436 "thread_id": "root-agent",
437 "turn_id": "turn-1",
438 })
439 .to_string();
440 let root = metadata(&[(CODEX_TURN_METADATA_HEADER, root_body.as_str())]);
441 assert!(!root.is_subagent);
442
443 let correlated_body = serde_json::json!({
447 "session_id": "root-session",
448 "thread_id": "child-thread",
449 "parent_thread_id": "root-thread",
450 "turn_id": "turn-3",
451 })
452 .to_string();
453 let correlated = metadata(&[(CODEX_TURN_METADATA_HEADER, correlated_body.as_str())]);
454 assert_eq!(correlated.parent_agent_id.as_deref(), Some("root-thread"));
455 assert!(!correlated.is_subagent);
456 assert!(!correlated.is_subagent_work());
457
458 let child_body = serde_json::json!({
461 "session_id": "root-session",
462 "thread_id": "child-thread",
463 "parent_thread_id": "root-thread",
464 "thread_source": "subagent",
465 "turn_id": "turn-4",
466 })
467 .to_string();
468 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
469 assert_eq!(child.parent_agent_id.as_deref(), Some("root-thread"));
470 assert!(child.is_subagent);
471 assert!(child.is_subagent_work());
472 }
473
474 #[test]
475 fn normalizes_claude_code_metadata_and_lineage() {
476 let session = metadata(&[(
479 "x-claude-code-session-id",
480 "fb46caae-eac6-4f5f-83fd-8fc8f5743abb",
481 )]);
482 assert_eq!(
483 session.session_id.as_deref(),
484 Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
485 );
486
487 let child = metadata(&[
490 ("x-claude-code-session-id", "claude-session"),
491 ("x-claude-code-agent-id", "claude-agent"),
492 ]);
493 assert_eq!(child.session_id.as_deref(), Some("claude-session"));
494 assert_eq!(child.agent_id.as_deref(), Some("claude-agent"));
495 assert_eq!(child.parent_agent_id.as_deref(), Some("claude-session"));
496 assert!(child.is_subagent);
497
498 let child_without_session = metadata(&[("x-claude-code-agent-id", "claude-agent")]);
499 assert_eq!(
500 child_without_session.agent_id.as_deref(),
501 Some("claude-agent")
502 );
503 assert_eq!(child_without_session.parent_agent_id, None);
504 assert!(child_without_session.is_subagent);
505
506 let explicit_parent = metadata(&[
507 ("x-claude-code-session-id", "claude-session"),
508 ("x-claude-code-agent-id", "claude-agent"),
509 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
510 ]);
511 assert_eq!(
512 explicit_parent.parent_agent_id.as_deref(),
513 Some("claude-parent-agent")
514 );
515
516 let root = metadata(&[
519 ("x-claude-code-session-id", "claude-session"),
520 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
521 ]);
522 assert_eq!(root.session_id.as_deref(), Some("claude-session"));
523 assert_eq!(root.agent_id, None);
524 assert_eq!(root.parent_agent_id, None);
525 assert!(!root.is_subagent);
526 }
527
528 #[test]
529 fn flags_claude_code_builds_without_child_identity() {
530 let unsupported = |user_agent: &str| {
531 metadata(&[("user-agent", user_agent)]).subagent_identity_unsupported
532 };
533 assert!(unsupported(" claude-cli/2.1.121 (external, cli) "));
535 assert!(unsupported("claude-cli/2.1.139-beta.1"));
536 assert!(!unsupported("claude-cli/2.1.139 (external, cli)"));
538 assert!(!unsupported("claude-cli/2.1.211+build (external, cli)"));
539 assert!(!unsupported("codex_cli_rs/0.120.0"));
541 assert!(!unsupported("claude-cli/nightly"));
542 assert!(!metadata(&[]).subagent_identity_unsupported);
543 }
544
545 #[test]
546 fn normalizes_correlation_and_session_headers_without_routing() {
547 let relay = metadata(&[
549 ("x-nemo-relay-session-id", "relay-session"),
550 ("x-nemo-relay-subagent-id", "relay-child"),
551 ("x-dynamo-parent-session-id", "relay-parent"),
552 ]);
553 assert_eq!(relay.session_id.as_deref(), Some("relay-session"));
554 assert_eq!(relay.agent_id.as_deref(), Some("relay-child"));
555 assert_eq!(relay.parent_agent_id.as_deref(), Some("relay-parent"));
556 assert!(!relay.is_subagent);
557 assert!(!relay.is_subagent_work());
558
559 let opencode = metadata(&[
560 ("x-session-id", "opencode-run"),
561 ("x-parent-session-id", "opencode-parent"),
562 ]);
563 assert_eq!(opencode.session_id.as_deref(), Some("opencode-run"));
564 assert_eq!(opencode.parent_agent_id, None);
565 assert!(!opencode.is_subagent);
566
567 let codex_session = metadata(&[
568 ("session-id", "codex-run"),
569 ("x-parent-session-id", "stray-parent"),
570 ]);
571 assert_eq!(codex_session.session_id.as_deref(), Some("codex-run"));
572 assert_eq!(codex_session.parent_agent_id, None);
573 assert!(!codex_session.is_subagent);
574
575 let final_session = metadata(&[
576 ("x-dynamo-session-id", "generic-run"),
577 ("x-dynamo-parent-session-id", "generic-parent"),
578 ("x-dynamo-session-final", "true"),
579 ]);
580 assert_eq!(final_session.agent_id.as_deref(), Some("generic-run"));
581 assert_eq!(
582 final_session.parent_agent_id.as_deref(),
583 Some("generic-parent")
584 );
585 assert_eq!(final_session.session_final, Some(true));
586
587 let active_session = metadata(&[
588 ("x-dynamo-session-id", "generic-run"),
589 ("x-dynamo-session-final", "false"),
590 ]);
591 assert_eq!(active_session.session_final, Some(false));
592 }
593
594 #[test]
595 fn sy_header_resolves_paths_in_order_and_descends_into_json() {
596 let body = serde_json::json!({ "session_id": "codex-session" }).to_string();
598 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
599 assert_eq!(
600 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
601 Some("codex-session")
602 );
603
604 let headers = slice_to_header_map(&[
606 (SWITCHYARD_SESSION_ID_HEADER, "explicit"),
607 (CODEX_TURN_METADATA_HEADER, body.as_str()),
608 ]);
609 assert_eq!(
610 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
611 Some("explicit")
612 );
613
614 assert_eq!(
616 sy_header(&http::HeaderMap::new(), SWITCHYARD_SESSION_ID_HEADER),
617 None
618 );
619 assert_eq!(sy_header(&headers, "x-not-a-field"), None);
620 }
621
622 #[test]
624 fn nested_metadata_strings_match_flat_header_normalization() {
625 let body = serde_json::json!({ "session_id": " codex-session " }).to_string();
626 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
627 assert_eq!(
628 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
629 Some("codex-session")
630 );
631
632 let blank_body = serde_json::json!({ "session_id": " " }).to_string();
633 let headers = slice_to_header_map(&[
634 (CODEX_TURN_METADATA_HEADER, blank_body.as_str()),
635 (SESSION_ID_HEADER, "fallback-session"),
636 ]);
637 assert_eq!(
638 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
639 Some("fallback-session")
640 );
641 }
642
643 #[test]
644 fn subagent_routing_honors_explicit_signals_and_delegated_work_kinds() {
645 let explicitly_root = metadata(&[
648 ("x-switchyard-is-subagent", "false"),
649 ("x-openai-subagent", "review"),
650 ]);
651 assert!(!explicitly_root.is_subagent);
652
653 let explicitly_child = metadata(&[("x-switchyard-is-subagent", "true")]);
654 assert!(explicitly_child.is_subagent);
655
656 let child_with_parent = metadata(&[
657 ("x-switchyard-is-subagent", "false"),
658 ("x-switchyard-parent-agent-id", "parent"),
659 ]);
660 assert!(!child_with_parent.is_subagent);
661
662 let with_openai = metadata(&[
664 ("x-openai-subagent", "review"),
665 ("x-switchyard-agent-kind", "researcher"),
666 ]);
667 assert!(with_openai.is_subagent);
668 assert!(with_openai.is_subagent_work());
669
670 let with_explicit = metadata(&[
671 ("x-switchyard-is-subagent", "true"),
672 ("x-switchyard-agent-kind", "researcher"),
673 ]);
674 assert!(with_explicit.is_subagent);
675 assert!(with_explicit.is_subagent_work());
676
677 let claude_child = metadata(&[
679 ("x-claude-code-session-id", "root"),
680 ("x-claude-code-agent-id", "worker"),
681 ]);
682 assert!(claude_child.is_subagent_work());
683
684 let review = metadata(&[("x-openai-subagent", "review")]);
686 assert!(review.is_subagent_work());
687
688 for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
691 let request = metadata(&[("x-openai-subagent", kind)]);
692 assert!(request.is_subagent, "{kind} keeps the lineage fact");
693 assert!(!request.is_subagent_work(), "{kind} is not routed as work");
694 }
695
696 assert!(!Metadata::default().is_subagent_work());
698 }
699}