diff --git a/.agents/skills/dco-signoff/SKILL.md b/.agents/skills/dco-signoff/SKILL.md new file mode 100644 index 000000000..8a6728395 --- /dev/null +++ b/.agents/skills/dco-signoff/SKILL.md @@ -0,0 +1,109 @@ +--- +name: dco-signoff +description: Fix missing DCO (Developer Certificate of Origin) sign-off on branch commits and prevent the issue going forward. Use when CI reports a DCO check failure, a PR is blocked by "Signed-off-by missing", or a collaborator asks you to add sign-off to commits. +--- + +# DCO Sign-Off + +Every commit merged into Switchyard must carry a `Signed-off-by` trailer: + +``` +Signed-off-by: Your Name +``` + +This is enforced by the DCO bot on every pull request. A branch with any commit missing this trailer will be blocked from merge. + +## Detect missing sign-offs + +Check which branch commits are missing the trailer: + +```bash +git log origin/main..HEAD --format="%H %s" | while read sha msg; do + if ! git show -s --format="%B" "$sha" | grep -q "^Signed-off-by:"; then + echo "MISSING: $sha $msg" + fi +done +``` + +If the output is empty, all commits are already signed and no action is needed. + +## Fix: add sign-off to all branch commits + +Rebase the branch onto `origin/main`, adding `--signoff` to retrofit the trailer on every commit: + +```bash +git rebase origin/main --signoff +``` + +Then push. Because a rebase rewrites SHAs, you must force-push: + +```bash +git push --force-with-lease origin HEAD +``` + +`--force-with-lease` is safer than `--force`: it aborts if the remote has received new commits since your last fetch, protecting against overwriting a collaborator's work. + +### When the rebase hits a conflict + +Resolve each conflict normally, then continue: + +```bash +git add +git rebase --continue +``` + +The `--signoff` flag was set at rebase-start; `--continue` applies it to each commit as it lands. You do not need to pass `--signoff` again. + +## Prevent it going forward + +Pass `-s` (shorthand for `--signoff`) on every `git commit`: + +```bash +git commit -s -m "type(scope): your message" +``` + +Or add it to the repo's local git config so it is applied automatically: + +```bash +git config commit.gpgSign false # unrelated — don't confuse with signoff +``` + +There is no `commit.signoff = true` git config option; the `-s` flag must be used explicitly each time, or the workflow must always pass it. The safest habit is to include `-s` in every `git commit` invocation. + +## What a correct trailer looks like + +``` +fix(protocol): correct sub-agent detection for Claude Code + +Signed-off-by: Lin Jia +``` + +The name and email must match the contributor's Git identity (`git config user.name` and `git config user.email`). A mismatch causes the DCO bot to reject the commit even when the trailer is present. + +## Verify the fix + +After rebasing and pushing, confirm locally before relying on CI: + +```bash +git log origin/main..HEAD --format="%H %s%n%b" | grep -E "(^[0-9a-f]{40}|Signed-off-by)" +``` + +Every commit SHA should be followed by a `Signed-off-by:` line. + +## Boundaries + +### Always do + +- Use `--force-with-lease` instead of `--force` when pushing a rebased branch. +- Verify that `git config user.name` and `git config user.email` match the expected identity before adding sign-off — a mismatch is caught by the DCO bot. +- Run the detection check first; if all commits already have sign-off, do nothing. + +### Ask first + +- Rebasing a branch that has open review comments attached to specific commit SHAs — the rewrite makes those comments orphaned on GitHub. +- Force-pushing a branch that is also used by another collaborator's local checkout. + +### Never do + +- Force-push `main` or any protected branch to add sign-off. The correct fix for a merged commit is to ensure future commits are signed; retroactive rewriting of main history is not possible. +- Use `--no-verify` to skip the DCO pre-push hook if one is configured — that bypasses the gate without fixing the root cause. diff --git a/.agents/skills/switchyard-lib-core/SKILL.md b/.agents/skills/switchyard-lib-core/SKILL.md index 7a1207663..7bf5268bd 100644 --- a/.agents/skills/switchyard-lib-core/SKILL.md +++ b/.agents/skills/switchyard-lib-core/SKILL.md @@ -39,6 +39,7 @@ right validation set. If the change is driven by a launcher need, also read | An OpenAI-compatible provider target such as NVIDIA Inference Hub or OpenRouter | Use the existing OpenAI-compatible backend/profile with `base_url`, `api_key`, and model id wiring. Add a new backend only when the provider has a real wire-format, auth, retry, or health contract that cannot fit that path. | | Direct Rust component bindings | Add concrete PyO3 classes under `crates/switchyard-py/src/component_bindings/`, keep config bindings near the component binding that consumes them, and expose them lazily from `switchyard_rust/components.py`. Do not keep growing `core_bindings.rs` or `switchyard_rust/core.py` with concrete component classes. | | Route YAML / model dispatch | Use `switchyard/cli/route_bundle.py` and `switchyard/lib/route_table_builders.py`. They build `RouteTable` entries from profile-backed runtimes and keep launchers plus `switchyard serve --routing-profiles` on one path. | +| Route sub-agent traffic to a fixed worker target | Set `subagent_target: ` in any profile's common envelope (consumed like `type` in Rust `SerializedProfileConfig`, `crates/switchyard-components-v2/src/config/parsing.rs`). The Python loader (`switchyard/lib/profiles/loader.py`) wraps the built profile in `SubagentOverrideProfile`; detection is the Rust-bound `is_subagent_request(headers)` from `switchyard_rust.profiles`, a thin wrapper over `Metadata::from_headers` + `Metadata::is_subagent_work` in `crates/protocol/src/metadata.rs` (the canonical lineage fact and work-vs-maintenance policy). On the libsy `Algorithm` path, wrap with the `SubagentOverride` combinator (`crates/libsy/src/algorithms/subagent_override.rs`; Python: `switchyard.libsy.algorithms.subagent_override`). Do not re-implement header sniffing inside individual profiles or algorithms. | | Shared/persistent session-affinity pins across workers or pod churn | Configure the latency route with `session_affinity: true` + `affinity_store: redis` + `affinity_store_url` (optional `affinity_store_ttl_seconds`, `affinity_key_prefix`); the escalation_router route takes the same `affinity_store*` keys (no `session_affinity` flag — its latch is always on; default prefix `swyd:esc:`). `SessionAffinity` keeps the Rust `SessionCache` as L1 and reads/writes through the `AffinityPinStore` L2 (`switchyard/lib/redis_pin_store.py`), fail-open behind a 0.1s socket timeout and a 3-failure/10s-cooldown circuit breaker (`switchyard_affinity_l2_breaker_open` gauge). Requires the `switchyard[affinity-redis]` extra. | | Stats / telemetry | Reuse `StatsRequestProcessor`, `StatsResponseProcessor`, `StatsLlmBackend`, and `StatsAccumulator`. A profile config should thread one accumulator through all three when stats are enabled. Do not write a parallel collector. | | A fixed-path endpoint contributed by per-route components | Set `Endpoint.register_once = True`; `build_switchyard_app(...)` mounts the first instance while still running every component's lifecycle. Leave the default `False` for configurable endpoint classes that may mount distinct instances. | diff --git a/Cargo.lock b/Cargo.lock index 9f7955951..b10531a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "switchyard-components", "switchyard-components-v2-macros", "switchyard-core", + "switchyard-protocol", "tokio", "toml", "tracing", @@ -1772,6 +1773,7 @@ dependencies = [ "switchyard-components-v2", "switchyard-core", "switchyard-libsy", + "switchyard-protocol", "switchyard-translation", "tokio", "tracing", diff --git a/crates/libsy/src/algorithms.rs b/crates/libsy/src/algorithms.rs index 4f1522931..c2b500704 100644 --- a/crates/libsy/src/algorithms.rs +++ b/crates/libsy/src/algorithms.rs @@ -10,8 +10,10 @@ pub mod fall_through; pub mod llm_class; pub mod noop; pub mod rand; +pub mod subagent_override; pub use fall_through::{FallThrough, FallThroughDecision}; pub use llm_class::{ClassifierDecision, ClassifierTier, LlmClassifier}; pub use noop::{Noop, NoopDecision}; pub use rand::{Random, RandomDecision}; +pub use subagent_override::{SubagentDecision, SubagentOverride}; diff --git a/crates/libsy/src/algorithms/subagent_override.rs b/crates/libsy/src/algorithms/subagent_override.rs new file mode 100644 index 000000000..599213b1f --- /dev/null +++ b/crates/libsy/src/algorithms/subagent_override.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sub-agent override combinator built on the [`Algorithm`] interfaces. +//! +//! Wraps any algorithm without changing its behavior for normal traffic. A +//! request whose [`Metadata`] marks delegated sub-agent work +//! ([`Metadata::is_subagent_work`]) is served by one fixed worker target — +//! keeping a sub-agent loop on an intentional, cache-compatible target — +//! while every other request delegates to the wrapped algorithm. The wrapped +//! algorithm never learns about harnesses or lineage headers, and a worker +//! failure surfaces as a normal target error rather than re-entering the +//! wrapped algorithm. + +use std::error::Error; +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::{Algorithm, Context, Decision, Driver, LlmTarget, Metadata, Request, Response}; + +/// Decision produced by [`SubagentOverride`] when it routes to the worker target. +pub struct SubagentDecision { + /// The fixed worker target selected for the sub-agent request. + pub selected_model: String, + /// Human-readable explanation of the override. + pub reasoning: String, +} + +impl Decision for SubagentDecision { + fn selected_model(&self) -> &str { + &self.selected_model + } + + fn reasoning(&self) -> Option<&str> { + Some(&self.reasoning) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Routes delegated sub-agent work to a fixed worker target; delegates the rest. +pub struct SubagentOverride { + inner: Arc, + worker: LlmTarget, +} + +impl SubagentOverride { + /// Wraps `inner`, sending recognized sub-agent work to `worker` instead. + /// + /// Wrap it in an [`Arc`] and drive it with [`Algorithm::run`] or + /// [`Algorithm::run_stream`]. + pub fn new(inner: Arc, worker: LlmTarget) -> Self { + Self { inner, worker } + } +} + +#[async_trait] +impl Algorithm for SubagentOverride { + async fn create_run_task( + self: Arc, + ctx: Context, + driver: Driver, + request: Request, + ) -> Result> { + let is_subagent_work = request + .metadata + .as_ref() + .is_some_and(Metadata::is_subagent_work); + if !is_subagent_work { + return Arc::clone(&self.inner) + .create_run_task(ctx, driver, request) + .await; + } + + let selected = self.worker.semantic_name.clone(); + let decision: Arc = Arc::new(SubagentDecision { + reasoning: format!("sub-agent work routed to fixed worker target '{selected}'"), + selected_model: selected, + }); + driver.info(ctx.clone(), Arc::clone(&decision)).await?; + driver + .call_llm_target(ctx, &self.worker, request, decision) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + use switchyard_protocol::{completion_text, text_request, text_response}; + + use crate::algorithms::Random; + use crate::{LlmResponse, LlmTargetSet, RoutedLlmClient}; + + /// Echoes the selected target so tests can inspect which target was called. + struct EchoClient; + + #[async_trait] + impl RoutedLlmClient for EchoClient { + async fn call( + &self, + _ctx: Context, + _request: Request, + decision: Arc, + ) -> Result> { + Ok(Response { + llm_response: LlmResponse::Agg(text_response(None, decision.selected_model())), + metadata: None, + }) + } + } + + fn target(name: &str) -> LlmTarget { + LlmTarget { + semantic_name: name.to_string(), + llm_client: Some(Arc::new(EchoClient)), + } + } + + fn request(headers: &[(&str, &str)]) -> Request { + let metadata = (!headers.is_empty()).then(|| { + Metadata::from_headers( + &headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect::>(), + ) + }); + Request { + llm_request: text_request(Some("auto".to_string()), "hi"), + raw_request: None, + metadata, + } + } + + /// Wraps single-target random routing so the inner selection is deterministic. + fn algorithm() -> Arc { + let inner: Arc = + Arc::new(Random::new(LlmTargetSet::new(vec![target("orchestrator")]))); + Arc::new(SubagentOverride::new(inner, target("worker"))) + } + + async fn selected_model( + headers: &[(&str, &str)], + ) -> Result> { + let (trace, response) = algorithm() + .run(Context::default(), request(headers)) + .await?; + let selected = response + .llm_response + .as_agg() + .map(completion_text) + .unwrap_or_default(); + assert_eq!( + trace.last().map(|d| d.selected_model().to_string()), + Some(selected.clone()) + ); + Ok(selected) + } + + #[tokio::test] + async fn requests_without_metadata_delegate_to_the_wrapped_algorithm( + ) -> Result<(), Box> { + assert_eq!(selected_model(&[]).await?, "orchestrator"); + Ok(()) + } + + #[tokio::test] + async fn subagent_work_is_routed_to_the_worker_target( + ) -> Result<(), Box> { + // Claude Code child-agent lineage. + let claude = &[ + ("x-claude-code-session-id", "root"), + ("x-claude-code-agent-id", "child-1"), + ]; + assert_eq!(selected_model(claude).await?, "worker"); + + // Codex delegated-work kinds. + assert_eq!( + selected_model(&[("x-openai-subagent", "review")]).await?, + "worker" + ); + assert_eq!( + selected_model(&[("x-openai-subagent", "collab_spawn")]).await?, + "worker" + ); + Ok(()) + } + + #[tokio::test] + async fn harness_maintenance_turns_stay_on_the_wrapped_algorithm( + ) -> Result<(), Box> { + assert_eq!( + selected_model(&[("x-openai-subagent", "compact")]).await?, + "orchestrator" + ); + assert_eq!( + selected_model(&[("x-switchyard-is-subagent", "false")]).await?, + "orchestrator" + ); + Ok(()) + } +} diff --git a/crates/protocol/src/metadata.rs b/crates/protocol/src/metadata.rs index a54502766..ae18b3cd7 100644 --- a/crates/protocol/src/metadata.rs +++ b/crates/protocol/src/metadata.rs @@ -54,9 +54,8 @@ 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. +// OpenCode session header — used for session_id correlation only (not a routing signal). 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"; @@ -65,16 +64,10 @@ 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, -]; +/// Harness-defined sub-agent kinds that carry delegated user work rather than +/// harness maintenance (`compact`, `memory_consolidation`, ...). Unknown kinds +/// are excluded deliberately; extend with captured request fixtures. +const SUBAGENT_WORK_KINDS: &[&str] = &["collab_spawn", "review"]; /// Ordered candidate lookup paths for each correlation field, keyed by the field's /// canonical `x-switchyard-*` header name. @@ -172,6 +165,11 @@ pub struct Metadata { pub parent_agent_id: Option, /// Whether the harness identified this request as coming from a child agent. pub is_subagent: bool, + /// Whether this request carries delegated sub-agent *work* and should be + /// routed to the sub-agent target. Computed from raw harness signals only, + /// independent of [`Self::agent_kind`], which may be set by an unrelated + /// operator label (`x-switchyard-agent-kind`). + pub is_delegated_work: 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`. @@ -199,23 +197,24 @@ 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. + /// headers are accepted for observability without driving routing. Codex's + /// structured turn metadata is preferred over its compatibility projections. + /// Claude Code carries agent lineage in native headers: a request with a + /// non-empty `x-claude-code-agent-id` is treated as a child agent (its parent + /// inferred from the session when not stated). Sub-agent routing status is taken + /// from an explicit `x-switchyard-is-subagent` header when present, and otherwise + /// inferred from Claude Code lineage or Codex harness signals. pub fn from_headers(headers: &BTreeMap) -> Self { let headers = &normalize_headers(headers); - let (parent_agent_id, is_subagent) = parse_sub_agent(headers); + let (parent_agent_id, is_subagent, is_delegated_work) = parse_sub_agent(headers); Metadata { session_id: sy_header(headers, SWITCHYARD_SESSION_ID_HEADER), agent_id: sy_header(headers, SWITCHYARD_AGENT_ID_HEADER), parent_agent_id, is_subagent, + is_delegated_work, 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), @@ -228,61 +227,76 @@ impl Metadata { ..Metadata::default() } } -} -/// 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 let Some(is_sub) = sy_is_sub_agent { - if parent.is_some() { - return (parent, is_sub); - } + /// Whether this request should be routed to the sub-agent target. + /// + /// Returns `self.is_delegated_work`, which is computed in `parse_sub_agent` + /// from raw harness signals only — independent of `agent_kind`, which may + /// be populated by an unrelated operator label (`x-switchyard-agent-kind`). + pub fn is_subagent_work(&self) -> bool { + self.is_delegated_work } - 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); - } +/// Returns `(parent_agent_id, is_subagent, is_delegated_work)` from the headers. +/// +/// Recognized sub-agent signals: Claude Code `x-claude-code-agent-id`, Codex +/// `x-openai-subagent` / `x-codex-turn-metadata.subagent_kind`, and explicit +/// `x-switchyard-is-subagent`. Correlation-only headers (Relay, Dynamo, OpenCode +/// parent sessions) populate observability fields but do not drive routing. +/// +/// `is_delegated_work` is computed from raw harness signals, not from `agent_kind`, +/// which may be set by an unrelated operator label (`x-switchyard-agent-kind`). +fn parse_sub_agent(headers: &BTreeMap) -> (Option, bool, bool) { + let explicit = header(headers, SWITCHYARD_IS_SUBAGENT_HEADER).and_then(parse_bool); - 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); - } + let (claude_parent, claude_subagent) = claude_lineage(headers); - is_subagent |= SUBAGENT_SIGNAL_PATHS - .iter() - .any(|path| resolve_path(headers, path).is_some()); + // Harness routing signal: Codex turn-metadata kind or flat OpenAI subagent header. + // `x-switchyard-agent-kind` (operator semantic label) is intentionally excluded. + let harness_kind = resolve_path(headers, CODEX_SUBAGENT_KIND_PATH) + .or_else(|| header(headers, OPENAI_SUBAGENT_HEADER).map(str::to_string)); + + // Parent resolved via HEADER_CONFIG precedence (covers Dynamo/Codex correlation); + // falls back to the Claude Code session the child was spawned under. + let parent = sy_header(headers, SWITCHYARD_PARENT_AGENT_ID_HEADER) + .or_else(|| claude_parent.map(str::to_string)); + + let is_subagent = explicit.unwrap_or(claude_subagent || harness_kind.is_some()); + + let is_delegated_work = match explicit { + Some(false) => false, + Some(true) => harness_kind + .as_deref() + .map(|k| SUBAGENT_WORK_KINDS.contains(&k)) + .unwrap_or(true), + None => { + claude_subagent + || harness_kind + .as_deref() + .is_some_and(|k| SUBAGENT_WORK_KINDS.contains(&k)) + } + }; - (parent, is_subagent) + (parent, is_subagent, is_delegated_work) } /// 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. +/// Claude Code only sends `x-claude-code-agent-id` for spawned sub-agents and +/// teammates; root agents omit it. Any non-empty value is therefore a +/// sub-agent signal. The parent is the explicit parent-agent header when +/// present, else the session the child was spawned under. 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 is_subagent = agent.is_some(); 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. fn parse_bool(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { @@ -393,6 +407,31 @@ mod tests { assert!(!metadata.is_subagent); } + #[test] + fn codex_parent_thread_id_alone_is_not_a_subagent_signal() { + // Parent-thread-id is correlation data, not a routing signal. A Codex + // turn that carries a parent thread id but no `x-openai-subagent` must + // not be treated as sub-agent work. + let headers = BTreeMap::from([( + CODEX_TURN_METADATA_HEADER.to_string(), + serde_json::json!({ + "session_id": "root-session", + "thread_id": "child-thread", + "parent_thread_id": "root-thread", + "turn_id": "turn-3", + // no subagent_kind + }) + .to_string(), + )]); + + let metadata = Metadata::from_headers(&headers); + // Parent id is still captured for observability. + assert_eq!(metadata.parent_agent_id.as_deref(), Some("root-thread")); + // But it must not drive routing. + assert!(!metadata.is_subagent); + assert!(!metadata.is_subagent_work()); + } + #[test] fn explicit_switchyard_subagent_flag_overrides_inference() { let headers = BTreeMap::from([ @@ -425,6 +464,8 @@ mod tests { #[test] fn normalizes_relay_and_dynamo_child_headers() { + // Relay and Dynamo headers are correlation data, not routing signals. + // They populate observability fields but must not trigger sub-agent routing. let headers = BTreeMap::from([ ( "x-nemo-relay-session-id".to_string(), @@ -443,13 +484,15 @@ mod tests { 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); + assert_eq!(metadata.parent_agent_id.as_deref(), Some("relay-parent")); + assert!(!metadata.is_subagent); + assert!(!metadata.is_subagent_work()); } #[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. + // Any non-empty agent id is a child agent. Without an explicit parent + // header the parent is inferred to be the session it was spawned under. let metadata = Metadata::from_headers(&BTreeMap::from([ ( "x-claude-code-session-id".to_string(), @@ -466,6 +509,19 @@ mod tests { assert_eq!(metadata.parent_agent_id.as_deref(), Some("claude-session")); } + #[test] + fn claude_code_agent_id_alone_marks_subagent() { + // The agent-id header is the detection predicate; the session header is + // correlation data. A request with only agent-id is still a child agent. + let metadata = Metadata::from_headers(&BTreeMap::from([( + "x-claude-code-agent-id".to_string(), + "claude-agent".to_string(), + )])); + assert!(metadata.is_subagent); + assert_eq!(metadata.agent_id.as_deref(), Some("claude-agent")); + assert_eq!(metadata.parent_agent_id, None); + } + #[test] fn explicit_claude_parent_agent_overrides_inferred_session() { let metadata = Metadata::from_headers(&BTreeMap::from([ @@ -489,9 +545,9 @@ mod tests { } #[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. + fn claude_root_agent_without_agent_id_is_not_a_subagent() { + // Root agents omit x-claude-code-agent-id entirely. A stray parent-agent + // header without an agent-id must not mark the request as a child. let metadata = Metadata::from_headers(&BTreeMap::from([ ( "x-claude-code-session-id".to_string(), @@ -509,7 +565,9 @@ mod tests { } #[test] - fn opencode_parent_session_marks_subagent() { + fn opencode_session_headers_are_correlation_only() { + // OpenCode's x-session-id / x-parent-session-id are correlation headers; + // they populate session_id for observability but do not trigger routing. let metadata = Metadata::from_headers(&BTreeMap::from([ ("x-session-id".to_string(), "opencode-run".to_string()), ( @@ -518,14 +576,14 @@ mod tests { ), ])); 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")); + assert!(!metadata.is_subagent); + assert_eq!(metadata.parent_agent_id, None); } #[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. + fn opencode_parent_header_is_not_a_parent_agent_id_source() { + // x-parent-session-id is not listed in HEADER_CONFIG for parent_agent_id; + // it must not surface as a parent regardless of adjacent session headers. let metadata = Metadata::from_headers(&BTreeMap::from([ ("session-id".to_string(), "codex-run".to_string()), ( @@ -603,4 +661,77 @@ mod tests { )])); assert_eq!(metadata.session_id.as_deref(), Some("codex-run")); } + + #[test] + fn explicit_subagent_flag_decides_without_a_parent_header() { + // Explicit `false` wins over presence-based inference even when no + // parent id accompanies it; the flag decides in both directions. + let metadata = Metadata::from_headers(&BTreeMap::from([ + ("x-switchyard-is-subagent".to_string(), "false".to_string()), + ("x-openai-subagent".to_string(), "review".to_string()), + ])); + assert!(!metadata.is_subagent); + + let metadata = Metadata::from_headers(&BTreeMap::from([( + "x-switchyard-is-subagent".to_string(), + "true".to_string(), + )])); + assert!(metadata.is_subagent); + } + + #[test] + fn operator_agent_kind_does_not_suppress_harness_subagent_routing() { + // x-switchyard-agent-kind is an operator semantic label and must not filter + // routing signals from the harness (x-openai-subagent, x-switchyard-is-subagent). + let with_openai = Metadata::from_headers(&BTreeMap::from([ + ("x-openai-subagent".to_string(), "review".to_string()), + ( + "x-switchyard-agent-kind".to_string(), + "researcher".to_string(), + ), + ])); + assert!(with_openai.is_subagent); + assert!(with_openai.is_subagent_work()); + + let with_explicit = Metadata::from_headers(&BTreeMap::from([ + ("x-switchyard-is-subagent".to_string(), "true".to_string()), + ( + "x-switchyard-agent-kind".to_string(), + "researcher".to_string(), + ), + ])); + assert!(with_explicit.is_subagent); + assert!(with_explicit.is_subagent_work()); + } + + #[test] + fn subagent_work_requires_a_delegated_work_kind_when_kinded() { + // Kindless lineage (Claude Code child agent) counts as delegated work. + let claude_child = Metadata::from_headers(&BTreeMap::from([ + ("x-claude-code-session-id".to_string(), "root".to_string()), + ("x-claude-code-agent-id".to_string(), "worker".to_string()), + ])); + assert!(claude_child.is_subagent_work()); + + // Codex delegated-work kinds route as sub-agent work. + let review = Metadata::from_headers(&BTreeMap::from([( + "x-openai-subagent".to_string(), + "review".to_string(), + )])); + assert!(review.is_subagent_work()); + + // Harness maintenance and unknown kinds stay on normal routing even + // though the lineage fact still marks them as child-agent requests. + for kind in ["compact", "memory_consolidation", "brand_new_kind"] { + let metadata = Metadata::from_headers(&BTreeMap::from([( + "x-openai-subagent".to_string(), + kind.to_string(), + )])); + assert!(metadata.is_subagent, "{kind} keeps the lineage fact"); + assert!(!metadata.is_subagent_work(), "{kind} is not routed as work"); + } + + // A non-subagent request is never work, whatever its kind says. + assert!(!Metadata::default().is_subagent_work()); + } } diff --git a/crates/switchyard-components-v2/Cargo.toml b/crates/switchyard-components-v2/Cargo.toml index 204c2a2eb..8e6b06374 100644 --- a/crates/switchyard-components-v2/Cargo.toml +++ b/crates/switchyard-components-v2/Cargo.toml @@ -23,6 +23,7 @@ serde_json = "1" switchyard-components = { path = "../switchyard-components" } switchyard-components-v2-macros = { path = "../switchyard-components-v2-macros" } switchyard-core = { path = "../switchyard-core" } +switchyard-protocol = { path = "../protocol" } toml = "1.1" tracing = { version = "0.1", default-features = false, features = ["std"] } yaml_serde = "0.10" diff --git a/crates/switchyard-components-v2/src/config/parsing.rs b/crates/switchyard-components-v2/src/config/parsing.rs index 8e87ead6a..e20fbb42a 100644 --- a/crates/switchyard-components-v2/src/config/parsing.rs +++ b/crates/switchyard-components-v2/src/config/parsing.rs @@ -57,6 +57,13 @@ impl ProfileConfigDocument { .map(SerializedProfileConfig::body) } + /// Returns a profile's envelope `subagent_target` reference, if configured. + pub fn profile_subagent_target(&self, profile_id: &ProfileId) -> Option<&LlmTargetId> { + self.profiles + .get(profile_id) + .and_then(SerializedProfileConfig::subagent_target) + } + /// Returns a copy of this document with the selected profiles removed. pub fn without_profiles(&self, profile_ids: &[ProfileId]) -> Self { let omitted = profile_ids.iter().collect::>(); @@ -68,16 +75,22 @@ impl ProfileConfigDocument { } } -/// Serialized profile body split into a type discriminator and profile-owned fields. +/// Serialized profile body split into a type discriminator, common envelope +/// fields, and profile-owned fields. #[derive(Clone, Debug, PartialEq)] pub(super) struct SerializedProfileConfig { profile_type: String, + subagent_target: Option, body: Value, } impl SerializedProfileConfig { /// Creates a serialized profile config after verifying the body is an object. - fn new(profile_type: impl Into, body: Value) -> Result { + fn new( + profile_type: impl Into, + subagent_target: Option, + body: Value, + ) -> Result { let profile_type = profile_type.into(); if profile_type.trim().is_empty() { return Err(SwitchyardError::InvalidConfig( @@ -89,7 +102,11 @@ impl SerializedProfileConfig { "profile config body must be an object".to_string(), )); } - Ok(Self { profile_type, body }) + Ok(Self { + profile_type, + subagent_target, + body, + }) } /// Returns the profile type discriminator from the file's `type` field. @@ -97,7 +114,12 @@ impl SerializedProfileConfig { &self.profile_type } - /// Returns the profile-owned config fields without the `type` discriminator. + /// Returns the envelope `subagent_target` reference from the file, if any. + pub(super) fn subagent_target(&self) -> Option<&LlmTargetId> { + self.subagent_target.as_ref() + } + + /// Returns the profile-owned config fields without the envelope fields. pub(super) fn body(&self) -> &Value { &self.body } @@ -118,7 +140,21 @@ impl<'de> Deserialize<'de> for SerializedProfileConfig { let Value::String(profile_type) = profile_type else { return Err(de::Error::custom("profile config `type` must be a string")); }; - SerializedProfileConfig::new(profile_type, value).map_err(de::Error::custom) + // `subagent_target` is a common envelope field consumed here, like `type`, + // so profile-owned bodies (which deny unknown fields) never see it. + let subagent_target = match map.remove("subagent_target") { + None => None, + Some(Value::String(target_id)) => { + Some(LlmTargetId::new(target_id).map_err(de::Error::custom)?) + } + Some(_) => { + return Err(de::Error::custom( + "profile config `subagent_target` must be a target id string", + )); + } + }; + SerializedProfileConfig::new(profile_type, subagent_target, value) + .map_err(de::Error::custom) } } @@ -131,6 +167,12 @@ impl Serialize for SerializedProfileConfig { return Err(ser::Error::custom("profile config body must be an object")); }; map.insert("type".to_string(), Value::String(self.profile_type.clone())); + if let Some(subagent_target) = &self.subagent_target { + map.insert( + "subagent_target".to_string(), + Value::String(subagent_target.as_str().to_owned()), + ); + } Value::Object(map).serialize(serializer) } } diff --git a/crates/switchyard-components-v2/src/config/resolving.rs b/crates/switchyard-components-v2/src/config/resolving.rs index e7fcd6092..b019e0c74 100644 --- a/crates/switchyard-components-v2/src/config/resolving.rs +++ b/crates/switchyard-components-v2/src/config/resolving.rs @@ -11,16 +11,18 @@ use switchyard_core::{ }; use super::parsing::{ProfileConfigDocument, SerializedProfileConfig, TargetConfig}; -use crate::profiles::ProfileConfigEntry; +use crate::profiles::{ProfileConfigEntry, SubagentOverrideProfile}; use crate::{Profile, ProfileHooks}; impl ProfileConfigDocument { /// Resolves endpoints and validates every profile through its owning profile type. pub fn resolve(&self) -> Result { let targets = self.resolve_targets()?; + let (profiles, profile_subagent_targets) = self.resolve_profiles(&targets)?; Ok(ProfileConfigPlan { - profiles: self.resolve_profiles(&targets)?, targets, + profiles, + profile_subagent_targets, }) } @@ -37,19 +39,35 @@ impl ProfileConfigDocument { } // Parses profile-specific config bodies into the generated typed profile enum. + // Also collects resolved sub-agent targets for profiles that declare one. fn resolve_profiles( &self, targets: &BTreeMap, - ) -> Result> { + ) -> Result<( + BTreeMap, + BTreeMap, + )> { let env = ProfileBuildEnv::new(targets); let mut profiles = BTreeMap::new(); + let mut subagent_targets: BTreeMap = BTreeMap::new(); for (profile_id, profile) in &self.profiles { + // The envelope-level sub-agent override must name a resolvable target; + // an unknown reference is a startup configuration error. + if let Some(target_id) = profile.subagent_target() { + let target = targets.get(target_id).ok_or_else(|| { + SwitchyardError::InvalidConfig(format!( + "profile {profile_id}: subagent_target references unknown target {}", + target_id.as_str() + )) + })?; + subagent_targets.insert(profile_id.clone(), target.clone()); + } profiles.insert( profile_id.clone(), resolve_profile(profile_id, profile, &env)?, ); } - Ok(profiles) + Ok((profiles, subagent_targets)) } } @@ -150,6 +168,8 @@ pub struct ProfileConfigPlan { targets: BTreeMap, /// Typed profile configs keyed by the user-facing profile ID. profiles: BTreeMap, + /// Resolved sub-agent override targets for profiles that declare one. + profile_subagent_targets: BTreeMap, } impl fmt::Debug for ProfileConfigPlan { @@ -227,14 +247,26 @@ impl ProfileConfigPlan { } // Builds one typed profile config with profile-ID context in errors. + // Wraps with SubagentOverrideProfile when the profile declares a subagent_target. fn build_typed_profile( &self, profile_id: &ProfileId, profile: &ProfileConfigEntry, ) -> Result> { - profile.build_boxed().map_err(|error| { + let inner = profile.build_boxed().map_err(|error| { SwitchyardError::InvalidConfig(format!("profile {profile_id}: {error}")) - }) + })?; + if let Some(target) = self.profile_subagent_targets.get(profile_id) { + SubagentOverrideProfile::new(inner, target.clone()) + .map(|p| Box::new(p) as Box) + .map_err(|error| { + SwitchyardError::InvalidConfig(format!( + "profile {profile_id}: subagent override: {error}" + )) + }) + } else { + Ok(inner) + } } } diff --git a/crates/switchyard-components-v2/src/profiles/mod.rs b/crates/switchyard-components-v2/src/profiles/mod.rs index 91ca996b2..f83960023 100644 --- a/crates/switchyard-components-v2/src/profiles/mod.rs +++ b/crates/switchyard-components-v2/src/profiles/mod.rs @@ -11,6 +11,7 @@ mod passthrough; mod profile_types; mod random_routing; mod stage_router; +mod subagent_override; pub use latency_service::{ EndpointHealth, EndpointHealthStatus, LatencyServiceProcessedRequest, LatencyServiceProfile, @@ -31,3 +32,4 @@ pub use stage_router::{ StageRouterPickerMode, StageRouterProcessedRequest, StageRouterProfile, StageRouterProfileConfig, StageRouterTier, }; +pub(crate) use subagent_override::SubagentOverrideProfile; diff --git a/crates/switchyard-components-v2/src/profiles/subagent_override.rs b/crates/switchyard-components-v2/src/profiles/subagent_override.rs new file mode 100644 index 000000000..b9275b4bd --- /dev/null +++ b/crates/switchyard-components-v2/src/profiles/subagent_override.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sub-agent override combinator for v2 profiles. +//! +//! Wraps any profile without changing its behavior for normal traffic. A +//! request whose headers mark delegated sub-agent work +//! ([`switchyard_protocol::Metadata::is_subagent_work`]) is served directly by +//! a fixed worker target — keeping a sub-agent loop on an intentional, +//! cache-compatible target — while every other request delegates to the wrapped +//! profile. A worker failure surfaces as a normal target error and is never +//! silently re-routed through the wrapped profile. + +use std::collections::BTreeMap; +use std::time::Instant; + +use async_trait::async_trait; +use switchyard_components::StatsAccumulator; +use switchyard_core::{LlmTarget, Result}; + +use crate::backend::{native_target_backend, TargetBackend}; +use crate::profile_stats_accumulator; +use crate::stats_recording::record_usage_or_wrap_stream; +use crate::{Profile, ProfileInput, ProfileResponse}; + +/// Wraps a profile, routing delegated sub-agent work to a fixed worker target. +pub(crate) struct SubagentOverrideProfile { + inner: Box, + worker: TargetBackend, + stats: StatsAccumulator, +} + +impl SubagentOverrideProfile { + /// Wraps `inner`, routing recognized sub-agent work requests to `worker`. + pub(crate) fn new(inner: Box, target: LlmTarget) -> Result { + Ok(Self { + inner, + worker: native_target_backend(target)?, + stats: profile_stats_accumulator(), + }) + } +} + +/// Returns true when the request headers signal delegated sub-agent work. +/// +/// Normalizes multi-value headers to single-value (first occurrence wins) +/// before calling the protocol-layer detection logic. +fn is_subagent_work(headers: &BTreeMap>) -> bool { + let flat: BTreeMap = headers + .iter() + .filter_map(|(k, vs)| vs.first().map(|v| (k.clone(), v.clone()))) + .collect(); + switchyard_protocol::Metadata::from_headers(&flat).is_subagent_work() +} + +#[async_trait] +impl Profile for SubagentOverrideProfile { + /// Routes sub-agent work to the worker target; delegates everything else. + async fn run(&self, mut input: ProfileInput) -> Result { + if !is_subagent_work(&input.metadata.headers) { + return self.inner.run(input).await; + } + + let profile_started_at = Instant::now(); + let target_model = self.worker.target().model.clone(); + input.request.set_model(target_model.as_str()); + + let backend_started_at = Instant::now(); + let response = match self.worker.call(&input.request).await { + Ok(response) => response, + Err(error) => { + self.stats.record_error(target_model.as_str(), None)?; + return Err(error); + } + }; + + let backend_latency_ms = backend_started_at.elapsed().as_secs_f64() * 1000.0; + self.stats + .record_success(target_model.to_string(), Some(backend_latency_ms), None)?; + let response = record_usage_or_wrap_stream( + &self.stats, + target_model.as_str(), + None, + profile_started_at, + backend_latency_ms, + response, + )?; + Ok(ProfileResponse::from(response)) + } +} diff --git a/crates/switchyard-components-v2/tests/config.rs b/crates/switchyard-components-v2/tests/config.rs index 2be46883c..943a34cf8 100644 --- a/crates/switchyard-components-v2/tests/config.rs +++ b/crates/switchyard-components-v2/tests/config.rs @@ -696,3 +696,100 @@ fn profile_config_format_rejects_unknown_extension() { .unwrap_or_else(|| "expected extension failure".to_string()); assert!(error.contains("unsupported profile config extension")); } + +// The envelope-level `subagent_target` is consumed before profile-owned parsing, +// so profile types that deny unknown fields still resolve, and the reference +// stays readable from the parsed document. +#[test] +fn subagent_target_envelope_field_is_stripped_and_exposed() -> Result<()> { + let input = r#" +targets: + weak: + model: weak/model + format: openai +profiles: + direct: + type: passthrough + target: weak + subagent_target: weak +"#; + let config = parse_yaml(input)?; + let profile_id = ProfileId::new("direct")?; + assert_eq!( + config.profile_subagent_target(&profile_id), + Some(&LlmTargetId::new("weak")?) + ); + let body_has_field = config + .profile_body(&profile_id) + .and_then(|body| body.get("subagent_target")) + .is_some(); + assert!(!body_has_field); + config.resolve()?; + Ok(()) +} + +// A profile without the envelope field reports no sub-agent target. +#[test] +fn subagent_target_is_absent_by_default() -> Result<()> { + let input = r#" +targets: + weak: + model: weak/model + format: openai +profiles: + direct: + type: passthrough + target: weak +"#; + let config = parse_yaml(input)?; + assert_eq!( + config.profile_subagent_target(&ProfileId::new("direct")?), + None + ); + Ok(()) +} + +// An unknown `subagent_target` reference is a startup configuration error. +#[test] +fn subagent_target_referencing_unknown_target_is_rejected_at_resolve() -> Result<()> { + let input = r#" +targets: + weak: + model: weak/model + format: openai +profiles: + direct: + type: passthrough + target: weak + subagent_target: ghost +"#; + let error = parse_yaml(input)? + .resolve() + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "expected resolve failure".to_string()); + assert!(error.contains("profile direct")); + assert!(error.contains("subagent_target references unknown target ghost")); + Ok(()) +} + +// The envelope field must be a target id string, not a structured value. +#[test] +fn subagent_target_must_be_a_target_id_string() { + let input = r#" +targets: + weak: + model: weak/model + format: openai +profiles: + direct: + type: passthrough + target: weak + subagent_target: [weak] +"#; + let error = parse_yaml(input) + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "expected parse failure".to_string()); + assert!(error.contains("`subagent_target` must be a target id string")); +} diff --git a/crates/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index 560ea2f65..fbd9fba93 100644 --- a/crates/switchyard-py/Cargo.toml +++ b/crates/switchyard-py/Cargo.toml @@ -25,6 +25,7 @@ pythonize = "0.28.0" serde = "1" serde_json = "1" switchyard-core = { path = "../switchyard-core" } +switchyard-protocol = { path = "../protocol" } switchyard-components = { path = "../switchyard-components" } switchyard-components-v2 = { path = "../switchyard-components-v2" } switchyard-translation = { path = "../switchyard-translation" } diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index 02ce29037..8eedb7195 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -10,10 +10,10 @@ use async_trait::async_trait; use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use serde_json::{json, Value}; -use switchyard_libsy::algorithms::{Noop, Random}; +use switchyard_libsy::algorithms::{Noop, Random, SubagentOverride}; use switchyard_libsy::{ - AggLlmResponse, Algorithm, Context, Decision, LlmResponse, LlmTarget, LlmTargetSet, Request, - Response, RoutedLlmClient, + AggLlmResponse, Algorithm, Context, Decision, LlmResponse, LlmTarget, LlmTargetSet, Metadata, + Request, Response, RoutedLlmClient, }; use crate::errors::py_libsy_error; @@ -111,12 +111,23 @@ impl PyAlgorithm { #[pymethods] impl PyAlgorithm { /// Run to completion using the clients configured on the algorithm's targets. - fn run<'py>(&self, py: Python<'py>, request: &Bound<'_, PyAny>) -> PyResult> { + /// + /// `headers`, when given, is normalized into the request's correlation + /// [`Metadata`] exactly as an HTTP host would (`Metadata::from_headers`), + /// so metadata-driven algorithms such as `subagent_override` see the same + /// signals in Python as when served over HTTP. + #[pyo3(signature = (request, headers=None))] + fn run<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + headers: Option>, + ) -> PyResult> { let algorithm = Arc::clone(&self.inner); let request = Request { llm_request: from_python(request)?, raw_request: None, - metadata: None, + metadata: headers.map(|headers| Metadata::from_headers(&headers)), }; pyo3_async_runtimes::tokio::future_into_py(py, async move { let (decisions, response) = algorithm @@ -167,6 +178,24 @@ fn random_algorithm(py: Python<'_>, targets: Vec>) -> PyResult

