Skip to content
Open
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
19 changes: 17 additions & 2 deletions backend/app/services/_agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,29 @@ def call_agent_step(client, model: str, system: str, messages: list[dict], tools
if client is None:
client = anthropic_client.get_client()

# Apply Anthropic prompt caching to the system prompt and the tools array.
# The system prompt (~3-5k tokens) and tool definitions including the
# emit_*_output schema (~5-15k tokens) are STATIC across every iteration
# of an agent loop. Marking the last block of each with cache_control=ephemeral
# caches them for 5 minutes — subsequent iterations pay 0.1× input cost on
# the cached portion (90% discount). For a typical 10-step loop with
# web_search this cuts total run cost roughly in half.
cached_system = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}]
cached_tools = list(tools)
if cached_tools:
# Marking the LAST tool caches every tool definition above it as well.
last_tool = dict(cached_tools[-1])
last_tool["cache_control"] = {"type": "ephemeral"}
cached_tools[-1] = last_tool

last_err: Exception | None = None
for attempt in range(_RATE_LIMIT_OUTER_RETRIES):
try:
return client.messages.create(
model=model,
max_tokens=4096,
system=system,
tools=tools,
system=cached_system,
tools=cached_tools,
messages=messages,
)
except RateLimitError as exc:
Expand Down
24 changes: 23 additions & 1 deletion backend/app/services/investment_plan_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

log = logging.getLogger(__name__)

MAX_AGENT_STEPS = 30
MAX_AGENT_STEPS = 15 # Tighter cap — each web_search-using step grows input by 5-20k tokens

EMIT_OUTPUT_TOOL = {
"name": "emit_investment_plan_output",
Expand Down Expand Up @@ -136,7 +136,29 @@ def _agent_loop(plan: InvestmentPlan, transcript: list[dict], usage_totals: dict
return None, last_message


def _normalize_payload(payload: dict) -> dict:
"""Heal common Anthropic tool-input quirks.

Sonnet sometimes serializes nested object/array values as JSON-encoded strings
when the tool's input_schema has deeply nested types. We pre-walk the payload
and json.loads any string that looks like a JSON object or array. Idempotent.
"""
if not isinstance(payload, dict):
return payload
healed: dict = {}
for k, v in payload.items():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: _normalize_payload only normalizes top-level keys, so nested stringified JSON fields are still left as strings and can continue to fail validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/app/services/investment_plan_runner.py, line 149:

<comment>`_normalize_payload` only normalizes top-level keys, so nested stringified JSON fields are still left as strings and can continue to fail validation.</comment>

<file context>
@@ -136,7 +136,29 @@ def _agent_loop(plan: InvestmentPlan, transcript: list[dict], usage_totals: dict
+    if not isinstance(payload, dict):
+        return payload
+    healed: dict = {}
+    for k, v in payload.items():
+        if isinstance(v, str) and v and v[0] in ("{", "["):
+            try:
</file context>

if isinstance(v, str) and v and v[0] in ("{", "["):
try:
healed[k] = json.loads(v)
continue
except json.JSONDecodeError:
pass
healed[k] = v
return healed


def _validate(payload: dict) -> tuple[dict, list[str]]:
payload = _normalize_payload(payload)
try:
validated = InvestmentPlanOutput.model_validate(payload).model_dump(by_alias=True)
return validated, []
Expand Down
24 changes: 23 additions & 1 deletion backend/app/services/routine_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

log = logging.getLogger(__name__)

MAX_AGENT_STEPS = 30
MAX_AGENT_STEPS = 15 # Tighter cap — each web_search-using step grows input by 5-20k tokens
EVIDENCE_THRESHOLD = 3

# Built from the Pydantic schema so the agent sees exactly what we'll validate against.
Expand Down Expand Up @@ -149,8 +149,30 @@ def _agent_loop(routine: Routine, transcript: list[dict], usage_totals: dict) ->
return None, last_message


def _normalize_payload(payload: dict) -> dict:
"""Heal common Anthropic tool-input quirks.

Sonnet sometimes serializes nested object/array values as JSON-encoded strings
when the tool's input_schema has deeply nested types. We pre-walk the payload
and json.loads any string that looks like a JSON object or array. Idempotent.
"""
if not isinstance(payload, dict):
return payload
healed: dict = {}
for k, v in payload.items():
if isinstance(v, str) and v and v[0] in ("{", "["):
try:
healed[k] = json.loads(v)
continue
except json.JSONDecodeError:
pass
healed[k] = v
return healed
Comment on lines +162 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: _normalize_payload only heals top-level fields, so nested stringified JSON can still bypass normalization and trigger validation failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/app/services/routine_runner.py, line 162:

<comment>`_normalize_payload` only heals top-level fields, so nested stringified JSON can still bypass normalization and trigger validation failures.</comment>

<file context>
@@ -149,8 +149,30 @@ def _agent_loop(routine: Routine, transcript: list[dict], usage_totals: dict) ->
+    if not isinstance(payload, dict):
+        return payload
+    healed: dict = {}
+    for k, v in payload.items():
+        if isinstance(v, str) and v and v[0] in ("{", "["):
+            try:
</file context>
Suggested change
for k, v in payload.items():
if isinstance(v, str) and v and v[0] in ("{", "["):
try:
healed[k] = json.loads(v)
continue
except json.JSONDecodeError:
pass
healed[k] = v
return healed
def _heal(value: Any) -> Any:
if isinstance(value, dict):
return {kk: _heal(vv) for kk, vv in value.items()}
if isinstance(value, list):
return [_heal(item) for item in value]
if isinstance(value, str):
stripped = value.lstrip()
if stripped and stripped[0] in ("{", "["):
try:
return _heal(json.loads(stripped))
except json.JSONDecodeError:
return value
return value
for k, v in payload.items():
healed[k] = _heal(v)
return healed



def _validate_or_downgrade(payload: dict) -> tuple[dict, list[str]]:
"""Validate against the Pydantic schema; if AMBER/RED with insufficient evidence, downgrade to GREEN."""
payload = _normalize_payload(payload)
errors: list[str] = []
try:
validated = RoutineOutput.model_validate(payload).model_dump(by_alias=True)
Expand Down
Loading