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
19 changes: 19 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,23 @@ def build_parser() -> argparse.ArgumentParser:
reset_p.add_argument("--path", help="Specific settings file to remove")
reset_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")

parser.add_argument(
"--skip-preflight",
action="store_true",
help="Skip Ollama preflight connectivity and model validation checks"
)

return parser


def check_ollama_preflight(settings) -> bool:
"""
Runs an optional preflight check for Ollama reachability and model availability
if the default configured model relies on a local Ollama instance.
"""
from rooms import ollama_preflight
return ollama_preflight.run_ollama_preflight(settings)

def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
Expand All @@ -330,6 +344,11 @@ def main(argv: Optional[List[str]] = None) -> int:
if found:
console.print(f"[dim]Using settings: {found}[/dim]")

# Run the Ollama preflight check unless --skip-preflight is passed.
if not args.skip_preflight:
if not check_ollama_preflight(settings):
return 1

main_menu(settings)
return 0

Expand Down
11 changes: 11 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,14 @@ The quality of your agents is entirely determined by the quality of their system
| Agent addresses you directly | Session auto-triggers HITL early | This is by design — respond or type `continue` |
| Agent has nothing to add | Returns `PASS` | Turn is silently skipped; visible in logs only |
| Topic is very broad | Agents go off-scope | Add Orchestrator with *"steer back to [topic] if agents drift"* |

---

## Advanced CLI Reference

### Skipping Preflight Checks
If you are running the application in a CI/CD automation environment, running automated test configurations, or simply wish to bypass the local Ollama connectivity and model verification sequence, append the `--skip-preflight` flag alongside your execution statement:

```bash
python cli.py --skip-preflight
```
69 changes: 69 additions & 0 deletions rooms/ollama_preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import urllib.request
import urllib.error
import json
import sys
from rich.console import Console
from rich.panel import Panel

def run_ollama_preflight(settings) -> bool:
"""
Verifies if the configured local Ollama instance is running
and contains the requested model tag.
"""
model_string = getattr(settings.defaults, "litellm_model", "")
if not model_string.startswith("ollama/"):
return True

# Extract the tag name (e.g., 'ollama/gemma4:e2b' -> 'gemma4:e2b')
configured_tag = model_string.split("/", 1)[1]
base_url = getattr(settings.ollama, "base_url", "http://localhost:11434").rstrip("/")
tags_url = f"{base_url}/api/tags"

console = Console()

try:
req = urllib.request.Request(tags_url, method="GET")
with urllib.request.urlopen(req, timeout=3.0) as response:
if response.status != 200:
raise urllib.error.URLError(f"HTTP Status {response.status}")

data = json.loads(response.read().decode("utf-8"))
models = data.get("models", [])

available_tags = []
for m in models:
if "name" in m:
available_tags.append(m["name"])
if "model" in m:
available_tags.append(m["model"])

if configured_tag in available_tags or f"{configured_tag}:latest" in available_tags:
return True

# Server is up, but model tag is missing
panel = Panel(
f"[bold yellow]Warning:[/bold yellow] Configured Ollama model [bold cyan]'{configured_tag}'[/bold cyan] was not found locally.\n\n"
f"[bold white]Actionable Fixes:[/bold white]\n"
f" • Run: [green]ollama pull {configured_tag}[/green]\n"
f" • Edit your configuration file to use an available tag.\n"
f" • Run with [green]python cli.py --skip-preflight[/green] to bypass.",
title="[bold red]Ollama Preflight Verification Failed[/bold red]",
expand=False
)
console.print(panel)
return False

except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
# Ollama service is completely unreachable
panel = Panel(
f"[bold yellow]Warning:[/bold yellow] Could not connect to Ollama server at [cyan]{base_url}[/cyan]\n"
f"Error Details: {str(e)}\n\n"
f"[bold white]Actionable Fixes:[/bold white]\n"
f" • Ensure Ollama is running by executing: [green]ollama serve[/green]\n"
f" • Verify your [magenta]ollama.base_url[/magenta] settings match your active instance.\n"
f" • Run with [green]python cli.py --skip-preflight[/green] to bypass.",
title="[bold red]Ollama Server Unreachable[/bold red]",
expand=False
)
console.print(panel)
return False
6 changes: 4 additions & 2 deletions tests/test_cli_settings_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ def test_cli_main_loads_explicit_config_without_wizard(tmp_path, monkeypatch):
encoding="utf-8",
)

with patch.object(cli, "main_menu") as mock_menu:
with patch.object(cli, "main_menu") as mock_menu, \
patch.object(cli, "check_ollama_preflight", return_value=True) as mock_preflight:
rc = cli.main(["--config", str(cfg)])

assert rc == 0
mock_menu.assert_called_once()
mock_preflight.assert_called_once()
settings = mock_menu.call_args[0][0]
assert settings.defaults.litellm_model == "ollama/smoke:1b"
assert settings.defaults.timeout == 99
Expand Down Expand Up @@ -88,4 +90,4 @@ def test_settings_error_from_init_guard(tmp_path, monkeypatch):
cli.main(["config", "init"])
with pytest.raises(SettingsError):
from rooms.settings import init_settings_file
init_settings_file()
init_settings_file()
48 changes: 48 additions & 0 deletions tests/test_ollama_preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import unittest
from unittest.mock import patch, MagicMock
import urllib.error
import io

from rooms.ollama_preflight import run_ollama_preflight

class TestOllamaPreflightLogic(unittest.TestCase):
def setUp(self):
# Setup clean configuration mock structure
self.mock_settings = MagicMock()
self.mock_settings.defaults.litellm_model = "ollama/gemma4:e2b"
self.mock_settings.ollama.base_url = "http://localhost:11434"

def test_skips_non_ollama_models(self):
self.mock_settings.defaults.litellm_model = "openai/gpt-4"
result = run_ollama_preflight(self.mock_settings)
self.assertTrue(result)

@patch("urllib.request.urlopen")
def test_preflight_success_exact_match(self, mock_urlopen):
# Simulate clean API json payload back from Ollama
mock_response = MagicMock()
mock_response.status = 200
mock_response.read.return_value = b'{"models": [{"name": "gemma4:e2b"}, {"name": "llama3:latest"}]}'
mock_urlopen.return_value.__enter__.return_value = mock_response

result = run_ollama_preflight(self.mock_settings)
self.assertTrue(result)

@patch("urllib.request.urlopen")
@patch("sys.stdout", new_callable=io.StringIO)
def test_preflight_missing_model_tag(self, mock_stdout, mock_urlopen):
mock_response = MagicMock()
mock_response.status = 200
mock_response.read.return_value = b'{"models": [{"name": "llama3:latest"}]}'
mock_urlopen.return_value.__enter__.return_value = mock_response

result = run_ollama_preflight(self.mock_settings)
self.assertFalse(result)

@patch("urllib.request.urlopen")
@patch("sys.stdout", new_callable=io.StringIO)
def test_preflight_server_unreachable(self, mock_stdout, mock_urlopen):
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")

result = run_ollama_preflight(self.mock_settings)
self.assertFalse(result)
Loading