Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .agents/skills/dco-signoff/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <your@email.com>
```

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 <resolved-files>
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 <linj@nvidia.com>
```

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.
1 change: 1 addition & 0 deletions .agents/skills/switchyard-lib-core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <target-id>` 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. |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/libsy/src/algorithms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
208 changes: 208 additions & 0 deletions crates/libsy/src/algorithms/subagent_override.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Algorithm>,
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<dyn Algorithm>, worker: LlmTarget) -> Self {
Self { inner, worker }
}
}

#[async_trait]
impl Algorithm for SubagentOverride {
async fn create_run_task(
self: Arc<Self>,
ctx: Context,
driver: Driver,
request: Request,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
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<dyn Decision> = 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<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
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::<BTreeMap<_, _>>(),
)
});
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<dyn Algorithm> {
let inner: Arc<dyn Algorithm> =
Arc::new(Random::new(LlmTargetSet::new(vec![target("orchestrator")])));
Arc::new(SubagentOverride::new(inner, target("worker")))
}

async fn selected_model(
headers: &[(&str, &str)],
) -> Result<String, Box<dyn Error + Send + Sync>> {
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<dyn Error + Send + Sync>> {
assert_eq!(selected_model(&[]).await?, "orchestrator");
Ok(())
}

#[tokio::test]
async fn subagent_work_is_routed_to_the_worker_target(
) -> Result<(), Box<dyn Error + Send + Sync>> {
// 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<dyn Error + Send + Sync>> {
assert_eq!(
selected_model(&[("x-openai-subagent", "compact")]).await?,
"orchestrator"
);
assert_eq!(
selected_model(&[("x-switchyard-is-subagent", "false")]).await?,
"orchestrator"
);
Ok(())
}
}
Loading
Loading