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