diff --git a/tests/test_structured_agents.py b/tests/test_structured_agents.py index dddf741b3..6f5cdb206 100644 --- a/tests/test_structured_agents.py +++ b/tests/test_structured_agents.py @@ -15,6 +15,7 @@ from pydantic import ValidationError from tradingagents.agents.analysts.sentiment_analyst import create_sentiment_analyst from tradingagents.agents.managers.research_manager import create_research_manager from tradingagents.agents.schemas import ( + PortfolioDecision, PortfolioRating, ResearchPlan, SentimentBand, @@ -67,6 +68,36 @@ class TestRenderTraderProposal: assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md +@pytest.mark.unit +class TestNullishFloatCoercion: + """A weak LLM may write "None"/"N/A" into an optional float field (#1058); + coerce those to None so the structured call validates instead of erroring.""" + + def test_trader_nullish_strings_coerce_to_none(self): + for sentinel in ("None", "N/A", "null", "-", "", "TBD"): + p = TraderProposal( + action=TraderAction.HOLD, + reasoning="x", + entry_price=sentinel, + stop_loss=sentinel, + ) + assert p.entry_price is None + assert p.stop_loss is None + + def test_trader_real_numeric_string_still_parses(self): + p = TraderProposal(action=TraderAction.BUY, reasoning="x", entry_price="189.5") + assert p.entry_price == 189.5 + + def test_pm_nullish_price_target_coerces_to_none(self): + d = PortfolioDecision( + rating=PortfolioRating.OVERWEIGHT, + executive_summary="s", + investment_thesis="t", + price_target="N/A", + ) + assert d.price_target is None + + @pytest.mark.unit class TestRenderResearchPlan: def test_required_fields(self): diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py index 53e5a906a..63ff221fd 100644 --- a/tradingagents/agents/schemas.py +++ b/tradingagents/agents/schemas.py @@ -21,7 +21,20 @@ from __future__ import annotations from enum import Enum from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +# LLMs sometimes write a placeholder string ("None", "N/A", ...) into an optional +# numeric field instead of omitting it. Coerce those to None so the structured +# call validates instead of erroring (#1058). Pydantic still parses real numeric +# strings ("189.5") to float. +_NULLISH_FLOAT = {"", "none", "n/a", "na", "null", "nil", "-", "tbd", "unknown"} + + +def _coerce_optional_float(value): + if isinstance(value, str) and value.strip().lower() in _NULLISH_FLOAT: + return None + return value + # --------------------------------------------------------------------------- # Shared rating types @@ -136,6 +149,11 @@ class TraderProposal(BaseModel): description="Optional sizing guidance, e.g. '5% of portfolio'.", ) + @field_validator("entry_price", "stop_loss", mode="before") + @classmethod + def _nullish_float_to_none(cls, v): + return _coerce_optional_float(v) + def render_trader_proposal(proposal: TraderProposal) -> str: """Render a TraderProposal to markdown. @@ -204,6 +222,11 @@ class PortfolioDecision(BaseModel): description="Optional recommended holding period, e.g. '3-6 months'.", ) + @field_validator("price_target", mode="before") + @classmethod + def _nullish_float_to_none(cls, v): + return _coerce_optional_float(v) + def render_pm_decision(decision: PortfolioDecision) -> str: """Render a PortfolioDecision back to the markdown shape the rest of the system expects.