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, harness_kind.as_deref()) {
270 (Some(false), _) => false,
271 (_, Some(kind)) => SUBAGENT_WORK_KINDS.contains(&kind),
272 (Some(true), None) => true,
273 (None, None) => claude_subagent || codex_child,
274 };
275
276 (parent, is_subagent, is_delegated_work)
277}
278
279fn claude_lineage(headers: &http::HeaderMap) -> (Option<&str>, bool) {
286 let session = header(headers, CLAUDE_SESSION_ID_HEADER);
287 let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
288 let is_subagent = agent.is_some();
289 let parent = is_subagent
290 .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
291 .flatten();
292 (parent, is_subagent)
293}
294
295fn parse_bool(value: &str) -> Option<bool> {
297 match value.trim().to_ascii_lowercase().as_str() {
298 "1" | "true" | "yes" | "on" => Some(true),
299 "0" | "false" | "no" | "off" => Some(false),
300 _ => None,
301 }
302}
303
304fn sy_header(headers: &http::HeaderMap, key: &str) -> Option<String> {
310 let (_, paths) = HEADER_CONFIG
311 .iter()
312 .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
313 paths.iter().find_map(|path| resolve_path(headers, path))
314}
315
316fn resolve_path(headers: &http::HeaderMap, path: &str) -> Option<String> {
319 let (header_name, nested) = match path.split_once('.') {
320 Some((name, rest)) => (name, Some(rest)),
321 None => (path, None),
322 };
323 let raw = headers.get(header_name)?.to_str().ok().map(|s| s.trim())?;
324 if raw.is_empty() {
325 return None;
326 }
327
328 let Some(nested) = nested else {
330 return Some(raw.to_string());
331 };
332
333 let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
335 for segment in nested.split('.') {
336 current = current.as_object()?.get(segment)?.clone();
337 }
338
339 match current {
340 serde_json::Value::String(s) => {
341 let value = s.trim();
342 (!value.is_empty()).then(|| value.to_string())
343 }
344 serde_json::Value::Null => None,
345 leaf => Some(leaf.to_string()),
346 }
347}
348
349fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> {
350 headers
351 .get(key)
352 .and_then(|s| s.to_str().ok())
353 .map(str::trim)
354 .filter(|s| !s.is_empty())
355}
356
357pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap {
359 let mut m = http::HeaderMap::with_capacity(sl.len());
360 for (k, v) in sl {
361 m.insert(
362 http::HeaderName::from_str(k).unwrap(),
363 (*v).try_into().unwrap(),
364 );
365 }
366 m
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
375
376 fn metadata(headers: &[(&str, &str)]) -> Metadata {
377 Metadata::from_headers(&slice_to_header_map(headers))
378 }
379
380 #[test]
381 fn normalizes_codex_metadata_and_lineage() {
382 let child_body = serde_json::json!({
383 "session_id": "root-session",
384 "thread_id": "child-agent",
385 "parent_thread_id": "root-agent",
386 "turn_id": "turn-7",
387 "subagent_kind": "collab_spawn",
388 })
389 .to_string();
390 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
391 assert_eq!(child.session_id.as_deref(), Some("root-session"));
392 assert_eq!(child.agent_id.as_deref(), Some("child-agent"));
393 assert_eq!(child.parent_agent_id.as_deref(), Some("root-agent"));
394 assert!(child.is_subagent);
395
396 let root_body = serde_json::json!({
397 "session_id": "root-session",
398 "thread_id": "root-agent",
399 "turn_id": "turn-1",
400 })
401 .to_string();
402 let root = metadata(&[(CODEX_TURN_METADATA_HEADER, root_body.as_str())]);
403 assert!(!root.is_subagent);
404
405 let correlated_body = serde_json::json!({
409 "session_id": "root-session",
410 "thread_id": "child-thread",
411 "parent_thread_id": "root-thread",
412 "turn_id": "turn-3",
413 })
414 .to_string();
415 let correlated = metadata(&[(CODEX_TURN_METADATA_HEADER, correlated_body.as_str())]);
416 assert_eq!(correlated.parent_agent_id.as_deref(), Some("root-thread"));
417 assert!(!correlated.is_subagent);
418 assert!(!correlated.is_subagent_work());
419
420 let child_body = serde_json::json!({
423 "session_id": "root-session",
424 "thread_id": "child-thread",
425 "parent_thread_id": "root-thread",
426 "thread_source": "subagent",
427 "turn_id": "turn-4",
428 })
429 .to_string();
430 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
431 assert_eq!(child.parent_agent_id.as_deref(), Some("root-thread"));
432 assert!(child.is_subagent);
433 assert!(child.is_subagent_work());
434 }
435
436 #[test]
437 fn normalizes_claude_code_metadata_and_lineage() {
438 let session = metadata(&[(
441 "x-claude-code-session-id",
442 "fb46caae-eac6-4f5f-83fd-8fc8f5743abb",
443 )]);
444 assert_eq!(
445 session.session_id.as_deref(),
446 Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
447 );
448
449 let child = metadata(&[
452 ("x-claude-code-session-id", "claude-session"),
453 ("x-claude-code-agent-id", "claude-agent"),
454 ]);
455 assert_eq!(child.session_id.as_deref(), Some("claude-session"));
456 assert_eq!(child.agent_id.as_deref(), Some("claude-agent"));
457 assert_eq!(child.parent_agent_id.as_deref(), Some("claude-session"));
458 assert!(child.is_subagent);
459
460 let child_without_session = metadata(&[("x-claude-code-agent-id", "claude-agent")]);
461 assert_eq!(
462 child_without_session.agent_id.as_deref(),
463 Some("claude-agent")
464 );
465 assert_eq!(child_without_session.parent_agent_id, None);
466 assert!(child_without_session.is_subagent);
467
468 let explicit_parent = metadata(&[
469 ("x-claude-code-session-id", "claude-session"),
470 ("x-claude-code-agent-id", "claude-agent"),
471 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
472 ]);
473 assert_eq!(
474 explicit_parent.parent_agent_id.as_deref(),
475 Some("claude-parent-agent")
476 );
477
478 let root = metadata(&[
481 ("x-claude-code-session-id", "claude-session"),
482 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
483 ]);
484 assert_eq!(root.session_id.as_deref(), Some("claude-session"));
485 assert_eq!(root.agent_id, None);
486 assert_eq!(root.parent_agent_id, None);
487 assert!(!root.is_subagent);
488 }
489
490 #[test]
491 fn normalizes_correlation_and_session_headers_without_routing() {
492 let relay = metadata(&[
494 ("x-nemo-relay-session-id", "relay-session"),
495 ("x-nemo-relay-subagent-id", "relay-child"),
496 ("x-dynamo-parent-session-id", "relay-parent"),
497 ]);
498 assert_eq!(relay.session_id.as_deref(), Some("relay-session"));
499 assert_eq!(relay.agent_id.as_deref(), Some("relay-child"));
500 assert_eq!(relay.parent_agent_id.as_deref(), Some("relay-parent"));
501 assert!(!relay.is_subagent);
502 assert!(!relay.is_subagent_work());
503
504 let opencode = metadata(&[
505 ("x-session-id", "opencode-run"),
506 ("x-parent-session-id", "opencode-parent"),
507 ]);
508 assert_eq!(opencode.session_id.as_deref(), Some("opencode-run"));
509 assert_eq!(opencode.parent_agent_id, None);
510 assert!(!opencode.is_subagent);
511
512 let codex_session = metadata(&[
513 ("session-id", "codex-run"),
514 ("x-parent-session-id", "stray-parent"),
515 ]);
516 assert_eq!(codex_session.session_id.as_deref(), Some("codex-run"));
517 assert_eq!(codex_session.parent_agent_id, None);
518 assert!(!codex_session.is_subagent);
519
520 let final_session = metadata(&[
521 ("x-dynamo-session-id", "generic-run"),
522 ("x-dynamo-parent-session-id", "generic-parent"),
523 ("x-dynamo-session-final", "true"),
524 ]);
525 assert_eq!(final_session.agent_id.as_deref(), Some("generic-run"));
526 assert_eq!(
527 final_session.parent_agent_id.as_deref(),
528 Some("generic-parent")
529 );
530 assert_eq!(final_session.session_final, Some(true));
531
532 let active_session = metadata(&[
533 ("x-dynamo-session-id", "generic-run"),
534 ("x-dynamo-session-final", "false"),
535 ]);
536 assert_eq!(active_session.session_final, Some(false));
537 }
538
539 #[test]
540 fn sy_header_resolves_paths_in_order_and_descends_into_json() {
541 let body = serde_json::json!({ "session_id": "codex-session" }).to_string();
543 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
544 assert_eq!(
545 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
546 Some("codex-session")
547 );
548
549 let headers = slice_to_header_map(&[
551 (SWITCHYARD_SESSION_ID_HEADER, "explicit"),
552 (CODEX_TURN_METADATA_HEADER, body.as_str()),
553 ]);
554 assert_eq!(
555 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
556 Some("explicit")
557 );
558
559 assert_eq!(
561 sy_header(&http::HeaderMap::new(), SWITCHYARD_SESSION_ID_HEADER),
562 None
563 );
564 assert_eq!(sy_header(&headers, "x-not-a-field"), None);
565 }
566
567 #[test]
569 fn nested_metadata_strings_match_flat_header_normalization() {
570 let body = serde_json::json!({ "session_id": " codex-session " }).to_string();
571 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
572 assert_eq!(
573 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
574 Some("codex-session")
575 );
576
577 let blank_body = serde_json::json!({ "session_id": " " }).to_string();
578 let headers = slice_to_header_map(&[
579 (CODEX_TURN_METADATA_HEADER, blank_body.as_str()),
580 (SESSION_ID_HEADER, "fallback-session"),
581 ]);
582 assert_eq!(
583 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
584 Some("fallback-session")
585 );
586 }
587
588 #[test]
589 fn subagent_routing_honors_explicit_signals_and_delegated_work_kinds() {
590 let explicitly_root = metadata(&[
593 ("x-switchyard-is-subagent", "false"),
594 ("x-openai-subagent", "review"),
595 ]);
596 assert!(!explicitly_root.is_subagent);
597
598 let explicitly_child = metadata(&[("x-switchyard-is-subagent", "true")]);
599 assert!(explicitly_child.is_subagent);
600
601 let child_with_parent = metadata(&[
602 ("x-switchyard-is-subagent", "false"),
603 ("x-switchyard-parent-agent-id", "parent"),
604 ]);
605 assert!(!child_with_parent.is_subagent);
606
607 let with_openai = metadata(&[
609 ("x-openai-subagent", "review"),
610 ("x-switchyard-agent-kind", "researcher"),
611 ]);
612 assert!(with_openai.is_subagent);
613 assert!(with_openai.is_subagent_work());
614
615 let with_explicit = metadata(&[
616 ("x-switchyard-is-subagent", "true"),
617 ("x-switchyard-agent-kind", "researcher"),
618 ]);
619 assert!(with_explicit.is_subagent);
620 assert!(with_explicit.is_subagent_work());
621
622 let claude_child = metadata(&[
624 ("x-claude-code-session-id", "root"),
625 ("x-claude-code-agent-id", "worker"),
626 ]);
627 assert!(claude_child.is_subagent_work());
628
629 let review = metadata(&[("x-openai-subagent", "review")]);
631 assert!(review.is_subagent_work());
632
633 for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
636 let request = metadata(&[("x-openai-subagent", kind)]);
637 assert!(request.is_subagent, "{kind} keeps the lineage fact");
638 assert!(!request.is_subagent_work(), "{kind} is not routed as work");
639 }
640
641 assert!(!Metadata::default().is_subagent_work());
643 }
644}