From 0405168f20869642e5365054e4ad4b3ff2e43bc9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 21 Jun 2026 22:31:35 +0000 Subject: [PATCH] fix(schema): coerce null-ish strings in optional float fields A weak model can write a placeholder ('None', 'N/A') into an optional price field, tripping schema validation. Coerce null-ish strings to None on the trader/PM float fields; real numeric strings still parse. --- tests/test_structured_agents.py | 31 +++++++++++++++++++++++++++++++ tradingagents/agents/schemas.py | 25 ++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) 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.