From b1351d59b137cf7bbcdabb1ca59cc35b36f6245f Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 11:43:03 -0400 Subject: [PATCH 1/3] 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 0615d40a2c82ed8a82dfd92fb1a7dd128ffad5ab Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 13:01:39 -0400 Subject: [PATCH 2/3] 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 1ba011b19501d371c0c8d17ad04d84c86e89932d Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Sat, 18 Jul 2026 13:04:50 -0400 Subject: [PATCH 3/3] 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() }