From 3473efef62297be9c002d90596dec1b824c62552 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 11:43:03 -0400 Subject: [PATCH 1/5] feat(libsy-protocol): add Metadata::from_headers harness header normalization Signed-off-by: Greg Clark --- crates/libsy-protocol/src/envelope.rs | 25 +- crates/libsy-protocol/src/lib.rs | 2 + crates/libsy-protocol/src/metadata.rs | 397 ++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 24 deletions(-) create mode 100644 crates/libsy-protocol/src/metadata.rs diff --git a/crates/libsy-protocol/src/envelope.rs b/crates/libsy-protocol/src/envelope.rs index 9906bd86..2d77f353 100644 --- a/crates/libsy-protocol/src/envelope.rs +++ b/crates/libsy-protocol/src/envelope.rs @@ -4,7 +4,7 @@ //! The request/response envelope: the normalized [`LlmRequest`]/[`LlmResponse`] paired //! with the original provider payload and correlation [`Metadata`]. -use crate::{LlmRequest, LlmResponse, WireFormat}; +use crate::{LlmRequest, LlmResponse, Metadata}; use std::collections::HashMap; /// Per-request state threaded to an algorithm alongside a request. A placeholder @@ -15,29 +15,6 @@ pub struct Context { pub values: HashMap, } -/// Correlation and routing metadata attached to a request or response. -/// -/// All fields are optional; algorithms and observers use whichever are present -/// (e.g. to key per-session state or emit correlated telemetry). `extra_metadata` -/// is a free-form escape hatch for host-specific keys. -#[derive(Clone)] -pub struct Metadata { - /// Stable id for a multi-request session/conversation. - pub session_id: Option, - /// Id of the agent making the request. - pub agent_id: Option, - /// Id of the task the request belongs to. - pub task_id: Option, - /// External trace/request id for joining with the host's telemetry. - pub correlation_id: Option, - /// Arbitrary host-defined key/value metadata. - pub extra_metadata: Option>, - /// HTTP headers to attach when forwarding the request/response, if any. - pub http_headers: Option>, - /// The wire format the request/response was originally encoded in, if known. - pub wire_format: Option, -} - /// A request an algorithm routes: the normalized [`LlmRequest`] plus the original /// provider payload and correlation [`Metadata`]. #[derive(Clone)] diff --git a/crates/libsy-protocol/src/lib.rs b/crates/libsy-protocol/src/lib.rs index a1ae2cf9..543f2d0b 100644 --- a/crates/libsy-protocol/src/lib.rs +++ b/crates/libsy-protocol/src/lib.rs @@ -24,12 +24,14 @@ pub mod client; pub mod envelope; pub mod format; pub mod llm; +pub mod metadata; pub mod stream; pub use client::*; pub use envelope::*; pub use format::*; pub use llm::*; +pub use metadata::*; pub use stream::*; /// Build a single-turn request: one user message carrying `prompt`, for `model`. diff --git a/crates/libsy-protocol/src/metadata.rs b/crates/libsy-protocol/src/metadata.rs new file mode 100644 index 00000000..d95a7e9b --- /dev/null +++ b/crates/libsy-protocol/src/metadata.rs @@ -0,0 +1,397 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Correlation metadata and harness header normalization. +//! +//! [`Metadata`] is the correlation/routing envelope carried alongside a request or +//! response. [`Metadata::from_headers`] normalizes the harness-specific HTTP headers +//! that Claude Code, Codex, NeMo Relay, and Dynamo attach into that neutral shape. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::WireFormat; + +/// Header carrying Codex's structured turn metadata as a JSON object. +const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; + +/// Correlation and routing metadata attached to a request or response. +/// +/// All fields are optional (or default-empty); algorithms and observers use whichever +/// are present (e.g. to key per-session state or emit correlated telemetry). The +/// agent-lineage fields (`parent_agent_id`, `is_subagent`, `agent_kind`, `agent_role`, +/// `task_kind`, `turn_id`, `session_final`) are populated for requests from a coding +/// agent. `extra_metadata` is a free-form escape hatch for host-specific keys. +#[derive(Clone, Default)] +pub struct Metadata { + /// Stable id for a multi-request session/conversation. + pub session_id: Option, + /// Id of the agent making the request. + pub agent_id: Option, + /// Id of the parent agent, when this request comes from a child agent. + pub parent_agent_id: Option, + /// Whether the harness identified this request as coming from a child agent. + pub is_subagent: bool, + /// Harness-defined kind of agent call, such as `collab_spawn` or `review`. + pub agent_kind: Option, + /// Semantic agent role, such as `explorer`, `worker`, or `reviewer`. + pub agent_role: Option, + /// Id of the task the request belongs to. + pub task_id: Option, + /// Semantic task class supplied by the harness. + pub task_kind: Option, + /// Id of the current agent turn. + pub turn_id: Option, + /// Whether the harness signalled this is the session's final request (e.g. the + /// host may evict per-session state). `None` when the harness said nothing. + pub session_final: Option, + /// External trace/request id for joining with the host's telemetry. + pub correlation_id: Option, + /// Arbitrary host-defined key/value metadata. + pub extra_metadata: Option>, + /// HTTP headers to attach when forwarding the request/response, if any. + pub http_headers: Option>, + /// The wire format the request/response was originally encoded in, if known. + pub wire_format: Option, +} + +impl Metadata { + /// Normalizes harness-specific request headers into correlation metadata. + /// + /// Explicit `x-switchyard-*` headers win. NeMo Relay and Dynamo correlation + /// headers are accepted without linking either runtime. Codex's structured turn + /// metadata is preferred over its compatibility projections. Claude Code and + /// OpenCode carry their agent lineage in native headers: a Claude Code request + /// naming a distinct `x-claude-code-agent-id` under its session is treated as a + /// child agent (its parent inferred to be the session when not stated). Subagent + /// status is taken from an explicit `x-switchyard-is-subagent` header when + /// present, and otherwise inferred from any parent/child lineage header. + pub fn from_headers(headers: &BTreeMap>) -> Self { + let codex = header(headers, CODEX_TURN_METADATA_HEADER) + .and_then(|value| serde_json::from_str::(&value).ok()) + .unwrap_or_default(); + + let switchyard_parent = header(headers, "x-switchyard-parent-agent-id"); + let relay_subagent = header(headers, "x-nemo-relay-subagent-id"); + let dynamo_parent = header(headers, "x-dynamo-parent-session-id"); + let codex_parent = header(headers, "x-codex-parent-thread-id"); + let openai_subagent = header(headers, "x-openai-subagent"); + + // Claude Code names a session and, for a spawned child, a distinct agent id. + // A child whose agent id differs from the session is a sub-agent; its parent is + // the explicit parent-agent header, else the session id it was spawned under. + let claude_session = header(headers, "x-claude-code-session-id"); + let claude_agent = header(headers, "x-claude-code-agent-id"); + let claude_subagent = matches!( + (&claude_agent, &claude_session), + (Some(agent), Some(session)) if agent != session + ); + let claude_parent = claude_subagent + .then(|| { + header(headers, "x-claude-code-parent-agent-id").or_else(|| claude_session.clone()) + }) + .flatten(); + + // OpenCode carries a session id and an optional parent session; the parent + // header is only meaningful alongside OpenCode's own session header. + let opencode_session = header(headers, "x-session-id"); + let opencode_parent = opencode_session + .as_ref() + .and_then(|_| header(headers, "x-parent-session-id")); + + let inferred_subagent = switchyard_parent.is_some() + || relay_subagent.is_some() + || dynamo_parent.is_some() + || codex.parent_thread_id.is_some() + || codex.subagent_kind.is_some() + || codex_parent.is_some() + || openai_subagent.is_some() + || claude_subagent + || opencode_parent.is_some(); + let is_subagent = header(headers, "x-switchyard-is-subagent") + .as_deref() + .and_then(parse_bool) + .unwrap_or(inferred_subagent); + + Metadata { + session_id: first_some([ + header(headers, "x-switchyard-session-id"), + claude_session, + header(headers, "x-nemo-relay-session-id"), + opencode_session, + codex.session_id, + header(headers, "session-id"), + ]), + agent_id: first_some([ + header(headers, "x-switchyard-agent-id"), + claude_agent, + relay_subagent, + header(headers, "x-dynamo-session-id"), + codex.thread_id, + header(headers, "thread-id"), + ]), + parent_agent_id: first_some([ + switchyard_parent, + dynamo_parent, + codex.parent_thread_id, + codex_parent, + claude_parent, + opencode_parent, + ]), + is_subagent, + agent_kind: first_some([ + header(headers, "x-switchyard-agent-kind"), + codex.subagent_kind, + openai_subagent, + ]), + agent_role: first_some([header(headers, "x-switchyard-agent-role"), codex.agent_role]), + task_id: first_some([ + header(headers, "x-switchyard-task-id"), + codex.task_id, + header(headers, "x-task-id"), + ]), + task_kind: first_some([header(headers, "x-switchyard-task-kind"), codex.task_kind]), + turn_id: first_some([header(headers, "x-switchyard-turn-id"), codex.turn_id]), + session_final: header(headers, "x-dynamo-session-final") + .as_deref() + .and_then(parse_bool), + correlation_id: first_some([ + header(headers, "x-request-id"), + header(headers, "x-client-request-id"), + ]), + ..Metadata::default() + } + } +} + +/// Codex's structured turn metadata, carried as JSON in [`CODEX_TURN_METADATA_HEADER`]. +#[derive(Default, Deserialize)] +struct CodexTurnMetadata { + session_id: Option, + thread_id: Option, + parent_thread_id: Option, + turn_id: Option, + subagent_kind: Option, + agent_role: Option, + task_id: Option, + task_kind: Option, +} + +/// Returns the first non-empty, trimmed value for a case-insensitive header name. +fn header(headers: &BTreeMap>, name: &str) -> Option { + headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .and_then(|(_, values)| values.iter().find(|value| !value.trim().is_empty())) + .map(|value| value.trim().to_string()) +} + +/// Parses the common textual spellings of a boolean header value. +fn parse_bool(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +/// Returns the first present value in preference order. +fn first_some(values: [Option; N]) -> Option { + values.into_iter().flatten().next() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a single-valued header map from `(name, value)` pairs. + fn headers(pairs: &[(&str, &str)]) -> BTreeMap> { + pairs + .iter() + .map(|(name, value)| (name.to_string(), vec![value.to_string()])) + .collect() + } + + #[test] + fn normalizes_codex_child_metadata() { + let headers = BTreeMap::from([( + CODEX_TURN_METADATA_HEADER.to_string(), + vec![serde_json::json!({ + "session_id": "root-session", + "thread_id": "child-agent", + "parent_thread_id": "root-agent", + "turn_id": "turn-7", + "subagent_kind": "collab_spawn", + }) + .to_string()], + )]); + + let metadata = Metadata::from_headers(&headers); + assert_eq!(metadata.session_id.as_deref(), Some("root-session")); + assert_eq!(metadata.agent_id.as_deref(), Some("child-agent")); + assert!(metadata.is_subagent); + assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-agent")); + } + + #[test] + fn root_codex_metadata_is_not_inferred_as_a_subagent() { + let headers = BTreeMap::from([( + CODEX_TURN_METADATA_HEADER.to_string(), + vec![serde_json::json!({ + "session_id": "root-session", + "thread_id": "root-agent", + "turn_id": "turn-1", + }) + .to_string()], + )]); + + let metadata = Metadata::from_headers(&headers); + assert!(!metadata.is_subagent); + } + + #[test] + fn explicit_switchyard_subagent_flag_overrides_inference() { + let headers = BTreeMap::from([ + ( + "x-switchyard-is-subagent".to_string(), + vec!["false".to_string()], + ), + ( + "x-switchyard-parent-agent-id".to_string(), + vec!["parent".to_string()], + ), + ]); + + let metadata = Metadata::from_headers(&headers); + assert!(!metadata.is_subagent); + } + + #[test] + fn normalizes_claude_code_session_header() { + // Claude Code identifies a session with `x-claude-code-session-id`; session + // affinity keys on it so a whole CLI session pins to one tier. + let headers = BTreeMap::from([( + "x-claude-code-session-id".to_string(), + vec!["fb46caae-eac6-4f5f-83fd-8fc8f5743abb".to_string()], + )]); + + let metadata = Metadata::from_headers(&headers); + assert_eq!( + metadata.session_id.as_deref(), + Some("fb46caae-eac6-4f5f-83fd-8fc8f5743abb") + ); + } + + #[test] + fn normalizes_relay_and_dynamo_child_headers() { + let headers = BTreeMap::from([ + ( + "x-nemo-relay-session-id".to_string(), + vec!["relay-session".to_string()], + ), + ( + "x-nemo-relay-subagent-id".to_string(), + vec!["relay-child".to_string()], + ), + ( + "x-dynamo-parent-session-id".to_string(), + vec!["relay-parent".to_string()], + ), + ]); + + let metadata = Metadata::from_headers(&headers); + assert_eq!(metadata.session_id.as_deref(), Some("relay-session")); + assert_eq!(metadata.agent_id.as_deref(), Some("relay-child")); + assert!(metadata.is_subagent); + } + + #[test] + fn claude_code_agent_lineage_marks_subagent_and_infers_parent() { + // A distinct agent id under a session is a child agent; with no explicit + // parent header its parent is inferred to be the session it was spawned under. + let metadata = Metadata::from_headers(&headers(&[ + ("x-claude-code-session-id", "claude-session"), + ("x-claude-code-agent-id", "claude-agent"), + ])); + assert_eq!(metadata.session_id.as_deref(), Some("claude-session")); + assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent")); + assert!(metadata.is_subagent); + assert_eq!(metadata.parent_agent_id.as_deref(), Some("claude-session")); + } + + #[test] + fn explicit_claude_parent_agent_overrides_inferred_session() { + let metadata = Metadata::from_headers(&headers(&[ + ("x-claude-code-session-id", "claude-session"), + ("x-claude-code-agent-id", "claude-agent"), + ("x-claude-code-parent-agent-id", "claude-parent-agent"), + ])); + assert_eq!( + metadata.parent_agent_id.as_deref(), + Some("claude-parent-agent") + ); + } + + #[test] + fn claude_root_agent_without_distinct_child_is_not_a_subagent() { + // Session but no distinct agent id: a root agent. A stray parent-agent header + // is only meaningful for a distinct child, so it must not leak in. + let metadata = Metadata::from_headers(&headers(&[ + ("x-claude-code-session-id", "claude-session"), + ("x-claude-code-parent-agent-id", "claude-parent-agent"), + ])); + assert_eq!(metadata.session_id.as_deref(), Some("claude-session")); + assert_eq!(metadata.agent_id, None); + assert!(!metadata.is_subagent); + assert_eq!(metadata.parent_agent_id, None); + } + + #[test] + fn opencode_parent_session_marks_subagent() { + let metadata = Metadata::from_headers(&headers(&[ + ("x-session-id", "opencode-run"), + ("x-parent-session-id", "opencode-parent"), + ])); + assert_eq!(metadata.session_id.as_deref(), Some("opencode-run")); + assert!(metadata.is_subagent); + assert_eq!(metadata.parent_agent_id.as_deref(), Some("opencode-parent")); + } + + #[test] + fn opencode_parent_ignored_without_opencode_session() { + // The OpenCode parent header only applies with OpenCode's own session header; + // next to a Codex `session-id` it must not surface as a parent. + let metadata = Metadata::from_headers(&headers(&[ + ("session-id", "codex-run"), + ("x-parent-session-id", "stray-parent"), + ])); + assert_eq!(metadata.session_id.as_deref(), Some("codex-run")); + assert!(!metadata.is_subagent); + assert_eq!(metadata.parent_agent_id, None); + } + + #[test] + fn dynamo_session_final_is_captured() { + let metadata = Metadata::from_headers(&headers(&[ + ("x-dynamo-session-id", "generic-run"), + ("x-dynamo-parent-session-id", "generic-parent"), + ("x-dynamo-session-final", "true"), + ])); + assert_eq!(metadata.agent_id.as_deref(), Some("generic-run")); + assert_eq!(metadata.parent_agent_id.as_deref(), Some("generic-parent")); + assert_eq!(metadata.session_final, Some(true)); + + let not_final = Metadata::from_headers(&headers(&[ + ("x-dynamo-session-id", "generic-run"), + ("x-dynamo-session-final", "false"), + ])); + assert_eq!(not_final.session_final, Some(false)); + } + + #[test] + fn codex_session_header_is_case_insensitive() { + let metadata = Metadata::from_headers(&headers(&[("Session-ID", "codex-run")])); + assert_eq!(metadata.session_id.as_deref(), Some("codex-run")); + } +} From 7c6bd9884906de7947f08328fa4c08f6f41099ea Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 13:01:39 -0400 Subject: [PATCH 2/5] chore: cleanup Signed-off-by: Greg Clark --- crates/libsy-protocol/src/metadata.rs | 196 +++++++++++++++----------- 1 file changed, 116 insertions(+), 80 deletions(-) diff --git a/crates/libsy-protocol/src/metadata.rs b/crates/libsy-protocol/src/metadata.rs index d95a7e9b..ab481473 100644 --- a/crates/libsy-protocol/src/metadata.rs +++ b/crates/libsy-protocol/src/metadata.rs @@ -67,9 +67,10 @@ impl Metadata { /// child agent (its parent inferred to be the session when not stated). Subagent /// status is taken from an explicit `x-switchyard-is-subagent` header when /// present, and otherwise inferred from any parent/child lineage header. - pub fn from_headers(headers: &BTreeMap>) -> Self { + pub fn from_headers(headers: &BTreeMap) -> Self { + let headers = &normalize_headers(headers); let codex = header(headers, CODEX_TURN_METADATA_HEADER) - .and_then(|value| serde_json::from_str::(&value).ok()) + .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or_default(); let switchyard_parent = header(headers, "x-switchyard-parent-agent-id"); @@ -88,9 +89,7 @@ impl Metadata { (Some(agent), Some(session)) if agent != session ); let claude_parent = claude_subagent - .then(|| { - header(headers, "x-claude-code-parent-agent-id").or_else(|| claude_session.clone()) - }) + .then(|| header(headers, "x-claude-code-parent-agent-id").or(claude_session)) .flatten(); // OpenCode carries a session id and an optional parent session; the parent @@ -110,53 +109,59 @@ impl Metadata { || claude_subagent || opencode_parent.is_some(); let is_subagent = header(headers, "x-switchyard-is-subagent") - .as_deref() .and_then(parse_bool) .unwrap_or(inferred_subagent); Metadata { - session_id: first_some([ + session_id: first_some(&[ header(headers, "x-switchyard-session-id"), claude_session, header(headers, "x-nemo-relay-session-id"), opencode_session, - codex.session_id, + codex.session_id.as_deref(), header(headers, "session-id"), ]), - agent_id: first_some([ + agent_id: first_some(&[ header(headers, "x-switchyard-agent-id"), claude_agent, relay_subagent, header(headers, "x-dynamo-session-id"), - codex.thread_id, + codex.thread_id.as_deref(), header(headers, "thread-id"), ]), - parent_agent_id: first_some([ + parent_agent_id: first_some(&[ switchyard_parent, dynamo_parent, - codex.parent_thread_id, + codex.parent_thread_id.as_deref(), codex_parent, claude_parent, opencode_parent, ]), is_subagent, - agent_kind: first_some([ + agent_kind: first_some(&[ header(headers, "x-switchyard-agent-kind"), - codex.subagent_kind, + codex.subagent_kind.as_deref(), openai_subagent, ]), - agent_role: first_some([header(headers, "x-switchyard-agent-role"), codex.agent_role]), - task_id: first_some([ + agent_role: first_some(&[ + header(headers, "x-switchyard-agent-role"), + codex.agent_role.as_deref(), + ]), + task_id: first_some(&[ header(headers, "x-switchyard-task-id"), - codex.task_id, + codex.task_id.as_deref(), header(headers, "x-task-id"), ]), - task_kind: first_some([header(headers, "x-switchyard-task-kind"), codex.task_kind]), - turn_id: first_some([header(headers, "x-switchyard-turn-id"), codex.turn_id]), - session_final: header(headers, "x-dynamo-session-final") - .as_deref() - .and_then(parse_bool), - correlation_id: first_some([ + task_kind: first_some(&[ + header(headers, "x-switchyard-task-kind"), + codex.task_kind.as_deref(), + ]), + turn_id: first_some(&[ + header(headers, "x-switchyard-turn-id"), + codex.turn_id.as_deref(), + ]), + session_final: header(headers, "x-dynamo-session-final").and_then(parse_bool), + correlation_id: first_some(&[ header(headers, "x-request-id"), header(headers, "x-client-request-id"), ]), @@ -178,15 +183,6 @@ struct CodexTurnMetadata { task_kind: Option, } -/// Returns the first non-empty, trimmed value for a case-insensitive header name. -fn header(headers: &BTreeMap>, name: &str) -> Option { - headers - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case(name)) - .and_then(|(_, values)| values.iter().find(|value| !value.trim().is_empty())) - .map(|value| value.trim().to_string()) -} - /// Parses the common textual spellings of a boolean header value. fn parse_bool(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { @@ -196,35 +192,45 @@ fn parse_bool(value: &str) -> Option { } } -/// Returns the first present value in preference order. -fn first_some(values: [Option; N]) -> Option { - values.into_iter().flatten().next() +/// Lowercases header names and keeps the first non-empty, trimmed value per name. +fn normalize_headers(headers: &BTreeMap) -> BTreeMap { + let mut normalized = BTreeMap::new(); + for (key, value) in headers { + let lower_key = key.to_ascii_lowercase(); + let trimmed_value = value.trim(); + if !normalized.contains_key(&lower_key) && !trimmed_value.is_empty() { + normalized.insert(lower_key, trimmed_value.to_string()); + } + } + + normalized +} + +fn header<'a>(headers: &'a BTreeMap, key: &str) -> Option<&'a str> { + let lower_key = key.to_ascii_lowercase(); + headers.get(&lower_key).map(|s| s.as_str()) +} + +fn first_some(options: &[Option<&str>]) -> Option { + options.iter().find_map(|opt| opt.map(|s| s.to_string())) } #[cfg(test)] mod tests { use super::*; - /// Builds a single-valued header map from `(name, value)` pairs. - fn headers(pairs: &[(&str, &str)]) -> BTreeMap> { - pairs - .iter() - .map(|(name, value)| (name.to_string(), vec![value.to_string()])) - .collect() - } - #[test] fn normalizes_codex_child_metadata() { let headers = BTreeMap::from([( CODEX_TURN_METADATA_HEADER.to_string(), - vec![serde_json::json!({ + serde_json::json!({ "session_id": "root-session", "thread_id": "child-agent", "parent_thread_id": "root-agent", "turn_id": "turn-7", "subagent_kind": "collab_spawn", }) - .to_string()], + .to_string(), )]); let metadata = Metadata::from_headers(&headers); @@ -238,12 +244,12 @@ mod tests { fn root_codex_metadata_is_not_inferred_as_a_subagent() { let headers = BTreeMap::from([( CODEX_TURN_METADATA_HEADER.to_string(), - vec![serde_json::json!({ + serde_json::json!({ "session_id": "root-session", "thread_id": "root-agent", "turn_id": "turn-1", }) - .to_string()], + .to_string(), )]); let metadata = Metadata::from_headers(&headers); @@ -253,13 +259,10 @@ mod tests { #[test] fn explicit_switchyard_subagent_flag_overrides_inference() { let headers = BTreeMap::from([ - ( - "x-switchyard-is-subagent".to_string(), - vec!["false".to_string()], - ), + ("x-switchyard-is-subagent".to_string(), "false".to_string()), ( "x-switchyard-parent-agent-id".to_string(), - vec!["parent".to_string()], + "parent".to_string(), ), ]); @@ -273,7 +276,7 @@ mod tests { // affinity keys on it so a whole CLI session pins to one tier. let headers = BTreeMap::from([( "x-claude-code-session-id".to_string(), - vec!["fb46caae-eac6-4f5f-83fd-8fc8f5743abb".to_string()], + "fb46caae-eac6-4f5f-83fd-8fc8f5743abb".to_string(), )]); let metadata = Metadata::from_headers(&headers); @@ -288,15 +291,15 @@ mod tests { let headers = BTreeMap::from([ ( "x-nemo-relay-session-id".to_string(), - vec!["relay-session".to_string()], + "relay-session".to_string(), ), ( "x-nemo-relay-subagent-id".to_string(), - vec!["relay-child".to_string()], + "relay-child".to_string(), ), ( "x-dynamo-parent-session-id".to_string(), - vec!["relay-parent".to_string()], + "relay-parent".to_string(), ), ]); @@ -310,9 +313,15 @@ mod tests { fn claude_code_agent_lineage_marks_subagent_and_infers_parent() { // A distinct agent id under a session is a child agent; with no explicit // parent header its parent is inferred to be the session it was spawned under. - let metadata = Metadata::from_headers(&headers(&[ - ("x-claude-code-session-id", "claude-session"), - ("x-claude-code-agent-id", "claude-agent"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ( + "x-claude-code-session-id".to_string(), + "claude-session".to_string(), + ), + ( + "x-claude-code-agent-id".to_string(), + "claude-agent".to_string(), + ), ])); assert_eq!(metadata.session_id.as_deref(), Some("claude-session")); assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent")); @@ -322,10 +331,19 @@ mod tests { #[test] fn explicit_claude_parent_agent_overrides_inferred_session() { - let metadata = Metadata::from_headers(&headers(&[ - ("x-claude-code-session-id", "claude-session"), - ("x-claude-code-agent-id", "claude-agent"), - ("x-claude-code-parent-agent-id", "claude-parent-agent"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ( + "x-claude-code-session-id".to_string(), + "claude-session".to_string(), + ), + ( + "x-claude-code-agent-id".to_string(), + "claude-agent".to_string(), + ), + ( + "x-claude-code-parent-agent-id".to_string(), + "claude-parent-agent".to_string(), + ), ])); assert_eq!( metadata.parent_agent_id.as_deref(), @@ -337,9 +355,15 @@ mod tests { fn claude_root_agent_without_distinct_child_is_not_a_subagent() { // Session but no distinct agent id: a root agent. A stray parent-agent header // is only meaningful for a distinct child, so it must not leak in. - let metadata = Metadata::from_headers(&headers(&[ - ("x-claude-code-session-id", "claude-session"), - ("x-claude-code-parent-agent-id", "claude-parent-agent"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ( + "x-claude-code-session-id".to_string(), + "claude-session".to_string(), + ), + ( + "x-claude-code-parent-agent-id".to_string(), + "claude-parent-agent".to_string(), + ), ])); assert_eq!(metadata.session_id.as_deref(), Some("claude-session")); assert_eq!(metadata.agent_id, None); @@ -349,9 +373,12 @@ mod tests { #[test] fn opencode_parent_session_marks_subagent() { - let metadata = Metadata::from_headers(&headers(&[ - ("x-session-id", "opencode-run"), - ("x-parent-session-id", "opencode-parent"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ("x-session-id".to_string(), "opencode-run".to_string()), + ( + "x-parent-session-id".to_string(), + "opencode-parent".to_string(), + ), ])); assert_eq!(metadata.session_id.as_deref(), Some("opencode-run")); assert!(metadata.is_subagent); @@ -362,9 +389,12 @@ mod tests { fn opencode_parent_ignored_without_opencode_session() { // The OpenCode parent header only applies with OpenCode's own session header; // next to a Codex `session-id` it must not surface as a parent. - let metadata = Metadata::from_headers(&headers(&[ - ("session-id", "codex-run"), - ("x-parent-session-id", "stray-parent"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ("session-id".to_string(), "codex-run".to_string()), + ( + "x-parent-session-id".to_string(), + "stray-parent".to_string(), + ), ])); assert_eq!(metadata.session_id.as_deref(), Some("codex-run")); assert!(!metadata.is_subagent); @@ -373,25 +403,31 @@ mod tests { #[test] fn dynamo_session_final_is_captured() { - let metadata = Metadata::from_headers(&headers(&[ - ("x-dynamo-session-id", "generic-run"), - ("x-dynamo-parent-session-id", "generic-parent"), - ("x-dynamo-session-final", "true"), + let metadata = Metadata::from_headers(&BTreeMap::from([ + ("x-dynamo-session-id".to_string(), "generic-run".to_string()), + ( + "x-dynamo-parent-session-id".to_string(), + "generic-parent".to_string(), + ), + ("x-dynamo-session-final".to_string(), "true".to_string()), ])); assert_eq!(metadata.agent_id.as_deref(), Some("generic-run")); assert_eq!(metadata.parent_agent_id.as_deref(), Some("generic-parent")); assert_eq!(metadata.session_final, Some(true)); - let not_final = Metadata::from_headers(&headers(&[ - ("x-dynamo-session-id", "generic-run"), - ("x-dynamo-session-final", "false"), + let not_final = Metadata::from_headers(&BTreeMap::from([ + ("x-dynamo-session-id".to_string(), "generic-run".to_string()), + ("x-dynamo-session-final".to_string(), "false".to_string()), ])); assert_eq!(not_final.session_final, Some(false)); } #[test] fn codex_session_header_is_case_insensitive() { - let metadata = Metadata::from_headers(&headers(&[("Session-ID", "codex-run")])); + let metadata = Metadata::from_headers(&BTreeMap::from([( + "Session-ID".to_string(), + "codex-run".to_string(), + )])); assert_eq!(metadata.session_id.as_deref(), Some("codex-run")); } } From f5e963aba00c1dc7f5cd38ee364b56f20377e83f Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 13:04:50 -0400 Subject: [PATCH 3/5] chore: str literals -> consts Signed-off-by: Greg Clark --- crates/libsy-protocol/src/metadata.rs | 94 +++++++++++++++++++-------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/crates/libsy-protocol/src/metadata.rs b/crates/libsy-protocol/src/metadata.rs index ab481473..d3d57fcf 100644 --- a/crates/libsy-protocol/src/metadata.rs +++ b/crates/libsy-protocol/src/metadata.rs @@ -16,6 +16,48 @@ use crate::WireFormat; /// Header carrying Codex's structured turn metadata as a JSON object. const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; +// Explicit Switchyard override headers; these take precedence over harness-native headers. +const SWITCHYARD_SESSION_ID_HEADER: &str = "x-switchyard-session-id"; +const SWITCHYARD_AGENT_ID_HEADER: &str = "x-switchyard-agent-id"; +const SWITCHYARD_PARENT_AGENT_ID_HEADER: &str = "x-switchyard-parent-agent-id"; +const SWITCHYARD_IS_SUBAGENT_HEADER: &str = "x-switchyard-is-subagent"; +const SWITCHYARD_AGENT_KIND_HEADER: &str = "x-switchyard-agent-kind"; +const SWITCHYARD_AGENT_ROLE_HEADER: &str = "x-switchyard-agent-role"; +const SWITCHYARD_TASK_ID_HEADER: &str = "x-switchyard-task-id"; +const SWITCHYARD_TASK_KIND_HEADER: &str = "x-switchyard-task-kind"; +const SWITCHYARD_TURN_ID_HEADER: &str = "x-switchyard-turn-id"; + +// NeMo Relay correlation headers. +const RELAY_SESSION_ID_HEADER: &str = "x-nemo-relay-session-id"; +const RELAY_SUBAGENT_ID_HEADER: &str = "x-nemo-relay-subagent-id"; + +// Dynamo correlation headers. +const DYNAMO_SESSION_ID_HEADER: &str = "x-dynamo-session-id"; +const DYNAMO_PARENT_SESSION_ID_HEADER: &str = "x-dynamo-parent-session-id"; +const DYNAMO_SESSION_FINAL_HEADER: &str = "x-dynamo-session-final"; + +// Codex compatibility projection of its parent thread id. +const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id"; + +// OpenAI subagent marker. +const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent"; + +// Claude Code agent-lineage headers. +const CLAUDE_SESSION_ID_HEADER: &str = "x-claude-code-session-id"; +const CLAUDE_AGENT_ID_HEADER: &str = "x-claude-code-agent-id"; +const CLAUDE_PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id"; + +// OpenCode session headers. +const OPENCODE_SESSION_ID_HEADER: &str = "x-session-id"; +const OPENCODE_PARENT_SESSION_ID_HEADER: &str = "x-parent-session-id"; + +// Generic Codex-compatible correlation headers. +const SESSION_ID_HEADER: &str = "session-id"; +const THREAD_ID_HEADER: &str = "thread-id"; +const TASK_ID_HEADER: &str = "x-task-id"; +const REQUEST_ID_HEADER: &str = "x-request-id"; +const CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; + /// Correlation and routing metadata attached to a request or response. /// /// All fields are optional (or default-empty); algorithms and observers use whichever @@ -73,31 +115,31 @@ impl Metadata { .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or_default(); - let switchyard_parent = header(headers, "x-switchyard-parent-agent-id"); - let relay_subagent = header(headers, "x-nemo-relay-subagent-id"); - let dynamo_parent = header(headers, "x-dynamo-parent-session-id"); - let codex_parent = header(headers, "x-codex-parent-thread-id"); - let openai_subagent = header(headers, "x-openai-subagent"); + let switchyard_parent = header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER); + let relay_subagent = header(headers, RELAY_SUBAGENT_ID_HEADER); + let dynamo_parent = header(headers, DYNAMO_PARENT_SESSION_ID_HEADER); + let codex_parent = header(headers, CODEX_PARENT_THREAD_ID_HEADER); + let openai_subagent = header(headers, OPENAI_SUBAGENT_HEADER); // Claude Code names a session and, for a spawned child, a distinct agent id. // A child whose agent id differs from the session is a sub-agent; its parent is // the explicit parent-agent header, else the session id it was spawned under. - let claude_session = header(headers, "x-claude-code-session-id"); - let claude_agent = header(headers, "x-claude-code-agent-id"); + let claude_session = header(headers, CLAUDE_SESSION_ID_HEADER); + let claude_agent = header(headers, CLAUDE_AGENT_ID_HEADER); let claude_subagent = matches!( (&claude_agent, &claude_session), (Some(agent), Some(session)) if agent != session ); let claude_parent = claude_subagent - .then(|| header(headers, "x-claude-code-parent-agent-id").or(claude_session)) + .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(claude_session)) .flatten(); // OpenCode carries a session id and an optional parent session; the parent // header is only meaningful alongside OpenCode's own session header. - let opencode_session = header(headers, "x-session-id"); + let opencode_session = header(headers, OPENCODE_SESSION_ID_HEADER); let opencode_parent = opencode_session .as_ref() - .and_then(|_| header(headers, "x-parent-session-id")); + .and_then(|_| header(headers, OPENCODE_PARENT_SESSION_ID_HEADER)); let inferred_subagent = switchyard_parent.is_some() || relay_subagent.is_some() @@ -108,26 +150,26 @@ impl Metadata { || openai_subagent.is_some() || claude_subagent || opencode_parent.is_some(); - let is_subagent = header(headers, "x-switchyard-is-subagent") + let is_subagent = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER) .and_then(parse_bool) .unwrap_or(inferred_subagent); Metadata { session_id: first_some(&[ - header(headers, "x-switchyard-session-id"), + header(headers, SWITCHYARD_SESSION_ID_HEADER), claude_session, - header(headers, "x-nemo-relay-session-id"), + header(headers, RELAY_SESSION_ID_HEADER), opencode_session, codex.session_id.as_deref(), - header(headers, "session-id"), + header(headers, SESSION_ID_HEADER), ]), agent_id: first_some(&[ - header(headers, "x-switchyard-agent-id"), + header(headers, SWITCHYARD_AGENT_ID_HEADER), claude_agent, relay_subagent, - header(headers, "x-dynamo-session-id"), + header(headers, DYNAMO_SESSION_ID_HEADER), codex.thread_id.as_deref(), - header(headers, "thread-id"), + header(headers, THREAD_ID_HEADER), ]), parent_agent_id: first_some(&[ switchyard_parent, @@ -139,31 +181,31 @@ impl Metadata { ]), is_subagent, agent_kind: first_some(&[ - header(headers, "x-switchyard-agent-kind"), + header(headers, SWITCHYARD_AGENT_KIND_HEADER), codex.subagent_kind.as_deref(), openai_subagent, ]), agent_role: first_some(&[ - header(headers, "x-switchyard-agent-role"), + header(headers, SWITCHYARD_AGENT_ROLE_HEADER), codex.agent_role.as_deref(), ]), task_id: first_some(&[ - header(headers, "x-switchyard-task-id"), + header(headers, SWITCHYARD_TASK_ID_HEADER), codex.task_id.as_deref(), - header(headers, "x-task-id"), + header(headers, TASK_ID_HEADER), ]), task_kind: first_some(&[ - header(headers, "x-switchyard-task-kind"), + header(headers, SWITCHYARD_TASK_KIND_HEADER), codex.task_kind.as_deref(), ]), turn_id: first_some(&[ - header(headers, "x-switchyard-turn-id"), + header(headers, SWITCHYARD_TURN_ID_HEADER), codex.turn_id.as_deref(), ]), - session_final: header(headers, "x-dynamo-session-final").and_then(parse_bool), + session_final: header(headers, DYNAMO_SESSION_FINAL_HEADER).and_then(parse_bool), correlation_id: first_some(&[ - header(headers, "x-request-id"), - header(headers, "x-client-request-id"), + header(headers, REQUEST_ID_HEADER), + header(headers, CLIENT_REQUEST_ID_HEADER), ]), ..Metadata::default() } From e3db0c52b1b20e4b7551aeb859c3fdbfe51611b2 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Mon, 20 Jul 2026 20:50:49 -0400 Subject: [PATCH 4/5] chore: review feeback Signed-off-by: Greg Clark --- crates/libsy-protocol/src/metadata.rs | 349 ++++++++++++++++++-------- 1 file changed, 241 insertions(+), 108 deletions(-) diff --git a/crates/libsy-protocol/src/metadata.rs b/crates/libsy-protocol/src/metadata.rs index d3d57fcf..1c7422a5 100644 --- a/crates/libsy-protocol/src/metadata.rs +++ b/crates/libsy-protocol/src/metadata.rs @@ -9,12 +9,17 @@ use std::collections::BTreeMap; -use serde::Deserialize; - use crate::WireFormat; -/// Header carrying Codex's structured turn metadata as a JSON object. -const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; +// Dotted paths addressing fields inside Codex's turn-metadata header JSON value. +const CODEX_SESSION_ID_PATH: &str = "x-codex-turn-metadata.session_id"; +const CODEX_THREAD_ID_PATH: &str = "x-codex-turn-metadata.thread_id"; +const CODEX_PARENT_THREAD_ID_PATH: &str = "x-codex-turn-metadata.parent_thread_id"; +const CODEX_TURN_ID_PATH: &str = "x-codex-turn-metadata.turn_id"; +const CODEX_SUBAGENT_KIND_PATH: &str = "x-codex-turn-metadata.subagent_kind"; +const CODEX_AGENT_ROLE_PATH: &str = "x-codex-turn-metadata.agent_role"; +const CODEX_TASK_ID_PATH: &str = "x-codex-turn-metadata.task_id"; +const CODEX_TASK_KIND_PATH: &str = "x-codex-turn-metadata.task_kind"; // Explicit Switchyard override headers; these take precedence over harness-native headers. const SWITCHYARD_SESSION_ID_HEADER: &str = "x-switchyard-session-id"; @@ -26,6 +31,8 @@ const SWITCHYARD_AGENT_ROLE_HEADER: &str = "x-switchyard-agent-role"; const SWITCHYARD_TASK_ID_HEADER: &str = "x-switchyard-task-id"; const SWITCHYARD_TASK_KIND_HEADER: &str = "x-switchyard-task-kind"; const SWITCHYARD_TURN_ID_HEADER: &str = "x-switchyard-turn-id"; +const SWITCHYARD_REQUEST_ID_HEADER: &str = "x-switchyard-request-id"; +const SWITCHYARD_SESSION_FINAL_HEADER: &str = "x-switchyard-session-final"; // NeMo Relay correlation headers. const RELAY_SESSION_ID_HEADER: &str = "x-nemo-relay-session-id"; @@ -58,6 +65,96 @@ const TASK_ID_HEADER: &str = "x-task-id"; const REQUEST_ID_HEADER: &str = "x-request-id"; const CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; +/// Header/JSON-path signals that, when any is present, mark a request as a sub-agent. +const SUBAGENT_SIGNAL_PATHS: &[&str] = &[ + SWITCHYARD_PARENT_AGENT_ID_HEADER, + RELAY_SUBAGENT_ID_HEADER, + DYNAMO_PARENT_SESSION_ID_HEADER, + CODEX_PARENT_THREAD_ID_PATH, + CODEX_SUBAGENT_KIND_PATH, + CODEX_PARENT_THREAD_ID_HEADER, + OPENAI_SUBAGENT_HEADER, +]; + +/// Ordered candidate lookup paths for each correlation field, keyed by the field's +/// canonical `x-switchyard-*` header name. +type HeaderConfig = [(&'static str, &'static [&'static str])]; + +/// Precedence of harness headers for each correlation field. See [`HeaderConfig`]. +const HEADER_CONFIG: &HeaderConfig = &[ + ( + SWITCHYARD_SESSION_ID_HEADER, + &[ + SWITCHYARD_SESSION_ID_HEADER, + CLAUDE_SESSION_ID_HEADER, + RELAY_SESSION_ID_HEADER, + OPENCODE_SESSION_ID_HEADER, + CODEX_SESSION_ID_PATH, + SESSION_ID_HEADER, + ], + ), + ( + SWITCHYARD_AGENT_ID_HEADER, + &[ + SWITCHYARD_AGENT_ID_HEADER, + CLAUDE_AGENT_ID_HEADER, + RELAY_SUBAGENT_ID_HEADER, + DYNAMO_SESSION_ID_HEADER, + CODEX_THREAD_ID_PATH, + THREAD_ID_HEADER, + ], + ), + ( + SWITCHYARD_PARENT_AGENT_ID_HEADER, + &[ + SWITCHYARD_PARENT_AGENT_ID_HEADER, + DYNAMO_PARENT_SESSION_ID_HEADER, + CODEX_PARENT_THREAD_ID_PATH, + CODEX_PARENT_THREAD_ID_HEADER, + ], + ), + ( + SWITCHYARD_AGENT_KIND_HEADER, + &[ + SWITCHYARD_AGENT_KIND_HEADER, + CODEX_SUBAGENT_KIND_PATH, + OPENAI_SUBAGENT_HEADER, + ], + ), + ( + SWITCHYARD_AGENT_ROLE_HEADER, + &[SWITCHYARD_AGENT_ROLE_HEADER, CODEX_AGENT_ROLE_PATH], + ), + ( + SWITCHYARD_TASK_ID_HEADER, + &[ + SWITCHYARD_TASK_ID_HEADER, + CODEX_TASK_ID_PATH, + TASK_ID_HEADER, + ], + ), + ( + SWITCHYARD_TASK_KIND_HEADER, + &[SWITCHYARD_TASK_KIND_HEADER, CODEX_TASK_KIND_PATH], + ), + ( + SWITCHYARD_TURN_ID_HEADER, + &[SWITCHYARD_TURN_ID_HEADER, CODEX_TURN_ID_PATH], + ), + ( + SWITCHYARD_REQUEST_ID_HEADER, + &[ + SWITCHYARD_REQUEST_ID_HEADER, + REQUEST_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, + ], + ), + ( + SWITCHYARD_SESSION_FINAL_HEADER, + &[SWITCHYARD_SESSION_FINAL_HEADER, DYNAMO_SESSION_FINAL_HEADER], + ), +]; + /// Correlation and routing metadata attached to a request or response. /// /// All fields are optional (or default-empty); algorithms and observers use whichever @@ -111,118 +208,77 @@ impl Metadata { /// present, and otherwise inferred from any parent/child lineage header. pub fn from_headers(headers: &BTreeMap) -> Self { let headers = &normalize_headers(headers); - let codex = header(headers, CODEX_TURN_METADATA_HEADER) - .and_then(|value| serde_json::from_str::(value).ok()) - .unwrap_or_default(); - - let switchyard_parent = header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER); - let relay_subagent = header(headers, RELAY_SUBAGENT_ID_HEADER); - let dynamo_parent = header(headers, DYNAMO_PARENT_SESSION_ID_HEADER); - let codex_parent = header(headers, CODEX_PARENT_THREAD_ID_HEADER); - let openai_subagent = header(headers, OPENAI_SUBAGENT_HEADER); - - // Claude Code names a session and, for a spawned child, a distinct agent id. - // A child whose agent id differs from the session is a sub-agent; its parent is - // the explicit parent-agent header, else the session id it was spawned under. - let claude_session = header(headers, CLAUDE_SESSION_ID_HEADER); - let claude_agent = header(headers, CLAUDE_AGENT_ID_HEADER); - let claude_subagent = matches!( - (&claude_agent, &claude_session), - (Some(agent), Some(session)) if agent != session - ); - let claude_parent = claude_subagent - .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(claude_session)) - .flatten(); - - // OpenCode carries a session id and an optional parent session; the parent - // header is only meaningful alongside OpenCode's own session header. - let opencode_session = header(headers, OPENCODE_SESSION_ID_HEADER); - let opencode_parent = opencode_session - .as_ref() - .and_then(|_| header(headers, OPENCODE_PARENT_SESSION_ID_HEADER)); - - let inferred_subagent = switchyard_parent.is_some() - || relay_subagent.is_some() - || dynamo_parent.is_some() - || codex.parent_thread_id.is_some() - || codex.subagent_kind.is_some() - || codex_parent.is_some() - || openai_subagent.is_some() - || claude_subagent - || opencode_parent.is_some(); - let is_subagent = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER) - .and_then(parse_bool) - .unwrap_or(inferred_subagent); + + let (parent_agent_id, is_subagent) = parse_sub_agent(headers); Metadata { - session_id: first_some(&[ - header(headers, SWITCHYARD_SESSION_ID_HEADER), - claude_session, - header(headers, RELAY_SESSION_ID_HEADER), - opencode_session, - codex.session_id.as_deref(), - header(headers, SESSION_ID_HEADER), - ]), - agent_id: first_some(&[ - header(headers, SWITCHYARD_AGENT_ID_HEADER), - claude_agent, - relay_subagent, - header(headers, DYNAMO_SESSION_ID_HEADER), - codex.thread_id.as_deref(), - header(headers, THREAD_ID_HEADER), - ]), - parent_agent_id: first_some(&[ - switchyard_parent, - dynamo_parent, - codex.parent_thread_id.as_deref(), - codex_parent, - claude_parent, - opencode_parent, - ]), + session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER), + agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER), + parent_agent_id, is_subagent, - agent_kind: first_some(&[ - header(headers, SWITCHYARD_AGENT_KIND_HEADER), - codex.subagent_kind.as_deref(), - openai_subagent, - ]), - agent_role: first_some(&[ - header(headers, SWITCHYARD_AGENT_ROLE_HEADER), - codex.agent_role.as_deref(), - ]), - task_id: first_some(&[ - header(headers, SWITCHYARD_TASK_ID_HEADER), - codex.task_id.as_deref(), - header(headers, TASK_ID_HEADER), - ]), - task_kind: first_some(&[ - header(headers, SWITCHYARD_TASK_KIND_HEADER), - codex.task_kind.as_deref(), - ]), - turn_id: first_some(&[ - header(headers, SWITCHYARD_TURN_ID_HEADER), - codex.turn_id.as_deref(), - ]), - session_final: header(headers, DYNAMO_SESSION_FINAL_HEADER).and_then(parse_bool), - correlation_id: first_some(&[ - header(headers, REQUEST_ID_HEADER), - header(headers, CLIENT_REQUEST_ID_HEADER), - ]), + agent_kind: sy_header(headers, SWITCHYARD_AGENT_KIND_HEADER), + agent_role: sy_header(headers, SWITCHYARD_AGENT_ROLE_HEADER), + task_id: sy_header(headers, SWITCHYARD_TASK_ID_HEADER), + task_kind: sy_header(headers, SWITCHYARD_TASK_KIND_HEADER), + turn_id: sy_header(headers, SWITCHYARD_TURN_ID_HEADER), + session_final: sy_header(headers, SWITCHYARD_SESSION_FINAL_HEADER) + .as_deref() + .and_then(parse_bool), + correlation_id: sy_header(headers, SWITCHYARD_REQUEST_ID_HEADER), ..Metadata::default() } } } -/// Codex's structured turn metadata, carried as JSON in [`CODEX_TURN_METADATA_HEADER`]. -#[derive(Default, Deserialize)] -struct CodexTurnMetadata { - session_id: Option, - thread_id: Option, - parent_thread_id: Option, - turn_id: Option, - subagent_kind: Option, - agent_role: Option, - task_id: Option, - task_kind: Option, +/// Returns `(parent_agent_id, is_subagent)` from the headers +fn parse_sub_agent(headers: &BTreeMap) -> (Option, bool) { + let mut is_subagent = false; + let sy_is_sub_agent = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool); + let mut parent: Option = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER); + if sy_is_sub_agent.is_some() && parent.is_some() { + return (parent, sy_is_sub_agent.unwrap()); + } + is_subagent |= sy_is_sub_agent.unwrap_or(false); + + let (claude_parent, claude_subagent) = claude_lineage(headers); + is_subagent |= claude_subagent; + parent = parent.or_else(|| claude_parent.map(str::to_string)); + if is_subagent && parent.is_some() { + return (parent, is_subagent); + } + + parent = parent.or_else(|| opencode_parent(headers).map(str::to_string)); + is_subagent = parent.is_some() || is_subagent; + if is_subagent && parent.is_some() { + return (parent, is_subagent); + } + + is_subagent |= SUBAGENT_SIGNAL_PATHS + .iter() + .any(|path| resolve_path(headers, path).is_some()); + + (parent, is_subagent) +} + +/// Claude Code's `(parent_agent, is_subagent)` from its native lineage headers. +/// +/// A request naming an `x-claude-code-agent-id` distinct from its session is a child +/// agent; its parent is the explicit parent-agent header, else the session it was +/// spawned under. A root agent (no distinct child id) has no parent. +fn claude_lineage(headers: &BTreeMap) -> (Option<&str>, bool) { + let session = header(headers, CLAUDE_SESSION_ID_HEADER); + let agent = header(headers, CLAUDE_AGENT_ID_HEADER); + let is_subagent = matches!((agent, session), (Some(a), Some(s)) if a != s); + let parent = is_subagent + .then(|| header(headers, CLAUDE_PARENT_AGENT_ID_HEADER).or(session)) + .flatten(); + (parent, is_subagent) +} + +/// OpenCode's parent session, meaningful only alongside its own session header. +fn opencode_parent(headers: &BTreeMap) -> Option<&str> { + header(headers, OPENCODE_SESSION_ID_HEADER) + .and(header(headers, OPENCODE_PARENT_SESSION_ID_HEADER)) } /// Parses the common textual spellings of a boolean header value. @@ -248,6 +304,44 @@ fn normalize_headers(headers: &BTreeMap) -> BTreeMap, key: &str) -> Option { + let (_, paths) = HEADER_CONFIG + .iter() + .find(|(field, _)| field.eq_ignore_ascii_case(key))?; + paths.iter().find_map(|path| resolve_path(headers, path)) +} + +/// Follows one dotted path, descending through a JSON-object header value. +fn resolve_path(headers: &BTreeMap, path: &str) -> Option { + let (header_name, nested) = match path.split_once('.') { + Some((name, rest)) => (name, Some(rest)), + None => (path, None), + }; + let raw = headers.get(&header_name.to_ascii_lowercase())?; + + // A bare header name resolves to its value verbatim; no JSON parsing needed. + let Some(nested) = nested else { + return Some(raw.clone()); + }; + + // Nested path: parse the header value as JSON and descend key by key. + let mut current: serde_json::Value = serde_json::from_str(raw).ok()?; + for segment in nested.split('.') { + current = current.as_object()?.get(segment)?.clone(); + } + + match current { + serde_json::Value::String(s) => Some(s), + serde_json::Value::Null => None, + leaf => Some(leaf.to_string()), + } +} + fn header<'a>(headers: &'a BTreeMap, key: &str) -> Option<&'a str> { let lower_key = key.to_ascii_lowercase(); headers.get(&lower_key).map(|s| s.as_str()) @@ -261,6 +355,9 @@ fn first_some(options: &[Option<&str>]) -> Option { mod tests { use super::*; + /// Header carrying Codex's structured turn metadata as a JSON object. + const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; + #[test] fn normalizes_codex_child_metadata() { let headers = BTreeMap::from([( @@ -464,6 +561,42 @@ mod tests { assert_eq!(not_final.session_final, Some(false)); } + #[test] + fn sy_header_resolves_paths_in_order_and_descends_into_json() { + // Only the JSON-nested Codex path is present, so descent supplies the value. + let headers = BTreeMap::from([( + CODEX_TURN_METADATA_HEADER.to_string(), + serde_json::json!({ "session_id": "codex-session" }).to_string(), + )]); + assert_eq!( + sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(), + Some("codex-session") + ); + + // The explicit Switchyard header outranks the Codex path when both resolve. + let headers = BTreeMap::from([ + ( + SWITCHYARD_SESSION_ID_HEADER.to_string(), + "explicit".to_string(), + ), + ( + CODEX_TURN_METADATA_HEADER.to_string(), + serde_json::json!({ "session_id": "codex-session" }).to_string(), + ), + ]); + assert_eq!( + sy_header(&headers, SWITCHYARD_SESSION_ID_HEADER).as_deref(), + Some("explicit") + ); + + // Nothing resolves for an empty header set or an unknown field. + assert_eq!( + sy_header(&BTreeMap::new(), SWITCHYARD_SESSION_ID_HEADER), + None + ); + assert_eq!(sy_header(&headers, "x-not-a-field"), None); + } + #[test] fn codex_session_header_is_case_insensitive() { let metadata = Metadata::from_headers(&BTreeMap::from([( From 567a88ab9982e572ea6a194ff3214a9416f2fa77 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Mon, 20 Jul 2026 21:06:30 -0400 Subject: [PATCH 5/5] chore: clippy Signed-off-by: Greg Clark --- crates/libsy-protocol/src/metadata.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/libsy-protocol/src/metadata.rs b/crates/libsy-protocol/src/metadata.rs index 1c7422a5..a5450276 100644 --- a/crates/libsy-protocol/src/metadata.rs +++ b/crates/libsy-protocol/src/metadata.rs @@ -235,8 +235,10 @@ fn parse_sub_agent(headers: &BTreeMap) -> (Option, bool) let mut is_subagent = false; let sy_is_sub_agent = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool); let mut parent: Option = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER); - if sy_is_sub_agent.is_some() && parent.is_some() { - return (parent, sy_is_sub_agent.unwrap()); + if let Some(is_sub) = sy_is_sub_agent { + if parent.is_some() { + return (parent, is_sub); + } } is_subagent |= sy_is_sub_agent.unwrap_or(false); @@ -347,10 +349,6 @@ fn header<'a>(headers: &'a BTreeMap, key: &str) -> Option<&'a st headers.get(&lower_key).map(|s| s.as_str()) } -fn first_some(options: &[Option<&str>]) -> Option { - options.iter().find_map(|opt| opt.map(|s| s.to_string())) -} - #[cfg(test)] mod tests { use super::*;