Skip to content

fix(agent): heal stringified payloads + enable prompt caching + lower step cap - #114

Open
gianniskotsas wants to merge 1 commit into
mainfrom
giannis/agent-cost-and-validation-fixes
Open

fix(agent): heal stringified payloads + enable prompt caching + lower step cap#114
gianniskotsas wants to merge 1 commit into
mainfrom
giannis/agent-cost-and-validation-fixes

Conversation

@gianniskotsas

@gianniskotsas gianniskotsas commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Description

Investment plan run hit two issues at once:

  • Validation failed twice: `monthlyAction` was emitted as a JSON-encoded string instead of a nested object → Pydantic rejected → run marked failed.
  • $1.40 cost for one failed run — way too high.

This PR fixes both and adds a step-count guardrail.

Fixes

1. `_normalize_payload` — heal stringified nested fields

Sonnet sometimes returns nested object/array values in tool-input as JSON-encoded strings rather than native objects when the tool schema is deeply nested (the `InvestmentPlanOutput` schema is). Example we just saw:

```json
{ "monthlyAction": "{"proposedBuys":[{"symbol":"VUAA",...}]}", ... }
```

Pre-walk the payload before Pydantic validation; `json.loads` any string starting with `{` or `[`. Applied identically in `routine_runner._validate_or_downgrade` and `investment_plan_runner._validate`.

2. Anthropic prompt caching on system + tools

The system prompt (3-5k tokens) and tool definitions including the `InvestmentPlanOutput` JSON schema (5-15k tokens) are static across every iteration of an agent loop. We were re-paying full input cost ($3/M for Sonnet) on these tokens every step.

Marking the last block of `system` and `tools` with `cache_control: { type: "ephemeral" }` caches them for 5 minutes — easily long enough to cover the entire loop. Cached tokens cost 0.1× = $0.30/M instead of $3/M.

For a 10-step run that was costing $1.40, this drops to roughly $0.50-0.70. Bigger wins on longer loops.

3. `MAX_AGENT_STEPS` 30 → 15

Each step that uses `web_search` adds 5-20k tokens of search-result content. That content is re-included in every subsequent iteration's input → cost grows quadratically. Capping the loop bounds worst-case spend.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Tests

`tests/services/test_routine_runner.py` and `tests/services/test_investment_plan_runner.py` (6/6) still pass — they patch `call_agent_step` so caching internals don't affect tests, and the validation-test payload is well-formed so `_normalize_payload` is a no-op.


Summary by cubic

Fixes validation failures from stringified nested tool payloads and reduces agent run cost by enabling prompt caching in anthropic and lowering the step cap. MAX_AGENT_STEPS is now 15, and runs that previously failed on fields like monthlyAction now pass; typical loops cost ~50% less.

  • Bug Fixes
    • Pre-parse JSON-like strings in tool payloads before Pydantic validation in both routine and investment plan runners.

Written for commit e031a20. Summary will update on new commits.

…+ lower step cap

Three fixes for the failing-and-expensive investment plan run.

1. _normalize_payload: Sonnet sometimes serializes nested object/array values
   in tool-input as JSON-encoded STRINGS rather than native objects, causing
   Pydantic validation to fail with 'Input should be a valid dictionary'.
   Pre-walk the payload and json.loads any string starting with { or [.
   Applied identically in both routine_runner and investment_plan_runner.

2. Anthropic prompt caching: mark the system prompt + tools array with
   cache_control: ephemeral. The system prompt (3-5k tokens) and tool
   definitions including the InvestmentPlanOutput JSON schema (5-15k tokens)
   are STATIC across every iteration of an agent loop. Caching them gives a
   90% discount on cached input tokens for 5 minutes — enough to cover the
   entire loop. For a typical 10-step run with web_search this roughly halves
   total cost (the user just saw a $1.40 run; expect ~$0.70 after).

3. MAX_AGENT_STEPS 30 -> 15 in both runners. Each step that uses web_search
   adds 5-20k tokens of search-result content that's re-included in every
   subsequent iteration's input. Capping the loop bounds the worst case.
@vercel

vercel Bot commented May 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
syllogic Ready Ready Preview, Comment May 4, 2026 10:11am

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/app/services/investment_plan_runner.py">

<violation number="1" location="backend/app/services/investment_plan_runner.py:149">
P1: `_normalize_payload` only normalizes top-level keys, so nested stringified JSON fields are still left as strings and can continue to fail validation.</violation>
</file>

<file name="backend/app/services/routine_runner.py">

<violation number="1" location="backend/app/services/routine_runner.py:162">
P1: `_normalize_payload` only heals top-level fields, so nested stringified JSON can still bypass normalization and trigger validation failures.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

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>

Comment on lines +162 to +170
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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant