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 SUBAGENT_WORK_KINDS: &[&str] = &["collab_spawn", "review"];
72
73type HeaderConfig = [(&'static str, &'static [&'static str])];
76
77const HEADER_CONFIG: &HeaderConfig = &[
79 (
80 SWITCHYARD_SESSION_ID_HEADER,
81 &[
82 SWITCHYARD_SESSION_ID_HEADER,
83 CLAUDE_SESSION_ID_HEADER,
84 RELAY_SESSION_ID_HEADER,
85 OPENCODE_SESSION_ID_HEADER,
86 CODEX_SESSION_ID_PATH,
87 SESSION_ID_HEADER,
88 ],
89 ),
90 (
91 SWITCHYARD_AGENT_ID_HEADER,
92 &[
93 SWITCHYARD_AGENT_ID_HEADER,
94 CLAUDE_AGENT_ID_HEADER,
95 RELAY_SUBAGENT_ID_HEADER,
96 DYNAMO_SESSION_ID_HEADER,
97 CODEX_THREAD_ID_PATH,
98 THREAD_ID_HEADER,
99 ],
100 ),
101 (
102 SWITCHYARD_PARENT_AGENT_ID_HEADER,
103 &[
104 SWITCHYARD_PARENT_AGENT_ID_HEADER,
105 DYNAMO_PARENT_SESSION_ID_HEADER,
106 CODEX_PARENT_THREAD_ID_PATH,
107 CODEX_PARENT_THREAD_ID_HEADER,
108 ],
109 ),
110 (
111 SWITCHYARD_AGENT_KIND_HEADER,
112 &[
113 SWITCHYARD_AGENT_KIND_HEADER,
114 CODEX_SUBAGENT_KIND_PATH,
115 OPENAI_SUBAGENT_HEADER,
116 ],
117 ),
118 (
119 SWITCHYARD_AGENT_ROLE_HEADER,
120 &[SWITCHYARD_AGENT_ROLE_HEADER, CODEX_AGENT_ROLE_PATH],
121 ),
122 (
123 SWITCHYARD_TASK_ID_HEADER,
124 &[
125 SWITCHYARD_TASK_ID_HEADER,
126 CODEX_TASK_ID_PATH,
127 TASK_ID_HEADER,
128 ],
129 ),
130 (
131 SWITCHYARD_TASK_KIND_HEADER,
132 &[SWITCHYARD_TASK_KIND_HEADER, CODEX_TASK_KIND_PATH],
133 ),
134 (
135 SWITCHYARD_TURN_ID_HEADER,
136 &[SWITCHYARD_TURN_ID_HEADER, CODEX_TURN_ID_PATH],
137 ),
138 (
139 SWITCHYARD_REQUEST_ID_HEADER,
140 &[
141 SWITCHYARD_REQUEST_ID_HEADER,
142 REQUEST_ID_HEADER,
143 CLIENT_REQUEST_ID_HEADER,
144 ],
145 ),
146 (
147 SWITCHYARD_SESSION_FINAL_HEADER,
148 &[SWITCHYARD_SESSION_FINAL_HEADER, DYNAMO_SESSION_FINAL_HEADER],
149 ),
150];
151
152#[derive(Clone, Default)]
160pub struct Metadata {
161 pub session_id: Option<String>,
163 pub agent_id: Option<String>,
165 pub parent_agent_id: Option<String>,
167 pub is_subagent: bool,
169 pub is_delegated_work: bool,
174 pub agent_kind: Option<String>,
176 pub agent_role: Option<String>,
178 pub task_id: Option<String>,
180 pub task_kind: Option<String>,
182 pub turn_id: Option<String>,
184 pub session_final: Option<bool>,
187 pub correlation_id: Option<String>,
189 pub served_model: Option<ModelId>,
191 pub extra_metadata: Option<BTreeMap<String, String>>,
193 pub http_headers: Option<http::HeaderMap>,
195 pub wire_format: Option<WireFormat>,
197}
198
199impl Metadata {
200 pub fn from_headers(headers: &http::HeaderMap) -> Self {
202 let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
203
204 Metadata {
205 session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
206 agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
207 parent_agent_id,
208 is_subagent,
209 is_delegated_work,
210 agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
211 agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
212 task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
213 task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
214 turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
215 session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
216 .as_deref()
217 .and_then(parse_bool),
218 correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
219 ..Metadata::default()
220 }
221 }
222
223 pub fn is_subagent_work(&self) -> bool {
229 self.is_delegated_work
230 }
231}
232
233fn parse_sub_agent(headers: &http::HeaderMap) -> (Option<String>, bool, bool) {
244 let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
245
246 let (claude_parent, claude_subagent) = claude_lineage(headers);
247
248 let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
251 .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
252
253 let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
256 .or_else(|| claude_parent.map(str::to_string));
257
258 let codex_child = parent.is_some()
262 && resolve_path(headers, CODEX_THREAD_SOURCE_PATH).as_deref() == Some("subagent");
263
264 let is_subagent = explicit.unwrap_or(claude_subagent || codex_child || harness_kind.is_some());
265
266 let is_delegated_work = match explicit {
267 Some(false) => false,
268 Some(true) => harness_kind
269 .as_deref()
270 .map(|k| SUBAGENT_WORK_KINDS.contains(&k))
271 .unwrap_or(true),
272 None => {
273 claude_subagent
274 || codex_child
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: &http::HeaderMap) -> (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 sy_header(headers: &http::HeaderMap, key: &str) -> Option<String> {
315 let (_, paths) = HEADER_CONFIG
316 .iter()
317 .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
318 paths.iter().find_map(|path| resolve_path(headers, path))
319}
320
321fn resolve_path(headers: &http::HeaderMap, path: &str) -> Option<String> {
324 let (header_name, nested) = match path.split_once('.') {
325 Some((name, rest)) => (name, Some(rest)),
326 None => (path, None),
327 };
328 let raw = headers.get(header_name)?.to_str().ok().map(|s| s.trim())?;
329 if raw.is_empty() {
330 return None;
331 }
332
333 let Some(nested) = nested else {
335 return Some(raw.to_string());
336 };
337
338 let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
340 for segment in nested.split('.') {
341 current = current.as_object()?.get(segment)?.clone();
342 }
343
344 match current {
345 serde_json::Value::String(s) => {
346 let value = s.trim();
347 (!value.is_empty()).then(|| value.to_string())
348 }
349 serde_json::Value::Null => None,
350 leaf => Some(leaf.to_string()),
351 }
352}
353
354fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> {
355 headers
356 .get(key)
357 .and_then(|s| s.to_str().ok())
358 .map(str::trim)
359 .filter(|s| !s.is_empty())
360}
361
362pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap {
364 let mut m = http::HeaderMap::with_capacity(sl.len());
365 for (k, v) in sl {
366 m.insert(
367 http::HeaderName::from_str(k).unwrap(),
368 (*v).try_into().unwrap(),
369 );
370 }
371 m
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
380
381 fn metadata(headers: &[(&str, &str)]) -> Metadata {
382 Metadata::from_headers(&slice_to_header_map(headers))
383 }
384
385 #[test]
386 fn normalizes_codex_metadata_and_lineage() {
387 let child_body = serde_json::json!({
388 "session_id": "root-session",
389 "thread_id": "child-agent",
390 "parent_thread_id": "root-agent",
391 "turn_id": "turn-7",
392 "subagent_kind": "collab_spawn",
393 })
394 .to_string();
395 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
396 assert_eq!(child.session_id.as_deref(), Some("root-session"));
397 assert_eq!(child.agent_id.as_deref(), Some("child-agent"));
398 assert_eq!(child.parent_agent_id.as_deref(), Some("root-agent"));
399 assert!(child.is_subagent);
400
401 let root_body = serde_json::json!({
402 "session_id": "root-session",
403 "thread_id": "root-agent",
404 "turn_id": "turn-1",
405 })
406 .to_string();
407 let root = metadata(&[(CODEX_TURN_METADATA_HEADER, root_body.as_str())]);
408 assert!(!root.is_subagent);
409
410 let correlated_body = serde_json::json!({
414 "session_id": "root-session",
415 "thread_id": "child-thread",
416 "parent_thread_id": "root-thread",
417 "turn_id": "turn-3",
418 })
419 .to_string();
420 let correlated = metadata(&[(CODEX_TURN_METADATA_HEADER, correlated_body.as_str())]);
421 assert_eq!(correlated.parent_agent_id.as_deref(), Some("root-thread"));
422 assert!(!correlated.is_subagent);
423 assert!(!correlated.is_subagent_work());
424
425 let child_body = serde_json::json!({
428 "session_id": "root-session",
429 "thread_id": "child-thread",
430 "parent_thread_id": "root-thread",
431 "thread_source": "subagent",
432 "turn_id": "turn-4",
433 })
434 .to_string();
435 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
436 assert_eq!(child.parent_agent_id.as_deref(), Some("root-thread"));
437 assert!(child.is_subagent);
438 assert!(child.is_subagent_work());
439 }
440
441 #[test]
442 fn normalizes_claude_code_metadata_and_lineage() {
443 let session = metadata(&[(
446 "x-claude-code-session-id",
447 "fb46caae-eac6-4f5f-83fd-8fc8f5743abb",
448 )]);
449 assert_eq!(
450 session.session_id.as_deref(),
451 Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
452 );
453
454 let child = metadata(&[
457 ("x-claude-code-session-id", "claude-session"),
458 ("x-claude-code-agent-id", "claude-agent"),
459 ]);
460 assert_eq!(child.session_id.as_deref(), Some("claude-session"));
461 assert_eq!(child.agent_id.as_deref(), Some("claude-agent"));
462 assert_eq!(child.parent_agent_id.as_deref(), Some("claude-session"));
463 assert!(child.is_subagent);
464
465 let child_without_session = metadata(&[("x-claude-code-agent-id", "claude-agent")]);
466 assert_eq!(
467 child_without_session.agent_id.as_deref(),
468 Some("claude-agent")
469 );
470 assert_eq!(child_without_session.parent_agent_id, None);
471 assert!(child_without_session.is_subagent);
472
473 let explicit_parent = metadata(&[
474 ("x-claude-code-session-id", "claude-session"),
475 ("x-claude-code-agent-id", "claude-agent"),
476 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
477 ]);
478 assert_eq!(
479 explicit_parent.parent_agent_id.as_deref(),
480 Some("claude-parent-agent")
481 );
482
483 let root = metadata(&[
486 ("x-claude-code-session-id", "claude-session"),
487 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
488 ]);
489 assert_eq!(root.session_id.as_deref(), Some("claude-session"));
490 assert_eq!(root.agent_id, None);
491 assert_eq!(root.parent_agent_id, None);
492 assert!(!root.is_subagent);
493 }
494
495 #[test]
496 fn normalizes_correlation_and_session_headers_without_routing() {
497 let relay = metadata(&[
499 ("x-nemo-relay-session-id", "relay-session"),
500 ("x-nemo-relay-subagent-id", "relay-child"),
501 ("x-dynamo-parent-session-id", "relay-parent"),
502 ]);
503 assert_eq!(relay.session_id.as_deref(), Some("relay-session"));
504 assert_eq!(relay.agent_id.as_deref(), Some("relay-child"));
505 assert_eq!(relay.parent_agent_id.as_deref(), Some("relay-parent"));
506 assert!(!relay.is_subagent);
507 assert!(!relay.is_subagent_work());
508
509 let opencode = metadata(&[
510 ("x-session-id", "opencode-run"),
511 ("x-parent-session-id", "opencode-parent"),
512 ]);
513 assert_eq!(opencode.session_id.as_deref(), Some("opencode-run"));
514 assert_eq!(opencode.parent_agent_id, None);
515 assert!(!opencode.is_subagent);
516
517 let codex_session = metadata(&[
518 ("session-id", "codex-run"),
519 ("x-parent-session-id", "stray-parent"),
520 ]);
521 assert_eq!(codex_session.session_id.as_deref(), Some("codex-run"));
522 assert_eq!(codex_session.parent_agent_id, None);
523 assert!(!codex_session.is_subagent);
524
525 let final_session = metadata(&[
526 ("x-dynamo-session-id", "generic-run"),
527 ("x-dynamo-parent-session-id", "generic-parent"),
528 ("x-dynamo-session-final", "true"),
529 ]);
530 assert_eq!(final_session.agent_id.as_deref(), Some("generic-run"));
531 assert_eq!(
532 final_session.parent_agent_id.as_deref(),
533 Some("generic-parent")
534 );
535 assert_eq!(final_session.session_final, Some(true));
536
537 let active_session = metadata(&[
538 ("x-dynamo-session-id", "generic-run"),
539 ("x-dynamo-session-final", "false"),
540 ]);
541 assert_eq!(active_session.session_final, Some(false));
542 }
543
544 #[test]
545 fn sy_header_resolves_paths_in_order_and_descends_into_json() {
546 let body = serde_json::json!({ "session_id": "codex-session" }).to_string();
548 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
549 assert_eq!(
550 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
551 Some("codex-session")
552 );
553
554 let headers = slice_to_header_map(&[
556 (SWITCHYARD_SESSION_ID_HEADER, "explicit"),
557 (CODEX_TURN_METADATA_HEADER, body.as_str()),
558 ]);
559 assert_eq!(
560 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
561 Some("explicit")
562 );
563
564 assert_eq!(
566 sy_header(&http::HeaderMap::new(), SWITCHYARD_SESSION_ID_HEADER),
567 None
568 );
569 assert_eq!(sy_header(&headers, "x-not-a-field"), None);
570 }
571
572 #[test]
574 fn nested_metadata_strings_match_flat_header_normalization() {
575 let body = serde_json::json!({ "session_id": " codex-session " }).to_string();
576 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
577 assert_eq!(
578 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
579 Some("codex-session")
580 );
581
582 let blank_body = serde_json::json!({ "session_id": " " }).to_string();
583 let headers = slice_to_header_map(&[
584 (CODEX_TURN_METADATA_HEADER, blank_body.as_str()),
585 (SESSION_ID_HEADER, "fallback-session"),
586 ]);
587 assert_eq!(
588 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
589 Some("fallback-session")
590 );
591 }
592
593 #[test]
594 fn subagent_routing_honors_explicit_signals_and_delegated_work_kinds() {
595 let explicitly_root = metadata(&[
598 ("x-switchyard-is-subagent", "false"),
599 ("x-openai-subagent", "review"),
600 ]);
601 assert!(!explicitly_root.is_subagent);
602
603 let explicitly_child = metadata(&[("x-switchyard-is-subagent", "true")]);
604 assert!(explicitly_child.is_subagent);
605
606 let child_with_parent = metadata(&[
607 ("x-switchyard-is-subagent", "false"),
608 ("x-switchyard-parent-agent-id", "parent"),
609 ]);
610 assert!(!child_with_parent.is_subagent);
611
612 let with_openai = metadata(&[
614 ("x-openai-subagent", "review"),
615 ("x-switchyard-agent-kind", "researcher"),
616 ]);
617 assert!(with_openai.is_subagent);
618 assert!(with_openai.is_subagent_work());
619
620 let with_explicit = metadata(&[
621 ("x-switchyard-is-subagent", "true"),
622 ("x-switchyard-agent-kind", "researcher"),
623 ]);
624 assert!(with_explicit.is_subagent);
625 assert!(with_explicit.is_subagent_work());
626
627 let claude_child = metadata(&[
629 ("x-claude-code-session-id", "root"),
630 ("x-claude-code-agent-id", "worker"),
631 ]);
632 assert!(claude_child.is_subagent_work());
633
634 let review = metadata(&[("x-openai-subagent", "review")]);
636 assert!(review.is_subagent_work());
637
638 for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
641 let request = metadata(&[("x-openai-subagent", kind)]);
642 assert!(request.is_subagent, "{kind} keeps the lineage fact");
643 assert!(!request.is_subagent_work(), "{kind} is not routed as work");
644 }
645
646 assert!(!Metadata::default().is_subagent_work());
648 }
649}