fix(graph): resolve instrument identity to stop wrong-company hallucination

Agents had no ground-truth ticker→company mapping, so the market analyst
could pattern-match a price chart to the wrong company (e.g. TOTDY read as
"TotalEnergies"), and every downstream agent inherited the bad framing.

Resolve identity once at run start via a cached, fail-open yfinance lookup
and inject company/sector/exchange into the shared instrument context that
all twelve agents consume, with an explicit do-not-substitute instruction.
Resolution runs on both the propagate() and CLI entry points.

Also replaces the bare "Continue" message-clear placeholder, which some
OpenAI-compatible providers interpreted as the user task, with a
context-anchored placeholder carrying the resolved identity and date.

#814 #888
This commit is contained in:
Yijia-Xiao committed 2026-05-30 23:56:32 +00:00
1 parent 61522e103e
commit d7b40a2a5c
18 files changed
+390 -44

No files matched your search

+135 -15
View File
@@ -1,3 +1,8 @@
import functools
import logging
from typing import Any, Mapping, Optional
import yfinance as yf
from langchain_core.messages import HumanMessage, RemoveMessage
# Import tools from separate utility files
@@ -19,6 +24,8 @@ from tradingagents.agents.utils.news_data_tools import (
get_global_news
)
logger = logging.getLogger(__name__)
def get_language_instruction() -> str:
"""Return a prompt instruction for the configured output language.
@@ -36,32 +43,145 @@ def get_language_instruction() -> str:
return f" Write your entire response in {lang}."
def build_instrument_context(ticker: str, asset_type: str = "stock") -> str:
"""Describe the exact instrument so agents preserve exchange-qualified tickers."""
instrument_label = "asset" if asset_type == "crypto" else "instrument"
extra_hint = (
" Treat it as a crypto asset rather than a company, and do not assume company fundamentals are available."
if asset_type == "crypto"
else ""
def _clean_identity_value(value: Any) -> Optional[str]:
"""Return a trimmed string, or None for empty / placeholder-ish values."""
if not isinstance(value, str):
return None
cleaned = value.strip()
if not cleaned or cleaned.lower() in {"none", "n/a", "nan", "null"}:
return None
return cleaned
@functools.lru_cache(maxsize=256)
def resolve_instrument_identity(ticker: str) -> dict:
"""Resolve deterministic identity metadata (company name, sector, …) for a ticker.
This exists to stop the pipeline from hallucinating a *different* company
when a chart pattern suggests a different industry than the real one
(#814): without a ground-truth name, the market analyst would pattern-match
the price action to a narrative and invent an identity that then cascaded
through every downstream agent.
Best-effort by design: if yfinance is unavailable, rate-limited, or doesn't
recognise the ticker, we return ``{}`` and the caller falls back to
ticker-only context rather than failing before analysis starts. Cached so
the lookup happens at most once per ticker per process.
"""
try:
info = yf.Ticker(ticker.upper()).info or {}
except Exception as exc: # noqa: BLE001 — fail open, never block the run
logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc)
return {}
identity: dict[str, str] = {}
company_name = _clean_identity_value(info.get("longName")) or _clean_identity_value(
info.get("shortName")
)
return (
if company_name:
identity["company_name"] = company_name
for source_key, target_key in (
("sector", "sector"),
("industry", "industry"),
("exchange", "exchange"),
("quoteType", "quote_type"),
):
value = _clean_identity_value(info.get(source_key))
if value:
identity[target_key] = value
return identity
def build_instrument_context(
ticker: str,
asset_type: str = "stock",
identity: Optional[Mapping[str, str]] = None,
) -> str:
"""Describe the exact instrument so agents preserve identity and ticker.
When ``identity`` is provided (resolved deterministically via
:func:`resolve_instrument_identity`), the company name and business
classification are injected so agents anchor to the real company rather
than pattern-matching the price chart to a wrong one (#814).
"""
is_crypto = asset_type == "crypto"
instrument_label = "asset" if is_crypto else "instrument"
context = (
f"The {instrument_label} to analyze is `{ticker}`. "
"Use this exact ticker in every tool call, report, and recommendation, "
"preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)."
+ extra_hint
)
details = []
if identity:
name = identity.get("company_name") or identity.get("name")
if name:
details.append(f"{'Name' if is_crypto else 'Company'}: {name}")
sector, industry = identity.get("sector"), identity.get("industry")
if sector and industry:
details.append(f"Business classification: {sector} / {industry}")
elif sector:
details.append(f"Sector: {sector}")
elif industry:
details.append(f"Industry: {industry}")
if identity.get("exchange"):
details.append(f"Exchange: {identity['exchange']}")
if details:
context += (
f" Resolved identity: {'; '.join(details)}. "
"Do not substitute a different company or ticker unless a tool "
"result explicitly disproves this resolved identity."
)
if is_crypto:
context += (
" Treat it as a crypto asset rather than a company, and do not "
"assume company fundamentals are available."
)
return context
def get_instrument_context_from_state(state: Mapping[str, Any]) -> str:
"""Return the instrument context for the current run.
Prefers the identity-resolved context computed once at run start and
stored on the state (see ``TradingAgentsGraph.resolve_instrument_context``).
Falls back to a ticker-only context — with no network lookup — when the
state was constructed without it (bare programmatic states, tests), so a
consumer is never forced to make a yfinance call mid-graph.
"""
context = state.get("instrument_context")
if isinstance(context, str) and context.strip():
return context
return build_instrument_context(
str(state["company_of_interest"]),
state.get("asset_type", "stock"),
)
def create_msg_delete():
def delete_messages(state):
"""Clear messages and add placeholder for Anthropic compatibility"""
messages = state["messages"]
"""Clear messages and add a context-anchored placeholder.
# Remove all messages
The placeholder must not be a bare ``"Continue"``: some
OpenAI-compatible providers interpret that literally as the user task
and produce output about the word "continue" instead of analysing the
instrument (#888). Anchoring it to the resolved instrument context and
date keeps the next analyst on-task even if the provider treats the
placeholder as a standalone request.
"""
messages = state["messages"]
removal_operations = [RemoveMessage(id=m.id) for m in messages]
# Add a minimal placeholder message
placeholder = HumanMessage(content="Continue")
instrument_context = get_instrument_context_from_state(state)
trade_date = state.get("trade_date", "the requested date")
placeholder = HumanMessage(
content=(
f"Proceed with your assigned analysis for this workflow. "
f"{instrument_context} The analysis date is {trade_date}."
)
)
return {"messages": removal_operations + [placeholder]}
return delete_messages