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:
1 parent
61522e103e
commit
d7b40a2a5c
18 files changed
+390
-44
No files matched your search
@@ -46,6 +46,7 @@ class RiskDebateState(TypedDict):
|
||||
class AgentState(MessagesState):
|
||||
company_of_interest: Annotated[str, "Company that we are interested in trading"]
|
||||
asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"]
|
||||
instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"]
|
||||
trade_date: Annotated[str, "What date we are trading at"]
|
||||
|
||||
sender: Annotated[str, "Agent that sent this message"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user