, + inner: Py, + worker: Py, +) -> PyResult { + let inner = Arc::clone(&inner.bind(py).get().inner); + let worker = worker.bind(py).try_borrow()?.clone_core(py); + Ok(PyAlgorithm::new(Arc::new(SubagentOverride::new( + inner, worker, + )))) +} + fn boxed_python_error(error: PyErr) -> BoxError { std::io::Error::other(error.to_string()).into() } @@ -177,6 +206,10 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { libsy_module.add_class::()?; libsy_module.add_function(wrap_pyfunction!(noop_algorithm, &libsy_module)?)?; libsy_module.add_function(wrap_pyfunction!(random_algorithm, &libsy_module)?)?; + libsy_module.add_function(wrap_pyfunction!( + subagent_override_algorithm, + &libsy_module + )?)?; libsy_module.add("LibsyError", module.getattr("LibsyError")?)?; module.add_submodule(&libsy_module)?; Ok(()) diff --git a/crates/switchyard-py/src/profile_bindings/mod.rs b/crates/switchyard-py/src/profile_bindings/mod.rs index 3438ae398..eb0822847 100644 --- a/crates/switchyard-py/src/profile_bindings/mod.rs +++ b/crates/switchyard-py/src/profile_bindings/mod.rs @@ -66,6 +66,15 @@ impl PyProfileConfigDocument { .map(std::borrow::ToOwned::to_owned)) } + /// Return a parsed profile's envelope `subagent_target` reference, or `None`. + fn profile_subagent_target(&self, profile_id: &str) -> PyResult> { + let profile_id = parse_profile_id(profile_id)?; + Ok(self + .inner + .profile_subagent_target(&profile_id) + .map(|target_id| target_id.as_str().to_owned())) + } + /// Return a parsed profile's body without the `type` discriminator. fn profile_body(&self, py: Python<'_>, profile_id: &str) -> PyResult>> { let profile_id = parse_profile_id(profile_id)?; @@ -264,6 +273,25 @@ fn py_parse_profile_config_path(path: PathBuf) -> PyResult) -> PyResult { + let headers = typed::metadata_headers_from_python(headers)?; + // Lineage headers are single-valued; keep the first value when repeated. + let headers: std::collections::BTreeMap = headers + .into_iter() + .filter_map(|(name, values)| values.into_iter().next().map(|value| (name, value))) + .collect(); + Ok(switchyard_protocol::Metadata::from_headers(&headers).is_subagent_work()) +} + /// Parse and resolve a components-v2 profile config file. #[pyfunction] fn load_profile_config(path: PathBuf) -> PyResult { @@ -300,6 +328,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(py_parse_profile_config_str, module)?)?; module.add_function(wrap_pyfunction!(py_parse_profile_config_path, module)?)?; module.add_function(wrap_pyfunction!(load_profile_config, module)?)?; + module.add_function(wrap_pyfunction!(is_subagent_request, module)?)?; typed::register(module)?; Ok(()) } diff --git a/crates/switchyard-py/src/profile_bindings/typed.rs b/crates/switchyard-py/src/profile_bindings/typed.rs index c62006aa5..2a905bee2 100644 --- a/crates/switchyard-py/src/profile_bindings/typed.rs +++ b/crates/switchyard-py/src/profile_bindings/typed.rs @@ -190,7 +190,7 @@ fn parse_request_id(request_id: Option) -> PyResult> { .map_err(|error| PyValueError::new_err(format!("invalid request_id: {error}"))) } -fn metadata_headers_from_python( +pub(crate) fn metadata_headers_from_python( headers: &Bound<'_, PyAny>, ) -> PyResult>> { let dict = PyDict::new(headers.py()); diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index 386c1d6a9..dc7440c3d 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -85,6 +85,38 @@ profiles: Use the profile ID to select policy behavior (`fast` or `smart`) and a target ID to bypass routing (`weak` or `strong`). +## Sub-agent override (`subagent_target`) + +Any profile can name an optional `subagent_target` in its common envelope, +alongside `type`. When a request carries a recognized sub-agent signal — +Codex delegated-work kinds (`x-openai-subagent: collab_spawn` or `review`), +Claude Code agent lineage headers, or an explicit +`x-switchyard-is-subagent: true` — it bypasses the profile's routing and runs +as a direct passthrough to that target: + +```yaml +profiles: + smart: + type: random-routing + strong: strong + weak: weak + strong_probability: 0.3 + subagent_target: weak +``` + +This keeps a sub-agent loop on one intentional, cache-compatible target +instead of re-routing every worker turn. Codex harness-maintenance turns +(`compact`, `memory_consolidation`) and unrecognized kinds stay on normal +profile routing, as does everything else when the field is absent. An unknown +target reference fails at startup, and a sub-agent target failure surfaces as +a normal target error — it is never silently re-routed through the profile. + +To suppress sub-agent routing for a request that carries a recognized signal, +send `x-switchyard-is-subagent: false`. This explicit header overrides Codex +and Claude Code lineage signals in either direction: `false` keeps the request +on normal profile routing even when delegated-work headers are present, and +`true` marks a request as a sub-agent even when no harness headers appear. + ## Direct targets and passthrough aliases For new profile configs, use one public model concept: diff --git a/switchyard/lib/profiles/loader.py b/switchyard/lib/profiles/loader.py index ee56925be..ed9b2a491 100644 --- a/switchyard/lib/profiles/loader.py +++ b/switchyard/lib/profiles/loader.py @@ -16,6 +16,7 @@ from typing import Any, cast from switchyard.lib.profiles.protocols import ProfileInput, ProfileRunner +from switchyard.lib.profiles.subagent_override import SubagentOverrideProfile from switchyard.lib.profiles.table import ( ProfileConfigError, build_profile, @@ -63,9 +64,43 @@ def load_profiles_and_targets( built[profile_id] = _RustProfileRunner(plan.build_profile(profile_id)) for profile_id, (profile_type, body) in python_profiles.items(): built[profile_id] = _build_python_profile(profile_id, profile_type, body, plan) + for profile_id in list(built): + built[profile_id] = _wrap_subagent_override(profile_id, built[profile_id], document, plan) return built, _targets(plan) +def _wrap_subagent_override( + profile_id: str, + profile: ProfileRunner, + document: Any, + plan: ProfileConfigPlan, +) -> ProfileRunner: + """Wrap a built profile when its envelope names a ``subagent_target``. + + Rust profiles already apply :class:`SubagentOverrideProfile` internally at + build time, so Python-level wrapping is skipped for them. Python-defined + profiles receive the Python wrapper here, which adds sub-agent routing + without Rust-level stats (stats for Python profiles remain in the + Python stats pipeline). + + An unknown target reference is a startup configuration error. + """ + target_id = document.profile_subagent_target(profile_id) + if target_id is None: + return profile + # Rust profiles handle subagent routing internally; skip double-wrapping. + if isinstance(profile, _RustProfileRunner): + return profile + target = plan.target(target_id) + if target is None: + raise ProfileConfigError( + f"profile {profile_id}: subagent_target references unknown target {target_id!r}" + ) + from switchyard.lib.profiles.passthrough import PassthroughProfileConfig + + return SubagentOverrideProfile(profile, PassthroughProfileConfig(target=target).build()) + + class _RustProfileRunner: """Adapts an erased Rust ``Profile`` to the ``run(input)`` runner contract.""" diff --git a/switchyard/lib/profiles/subagent_override.py b/switchyard/lib/profiles/subagent_override.py new file mode 100644 index 000000000..794932693 --- /dev/null +++ b/switchyard/lib/profiles/subagent_override.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Sub-agent override wrapper routing delegated worker requests to a fixed target.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from switchyard.lib.profiles.protocols import ( + ContextAwareProfile, + ProfileLifecycle, + ProfileRunner, +) +from switchyard.lib.proxy_context import ProxyContext +from switchyard_rust.core import ChatResponse +from switchyard_rust.profiles import ProfileInput, is_subagent_request + + +@dataclass(slots=True) +class SubagentProcessedRequest: + """Pairs the branch selected for one request with its request-side state.""" + + branch: Any + processed: Any + + +class SubagentOverrideProfile: + """Route recognized sub-agent requests to a fixed override branch. + + Wraps a built profile without changing its behavior for normal traffic. A + request whose headers carry a delegated sub-agent signal runs through the + override branch (a passthrough to the profile's configured + ``subagent_target``); every other request runs the wrapped profile + unchanged. The override never rewrites the request or response, and an + override-branch failure surfaces as a normal target error — it is not + re-routed through the wrapped profile. + """ + + def __init__(self, inner: ProfileRunner, override: ProfileRunner) -> None: + """Wrap ``inner``, sending sub-agent requests to ``override`` instead.""" + self._inner = inner + self._override = override + + def iter_components(self) -> list[object]: + """Return lifecycle components of both branches in startup order.""" + return [*_components(self._inner), *_components(self._override)] + + def _branch(self, input: ProfileInput) -> Any: + return self._override if is_subagent_request(input.metadata.headers) else self._inner + + async def run(self, input: ProfileInput) -> ChatResponse: + """Execute the branch selected by the request's sub-agent signal.""" + response = await self._branch(input).run(input) + return response # type: ignore[no-any-return] + + async def run_with_context( + self, + input: ProfileInput, + ctx: ProxyContext, + ) -> ChatResponse: + """Execute the selected branch, preserving the caller-owned context.""" + branch = self._branch(input) + if isinstance(branch, ContextAwareProfile): + return await branch.run_with_context(input, ctx) + response = await branch.run(input) + return response # type: ignore[no-any-return] + + async def process(self, input: ProfileInput) -> SubagentProcessedRequest: + """Run the selected branch's request side, remembering the branch.""" + branch = self._branch(input) + return SubagentProcessedRequest(branch, await branch.process(input)) + + async def process_with_context( + self, + input: ProfileInput, + ctx: ProxyContext, + ) -> SubagentProcessedRequest: + """Run the selected branch's request side with the caller's context.""" + branch = self._branch(input) + if isinstance(branch, ContextAwareProfile): + return SubagentProcessedRequest(branch, await branch.process_with_context(input, ctx)) + return SubagentProcessedRequest(branch, await branch.process(input)) + + async def rprocess( + self, + processed: SubagentProcessedRequest, + response: ChatResponse, + ) -> ChatResponse: + """Run the response side of the branch that processed the request.""" + result = await processed.branch.rprocess(processed.processed, response) + return result # type: ignore[no-any-return] + + +def _components(profile: object) -> list[object]: + """Return a branch's lifecycle components, mirroring ``ProfileSwitchyard``.""" + if isinstance(profile, ProfileLifecycle): + return profile.iter_components() + return [profile] + + +__all__ = ["SubagentOverrideProfile", "SubagentProcessedRequest"] diff --git a/switchyard/libsy/algorithms.py b/switchyard/libsy/algorithms.py index 434526feb..d5f2e64a9 100644 --- a/switchyard/libsy/algorithms.py +++ b/switchyard/libsy/algorithms.py @@ -5,5 +5,6 @@ from switchyard_rust.libsy import noop as noop from switchyard_rust.libsy import random as random +from switchyard_rust.libsy import subagent_override as subagent_override -__all__ = ["noop", "random"] +__all__ = ["noop", "random", "subagent_override"] diff --git a/switchyard_rust/libsy.py b/switchyard_rust/libsy.py index b7ef7bfbe..ff6c3123e 100644 --- a/switchyard_rust/libsy.py +++ b/switchyard_rust/libsy.py @@ -10,7 +10,9 @@ from switchyard_rust.core import _load_native -_EXPORTS = frozenset({"Algorithm", "LibsyError", "LlmTarget", "noop", "random"}) +_EXPORTS = frozenset( + {"Algorithm", "LibsyError", "LlmTarget", "noop", "random", "subagent_override"} +) class LlmClient(Protocol): @@ -44,12 +46,15 @@ class Algorithm: async def run( self, request: Mapping[str, object], + headers: Mapping[str, str] | None = None, ) -> tuple[list[dict[str, object]], dict[str, object]]: ... def noop() -> Algorithm: ... def random(targets: Sequence[LlmTarget]) -> Algorithm: ... + def subagent_override(inner: Algorithm, worker: LlmTarget) -> Algorithm: ... + def __getattr__(name: str) -> object: if name in _EXPORTS: diff --git a/switchyard_rust/profiles.py b/switchyard_rust/profiles.py index 088ba54d7..7da35c46c 100644 --- a/switchyard_rust/profiles.py +++ b/switchyard_rust/profiles.py @@ -26,6 +26,7 @@ "parse_profile_config_str", "ProfileInput", "ProfileRequestMetadata", + "is_subagent_request", ) __all__ = _PROFILE_EXPORTS diff --git a/switchyard_rust/profiles.pyi b/switchyard_rust/profiles.pyi index 866d63696..7c00a0d61 100644 --- a/switchyard_rust/profiles.pyi +++ b/switchyard_rust/profiles.pyi @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +from collections.abc import Mapping from os import PathLike from typing import Literal @@ -33,6 +34,7 @@ def parse_profile_config_str( ) -> ProfileConfigDocument: ... def parse_profile_config_path(path: str | PathLike[str]) -> ProfileConfigDocument: ... def load_profile_config(path: str | PathLike[str]) -> ProfileConfigPlan: ... +def is_subagent_request(headers: Mapping[str, str | list[str]]) -> bool: ... # --- Metadata for direct typed profile calls ---------------------------------- diff --git a/tests/test_libsy_minimal_bindings.py b/tests/test_libsy_minimal_bindings.py index b2014a33a..504ab3cd9 100644 --- a/tests/test_libsy_minimal_bindings.py +++ b/tests/test_libsy_minimal_bindings.py @@ -99,6 +99,37 @@ async def test_invalid_request_is_rejected_at_the_boundary() -> None: ) +async def test_subagent_override_routes_marked_work_to_the_worker() -> None: + orchestrator = EchoClient("orchestrator") + worker = EchoClient("worker") + algorithm = algorithms.subagent_override( + algorithms.random([LlmTarget("orchestrator", orchestrator)]), + LlmTarget("worker", worker), + ) + + # No headers: the wrapped algorithm serves the request. + _, response = await algorithm.run(request_body()) + assert response["model"] == "orchestrator" + + # Claude Code child-agent lineage routes to the fixed worker target. + decisions, response = await algorithm.run( + request_body(), + headers={ + "x-claude-code-session-id": "root", + "x-claude-code-agent-id": "child-1", + }, + ) + assert response["model"] == "worker" + assert decisions[-1]["selected_model"] == "worker" + + # Harness maintenance stays on the wrapped algorithm. + _, response = await algorithm.run( + request_body(), headers={"x-openai-subagent": "compact"} + ) + assert response["model"] == "orchestrator" + assert len(worker.calls) == 1 + + async def test_client_failure_becomes_libsy_error() -> None: class FailingClient: async def call(self, request: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_subagent_routing.py b/tests/test_subagent_routing.py new file mode 100644 index 000000000..310f98172 --- /dev/null +++ b/tests/test_subagent_routing.py @@ -0,0 +1,319 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for envelope-level sub-agent routing (``subagent_target``). + +A profile config may name a ``subagent_target`` in its common envelope; the +loader then wraps the built profile so recognized sub-agent requests run +through a passthrough to that target while all other traffic keeps the +profile's own routing. +""" + +import json +import threading +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from switchyard import load_profiles +from switchyard.lib.profiles import ProfileConfigError +from switchyard.lib.profiles.subagent_override import SubagentOverrideProfile +from switchyard_rust.core import ChatRequest, SwitchyardConfigError +from switchyard_rust.profiles import ( + ProfileInput, + ProfileRequestMetadata, + is_subagent_request, +) + +SUBAGENT_HEADERS = { + "x-claude-code-session-id": "root-session", + "x-claude-code-agent-id": "worker-1", +} + + +class _MockOpenAIServer(ThreadingHTTPServer): + calls: list[dict[str, Any]] + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), _MockOpenAIHandler) + self.calls = [] + + @property + def base_url(self) -> str: + host, port = self.server_address + return f"http://{host}:{port}" + + +class _MockOpenAIHandler(BaseHTTPRequestHandler): + server: _MockOpenAIServer + + def do_POST(self) -> None: + content_length = int(self.headers.get("content-length", "0")) + raw_body = self.rfile.read(content_length) + body = json.loads(raw_body.decode("utf-8")) if raw_body else {} + self.server.calls.append({"path": self.path, "body": body}) + response = { + "id": "chatcmpl-subagent", + "object": "chat.completion", + "model": body.get("model"), + "mock_path": self.path, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3}, + } + payload = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +@pytest.fixture +def mock_openai_server() -> Iterator[_MockOpenAIServer]: + try: + server = _MockOpenAIServer() + except PermissionError as exc: + pytest.skip(f"loopback socket binding is unavailable in this sandbox: {exc}") + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def _config(base_url: str) -> str: + """Rust and Python profiles that both name a ``subagent_target``.""" + return f""" +targets: + strong: + model: provider/strong + format: openai + base_url: "{base_url}/strong/v1" + api_key: test-key + worker: + model: provider/worker + format: openai + base_url: "{base_url}/worker/v1" + api_key: test-key + +profiles: + direct: + type: passthrough + target: strong + subagent_target: worker + smart: + type: header-routing + strong: strong + weak: strong + subagent_target: worker + plain: + type: passthrough + target: strong +""" + + +def _write(tmp_path: Path, text: str, name: str = "profiles.yaml") -> Path: + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path + + +def _request(model: str = "client/x") -> ChatRequest: + return ChatRequest.openai_chat( + {"model": model, "messages": [{"role": "user", "content": "hello"}]} + ) + + +def _subagent_input() -> ProfileInput: + return ProfileInput( + _request(), + ProfileRequestMetadata(headers=dict(SUBAGENT_HEADERS)), + ) + + +# --- Detection ------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("headers", "expected"), + [ + # No signal at all. + ({}, False), + # Claude Code lineage: any non-empty agent id marks a child agent. + (SUBAGENT_HEADERS, True), + # Agent id alone (no session header) is still a child agent. + ({"x-claude-code-agent-id": "child-1"}, True), + # Session alone — root agent, no agent-id sent. + ({"x-claude-code-session-id": "s"}, False), + # Codex delegated-work kinds route as sub-agent work. + ({"x-openai-subagent": "review"}, True), + ({"x-openai-subagent": "collab_spawn"}, True), + # Codex harness maintenance and unknown kinds stay on normal routing. + ({"x-openai-subagent": "compact"}, False), + ({"x-openai-subagent": "memory_consolidation"}, False), + ({"x-openai-subagent": "brand_new_kind"}, False), + # The explicit Switchyard header decides the lineage fact, in both + # directions; the work-kind policy still applies on top of it. + ({"x-switchyard-is-subagent": "true"}, True), + ({"x-switchyard-is-subagent": "false", "x-openai-subagent": "review"}, False), + ({"x-switchyard-is-subagent": "true", "x-openai-subagent": "compact"}, False), + # Header names are case-insensitive and values are trimmed. + ({"X-OpenAI-Subagent": " review "}, True), + # Bug #2 fix: operator label x-switchyard-agent-kind must not suppress + # routing signals from the harness (x-openai-subagent, x-switchyard-is-subagent). + ({"x-openai-subagent": "review", "x-switchyard-agent-kind": "researcher"}, True), + ({"x-switchyard-is-subagent": "true", "x-switchyard-agent-kind": "researcher"}, True), + # Bug #1 fix: correlation-only headers are not routing signals. + ({"x-switchyard-parent-agent-id": "parent"}, False), + ({"x-nemo-relay-subagent-id": "relay-child"}, False), + ({"x-dynamo-parent-session-id": "dynamo-parent"}, False), + ({"x-session-id": "s", "x-parent-session-id": "p"}, False), + ], +) +def test_is_subagent_request_detection(headers: dict[str, str], expected: bool) -> None: + assert is_subagent_request(headers) is expected + + +# --- Loader wiring ---------------------------------------------------------- + + +def test_loader_wraps_profiles_with_subagent_target(tmp_path: Path) -> None: + path = _write(tmp_path, _config("http://127.0.0.1:9")) + profiles = load_profiles(path) + # Rust profiles (direct / passthrough) handle subagent routing at the Rust level; + # the Python loader must not double-wrap them with SubagentOverrideProfile. + assert not isinstance(profiles["direct"], SubagentOverrideProfile) + # Python profiles (smart / header-routing) still receive the Python wrapper. + assert isinstance(profiles["smart"], SubagentOverrideProfile) + assert not isinstance(profiles["plain"], SubagentOverrideProfile) + + +async def test_rust_profile_routes_subagent_requests_to_target( + mock_openai_server: _MockOpenAIServer, + tmp_path: Path, +) -> None: + path = _write(tmp_path, _config(mock_openai_server.base_url)) + profiles = load_profiles(path) + + normal = await profiles["direct"].run(ProfileInput(_request())) + assert normal.body["model"] == "provider/strong" + assert normal.body["mock_path"] == "/strong/v1/chat/completions" + + subagent = await profiles["direct"].run(_subagent_input()) + assert subagent.body["model"] == "provider/worker" + assert subagent.body["mock_path"] == "/worker/v1/chat/completions" + + +async def test_python_profile_routes_subagent_requests_to_target( + mock_openai_server: _MockOpenAIServer, + tmp_path: Path, +) -> None: + path = _write(tmp_path, _config(mock_openai_server.base_url)) + profiles = load_profiles(path) + + normal = await profiles["smart"].run(ProfileInput(_request())) + assert normal.body["model"] == "provider/strong" + + subagent = await profiles["smart"].run(_subagent_input()) + assert subagent.body["model"] == "provider/worker" + assert subagent.body["mock_path"] == "/worker/v1/chat/completions" + + +async def test_maintenance_kinds_keep_normal_routing( + mock_openai_server: _MockOpenAIServer, + tmp_path: Path, +) -> None: + path = _write(tmp_path, _config(mock_openai_server.base_url)) + profiles = load_profiles(path) + + metadata = ProfileRequestMetadata(headers={"x-openai-subagent": "compact"}) + response = await profiles["direct"].run(ProfileInput(_request(), metadata)) + assert response.body["model"] == "provider/strong" + + +def test_rust_profile_unknown_subagent_target_is_rejected(tmp_path: Path) -> None: + config = """ +targets: + strong: + model: provider/strong + format: openai + base_url: http://127.0.0.1:9/v1 + api_key: test-key + +profiles: + direct: + type: passthrough + target: strong + subagent_target: ghost +""" + path = _write(tmp_path, config) + with pytest.raises(SwitchyardConfigError, match="unknown target ghost"): + load_profiles(path) + + +def test_python_profile_unknown_subagent_target_is_rejected(tmp_path: Path) -> None: + config = """ +targets: + strong: + model: provider/strong + format: openai + base_url: http://127.0.0.1:9/v1 + api_key: test-key + +profiles: + smart: + type: header-routing + strong: strong + weak: strong + subagent_target: ghost +""" + path = _write(tmp_path, config) + with pytest.raises(ProfileConfigError, match="unknown target 'ghost'"): + load_profiles(path) + + +# --- Wrapper behavior ------------------------------------------------------- + + +class _StubRunner: + def __init__(self, label: str) -> None: + self.label = label + self.calls = 0 + + async def run(self, input: ProfileInput) -> Any: + self.calls += 1 + return self.label + + +def test_iter_components_spans_both_branches() -> None: + inner = _StubRunner("inner") + override = _StubRunner("override") + wrapper = SubagentOverrideProfile(inner, override) + assert wrapper.iter_components() == [inner, override] + + +async def test_override_failure_is_not_rerouted_to_the_wrapped_profile() -> None: + class _FailingRunner: + async def run(self, input: ProfileInput) -> Any: + raise RuntimeError("worker target unavailable") + + inner = _StubRunner("inner") + wrapper = SubagentOverrideProfile(inner, _FailingRunner()) + with pytest.raises(RuntimeError, match="worker target unavailable"): + await wrapper.run(_subagent_input()) + assert inner.calls == 0