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_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 served_model: Option<ModelId>,
190 pub extra_metadata: Option<BTreeMap<String, String>>,
192 pub http_headers: Option<http::HeaderMap>,
194 pub wire_format: Option<WireFormat>,
196}
197
198impl Metadata {
199 pub fn from_headers(headers: &http::HeaderMap) -> Self {
201 let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers);
202
203 Metadata {
204 session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER),
205 agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER),
206 parent_agent_id,
207 is_subagent,
208 is_delegated_work,
209 agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER),
210 agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER),
211 task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER),
212 task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER),
213 turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER),
214 session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER)
215 .as_deref()
216 .and_then(parse_bool),
217 correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER),
218 ..Metadata::default()
219 }
220 }
221
222 pub fn is_subagent_work(&self) -> bool {
228 self.is_delegated_work
229 }
230}
231
232fn parse_sub_agent(headers: &http::HeaderMap) -> (Option<String>, bool, bool) {
242 let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool);
243
244 let (claude_parent, claude_subagent) = claude_lineage(headers);
245
246 let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH)
249 .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string));
250
251 let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER)
254 .or_else(|| claude_parent.map(str::to_string));
255
256 let is_subagent = explicit.unwrap_or(claude_subagent || harness_kind.is_some());
257
258 let is_delegated_work = match explicit {
259 Some(false) => false,
260 Some(true) => harness_kind
261 .as_deref()
262 .map(|k| SUBAGENT_WORK_KINDS.contains(&k))
263 .unwrap_or(true),
264 None => {
265 claude_subagent
266 || harness_kind
267 .as_deref()
268 .is_some_and(|k| SUBAGENT_WORK_KINDS.contains(&k))
269 }
270 };
271
272 (parent, is_subagent, is_delegated_work)
273}
274
275fn claude_lineage(headers: &http::HeaderMap) -> (Option<&str>, bool) {
282 let session = header(headers, CLAUDE_SESSION_ID_HEADER);
283 let agent = header(headers, CLAUDE_AGENT_ID_HEADER);
284 let is_subagent = agent.is_some();
285 let parent = is_subagent
286 .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session))
287 .flatten();
288 (parent, is_subagent)
289}
290
291fn parse_bool(value: &str) -> Option<bool> {
293 match value.trim().to_ascii_lowercase().as_str() {
294 "1" | "true" | "yes" | "on" => Some(true),
295 "0" | "false" | "no" | "off" => Some(false),
296 _ => None,
297 }
298}
299
300fn sy_header(headers: &http::HeaderMap, key: &str) -> Option<String> {
306 let (_, paths) = HEADER_CONFIG
307 .iter()
308 .find(|(field, _)| field.eq_ignore_ascii_case(key))?;
309 paths.iter().find_map(|path| resolve_path(headers, path))
310}
311
312fn resolve_path(headers: &http::HeaderMap, path: &str) -> Option<String> {
315 let (header_name, nested) = match path.split_once('.') {
316 Some((name, rest)) => (name, Some(rest)),
317 None => (path, None),
318 };
319 let raw = headers.get(header_name)?.to_str().ok().map(|s| s.trim())?;
320 if raw.is_empty() {
321 return None;
322 }
323
324 let Some(nested) = nested else {
326 return Some(raw.to_string());
327 };
328
329 let mut current: serde_json::Value = serde_json::from_str(raw).ok()?;
331 for segment in nested.split('.') {
332 current = current.as_object()?.get(segment)?.clone();
333 }
334
335 match current {
336 serde_json::Value::String(s) => {
337 let value = s.trim();
338 (!value.is_empty()).then(|| value.to_string())
339 }
340 serde_json::Value::Null => None,
341 leaf => Some(leaf.to_string()),
342 }
343}
344
345fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> {
346 headers
347 .get(key)
348 .and_then(|s| s.to_str().ok())
349 .map(str::trim)
350 .filter(|s| !s.is_empty())
351}
352
353pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap {
355 let mut m = http::HeaderMap::with_capacity(sl.len());
356 for (k, v) in sl {
357 m.insert(
358 http::HeaderName::from_str(k).unwrap(),
359 (*v).try_into().unwrap(),
360 );
361 }
362 m
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
371
372 fn metadata(headers: &[(&str, &str)]) -> Metadata {
373 Metadata::from_headers(&slice_to_header_map(headers))
374 }
375
376 #[test]
377 fn normalizes_codex_metadata_and_lineage() {
378 let child_body = serde_json::json!({
379 "session_id": "root-session",
380 "thread_id": "child-agent",
381 "parent_thread_id": "root-agent",
382 "turn_id": "turn-7",
383 "subagent_kind": "collab_spawn",
384 })
385 .to_string();
386 let child = metadata(&[(CODEX_TURN_METADATA_HEADER, child_body.as_str())]);
387 assert_eq!(child.session_id.as_deref(), Some("root-session"));
388 assert_eq!(child.agent_id.as_deref(), Some("child-agent"));
389 assert_eq!(child.parent_agent_id.as_deref(), Some("root-agent"));
390 assert!(child.is_subagent);
391
392 let root_body = serde_json::json!({
393 "session_id": "root-session",
394 "thread_id": "root-agent",
395 "turn_id": "turn-1",
396 })
397 .to_string();
398 let root = metadata(&[(CODEX_TURN_METADATA_HEADER, root_body.as_str())]);
399 assert!(!root.is_subagent);
400
401 let correlated_body = serde_json::json!({
405 "session_id": "root-session",
406 "thread_id": "child-thread",
407 "parent_thread_id": "root-thread",
408 "turn_id": "turn-3",
409 })
410 .to_string();
411 let correlated = metadata(&[(CODEX_TURN_METADATA_HEADER, correlated_body.as_str())]);
412 assert_eq!(correlated.parent_agent_id.as_deref(), Some("root-thread"));
413 assert!(!correlated.is_subagent);
414 assert!(!correlated.is_subagent_work());
415 }
416
417 #[test]
418 fn normalizes_claude_code_metadata_and_lineage() {
419 let session = metadata(&[(
422 "x-claude-code-session-id",
423 "fb46caae-eac6-4f5f-83fd-8fc8f5743abb",
424 )]);
425 assert_eq!(
426 session.session_id.as_deref(),
427 Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb")
428 );
429
430 let child = metadata(&[
433 ("x-claude-code-session-id", "claude-session"),
434 ("x-claude-code-agent-id", "claude-agent"),
435 ]);
436 assert_eq!(child.session_id.as_deref(), Some("claude-session"));
437 assert_eq!(child.agent_id.as_deref(), Some("claude-agent"));
438 assert_eq!(child.parent_agent_id.as_deref(), Some("claude-session"));
439 assert!(child.is_subagent);
440
441 let child_without_session = metadata(&[("x-claude-code-agent-id", "claude-agent")]);
442 assert_eq!(
443 child_without_session.agent_id.as_deref(),
444 Some("claude-agent")
445 );
446 assert_eq!(child_without_session.parent_agent_id, None);
447 assert!(child_without_session.is_subagent);
448
449 let explicit_parent = metadata(&[
450 ("x-claude-code-session-id", "claude-session"),
451 ("x-claude-code-agent-id", "claude-agent"),
452 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
453 ]);
454 assert_eq!(
455 explicit_parent.parent_agent_id.as_deref(),
456 Some("claude-parent-agent")
457 );
458
459 let root = metadata(&[
462 ("x-claude-code-session-id", "claude-session"),
463 ("x-claude-code-parent-agent-id", "claude-parent-agent"),
464 ]);
465 assert_eq!(root.session_id.as_deref(), Some("claude-session"));
466 assert_eq!(root.agent_id, None);
467 assert_eq!(root.parent_agent_id, None);
468 assert!(!root.is_subagent);
469 }
470
471 #[test]
472 fn normalizes_correlation_and_session_headers_without_routing() {
473 let relay = metadata(&[
475 ("x-nemo-relay-session-id", "relay-session"),
476 ("x-nemo-relay-subagent-id", "relay-child"),
477 ("x-dynamo-parent-session-id", "relay-parent"),
478 ]);
479 assert_eq!(relay.session_id.as_deref(), Some("relay-session"));
480 assert_eq!(relay.agent_id.as_deref(), Some("relay-child"));
481 assert_eq!(relay.parent_agent_id.as_deref(), Some("relay-parent"));
482 assert!(!relay.is_subagent);
483 assert!(!relay.is_subagent_work());
484
485 let opencode = metadata(&[
486 ("x-session-id", "opencode-run"),
487 ("x-parent-session-id", "opencode-parent"),
488 ]);
489 assert_eq!(opencode.session_id.as_deref(), Some("opencode-run"));
490 assert_eq!(opencode.parent_agent_id, None);
491 assert!(!opencode.is_subagent);
492
493 let codex_session = metadata(&[
494 ("session-id", "codex-run"),
495 ("x-parent-session-id", "stray-parent"),
496 ]);
497 assert_eq!(codex_session.session_id.as_deref(), Some("codex-run"));
498 assert_eq!(codex_session.parent_agent_id, None);
499 assert!(!codex_session.is_subagent);
500
501 let final_session = metadata(&[
502 ("x-dynamo-session-id", "generic-run"),
503 ("x-dynamo-parent-session-id", "generic-parent"),
504 ("x-dynamo-session-final", "true"),
505 ]);
506 assert_eq!(final_session.agent_id.as_deref(), Some("generic-run"));
507 assert_eq!(
508 final_session.parent_agent_id.as_deref(),
509 Some("generic-parent")
510 );
511 assert_eq!(final_session.session_final, Some(true));
512
513 let active_session = metadata(&[
514 ("x-dynamo-session-id", "generic-run"),
515 ("x-dynamo-session-final", "false"),
516 ]);
517 assert_eq!(active_session.session_final, Some(false));
518 }
519
520 #[test]
521 fn sy_header_resolves_paths_in_order_and_descends_into_json() {
522 let body = serde_json::json!({ "session_id": "codex-session" }).to_string();
524 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
525 assert_eq!(
526 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
527 Some("codex-session")
528 );
529
530 let headers = slice_to_header_map(&[
532 (SWITCHYARD_SESSION_ID_HEADER, "explicit"),
533 (CODEX_TURN_METADATA_HEADER, body.as_str()),
534 ]);
535 assert_eq!(
536 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
537 Some("explicit")
538 );
539
540 assert_eq!(
542 sy_header(&http::HeaderMap::new(), SWITCHYARD_SESSION_ID_HEADER),
543 None
544 );
545 assert_eq!(sy_header(&headers, "x-not-a-field"), None);
546 }
547
548 #[test]
550 fn nested_metadata_strings_match_flat_header_normalization() {
551 let body = serde_json::json!({ "session_id": " codex-session " }).to_string();
552 let headers = slice_to_header_map(&[(CODEX_TURN_METADATA_HEADER, body.as_str())]);
553 assert_eq!(
554 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
555 Some("codex-session")
556 );
557
558 let blank_body = serde_json::json!({ "session_id": " " }).to_string();
559 let headers = slice_to_header_map(&[
560 (CODEX_TURN_METADATA_HEADER, blank_body.as_str()),
561 (SESSION_ID_HEADER, "fallback-session"),
562 ]);
563 assert_eq!(
564 sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(),
565 Some("fallback-session")
566 );
567 }
568
569 #[test]
570 fn subagent_routing_honors_explicit_signals_and_delegated_work_kinds() {
571 let explicitly_root = metadata(&[
574 ("x-switchyard-is-subagent", "false"),
575 ("x-openai-subagent", "review"),
576 ]);
577 assert!(!explicitly_root.is_subagent);
578
579 let explicitly_child = metadata(&[("x-switchyard-is-subagent", "true")]);
580 assert!(explicitly_child.is_subagent);
581
582 let child_with_parent = metadata(&[
583 ("x-switchyard-is-subagent", "false"),
584 ("x-switchyard-parent-agent-id", "parent"),
585 ]);
586 assert!(!child_with_parent.is_subagent);
587
588 let with_openai = metadata(&[
590 ("x-openai-subagent", "review"),
591 ("x-switchyard-agent-kind", "researcher"),
592 ]);
593 assert!(with_openai.is_subagent);
594 assert!(with_openai.is_subagent_work());
595
596 let with_explicit = metadata(&[
597 ("x-switchyard-is-subagent", "true"),
598 ("x-switchyard-agent-kind", "researcher"),
599 ]);
600 assert!(with_explicit.is_subagent);
601 assert!(with_explicit.is_subagent_work());
602
603 let claude_child = metadata(&[
605 ("x-claude-code-session-id", "root"),
606 ("x-claude-code-agent-id", "worker"),
607 ]);
608 assert!(claude_child.is_subagent_work());
609
610 let review = metadata(&[("x-openai-subagent", "review")]);
612 assert!(review.is_subagent_work());
613
614 for kind in ["compact", "memory_consolidation", "brand_new_kind"] {
617 let request = metadata(&[("x-openai-subagent", kind)]);
618 assert!(request.is_subagent, "{kind} keeps the lineage fact");
619 assert!(!request.is_subagent_work(), "{kind} is not routed as work");
620 }
621
622 assert!(!Metadata::default().is_subagent_work());
624 }
625}