Add a pre-call USD belt on top of the post-charge token/round meter (§8), so no future live run can loop past its run budget. Belt-and-braces above the SDK's per-call max_budget_usd cap. - budget.py: optional run-total `max_cost_usd` on BudgetMeter (fail-fast on non-positive, §10) + `guard_before_call(spent_usd)` raising the same structured stop event (BudgetKind widened with "cost_usd"; limit/observed → float). Reaching the cap exactly does not stop; crossing it does (mirrors the token cap). - loop.py: `_guarded_complete` helper reads the client's accumulated total_cost_usd (0.0 for scripted clients) and guards BEFORE every client.complete; all three call sites routed through it — one detach point. - sdk_client.py: total_cost_usd already exposed/accumulated — untouched. - tests/test_budget.py: meter-level cap tests + load-bearing loop-wiring test (counting client; detach the guard → unguarded loop runs to the round cap → kind "rounds" not "cost_usd" → red). 457→462 green, golden byte-exact, full gate clean (ruff+format+mypy strict, 22 src files), run_s10.py/runs/ byte-untouched. README test-count sync ×2 + budget.py belt note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""The agentic loop, steps 2–5 (method-spec §3): generate, debate, gate, refine.
|
||
|
||
The model is reached ONLY through the ``ModelClient`` protocol — an injected
|
||
client (the Claude Agent SDK on the run path, S10; a scripted stand-in in the
|
||
offline suite, honesty rule §1). Step 2: a reply that fails to parse into the
|
||
typed IR is retried BLIND, never silently accepted or repaired downstream,
|
||
bounded by the budget meter (§8). Step 3: the round-capped maker-checker
|
||
debate, fresh state per run, the checker instructed to end with exactly one
|
||
verdict line. Step 4 (second falsifier): the checker gate — opt-in-reject
|
||
(fail-open), parsed from the checker's LAST surfaced output; an explicit
|
||
REJECT overrides an otherwise-validated outcome; a validator rejection stands
|
||
regardless. Step 5: informed refinement — the most recent rejection REASON
|
||
verbatim in the next prompt (never history, never the prior proposal JSON),
|
||
under the existing ``max_attempts`` and meter caps. The two falsifiers are
|
||
recorded separately (§9): ``validator_decision`` mirrors the deterministic
|
||
validator only, stamped BEFORE any checker override.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Literal, Protocol
|
||
|
||
from pydantic import ValidationError
|
||
|
||
from portfolio_optimiser_claude.budget import BudgetMeter
|
||
from portfolio_optimiser_claude.ir import SavingsProposal
|
||
from portfolio_optimiser_claude.provenance import stamp_validator_decision
|
||
from portfolio_optimiser_claude.validator import Rejection, ValidatedProposal, validate_proposal
|
||
|
||
_PROPOSER_ROLE = "proposer"
|
||
_CHECKER_ROLE = "checker"
|
||
# §3 Step 3: the checker's mandated closing line, both marker forms spelled out.
|
||
_CHECKER_INSTRUCTION = (
|
||
"End your reply with exactly one verdict line: `VERDICT: APPROVE` if the "
|
||
"reasoning holds, or `VERDICT: REJECT - <short reason>` if not."
|
||
)
|
||
# §3 Step 4: parsed case-insensitively; the reject marker takes precedence and
|
||
# its trailing text is the reason.
|
||
_REJECT_PATTERN = re.compile(r"VERDICT:\s*REJECT(?:\s*[-–—:]\s*(.*))?", re.IGNORECASE)
|
||
_APPROVE_PATTERN = re.compile(r"VERDICT:\s*APPROVE", re.IGNORECASE)
|
||
|
||
CheckerDecision = Literal["approve", "reject", "absent"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelReply:
|
||
"""One model reply: text + provider-reported usage (+ real model id, §9)."""
|
||
|
||
text: str
|
||
usage_tokens: int | None = None
|
||
model: str | None = None
|
||
|
||
|
||
class ModelClient(Protocol):
|
||
"""The injected model seam — SDK client on the run path, stand-in in tests."""
|
||
|
||
def complete(self, prompt: str, *, role: str) -> ModelReply: ...
|
||
|
||
|
||
def _guarded_complete(
|
||
client: ModelClient, prompt: str, *, role: str, meter: BudgetMeter
|
||
) -> ModelReply:
|
||
"""One model call, pre-guarded by the run-total USD belt (C3.5, §8).
|
||
|
||
Every model call in the loop goes through here. The pre-call guard reads the
|
||
client's accumulated ``total_cost_usd`` (0.0 for scripted clients that carry
|
||
no cost) and refuses to make the call once the run-total USD cap is crossed
|
||
— the belt that stops the loop from spending past its run budget, on TOP of
|
||
the per-call SDK cap and the post-charge token/round meter.
|
||
"""
|
||
spent_usd: float = getattr(client, "total_cost_usd", 0.0)
|
||
meter.guard_before_call(spent_usd)
|
||
return client.complete(prompt, role=role)
|
||
|
||
|
||
# --- Step 2: hypothesise (structured candidate generation) ---------------------------------
|
||
|
||
|
||
def _parse_candidate(text: str, default_project_id: str | None) -> SavingsProposal:
|
||
raw = json.loads(text)
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("candidate reply is not a JSON object")
|
||
if default_project_id is not None:
|
||
# §3 Step 2: project_id MAY be defaulted from the project when omitted.
|
||
raw.setdefault("project_id", default_project_id)
|
||
return SavingsProposal.model_validate(raw)
|
||
|
||
|
||
def generate_candidate(
|
||
client: ModelClient,
|
||
prompt: str,
|
||
*,
|
||
meter: BudgetMeter,
|
||
default_project_id: str | None = None,
|
||
) -> SavingsProposal:
|
||
"""Ask for exactly one candidate as JSON for the IR — blind parse-retry (§3 Step 2).
|
||
|
||
A reply that fails to parse into the typed IR is retried with the SAME
|
||
prompt, never silently accepted or repaired downstream. The retry loop is
|
||
bounded by the budget meter: a round tick is charged between attempts (§8).
|
||
"""
|
||
while True:
|
||
model_reply = _guarded_complete(client, prompt, role=_PROPOSER_ROLE, meter=meter)
|
||
meter.charge_tokens(model_reply.usage_tokens)
|
||
try:
|
||
# JSONDecodeError and pydantic's ValidationError are ValueErrors.
|
||
return _parse_candidate(model_reply.text, default_project_id)
|
||
except (ValueError, ValidationError):
|
||
meter.charge_round()
|
||
|
||
|
||
# --- Step 3: debate (maker-checker) ---------------------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DebateResult:
|
||
"""The debate's converged output + the checker's LAST surfaced reply (§3)."""
|
||
|
||
proposer_output: str
|
||
checker_last: str
|
||
rounds: int
|
||
|
||
|
||
def check_turn_safety_net(turns: int, max_rounds: int) -> None:
|
||
"""The turn-count termination safety net ABOVE the round cap (§3 Step 3, §8).
|
||
|
||
Under the ``range(max_rounds)``-bounded debate loop, ``turns`` never exceeds
|
||
``2 * max_rounds``, so this net is structurally unreachable — it is a
|
||
deliberate belt that fires only if a refactor breaks the loop's own bound.
|
||
"""
|
||
if turns > 2 * max_rounds + 2:
|
||
raise RuntimeError(
|
||
f"debate turn-count safety net tripped: {turns} turns with max_rounds={max_rounds}"
|
||
)
|
||
|
||
|
||
def _proposer_debate_prompt(context: str, critique: str | None) -> str:
|
||
prompt = (
|
||
f"{context}\n\n"
|
||
"Propose the reasoning for exactly one cost-saving candidate measure "
|
||
"for this project."
|
||
)
|
||
if critique is not None:
|
||
prompt += f"\n\nThe checker challenged your reasoning:\n{critique}\n\nAddress it."
|
||
return prompt
|
||
|
||
|
||
def _checker_prompt(proposer_output: str) -> str:
|
||
return (
|
||
f"Check the proposer's reasoning for flaws:\n\n{proposer_output}\n\n{_CHECKER_INSTRUCTION}"
|
||
)
|
||
|
||
|
||
def run_debate(
|
||
client: ModelClient, context: str, *, max_rounds: int, meter: BudgetMeter
|
||
) -> DebateResult:
|
||
"""Alternate proposer/checker turns — round-capped, fresh state per run (§3 Step 3).
|
||
|
||
Converges when the checker approves; otherwise the checker's critique
|
||
feeds the next proposer turn until the round cap. All state is local to
|
||
this call — nothing survives from one project run into the next.
|
||
"""
|
||
if max_rounds <= 0:
|
||
raise ValueError(f"max_rounds must be positive, got {max_rounds}")
|
||
proposer_output = ""
|
||
checker_last = ""
|
||
critique: str | None = None
|
||
rounds = 0
|
||
turns = 0
|
||
for _ in range(max_rounds):
|
||
turns += 1
|
||
check_turn_safety_net(turns, max_rounds)
|
||
proposer_reply = _guarded_complete(
|
||
client, _proposer_debate_prompt(context, critique), role=_PROPOSER_ROLE, meter=meter
|
||
)
|
||
meter.charge_tokens(proposer_reply.usage_tokens)
|
||
proposer_output = proposer_reply.text
|
||
|
||
turns += 1
|
||
check_turn_safety_net(turns, max_rounds)
|
||
checker_reply = _guarded_complete(
|
||
client, _checker_prompt(proposer_output), role=_CHECKER_ROLE, meter=meter
|
||
)
|
||
meter.charge_tokens(checker_reply.usage_tokens)
|
||
checker_last = checker_reply.text
|
||
|
||
rounds += 1
|
||
meter.charge_round()
|
||
if parse_checker_verdict(checker_last).decision == "approve":
|
||
break
|
||
critique = checker_last
|
||
return DebateResult(proposer_output=proposer_output, checker_last=checker_last, rounds=rounds)
|
||
|
||
|
||
# --- Step 4 (second falsifier): the checker gate --------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CheckerVerdict:
|
||
"""The parsed checker decision: approve / reject(+reason) / absent (§3 Step 4)."""
|
||
|
||
decision: CheckerDecision
|
||
reason: str | None
|
||
|
||
|
||
def parse_checker_verdict(text: str) -> CheckerVerdict:
|
||
"""Parse the verdict marker case-insensitively; REJECT takes precedence (§3 Step 4)."""
|
||
reject = _REJECT_PATTERN.search(text)
|
||
if reject:
|
||
reason = (reject.group(1) or "").strip()
|
||
return CheckerVerdict(decision="reject", reason=reason)
|
||
if _APPROVE_PATTERN.search(text):
|
||
return CheckerVerdict(decision="approve", reason=None)
|
||
return CheckerVerdict(decision="absent", reason=None)
|
||
|
||
|
||
def apply_checker_gate(
|
||
outcome: ValidatedProposal | Rejection, verdict: CheckerVerdict
|
||
) -> ValidatedProposal | Rejection:
|
||
"""Opt-in-reject gate (fail-open): only an explicit REJECT overrides (§3 Step 4).
|
||
|
||
A validator rejection stands regardless of the checker; APPROVE or a
|
||
missing/unparseable marker never blocks. An explicit REJECT turns an
|
||
otherwise-validated outcome into a rejection whose reason is prefixed
|
||
with the checker's reason.
|
||
"""
|
||
if isinstance(outcome, Rejection):
|
||
return outcome
|
||
if verdict.decision != "reject":
|
||
return outcome
|
||
reason = verdict.reason or "checker rejected the reasoning"
|
||
return Rejection(reason=f"{reason} (checker REJECT overrode a validated outcome)")
|
||
|
||
|
||
# --- Step 5: refine, informed and bounded ----------------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CandidateRun:
|
||
"""The refinement loop's result: last proposal, its outcome, attempts used."""
|
||
|
||
proposal: SavingsProposal
|
||
outcome: ValidatedProposal | Rejection
|
||
attempts: int
|
||
|
||
|
||
def _informed_prompt(base_prompt: str, reason: str) -> str:
|
||
# Only the most recent rejection REASON crosses attempts — never an
|
||
# accumulated history, never the prior proposal JSON (§3 Step 5).
|
||
return (
|
||
f"{base_prompt}\n\n"
|
||
"The previous attempt was rejected by the deterministic validator. "
|
||
"Revise the candidate to address this falsification:\n"
|
||
f"{reason}"
|
||
)
|
||
|
||
|
||
def run_candidate_loop(
|
||
client: ModelClient,
|
||
base_prompt: str,
|
||
*,
|
||
meter: BudgetMeter,
|
||
max_attempts: int = 3,
|
||
default_project_id: str | None = None,
|
||
) -> CandidateRun:
|
||
"""Generate → validate, informed by the last rejection reason (§3 Steps 2+5).
|
||
|
||
Attempt 1 uses the unchanged base prompt; each later attempt appends the
|
||
previous rejection reason VERBATIM as a revision instruction. Bounded by
|
||
``max_attempts`` and the meter (a round tick between attempts, §8). The
|
||
only per-attempt falsifier is the deterministic validator.
|
||
"""
|
||
if max_attempts <= 0:
|
||
raise ValueError(f"max_attempts must be positive, got {max_attempts}")
|
||
reason: str | None = None
|
||
for attempt in range(1, max_attempts + 1):
|
||
prompt = base_prompt if reason is None else _informed_prompt(base_prompt, reason)
|
||
proposal = generate_candidate(
|
||
client, prompt, meter=meter, default_project_id=default_project_id
|
||
)
|
||
outcome = validate_proposal(proposal)
|
||
if isinstance(outcome, ValidatedProposal):
|
||
return CandidateRun(proposal=proposal, outcome=outcome, attempts=attempt)
|
||
reason = outcome.reason
|
||
if attempt < max_attempts:
|
||
meter.charge_round()
|
||
return CandidateRun(proposal=proposal, outcome=outcome, attempts=max_attempts)
|
||
|
||
|
||
# --- The run: debate → generation → both falsifiers, recorded separately --------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RunResult:
|
||
"""One project run (§3 Step 6): a validated proposal or a TYPED rejection.
|
||
|
||
``validator_decision`` mirrors the DETERMINISTIC VALIDATOR only, stamped
|
||
before any checker override (§9); ``checker_decision`` is the second
|
||
falsifier's own result field — the two are never conflated.
|
||
"""
|
||
|
||
outcome: ValidatedProposal | Rejection
|
||
validator_decision: Literal["validated", "rejected"]
|
||
checker_decision: CheckerDecision
|
||
attempts: int
|
||
proposal: SavingsProposal
|
||
|
||
|
||
def _generation_prompt(context: str, converged_reasoning: str) -> str:
|
||
# §3 Step 3: the debate's converged proposer output feeds generation.
|
||
return (
|
||
f"{context}\n\n"
|
||
f"Converged reasoning from the maker-checker debate:\n{converged_reasoning}\n\n"
|
||
"Reply with exactly one candidate measure as a JSON object with the "
|
||
"fields: project_id, measure, affected_items (list of {code, quantity, "
|
||
"unit_cost}), claimed_saving_nok, and optionally assumptions. "
|
||
"Output ONLY the raw JSON object — no markdown fences, no commentary."
|
||
)
|
||
|
||
|
||
def run_project(
|
||
client: ModelClient,
|
||
context: str,
|
||
*,
|
||
meter: BudgetMeter,
|
||
max_debate_rounds: int,
|
||
max_attempts: int = 3,
|
||
default_project_id: str | None = None,
|
||
) -> RunResult:
|
||
"""Run steps 2–5 for one project: debate, generate, validate, gate (§3)."""
|
||
debate = run_debate(client, context, max_rounds=max_debate_rounds, meter=meter)
|
||
run = run_candidate_loop(
|
||
client,
|
||
_generation_prompt(context, debate.proposer_output),
|
||
meter=meter,
|
||
max_attempts=max_attempts,
|
||
default_project_id=default_project_id,
|
||
)
|
||
verdict = parse_checker_verdict(debate.checker_last)
|
||
# §9: stamped from the validator's outcome BEFORE the checker override.
|
||
validator_decision = stamp_validator_decision(run.outcome)
|
||
return RunResult(
|
||
outcome=apply_checker_gate(run.outcome, verdict),
|
||
validator_decision=validator_decision,
|
||
checker_decision=verdict.decision,
|
||
attempts=run.attempts,
|
||
proposal=run.proposal,
|
||
)
|