Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 144 additions & 27 deletions backend/app/agents/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Shared agentic loop runner used by all agents."""
"""Shared agentic loop runner — supports Anthropic and OpenAI-compatible backends (Groq etc.)."""
from __future__ import annotations

import asyncio
Expand All @@ -8,14 +8,25 @@
from dataclasses import dataclass, field
from typing import Any

import anthropic

from .tools import TOOL_SCHEMAS, ToolContext, execute_tool

logger = logging.getLogger(__name__)

_RATE_LIMIT_WAITS = [15, 30, 60, 120] # seconds to wait on successive 429s

# OpenAI-compatible tool schema (used for Groq and any OpenAI endpoint)
_OPENAI_TOOL_SCHEMAS: list[dict] = [
{
"type": "function",
"function": {
"name": t["name"],
"description": t.get("description", ""),
"parameters": t.get("input_schema", {"type": "object", "properties": {}}),
},
}
for t in TOOL_SCHEMAS
]


@dataclass
class AgentResult:
Expand All @@ -25,52 +36,78 @@ class AgentResult:
error: str | None = None


def _is_anthropic(client: Any) -> bool:
return type(client).__name__ == "AsyncAnthropic"


async def run_agent(
client: anthropic.AsyncAnthropic,
client: Any,
model: str,
agent_name: str,
system_prompt: str,
initial_message: str,
tool_context: ToolContext,
max_turns: int = 12,
) -> AgentResult:
if _is_anthropic(client):
return await _run_anthropic(client, model, agent_name, system_prompt, initial_message, tool_context, max_turns)
return await _run_openai(client, model, agent_name, system_prompt, initial_message, tool_context, max_turns)


# ---------------------------------------------------------------------------
# Anthropic path
# ---------------------------------------------------------------------------

async def _run_anthropic(
client: Any,
model: str,
agent_name: str,
system_prompt: str,
initial_message: str,
tool_context: ToolContext,
max_turns: int,
) -> AgentResult:
import anthropic as _anthropic

messages: list[dict] = [{"role": "user", "content": initial_message}]

for turn in range(max_turns):
response = None
for attempt, wait in enumerate([0] + _RATE_LIMIT_WAITS):
if wait:
logger.warning(
"Agent %s rate-limited on turn %d, waiting %ds (attempt %d)",
agent_name, turn, wait, attempt,
)
logger.warning("Agent %s rate-limited turn %d, waiting %ds", agent_name, turn, wait)
await asyncio.sleep(wait)
try:
response = await client.messages.create(
model=model,
max_tokens=8192,
system=[
{
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"},
}
],
max_tokens=2048,
system=[{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}],
tools=TOOL_SCHEMAS,
messages=messages,
)
break
except anthropic.RateLimitError as exc:
except _anthropic.RateLimitError as exc:
if attempt == len(_RATE_LIMIT_WAITS):
logger.error("Agent %s exhausted retries on turn %d", agent_name, turn)
logger.error("Agent %s exhausted retries turn %d", agent_name, turn)
return AgentResult(name=agent_name, text="", error=str(exc))
except Exception as exc:
logger.error("Agent %s failed on turn %d: %s", agent_name, turn, exc)
logger.error("Agent %s failed turn %d: %s", agent_name, turn, exc)
return AgentResult(name=agent_name, text="", error=str(exc))

if response is None:
return AgentResult(name=agent_name, text="", error="rate_limit_exhausted")

messages.append({"role": "assistant", "content": response.content})
# Anthropic rejects messages where a TextBlock ends with trailing whitespace.
# Convert content blocks to dicts and strip to avoid the 400 error.
cleaned: list[dict] = []
for blk in response.content:
if blk.type == "text":
cleaned.append({"type": "text", "text": blk.text.rstrip() or " "})
elif blk.type == "tool_use":
cleaned.append({"type": "tool_use", "id": blk.id, "name": blk.name, "input": blk.input})
else:
cleaned.append(blk)
messages.append({"role": "assistant", "content": cleaned})

if response.stop_reason == "end_turn":
text = "".join(b.text for b in response.content if hasattr(b, "text"))
Expand All @@ -86,19 +123,99 @@ async def run_agent(
except Exception as exc:
logger.warning("Tool %s raised: %s", block.name, exc)
content = json.dumps({"error": str(exc)})
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": content,
}
)
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": content})
messages.append({"role": "user", "content": tool_results})

logger.warning("Agent %s hit max_turns=%d", agent_name, max_turns)
return AgentResult(name=agent_name, text="Max turns reached", error="max_turns_exceeded")


# ---------------------------------------------------------------------------
# OpenAI-compatible path (Groq, OpenAI, Ollama, etc.)
# ---------------------------------------------------------------------------

async def _run_openai(
client: Any,
model: str,
agent_name: str,
system_prompt: str,
initial_message: str,
tool_context: ToolContext,
max_turns: int,
) -> AgentResult:
messages: list[dict] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": initial_message},
]

for turn in range(max_turns):
response = None
for attempt, wait in enumerate([0] + _RATE_LIMIT_WAITS):
if wait:
logger.warning("Agent %s rate-limited turn %d, waiting %ds", agent_name, turn, wait)
await asyncio.sleep(wait)
try:
response = await client.chat.completions.create(
model=model,
max_tokens=2048,
tools=_OPENAI_TOOL_SCHEMAS,
messages=messages,
)
break
except Exception as exc:
err = str(exc)
is_rate_limit = (
"429" in err
or "rate_limit" in err.lower()
or "RateLimitError" in type(exc).__name__
)
if is_rate_limit and attempt < len(_RATE_LIMIT_WAITS):
continue
logger.error("Agent %s failed turn %d: %s", agent_name, turn, exc)
return AgentResult(name=agent_name, text="", error=err)

if response is None:
return AgentResult(name=agent_name, text="", error="rate_limit_exhausted")

choice = response.choices[0]
msg = choice.message

if choice.finish_reason in ("stop", "end_turn", None) and not msg.tool_calls:
text = msg.content or ""
return AgentResult(name=agent_name, text=text, parsed=_extract_json(text))

if choice.finish_reason == "tool_calls" or msg.tool_calls:
# Append assistant message with tool_calls
messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in (msg.tool_calls or [])
],
})
for tc in (msg.tool_calls or []):
try:
args = json.loads(tc.function.arguments or "{}")
result = await execute_tool(tc.function.name, args, tool_context)
content = json.dumps(result, default=str)
except Exception as exc:
logger.warning("Tool %s raised: %s", tc.function.name, exc)
content = json.dumps({"error": str(exc)})
messages.append({"role": "tool", "tool_call_id": tc.id, "content": content})
else:
# Unexpected finish reason — treat as end
text = msg.content or ""
return AgentResult(name=agent_name, text=text, parsed=_extract_json(text))

logger.warning("Agent %s hit max_turns=%d", agent_name, max_turns)
return AgentResult(name=agent_name, text="Max turns reached", error="max_turns_exceeded")


def _extract_json(text: str) -> dict[str, Any]:
m = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
Expand Down
7 changes: 3 additions & 4 deletions backend/app/agents/bear_case_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
from datetime import UTC, datetime
from typing import Any

import anthropic

from app.core.config import get_settings
from app.core.config import get_active_model, get_settings

from .base import AgentResult, run_agent
from .tools import ToolContext
Expand Down Expand Up @@ -51,7 +50,7 @@
async def run_bear_case_agent(
sector_top_picks: list[str],
sector_summaries: dict[str, Any],
client: anthropic.AsyncAnthropic,
client: object,
tool_context: ToolContext,
) -> AgentResult:
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
Expand All @@ -76,7 +75,7 @@ async def run_bear_case_agent(

return await run_agent(
client=client,
model=get_settings().agent_model,
model=get_active_model(),
agent_name="bear_case",
system_prompt=_BEAR_SYSTEM,
initial_message=initial_message,
Expand Down
7 changes: 3 additions & 4 deletions backend/app/agents/catalyst_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
import logging
from datetime import UTC, datetime

import anthropic

from app.core.config import get_settings
from app.core.config import get_active_model, get_settings

from .base import AgentResult, run_agent
from .tools import ToolContext
Expand Down Expand Up @@ -52,7 +51,7 @@

async def run_catalyst_agent(
sector_top_picks: list[str],
client: anthropic.AsyncAnthropic,
client: object,
tool_context: ToolContext,
) -> AgentResult:
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
Expand All @@ -72,7 +71,7 @@ async def run_catalyst_agent(

return await run_agent(
client=client,
model=get_settings().agent_model,
model=get_active_model(),
agent_name="catalyst",
system_prompt=_CATALYST_SYSTEM,
initial_message=initial_message,
Expand Down
16 changes: 6 additions & 10 deletions backend/app/agents/daily_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
from datetime import UTC, datetime
from typing import Any

import anthropic

from app.constants import REDIS_AGENT_ANALYSIS_KEY, REDIS_DAILY_SCAN_KEY
from app.core.redis_client import cache_load_json, cache_save_json
from app.db.session import async_session_factory

from .base import run_agent
from .llm_client import make_agent_client
from .tools import ToolContext

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -57,10 +56,10 @@ async def run_daily_scan() -> dict[str, Any]:
if not active_picks:
return {"skipped": True, "reason": "No active BUY picks to monitor"}

from app.core.config import get_settings
settings = get_settings()
if not settings.anthropic_api_key:
return {"skipped": True, "reason": "No ANTHROPIC_API_KEY configured"}
try:
client, sub_model, _ = make_agent_client()
except ValueError as exc:
return {"skipped": True, "reason": str(exc)}

picks_summary = "\n".join(
f"- {p['ticker']} ({p.get('horizon','?')}-term, {p.get('final_recommendation')}): "
Expand All @@ -77,14 +76,11 @@ async def run_daily_scan() -> dict[str, Any]:
"3. Output your JSON health check"
)

client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
haiku_model = "claude-haiku-4-5-20251001"

async with async_session_factory() as session:
tool_context = ToolContext(session=session, top_n=10)
result = await run_agent(
client=client,
model=haiku_model,
model=sub_model,
agent_name="daily_scan",
system_prompt=_SCAN_SYSTEM,
initial_message=initial_message,
Expand Down
13 changes: 6 additions & 7 deletions backend/app/agents/debate_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
from datetime import UTC, datetime
from typing import Any

import anthropic

from app.core.config import get_settings
from app.core.config import get_active_overseer_model, get_settings

from .base import AgentResult, run_agent
from .tools import ToolContext
Expand Down Expand Up @@ -73,7 +72,7 @@ async def run_bull_debate_agent(
ticker: str,
sector_thesis: str,
bear_objection: str,
client: anthropic.AsyncAnthropic,
client: object,
tool_context: ToolContext,
) -> AgentResult:
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
Expand All @@ -86,7 +85,7 @@ async def run_bull_debate_agent(
)
return await run_agent(
client=client,
model=get_settings().agent_model,
model=get_active_overseer_model(),
agent_name=f"bull_debate_{ticker}",
system_prompt=_BULL_SYSTEM,
initial_message=message,
Expand All @@ -99,7 +98,7 @@ async def run_bear_rebuttal_agent(
ticker: str,
bear_thesis: str,
sector_objection: str,
client: anthropic.AsyncAnthropic,
client: object,
tool_context: ToolContext,
) -> AgentResult:
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
Expand All @@ -112,7 +111,7 @@ async def run_bear_rebuttal_agent(
)
return await run_agent(
client=client,
model=get_settings().agent_model,
model=get_active_overseer_model(),
agent_name=f"bear_rebuttal_{ticker}",
system_prompt=_BEAR_REBUTTAL_SYSTEM,
initial_message=message,
Expand All @@ -125,7 +124,7 @@ async def run_debate_round(
overseer_parsed: dict[str, Any],
bear_parsed: dict[str, Any],
sector_summaries: dict[str, str],
client: anthropic.AsyncAnthropic,
client: object,
tool_context: ToolContext,
) -> dict[str, Any]:
"""Run debate agents on STRONG_BUY and AVOID tickers in parallel."""
Expand Down
Loading
Loading