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
69 changes: 62 additions & 7 deletions backend/ai_runtime.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Shared sync→async bridge for the Claude-agent subsystems (REFACTOR-003).
"""Shared runtime helpers for the Claude-agent subsystems (REFACTOR-003, AI-006).

The fundamentals, technical, and 67-Ka-Funda agents each need to run one Agent
SDK coroutine to completion from synchronous (Streamlit) code. All three used
to carry a private copy of the same bridge; the copies drifted once (the
technical agent's lost the context fix below), so the logic now lives here and
the agents delegate. Design rationale and the options weighed are in the ADR:
``docs/architecture/refactor-003-ai-runtime.md``.
SDK coroutine to completion from synchronous (Streamlit) code, and to pull the
verdict JSON object out of the model's final message. All three used to carry
private copies of both helpers; the bridge copies drifted once (the technical
agent's lost the context fix below), so the logic now lives here and the
agents delegate. Design rationale and the options weighed are in the ADR:
``docs/architecture/refactor-003-ai-runtime.md`` (the extractor was folded in
by its AI-006 amendment).

Two subtleties are handled, both easy to get wrong:

Expand Down Expand Up @@ -35,13 +37,66 @@
import asyncio
import concurrent.futures
import contextvars
import json
import re
import sys
from collections.abc import Awaitable
from typing import TypeVar
from typing import Any, NoReturn, TypeVar

T = TypeVar("T")


def _reject_non_json_constant(value: str) -> NoReturn:
"""Reject Python's optional NaN/Infinity JSON decoder extensions.

Beginner note:
The JSON standard only permits finite numbers, but ``json.loads`` accepts
``NaN`` and infinities unless a ``parse_constant`` callback rejects them.
Agent output is persisted and signed as strict JSON, so accepting those
values here would merely move the failure to the later cache boundary.
"""
raise ValueError(f"Non-standard JSON numeric constant: {value}")


def extract_json_object(text: str) -> dict[str, Any] | None:
"""Pull a single JSON object out of an agent's final message text.

Each agent instructs the model to emit ONLY a JSON object (AgentVerdict,
TechnicalVerdict, or the 67-Ka-Funda verdict), but real models
occasionally wrap it in a ```json fence or add a stray sentence. This
helper is tolerant: it first looks for a fenced block, then falls back to
the outermost {...} span. Returns None when nothing parses.

Beginner note:
All three agents used to carry a logic-identical private copy of this
function — the same drift-prone shape as the sync bridge below, which DID
drift once. AI-006 moved the single implementation here; each agent
imports it under its old private name (``_extract_json_object``) so call
sites and per-agent parse-fallback behavior stay exactly as they were.
"""
if not text:
return None

fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if fenced:
candidate = fenced.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end < start:
return None
candidate = text[start : end + 1]

try:
parsed = json.loads(candidate, parse_constant=_reject_non_json_constant)
except (ValueError, RecursionError):
# JSONDecodeError subclasses ValueError. RecursionError is also a
# model-controlled parse failure when the response is deeply nested;
# both should enter the agents' normal retry path instead of escaping.
return None
return parsed if isinstance(parsed, dict) else None


def run_agent_coroutine(coro: Awaitable[T]) -> T:
"""Run one agent coroutine to completion from sync code and return its result.

Expand Down
9 changes: 7 additions & 2 deletions backend/ai_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@


class StrictAIModel(BaseModel):
"""Strict base class for model-produced JSON."""
"""Strict base class for finite, model-produced JSON values.

model_config = ConfigDict(strict=True, extra="forbid")
``allow_inf_nan=False`` repeats the runtime decoder's finite-number rule at
the schema boundary. This defense in depth also protects callers that
validate an already-built Python mapping instead of parsing JSON text.
"""

model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)


class AIValidationError(RuntimeError):
Expand Down
33 changes: 4 additions & 29 deletions backend/fundamentals/fundamental_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,17 @@
import contextvars
import json
import logging
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Literal

from pydantic import Field, ValidationError, ValidationInfo, field_validator

# The aliased import keeps this module's call sites unchanged: the shared
# extractor pulls the AgentVerdict JSON out of the model's final message
# (AI-006 — one tolerant implementation for all three agents).
from backend.ai_runtime import extract_json_object as _extract_json_object
from backend.ai_runtime import run_agent_coroutine
from backend.ai_validation import StrictAIModel, parse_with_retry
from backend.config import get_ai_max_attempts
Expand Down Expand Up @@ -668,34 +671,6 @@ def _data_date_from_payload(data: dict[str, Any]) -> str:
return datetime.now(UTC).date().isoformat()


def _extract_json_object(text: str) -> dict[str, Any] | None:
"""Pull the AgentVerdict JSON object out of the model's final message.

The model is instructed to emit ONLY a JSON object, but real models
occasionally wrap it in a ```json fence or add a stray sentence. This
helper is tolerant: it first looks for a fenced block, then falls back to
the outermost {...} span. Returns None when nothing parses.
"""
if not text:
return None

fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if fenced:
candidate = fenced.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end < start:
return None
candidate = text[start : end + 1]

try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None


def _build_user_prompt(symbol: str, mode: str, model: str) -> str:
"""Build the per-stock kickoff message for the agent."""
return (
Expand Down
32 changes: 5 additions & 27 deletions backend/sixty_seven/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import hashlib
import json
import logging
import re
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any, Literal
Expand All @@ -42,6 +41,11 @@
sign_cache_envelope,
verify_cache_envelope,
)

# The aliased import keeps this module's call sites unchanged: the shared
# extractor pulls the 67-Ka-Funda verdict JSON out of the model's final message
# (AI-006 — one tolerant implementation for all three agents).
from backend.ai_runtime import extract_json_object as _extract_json_object
from backend.ai_runtime import run_agent_coroutine
from backend.ai_validation import StrictAIModel, parse_with_retry
from backend.config import get_agent_fast_mode, get_ai_max_attempts, get_fundamentals_model
Expand Down Expand Up @@ -250,32 +254,6 @@ class SixtySevenEvaluationResult:
"""


def _extract_json_object(text: str) -> dict[str, Any] | None:
"""Pull the verdict JSON object out of the model's final message.

Tolerant of a stray ```json fence or a leading sentence: it looks for a fenced
block first, then falls back to the outermost {...} span. Returns None when
nothing parses. (Mirrors the fundamental / technical agents' extractor; kept
local so the three agents stay independent.)
"""
if not text:
return None
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if fenced:
candidate = fenced.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end < start:
return None
candidate = text[start : end + 1]
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None


def _candidate_hash(candidate: DrawdownCandidate) -> str:
"""Return a short, stable digest of the candidate's deterministic price facts.

Expand Down
39 changes: 5 additions & 34 deletions backend/technical/technical_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import hashlib
import json
import logging
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
Expand All @@ -49,6 +48,11 @@
sign_cache_envelope,
verify_cache_envelope,
)

# The aliased import keeps this module's call sites unchanged: the shared
# extractor pulls the TechnicalVerdict JSON out of the model's final message
# (AI-006 — one tolerant implementation for all three agents).
from backend.ai_runtime import extract_json_object as _extract_json_object
from backend.ai_runtime import run_agent_coroutine
from backend.ai_validation import StrictAIModel, parse_with_retry
from backend.config import get_ai_max_attempts
Expand Down Expand Up @@ -333,39 +337,6 @@ class TechnicalEvaluationResult:
_FINAL_OUTPUT_INSTRUCTION = FINAL_OUTPUT_INSTRUCTION


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _extract_json_object(text: str) -> dict[str, Any] | None:
"""Pull the TechnicalVerdict JSON object out of the model's final message.

Tolerant of a stray ```json fence or a leading sentence: it looks for a
fenced block first, then falls back to the outermost {...} span. Returns
None when nothing parses. (Mirrors the fundamentals agent's extractor; kept
local so the two agents stay independent.)
"""
if not text:
return None

fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if fenced:
candidate = fenced.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end < start:
return None
candidate = text[start : end + 1]

try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None


# ---------------------------------------------------------------------------
# Agent
# ---------------------------------------------------------------------------
Expand Down
26 changes: 25 additions & 1 deletion docs/architecture/refactor-003-ai-runtime.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR — REFACTOR-003: one shared sync bridge for the Claude-agent subsystems

**Status:** Accepted
**Status:** Accepted — amended by AI-006 (2026-07-10, see the Amendment section)
**Date:** 2026-07-07
**Deciders:** repo maintainer (PR review); authored by Claude (Fable) during the July 2026 whole-app review
**Relates to:** [audit-2026-06.md](audit-2026-06.md) "Deferred follow-ups" (Agent SDK boilerplate dedup) · TEST-003 (context-propagation bug class)
Expand Down Expand Up @@ -91,3 +91,27 @@ safety gain. Option A takes exactly the shared-and-dangerous part and nothing el
2. [x] `tests/test_ai_runtime.py`: contextvar propagation, result/exception passthrough, sequential reuse.
3. [x] Migrate agents one commit each (technical → sixty_seven → fundamental), keeping delegating `_run_sync` staticmethods.
4. [x] Full gate suite + the three agent test suites green.

## Amendment — AI-006 (2026-07-10): the JSON extractor joins the shared runtime

Option A's accepted con was that everything outside the bridge "stays per-agent".
For the options-construction blocks that remains the right call (they differ for real
domain reasons — see the trade-off analysis above). But one of the leftover blocks,
`_extract_json_object`, failed the same three-part test that justified moving the
bridge: it was (a) **logic-identical** across all three agents (verified line-by-line
before the move — only docstrings differed), (b) **relied on for correctness** of every
verdict parse, and (c) **drift-prone in exactly the bridge's way** — two of the three
copies carried a "kept local so the agents stay independent" comment while being
character-for-character the same logic, which is copy-paste drift waiting for its
first inconsistent fix.

AI-006 therefore moved the single implementation into `backend/ai_runtime.py` as
`extract_json_object(text)`. Each agent imports it under its old private name
(`from backend.ai_runtime import extract_json_object as _extract_json_object`), so
call sites, per-agent parse-fallback behavior, and the agent test suites are all
unchanged. Extractor unit tests (fenced block, missing language tag, surrounding
prose, no-JSON/invalid-JSON/reversed-brace edges) live in `tests/test_ai_runtime.py`.

This does **not** reopen Option B: the extractor, like the bridge, is shared-and-
identical; the options-construction/retry/error-taxonomy blocks remain genuinely
different per agent and stay where they are.
Loading
Loading