diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index df2c3e530..718a06709 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -1,5 +1,6 @@ import json import logging +import math from typing import Any, Callable, Optional, Union import httpx @@ -167,6 +168,45 @@ def header_provider(readonly_context: Optional[ReadonlyContext]) -> dict[str, st return header_provider +_BOOL_TRUE = {"1", "true", "yes", "on"} +_BOOL_FALSE = {"0", "false", "no", "off"} + + +def _coerce_option_bool(value: str) -> bool: + """Parse a CRD string into a bool, accepting the common truthy/falsy spellings. + + The previous implementation treated only the exact string ``"true"`` as True, + so ``"1"``, ``"yes"`` or ``"True"`` silently became False. Anything outside the + recognized spellings raises ValueError so the caller can fall back and warn. + """ + normalized = value.strip().lower() + if normalized in _BOOL_TRUE: + return True + if normalized in _BOOL_FALSE: + return False + raise ValueError(f"invalid boolean option value: {value!r}") + + +def _coerce_option_int(value: str) -> int: + """Parse a CRD string into an int. + + Accepts a plain integer ("2048") and a float that is finite and exactly + integral ("2048.0"), which JSON/YAML tooling can produce for an int-typed + option. Rejects a non-integral float ("2048.5") rather than truncating it, + and rejects "inf"/"nan", so the caller falls back and warns instead of + silently losing precision or raising OverflowError. + """ + stripped = value.strip() + try: + return int(stripped) + except ValueError: + pass + number = float(stripped) + if not math.isfinite(number) or not number.is_integer(): + raise ValueError(f"not an integer option value: {value!r}") + return int(number) + + def _convert_ollama_options(options: dict[str, str] | None) -> dict[str, Any]: """Convert Ollama options from string values to their correct types. @@ -204,15 +244,27 @@ def _convert_ollama_options(options: dict[str, str] | None) -> dict[str, Any]: else: target_type = annotation - # Convert based on the target type - if target_type is int: - converted[key] = int(value) - elif target_type is float: - converted[key] = float(value) - elif target_type is bool: - converted[key] = value.lower() == "true" - else: - # Keep as string for other types (e.g., Sequence[str]) + # Convert based on the target type. Values come from CRD strings, so + # coercion is tolerant: a malformed value is kept as a string and + # left for the Ollama SDK to report, rather than raising here and + # failing the whole agent build. + try: + if target_type is bool: + converted[key] = _coerce_option_bool(value) + elif target_type is int: + converted[key] = _coerce_option_int(value) + elif target_type is float: + converted[key] = float(value) + else: + # Keep as string for other types (e.g., Sequence[str]) + converted[key] = value + except (TypeError, ValueError, OverflowError): + logger.warning( + "Could not convert Ollama option %r=%r to %s; forwarding the raw value.", + key, + value, + getattr(target_type, "__name__", target_type), + ) converted[key] = value else: # Unknown option - keep as string diff --git a/python/packages/kagent-adk/tests/unittests/models/test_ollama.py b/python/packages/kagent-adk/tests/unittests/models/test_ollama.py index f33815ed9..41ef4849d 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_ollama.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_ollama.py @@ -231,3 +231,69 @@ def test_create_llm_from_ollama_model_config(self): # Options are type-coerced by _convert_ollama_options before reaching create_ollama_llm assert result.ollama_options["temperature"] == 0.8 assert result.ollama_options["top_p"] == 0.9 + + +class TestConvertOllamaOptions: + """Coercion of CRD string options into the Ollama SDK's typed values.""" + + def test_int_option_coerced(self): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"num_ctx": "64000"}) == {"num_ctx": 64000} + + def test_int_option_accepts_integral_float_string(self): + """A value like "64000.0" (which JSON/YAML tooling can produce for an int + field) must not crash the whole agent build.""" + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"num_ctx": "64000.0"}) == {"num_ctx": 64000} + + def test_float_option_coerced(self): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"temperature": "0.7"}) == {"temperature": 0.7} + + @pytest.mark.parametrize("value", ["1", "true", "True", "YES", "on", " true "]) + def test_bool_option_truthy_spellings(self, value): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"low_vram": value}) == {"low_vram": True} + + @pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) + def test_bool_option_falsy_spellings(self, value): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"low_vram": value}) == {"low_vram": False} + + def test_unparseable_value_is_forwarded_not_raised(self): + """A malformed value is kept as a string for the SDK to report, rather + than raising and failing agent construction.""" + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"num_ctx": "not-a-number"}) == {"num_ctx": "not-a-number"} + + def test_non_integral_float_is_not_truncated(self): + """A fractional value for an int option is a mistake; forward it rather + than silently truncating "2048.5" to 2048.""" + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"num_ctx": "2048.5"}) == {"num_ctx": "2048.5"} + + @pytest.mark.parametrize("value", ["inf", "-inf", "nan", "1e400"]) + def test_non_finite_int_value_is_forwarded(self, value): + """int(float("inf")) raises OverflowError, so these must fall back to the + raw value instead of crashing agent construction.""" + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"num_ctx": value}) == {"num_ctx": value} + + def test_unknown_option_kept_as_string(self): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options({"custom_flag": "value"}) == {"custom_flag": "value"} + + def test_empty_and_none(self): + from kagent.adk.types import _convert_ollama_options + + assert _convert_ollama_options(None) == {} + assert _convert_ollama_options({}) == {}