An agent or router often needs to return one action from a closed set. Modeling every possible field as nilable moves the decision into application code and permits contradictory states. A union-typed output makes the alternatives explicit.

module CoffeeShopActions
  class TakeOrder < T::Struct
    const :drink, String
    const :size, String
  end

  class HandleComplaint < T::Struct
    const :summary, String
    const :severity, Integer
  end

  class TellJoke < T::Struct
    const :topic, String
  end
end

class CoffeeShopSignature < DSPy::Signature
  description "Choose the next response for a coffee-shop request"

  input do
    const :request, String
  end

  output do
    const :action, T.any(
      CoffeeShopActions::TakeOrder,
      CoffeeShopActions::HandleComplaint,
      CoffeeShopActions::TellJoke
    )
  end
end

The signature says that action contains exactly one variant. Here, the predictor makes one typed decision. An agent adds a loop in which the model selects actions or tools over several steps.

The Discriminator

DSPy.rb’s JSON Schema converter represents struct unions with anyOf. Each variant includes a generated _type discriminator whose value is the struct’s simple class name, without its enclosing module:

{
  "action": {
    "_type": "TakeOrder",
    "drink": "flat white",
    "size": "small"
  }
}

DSPy::Prediction uses _type to select the struct class and removes fields that are not declared by that class before instantiation. Arrays of union values use the same conversion for each element.

predictor = DSPy::Predict.new(CoffeeShopSignature)
result = predictor.call(request: "A small flat white, please")

case result.action
when CoffeeShopActions::TakeOrder
  submit_order(result.action.drink, result.action.size)
when CoffeeShopActions::HandleComplaint
  open_support_case(result.action.summary, result.action.severity)
when CoffeeShopActions::TellJoke
  tell_joke_about(result.action.topic)
end

If _type is absent, prediction conversion can fall back to structural matching. That fallback is less precise when variants share fields. Keep the discriminator in model responses whenever possible.

Boundaries and Failure Modes

The union constrains the result shape. Application code must still judge whether the action is appropriate, authorize side effects, and decide whether to run the prediction inside an agent loop. Permissions, budgets, retries, and execution remain outside the type.

Use variants with distinct responsibilities and fields. If two structs have the same shape, the discriminator is the only reliable distinction. A user-defined _type property also conflicts with DSPy.rb’s generated discriminator and raises during schema generation.

Union types fit routers, next-action decisions, tool results, and state transitions where the alternatives are known. Open-ended tool discovery or dynamic schemas need a different boundary.