Skip to content

routing: make system prompts follow answer targets #496

Description

@afourniernv

Plan: Target system prompts follow routed answer candidates

Linear: SWITCH-1253

Goal

A client sends a request to a Switchyard route and does not know which upstream target will answer.

Different targets may require different standing system instructions. Today, only Stage Router
supports this through capable_system_prompt and efficient_system_prompt. Other routers cannot
configure the same behavior.

The selected target may also fail after routing completes:

route selects fast
    -> fast fails
        -> libsy-llm-client falls back to capable

Preparing the request once for fast would cause the capable fallback to inherit the wrong prompt.
Switchyard must prepare the request independently for each answer candidate.

The resulting boundary is:

Native server
  read targets.*.system_prompt
            |
            v
libsy Algorithm
  select target and ordered fallbacks
  return RoutingOutcome
            |
            v
libsy-llm-client or custom host
  outcome.request_for(candidate)
            |
            v
switchyard-translation
  stamp candidate model
  prepend candidate prompt
  encode provider request

Existing TOML without target prompts remains unchanged. Legacy Stage prompt fields remain
supported.

Configuration interface

Move answer prompt configuration from Stage-specific route roles:

[targets.fast]
id = "meta/llama-small"
llm_client = "together"

[targets.capable]
id = "meta/llama-large"
llm_client = "together"

[routes.agent]
id = "switchyard/agent"
type = "stage_router"
efficient_target = "fast"
capable_target = "capable"
efficient_system_prompt = "Be concise."
capable_system_prompt = "Reason carefully before answering."

To the targets themselves:

[targets.fast]
id = "meta/llama-small"
llm_client = "together"
system_prompt = "Be concise."

[targets.capable]
id = "meta/llama-large"
llm_client = "together"
system_prompt = "Reason carefully before answering."

[routes.agent]
id = "switchyard/agent"
type = "stage_router"
efficient_target = "fast"
capable_target = "capable"

The target-level prompt follows that target through every built-in client-visible answer path,
including fallback. If both forms configure the same Stage target,
targets.*.system_prompt takes precedence.

Request preparation ownership

switchyard-server owns configuration:

  • Read targets.*.system_prompt.
  • Build the effective answer prompt map for each route.
  • Exclude judge-only targets.
  • Preserve legacy Stage configuration.

libsy owns candidate policy:

  • Keep the selected target and ordered fallbacks in RoutingOutcome.
  • Retain an unprompted request snapshot when prompted fallback requires one.
  • Prepare the request separately for each attempted candidate.
  • Expose the same behavior to native and custom hosts.

switchyard-translation owns request mutation:

  • Stamp the upstream model ID.
  • Prepend the target system prompt.
  • Invalidate exact request replay when it becomes stale.

Retry and fallback policy remain in libsy-llm-client. This work does not change which errors
permit fallback or how backend retries work.

Call boundary

Target prompts are answer policy, not general instructions for every call made through a target.

Call purpose Target prompt
Passthrough, Random, Stage, or LLM Classifier final answer Applied
Selected or fallback answer candidate Applied
Escalation provisional answer Applied
Advisor executor answer Applied
Classifier or Stage judge Not applied
Advisor reviewer Not applied
Noop No model call
Anthropic count-tokens Applied explicitly

Algorithms use Driver::call_model(...) for classifier, judge, and reviewer work. An algorithm
making a call whose response may become client-visible uses Driver::call_answer_model(...).

Candidate request API

Add candidate-aware preparation to terminal outcomes:

impl RoutingOutcome {
    pub fn request_for(&self, target: &ModelId) -> Result<Request>;
}

Add the same operation for routing-time candidate calls:

impl CallModel {
    pub fn request_for(&self, target: &ModelId) -> Result<Request>;
}

The Python bindings expose equivalent methods. A host uses
outcome.request_for(target) rather than copying outcome.request and replacing only its model.

RoutingOutcome gains private preparation state and becomes #[non_exhaustive]. Rust code
tracking unreleased main that constructs it with a struct literal must use
RoutingOutcome::route_to(...) or RoutingOutcome::answered(...).

Success criteria

  • targets.*.system_prompt is optional.
  • Existing TOML without the field behaves as before.
  • Every built-in client-visible answer path applies the selected target's prompt.
  • A fallback receives its own prompt and never inherits the selected target's prompt.
  • Caller-provided system instructions remain after the target prompt.
  • Classifier, judge, and reviewer calls remain unchanged.
  • Legacy Stage prompt fields remain supported.
  • Target-level configuration takes precedence over legacy Stage configuration.
  • Native Rust and Python hosts prepare candidates through request_for().
  • Two answer aliases that collapse to one model ID with different prompts fail during configuration.
  • Retry order, fallback eligibility, affinity, and routing decisions remain unchanged.

Step-by-step

Step 1 - add target request preparation

In switchyard-translation:

  • Add prepare_request_for_target(...).
  • Stamp the selected upstream model.
  • Prepend an optional target prompt.
  • Clear preserved exact requests when prompt insertion makes them stale.
  • Test prompted and unprompted preparation.

Implemented in #455.

Step 2 - prepare every candidate in libsy

In switchyard-libsy and switchyard-llm-client:

  • Add immutable TargetPrompts policy.
  • Add RoutingOutcome::request_for(...) and CallModel::request_for(...).
  • Prepare selected and fallback candidates independently.
  • Distinguish routing-only calls from provisional answer calls.
  • Add Rust and Python host support.
  • Preserve Stage's existing tier-prompt behavior.

Implemented in #463.

Step 3 - expose native target configuration

In switchyard-server:

  • Add system_prompt to TargetConfig.
  • Build effective prompt policy from each route's answer targets.
  • Preserve legacy Stage fields and define precedence.
  • Reject ambiguous target aliases.
  • Apply the prompt to count-tokens requests.
  • Update documentation and server tests.

Implemented in #464.

Tests to add or adapt

Test Asserts
selected candidate Selected target receives its configured prompt
fallback candidate Fallback receives only its own prompt
unconfigured target Request remains unprompted
caller instructions Existing system instructions remain after the target prompt
Stage compatibility Legacy fields continue to work
precedence Target-level prompt wins over legacy Stage prompt
judge isolation Classifier and judge calls receive no answer prompt
Advisor isolation Executor receives the prompt; reviewer does not
alias conflict Different prompts for one resolved model fail at startup
Python host request_for() prepares selected and fallback candidates
count-tokens Token counting includes the effective target prompt

Verification

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
uv run pytest tests/ -v
uv run ruff check .
uv run mypy switchyard

Out of scope

  1. Changing how routers select targets.
  2. Changing retry budgets, retryable errors, or fallback ordering.
  3. Applying answer prompts to classifier, judge, or reviewer calls.
  4. Introducing a first-class TargetId separate from ModelId.
  5. Supporting different prompts for the same target in different routes.
  6. Preserving unknown provider-only request fields after prompt insertion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions