fix(run): S10 del 2 — post-mortem: stopp-artefakt, SDK-isolasjon, raw-JSON-direktiv

Transkript-analyse av den stoppede live-kjøringen (10 kall, 162 250 tokens,
$0.331506): konfig-lekkasjen (setting_sources=None laster ALLE filsystem-
settings) injiserte operatørens Claude-konfig i hvert kall — ~10-15k uncachede
tokens, en påtvunget bekreftelses-preamble som gjorde ren-JSON-svar umulige,
og en checker kapret av lekkede instrukser (debatt konvergerte aldri).

- persist_stop_artifacts: stopp-event verbatim + usage/kost persisteres ALLTID
  ved BudgetExceeded (delt usage-shape med fullført-run-stien)
- build_call_options: setting_sources=[] (SDK isolation mode, verifisert mot
  installert 0.2.110-kilde), system_prompt=None → tom system-prompt; detach-
  bevis via monkeypatchet query
- _generation_prompt: krever ONLY the raw JSON object (fence-innpakning ga
  4 fullpris parse-retries)

187/187 uten nøkkel · ruff + mypy --strict rene · tre detach-bevis RØDE → grønn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
Kjell Tore Guttormsen 2026-07-03 10:49:51 +02:00
commit 7637c6feae
7 changed files with 253 additions and 25 deletions

View file

@ -18,6 +18,7 @@ import json
from pathlib import Path
from typing import Any
from portfolio_optimiser_claude.budget import BudgetExceeded
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.loop import RunResult
from portfolio_optimiser_claude.okf import ConceptFile
@ -62,6 +63,23 @@ def _dump_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
def _usage_payload(
termination: TerminationContract,
tokens_used: int,
rounds_used: int,
cost_usd: float | None,
) -> dict[str, Any]:
# ONE usage shape for both outcomes (completed run and budget stop) —
# S11 must never branch on which path wrote the artifact.
return {
"tokens_used": tokens_used,
"rounds_used": rounds_used,
"max_tokens": termination.max_tokens,
"max_rounds": termination.max_rounds,
"cost_usd": cost_usd,
}
def persist_run_artifacts(
out_dir: Path,
*,
@ -101,14 +119,34 @@ def persist_run_artifacts(
},
)
_dump_json(paths["provenance"], provenance.model_dump())
_dump_json(
paths["usage"],
{
"tokens_used": tokens_used,
"rounds_used": rounds_used,
"max_tokens": termination.max_tokens,
"max_rounds": termination.max_rounds,
"cost_usd": cost_usd,
},
)
_dump_json(paths["usage"], _usage_payload(termination, tokens_used, rounds_used, cost_usd))
return paths
def persist_stop_artifacts(
out_dir: Path,
*,
stop: BudgetExceeded,
termination: TerminationContract,
tokens_used: int,
rounds_used: int,
cost_usd: float | None,
) -> dict[str, Path]:
"""Persist a budget-stopped run: the stop event verbatim + usage-vs-caps.
A budget stop is a run outcome, not an absence of one (§8): the S10 live
run stopped structurally on the token cap and persisted NOTHING real
spend without a record. The stop event mirrors the raised ``BudgetExceeded``
fields exactly; the usage artifact keeps the completed-run shape.
"""
out_dir.mkdir(parents=True, exist_ok=True)
paths = {
"stop": out_dir / "stop.json",
"usage": out_dir / "usage.json",
}
_dump_json(
paths["stop"],
{"kind": stop.kind, "limit": stop.limit, "observed": stop.observed},
)
_dump_json(paths["usage"], _usage_payload(termination, tokens_used, rounds_used, cost_usd))
return paths

View file

@ -293,7 +293,8 @@ def _generation_prompt(context: str, converged_reasoning: str) -> str:
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."
"unit_cost}), claimed_saving_nok, and optionally assumptions. "
"Output ONLY the raw JSON object — no markdown fences, no commentary."
)

View file

@ -17,7 +17,11 @@ import argparse
import os
from pathlib import Path
from portfolio_optimiser_claude.artifacts import build_citations, persist_run_artifacts
from portfolio_optimiser_claude.artifacts import (
build_citations,
persist_run_artifacts,
persist_stop_artifacts,
)
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter
from portfolio_optimiser_claude.contracts import load_contracts
from portfolio_optimiser_claude.experience import (
@ -93,9 +97,20 @@ def main(argv: list[str] | None = None) -> int:
default_project_id=ir_projection.project_id,
)
except BudgetExceeded as stop:
# §8: the structured stop event — report it, never a silent hang.
# §8: the structured stop event — report it, never a silent hang, and
# persist the spend (the first live run stopped here with NO record).
print(f"STOPPED by budget: {stop.kind} observed {stop.observed} > limit {stop.limit}")
print(f"cost so far: {client.total_cost_usd:.6f} USD")
stop_paths = persist_stop_artifacts(
args.out,
stop=stop,
termination=contracts.termination,
tokens_used=meter.tokens_used,
rounds_used=meter.rounds_used,
cost_usd=round(client.total_cost_usd, 6),
)
for name, path in sorted(stop_paths.items()):
print(f"artifact: {name} -> {path}")
return 3
provenance = Provenance(

View file

@ -1,11 +1,13 @@
"""The Claude Agent SDK model client — RUN-PATH ONLY (honesty rule §1, §11).
"""The Claude Agent SDK model client (honesty rule §1, §11).
This module is the ONE place the programme touches a real model (S10, D6). It
is never imported by the test suite the offline suite proves the loop with
the scripted stand-in, and this client slots into the same ``ModelClient``
protocol seam. Each ``complete()`` is one bounded ``query()`` call: no tools,
one turn, a first-class USD cap (``ClaudeAgentOptions.max_budget_usd``) ON TOP
of the §8 token/round meter that the loop already charges.
This module is the ONE place the programme touches a real model (S10, D6).
The network path (``query()``) is run-path-only; the offline suite proves the
loop with the scripted stand-in, imports this module WITHOUT touching a key
or the network, and pins the call options (``tests/test_sdk_isolation.py``).
Each ``complete()`` is one bounded, ISOLATED ``query()`` call: no tools, one
turn, a first-class USD cap (``ClaudeAgentOptions.max_budget_usd``) ON TOP of
the §8 token/round meter that the loop already charges, and NO filesystem
settings (``setting_sources=[]``).
Verified against claude-agent-sdk 0.2.110: ``query()`` yields
``AssistantMessage`` (text blocks + real model id) and a closing
@ -39,6 +41,28 @@ _USAGE_TOKEN_FIELDS = (
)
def build_call_options(model_id: str, *, max_budget_usd: float) -> ClaudeAgentOptions:
"""One bounded, ISOLATED completion call (§8 + S10 post-mortem).
``setting_sources=[]`` is the SDK's documented isolation mode (verified
against 0.2.110): the spawned CLI loads NO filesystem settings no
session hooks, no CLAUDE.md, no operator instructions. The default
(``None``) loads ALL sources: in the S10 live run that injected the
operator's config into every call (~10-15k uncached tokens each) and
mandated a confirmation preamble that made pure-JSON replies impossible.
``system_prompt=None`` serializes to an EMPTY system prompt, not the
Claude Code preset.
"""
return ClaudeAgentOptions(
model=model_id,
max_turns=1, # a single completion — the agentic loop lives in loop.py, not here
tools=[], # pure text completion: no tool surface, no silent egress
max_budget_usd=max_budget_usd,
setting_sources=[],
system_prompt=None,
)
def _total_tokens(usage: dict[str, Any] | None) -> int | None:
"""Sum the provider-reported token fields; no usage stays ``None`` (§8)."""
if usage is None:
@ -77,12 +101,7 @@ class SdkModelClient:
return asyncio.run(self._complete_async(prompt, model_id))
async def _complete_async(self, prompt: str, model_id: str) -> ModelReply:
options = ClaudeAgentOptions(
model=model_id,
max_turns=1, # a single completion — the agentic loop lives in loop.py, not here
tools=[], # pure text completion: no tool surface, no silent egress
max_budget_usd=self._max_budget_usd_per_call,
)
options = build_call_options(model_id, max_budget_usd=self._max_budget_usd_per_call)
text_parts: list[str] = []
reply_model: str | None = None
usage_tokens: int | None = None