Repository: NVIDIA/NeMo-Guardrails

Issue:
bug: RailsConfig throws error when adding configs
### Did you check docs and existing issues?

- [x] I have read all the NeMo-Guardrails docs
- [x] I have updated the package to the latest version before submitting this issue
- [x] (optional) I have used the develop branch
- [x] I have searched the existing issues of NeMo-Guardrails

### Python version (python --version)

Python 3.10.2

### Operating system/version

MacOS 15.5

### NeMo-Guardrails version (if you must use a specific version and not the latest

0.15.0

### Describe the bug

The `RailsConfig` class allows for [adding two config objects](https://github.com/NVIDIA/NeMo-Guardrails/blob/408d641e212cd86290c3b5ef428aadffb807941e/nemoguardrails/rails/llm/config.py#L1593). I encountered two cases where this function throws an error:

1. `prompts` is `None` for at least one `RailsConfig` object:
```
config1 = RailsConfig(
  models=[
    Model(type="main", engine="openai", model="gpt-3.5-turbo")
  ],
)
config2 = RailsConfig(
  models=[
    Model(type="secondary", engine="anthropic", model="claude-3")
  ],
)

combined_config = config1 + config2
```

Error:
```
    @root_validator(pre=True, allow_reuse=True)
    def check_output_parser_exists(cls, values):
        tasks_requiring_output_parser = [
            "self_check_input",
            "self_check_facts",
            "self_check_output",
            # "content_safety_check input $model",
            # "content_safety_check output $model",
        ]
        prompts = values.get("prompts", [])
>       for prompt in prompts:
E       TypeError: 'NoneType' object is not iterable

nemoguardrails/rails/llm/config.py:1414: TypeError: 'NoneType' object is not iterable
```

2. `config_path` is `None` for at least one `RailsConfig` object:
```
config1 = RailsConfig(
  models=[
    Model(type="main", engine="openai", model="gpt-3.5-turbo")
  ],
  prompts=[]
)
config2 = RailsConfig(
  models=[
    Model(type="secondary", engine="anthropic", model="claude-3")
  ],
  prompts=[]
)

combined_config = config1 + config2
```

Error:
```
def _join_rails_configs(
        base_rails_config: RailsConfig, updated_rails_config: RailsConfig
    ):
        """Helper to join two rails configuration."""
    
        config_old_types = {}
        for model_old in base_rails_config.models:
            config_old_types[model_old.type] = model_old
    
        for model_new in updated_rails_config.models:
            if model_new.type in config_old_types:
                if model_new.engine != config_old_types[model_new.type].engine:
                    raise ValueError(
                        "Both config files should have the same engine for the same model type"
                    )
                if model_new.model != config_old_types[model_new.type].model:
                    raise ValueError(
                        "Both config files should have the same model for the same model type"
                    )
    
        if base_rails_config.actions_server_url != updated_rails_config.actions_server_url:
            raise ValueError("Both config files should have the same actions_server_url")
    
        combined_rails_config_dict = _join_dict(
            base_rails_config.dict(), updated_rails_config.dict()
        )
>       combined_rails_config_dict["config_path"] = ",".join(
            [
                base_rails_config.dict()["config_path"],
                updated_rails_config.dict()["config_path"],
            ]
        )
E       TypeError: sequence item 0: expected str instance, NoneType found
```

### Steps To Reproduce

I ran into this error upstream using the [NeMo Guardrails service](https://docs.nvidia.com/nemo/microservices/latest/guardrails/index.html). Internally, the `/v1/chat/completions` endpoint invokes `RailsConfig.__add__` given multiple config IDs, where I ran into the two errors above.

To reproduce, you can execute the code snippets shared above.

### Expected Behavior

Adding two configs _does not_ throw an error in the following cases:
- `prompts` is `None` for at least one config
- `config_path` is `None` for at least one config

### Actual Behavior

Adding two configs _does_ throw an error in the following cases:
- `prompts` is `None` for at least one config
- `config_path` is `None` for at least one config


Discussion:

Patch:
diff --git a/nemoguardrails/rails/llm/config.py b/nemoguardrails/rails/llm/config.py
index ffdd10220..e2f455d55 100644
--- a/nemoguardrails/rails/llm/config.py
+++ b/nemoguardrails/rails/llm/config.py
@@ -1350,12 +1350,13 @@ def check_reasoning_traces_with_dialog_rails(cls, values):
     @root_validator(pre=True, allow_reuse=True)
     def check_prompt_exist_for_self_check_rails(cls, values):
         rails = values.get("rails", {})
+        prompts = values.get("prompts", []) or []
 
         enabled_input_rails = rails.get("input", {}).get("flows", [])
         enabled_output_rails = rails.get("output", {}).get("flows", [])
         provided_task_prompts = [
             prompt.task if hasattr(prompt, "task") else prompt.get("task")
-            for prompt in values.get("prompts", [])
+            for prompt in prompts
         ]
 
         # Input moderation prompt verification
@@ -1410,7 +1411,7 @@ def check_output_parser_exists(cls, values):
             # "content_safety_check input $model",
             # "content_safety_check output $model",
         ]
-        prompts = values.get("prompts", [])
+        prompts = values.get("prompts") or []
         for prompt in prompts:
             task = prompt.task if hasattr(prompt, "task") else prompt.get("task")
             output_parser = (
@@ -1657,12 +1658,12 @@ def _join_rails_configs(
     combined_rails_config_dict = _join_dict(
         base_rails_config.dict(), updated_rails_config.dict()
     )
-    combined_rails_config_dict["config_path"] = ",".join(
-        [
-            base_rails_config.dict()["config_path"],
-            updated_rails_config.dict()["config_path"],
-        ]
-    )
+    # filter out empty strings to avoid leading/trailing commas
+    config_paths = [
+        base_rails_config.dict()["config_path"] or "",
+        updated_rails_config.dict()["config_path"] or "",
+    ]
+    combined_rails_config_dict["config_path"] = ",".join(filter(None, config_paths))
     combined_rails_config = RailsConfig(**combined_rails_config_dict)
     return combined_rails_config
 


Test patch:
diff --git a/tests/rails/llm/test_config.py b/tests/rails/llm/test_config.py
index 7213c56cc..7b4a3cfe1 100644
--- a/tests/rails/llm/test_config.py
+++ b/tests/rails/llm/test_config.py
@@ -16,7 +16,13 @@
 import pytest
 from pydantic import ValidationError
 
-from nemoguardrails.rails.llm.config import TaskPrompt
+from nemoguardrails.rails.llm.config import (
+    Document,
+    Instruction,
+    Model,
+    RailsConfig,
+    TaskPrompt,
+)
 
 
 def test_task_prompt_valid_content():
@@ -123,3 +129,181 @@ def test_task_prompt_max_tokens_validation():
     with pytest.raises(ValidationError) as excinfo:
         TaskPrompt(task="example_task", content="Test prompt", max_tokens=-1)
     assert "Input should be greater than or equal to 1" in str(excinfo.value)
+
+
+def test_rails_config_addition():
+    """Tests that adding two RailsConfig objects merges both into a single RailsConfig."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        config_path="test_config.yml",
+    )
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        config_path="test_config2.yml",
+    )
+
+    result = config1 + config2
+
+    assert isinstance(result, RailsConfig)
+    assert len(result.models) == 2
+    assert result.config_path == "test_config.yml,test_config2.yml"
+
+
+def test_rails_config_model_conflicts():
+    """Tests that adding two RailsConfig objects with conflicting models raises an error."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        config_path="config1.yml",
+    )
+
+    # Different engine for same model type
+    config2 = RailsConfig(
+        models=[Model(type="main", engine="nim", model="gpt-3.5-turbo")],
+        config_path="config2.yml",
+    )
+    with pytest.raises(
+        ValueError,
+        match="Both config files should have the same engine for the same model type",
+    ):
+        config1 + config2
+
+    # Different model for same model type
+    config3 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-4")],
+        config_path="config3.yml",
+    )
+    with pytest.raises(
+        ValueError,
+        match="Both config files should have the same model for the same model type",
+    ):
+        config1 + config3
+
+
+def test_rails_config_actions_server_url_conflicts():
+    """Tests that adding two RailsConfig objects with different values for `actions_server_url` raises an error."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        actions_server_url="http://localhost:8000",
+    )
+
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        actions_server_url="http://localhost:9000",
+    )
+
+    with pytest.raises(
+        ValueError, match="Both config files should have the same actions_server_url"
+    ):
+        config1 + config2
+
+
+def test_rails_config_simple_field_overwriting():
+    """Tests that fields from the second config overwrite fields from the first config."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        streaming=False,
+        lowest_temperature=0.1,
+        colang_version="1.0",
+    )
+
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        streaming=True,
+        lowest_temperature=0.5,
+        colang_version="2.x",
+    )
+
+    result = config1 + config2
+
+    assert result.streaming is True
+    assert result.lowest_temperature == 0.5
+    assert result.colang_version == "2.x"
+
+
+def test_rails_config_nested_dictionary_merging():
+    """Tests nested dictionaries are merged correctly."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        rails={
+            "input": {"flows": ["flow1"], "parallel": False},
+            "output": {"flows": ["flow2"]},
+        },
+        knowledge_base={
+            "folder": "kb1",
+            "embedding_search_provider": {"name": "provider1"},
+        },
+        custom_data={"setting1": "value1", "nested": {"key1": "val1"}},
+    )
+
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        rails={
+            "input": {"flows": ["flow3"], "parallel": True},
+            "retrieval": {"flows": ["flow4"]},
+        },
+        knowledge_base={
+            "folder": "kb2",
+            "embedding_search_provider": {"name": "provider2"},
+        },
+        custom_data={"setting2": "value2", "nested": {"key2": "val2"}},
+    )
+
+    result = config1 + config2
+
+    assert result.rails.input.flows == ["flow3", "flow1"]
+    assert result.rails.input.parallel is True
+    assert result.rails.output.flows == ["flow2"]
+    assert result.rails.retrieval.flows == ["flow4"]
+
+    assert result.knowledge_base.folder == "kb2"
+    assert result.knowledge_base.embedding_search_provider.name == "provider2"
+
+    assert result.custom_data["setting1"] == "value1"
+    assert result.custom_data["setting2"] == "value2"
+    assert result.custom_data["nested"]["key1"] == "val1"
+    assert result.custom_data["nested"]["key2"] == "val2"
+
+
+def test_rails_config_none_prompts():
+    """Test that configs with None prompts can be added without errors."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        prompts=None,
+        rails={"input": {"flows": ["self_check_input"]}},
+    )
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        prompts=[],
+    )
+
+    result = config1 + config2
+    assert result is not None
+    assert result.prompts is not None
+
+
+def test_rails_config_none_config_path():
+    """Test that configs with None config_path can be added."""
+    config1 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        config_path=None,
+    )
+    config2 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        config_path="config2.yml",
+    )
+
+    result = config1 + config2
+    # should not have leading comma after fix
+    assert result.config_path == "config2.yml"
+
+    config3 = RailsConfig(
+        models=[Model(type="main", engine="openai", model="gpt-3.5-turbo")],
+        config_path=None,
+    )
+    config4 = RailsConfig(
+        models=[Model(type="secondary", engine="anthropic", model="claude-3")],
+        config_path=None,
+    )
+
+    result2 = config3 + config4
+    assert result2.config_path == ""