From 2e8682f640c51151e4fbccd4f06bc89d201a6b28 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Thu, 15 Oct 2026 11:41:27 +0300 Subject: [PATCH 1/3] feat(server): record agent identity in the routing log Signed-off-by: Eugen Nekhai --- crates/switchyard-server/README.md | 6 + crates/switchyard-server/src/routing_log.rs | 128 ++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/crates/switchyard-server/README.md b/crates/switchyard-server/README.md index 08335197f5..cf97ad1527 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -129,6 +129,12 @@ Clients can send `x-switchyard-origin: codex-cli` (or another client label) to i `"origin": null`. The value is supplied by the caller; it is not inferred from `User-Agent`. Older records without `origin` remain readable by the session stats endpoint. +A child agent can share its parent's session ID. To tell their calls apart, records also include +the agent identity from the request metadata: `agent_id`, `parent_agent_id`, `agent_kind`, +`agent_role`, and `delegated` (`true` when the request carries delegated sub-agent work). When the client +cannot identify its child agents, the record has `"subagent_identity_unsupported": true`. Each of +these fields is left out when it is not set, so records without agent identity are unchanged. + An `llm_classifier` route sends each task to `classifier_target` for a capability verdict, then routes to `weak_target` or `strong_target`. Beyond the three targets it accepts these keys; only `base_threshold` is required, and anything the judge cannot decide routes to `strong_target`: diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index 674db1b6c5..32d2b087ce 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -59,6 +59,12 @@ impl RoutingLog { trial_id: context.trial_id.map(Cow::Owned), session_id: context.session_id.map(Cow::Owned), turn_id: context.turn_id.map(Cow::Owned), + agent_id: context.agent.agent_id.map(Cow::Owned), + parent_agent_id: context.agent.parent_agent_id.map(Cow::Owned), + delegated: context.agent.delegated, + agent_kind: context.agent.kind.map(Cow::Owned), + agent_role: context.agent.role.map(Cow::Owned), + subagent_identity_unsupported: context.agent.identity_unsupported, model: model.into(), tier: tier.unwrap_or("").into(), prompt_tokens: usage.prompt_tokens, @@ -100,6 +106,36 @@ pub(crate) fn snapshot( Ok((snapshot.total_calls > 0).then_some(snapshot)) } +/// Which agent made a call. A child agent can share its parent's session id, so +/// the session alone cannot tell delegated work apart from the parent's turns. +/// Every field is copied from the request metadata, never inferred. +#[derive(Clone, Default)] +pub(crate) struct AgentIdentity { + agent_id: Option, + parent_agent_id: Option, + /// [`Metadata::is_subagent_work`], the signal that sends a call to the + /// sub-agent route, rather than the weaker `is_subagent` hint. + delegated: bool, + kind: Option, + role: Option, + /// The harness cannot identify its children, so a reader should not take + /// a missing `delegated` flag to mean that no work was delegated. + identity_unsupported: bool, +} + +impl AgentIdentity { + fn from_metadata(metadata: &Metadata) -> Self { + Self { + agent_id: metadata.agent_id.clone(), + parent_agent_id: metadata.parent_agent_id.clone(), + delegated: metadata.is_subagent_work(), + kind: metadata.agent_kind.clone(), + role: metadata.agent_role.clone(), + identity_unsupported: metadata.subagent_identity_unsupported, + } + } +} + /// Holds request metadata until route resolution attaches the durable route identity. #[derive(Clone)] pub(crate) struct RoutingLogContext { @@ -110,6 +146,7 @@ pub(crate) struct RoutingLogContext { trial_id: Option, session_id: Option, turn_id: Option, + agent: AgentIdentity, } impl RoutingLogContext { @@ -134,6 +171,7 @@ impl RoutingLogContext { .and_then(|headers| nonempty_header(headers, LEGACY_SESSION_ID_HEADER)) .map(str::to_string) }), + agent: AgentIdentity::from_metadata(metadata), } } @@ -164,6 +202,20 @@ struct RoutingRecord<'a> { session_id: Option>, #[serde(borrow)] turn_id: Option>, + // Most calls carry no agent identity, so these fields are written only when + // set. Records without them still parse through `serde(default)`. + #[serde(borrow, skip_serializing_if = "Option::is_none")] + agent_id: Option>, + #[serde(borrow, skip_serializing_if = "Option::is_none")] + parent_agent_id: Option>, + #[serde(skip_serializing_if = "is_false")] + delegated: bool, + #[serde(borrow, skip_serializing_if = "Option::is_none")] + agent_kind: Option>, + #[serde(borrow, skip_serializing_if = "Option::is_none")] + agent_role: Option>, + #[serde(skip_serializing_if = "is_false")] + subagent_identity_unsupported: bool, model: Cow<'a, str>, tier: Cow<'a, str>, prompt_tokens: u64, @@ -245,6 +297,10 @@ impl SessionStatsSnapshot { } } +fn is_false(flag: &bool) -> bool { + !flag +} + fn nonempty_header<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> { headers .get(name) @@ -292,6 +348,78 @@ mod tests { ); } + // Appends one record for `metadata` and returns it as JSON. + fn logged_record(metadata: &Metadata) -> serde_json::Value { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("routing.jsonl"); + let mut log = RoutingLog::new(&path).expect("routing log"); + log.append( + RoutingLogContext::from_metadata(metadata).with_route("model-router", "passthrough"), + "worker-model", + None, + &Usage::default(), + ) + .expect("append"); + let line = fs::read_to_string(&path).expect("read log"); + serde_json::from_str(line.trim()).expect("json record") + } + + /// The record names the child, its parent, and the flag that sent it to the + /// sub-agent route, so it can be told apart from the parent's turns under + /// the shared session id. + #[test] + fn a_delegated_call_records_its_agent_lineage() { + let record = logged_record(&Metadata { + session_id: Some("session-1".to_string()), + agent_id: Some("child-7".to_string()), + parent_agent_id: Some("thread-1".to_string()), + is_subagent: true, + is_delegated_work: true, + agent_kind: Some("review".to_string()), + agent_role: Some("reviewer".to_string()), + ..Metadata::default() + }); + assert_eq!(record["session_id"], "session-1"); + assert_eq!(record["agent_id"], "child-7"); + assert_eq!(record["parent_agent_id"], "thread-1"); + assert_eq!(record["delegated"], true); + assert_eq!(record["agent_kind"], "review"); + assert_eq!(record["agent_role"], "reviewer"); + } + + /// A harness that cannot identify its children is flagged, so a reader does + /// not take the missing `delegated` flag as proof that nothing was delegated. + #[test] + fn a_call_from_a_harness_without_child_identity_is_flagged() { + let record = logged_record(&Metadata { + session_id: Some("session-1".to_string()), + subagent_identity_unsupported: true, + ..Metadata::default() + }); + assert_eq!(record["subagent_identity_unsupported"], true); + assert!(record.get("delegated").is_none()); + } + + /// A call without agent identity keeps the record it had before, with no + /// agent fields at all. + #[test] + fn a_call_without_agent_identity_writes_no_agent_fields() { + let record = logged_record(&Metadata { + session_id: Some("session-1".to_string()), + ..Metadata::default() + }); + for field in [ + "agent_id", + "parent_agent_id", + "delegated", + "agent_kind", + "agent_role", + "subagent_identity_unsupported", + ] { + assert!(record.get(field).is_none(), "{field} should be absent"); + } + } + /// Only the requested session is counted, absent fields fall back to zero /// and `unknown`, and an unparseable line does not abort the scan. #[test] From ad56dbbc420c1163bc21659e30e5a7c2c245be61 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Thu, 15 Oct 2026 15:51:27 +0300 Subject: [PATCH 2/3] docs(server): document routing log helpers Signed-off-by: Eugen Nekhai --- crates/switchyard-server/src/routing_log.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index 32d2b087ce..3220244aba 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -42,6 +42,7 @@ impl RoutingLog { Ok(Self(file)) } + /// Writes one record for a finished call as a single JSON line. pub(crate) fn append( &mut self, context: RoutingLogContext, @@ -124,6 +125,7 @@ pub(crate) struct AgentIdentity { } impl AgentIdentity { + /// Copies the agent fields from the request metadata. fn from_metadata(metadata: &Metadata) -> Self { Self { agent_id: metadata.agent_id.clone(), @@ -297,10 +299,12 @@ impl SessionStatsSnapshot { } } +/// Lets serde leave out a `false` flag, so it is written only when set. fn is_false(flag: &bool) -> bool { !flag } +/// Returns a header value only when it is present, non-empty, and valid text. fn nonempty_header<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> { headers .get(name) @@ -348,7 +352,7 @@ mod tests { ); } - // Appends one record for `metadata` and returns it as JSON. + /// Appends one record for `metadata` and returns it as JSON. fn logged_record(metadata: &Metadata) -> serde_json::Value { let dir = tempfile::tempdir().expect("temp dir"); let path = dir.path().join("routing.jsonl"); From f76d56a3abc2cb69a396cbca97dbc4b3cb51e493 Mon Sep 17 00:00:00 2001 From: Eugen Nekhai Date: Tue, 20 Oct 2026 11:55:43 +0300 Subject: [PATCH 3/3] test(server): simplify agent identity routing log coverage Signed-off-by: Eugen Nekhai --- crates/switchyard-server/src/routing_log.rs | 70 +++++---------------- 1 file changed, 17 insertions(+), 53 deletions(-) diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index 3220244aba..a3feef35a8 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -352,76 +352,40 @@ mod tests { ); } - /// Appends one record for `metadata` and returns it as JSON. - fn logged_record(metadata: &Metadata) -> serde_json::Value { + #[test] + fn a_delegated_call_records_its_agent_lineage() { + let metadata = Metadata { + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + agent_id: Some("child-7".to_string()), + parent_agent_id: Some("thread-1".to_string()), + is_subagent: true, + is_delegated_work: true, + agent_kind: Some("review".to_string()), + agent_role: Some("reviewer".to_string()), + ..Metadata::default() + }; let dir = tempfile::tempdir().expect("temp dir"); let path = dir.path().join("routing.jsonl"); let mut log = RoutingLog::new(&path).expect("routing log"); log.append( - RoutingLogContext::from_metadata(metadata).with_route("model-router", "passthrough"), + RoutingLogContext::from_metadata(&metadata).with_route("model-router", "passthrough"), "worker-model", None, &Usage::default(), ) .expect("append"); let line = fs::read_to_string(&path).expect("read log"); - serde_json::from_str(line.trim()).expect("json record") - } + let record: serde_json::Value = serde_json::from_str(line.trim()).expect("json record"); - /// The record names the child, its parent, and the flag that sent it to the - /// sub-agent route, so it can be told apart from the parent's turns under - /// the shared session id. - #[test] - fn a_delegated_call_records_its_agent_lineage() { - let record = logged_record(&Metadata { - session_id: Some("session-1".to_string()), - agent_id: Some("child-7".to_string()), - parent_agent_id: Some("thread-1".to_string()), - is_subagent: true, - is_delegated_work: true, - agent_kind: Some("review".to_string()), - agent_role: Some("reviewer".to_string()), - ..Metadata::default() - }); assert_eq!(record["session_id"], "session-1"); + assert_eq!(record["turn_id"], "turn-1"); assert_eq!(record["agent_id"], "child-7"); assert_eq!(record["parent_agent_id"], "thread-1"); assert_eq!(record["delegated"], true); assert_eq!(record["agent_kind"], "review"); assert_eq!(record["agent_role"], "reviewer"); - } - - /// A harness that cannot identify its children is flagged, so a reader does - /// not take the missing `delegated` flag as proof that nothing was delegated. - #[test] - fn a_call_from_a_harness_without_child_identity_is_flagged() { - let record = logged_record(&Metadata { - session_id: Some("session-1".to_string()), - subagent_identity_unsupported: true, - ..Metadata::default() - }); - assert_eq!(record["subagent_identity_unsupported"], true); - assert!(record.get("delegated").is_none()); - } - - /// A call without agent identity keeps the record it had before, with no - /// agent fields at all. - #[test] - fn a_call_without_agent_identity_writes_no_agent_fields() { - let record = logged_record(&Metadata { - session_id: Some("session-1".to_string()), - ..Metadata::default() - }); - for field in [ - "agent_id", - "parent_agent_id", - "delegated", - "agent_kind", - "agent_role", - "subagent_identity_unsupported", - ] { - assert!(record.get(field).is_none(), "{field} should be absent"); - } + assert!(record.get("subagent_identity_unsupported").is_none()); } /// Only the requested session is counted, absent fields fall back to zero