fix(cli): a provider failure leaves the CLI as one line, and a guessed base id is correctable

Funn 99, measured offline against the artefacts the paid Q5=B run left behind — no paid
run here.

ROOT, verbatim from the records: the three failing quick_validate calls all sent
bundle_id="renholdstekniske_funksjonskrav" — a CONCEPT name guessed out of the seeded cut,
while the base's id is k2-trinn1-20260903. Both arguments parsed against the signature, so
it was _resolve_bundle's raise MAF counted, proven by quick_validations being EMPTY while
all three stand in tool_calls. Denominator: 12 tool calls, and those three came BEFORE
list_bundles.

The order's causal chain is FELLED: the quick_validate triple is records 4-6 and the run
continued for 13 more model calls; the triple immediately before the 400 is the navigator's
three read_file refusals on del-ii-bilag-7-prisskjema*. The limit fired TWICE.

(A) ChatClientException is caught on BOTH seams — the exploration dispatch and the full-run
dispatch — because the debate's own model calls go through the same provider. The line is
"run stopped:", not "run refused:" (a stated divergence from the order): the argv was fine
and tokens were already spent, which is the MAJOR-2 arm's own reason, verbatim. Caught
INSIDE the try/finally so the exploration artefact still lands.

(B) quick_validate answers an unknown base id with {"decision": "refused", ...} naming the
configured ids, and records it in the sink. MAF turns a tool raise into the opaque
"Error: Function failed." (_tools.py:1426), so the one thing the refusal knew and the model
did not never reached it — the replies show it guessing at the JSON format instead.
read_file/read_dir/read_bundle still raise: measured, reported, out of scope.

Seven mutations all red against the whole suite, green control 1529/5, golden ea8c534
unchanged. One existing gate REWRITTEN, not deleted; its second half is what keeps (B)
scoped. The test double raises from the reply_selector seam rather than a new
_inner_get_response body, so the S2.5 consolidation guard stays untouched.

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-08 21:03:18 +02:00
commit 078a099898
5 changed files with 543 additions and 5 deletions

View file

@ -0,0 +1,190 @@
# Funn 99 — `ChatClientException` forlot CLI-en som traceback
**Dato:** 2026-09-08 · **Ordre:** `20260908T180612Z-6878816140-from-.claude` · **HEAD før:** `4f23fa2`
Ingen betalt kjøring. Alt under er målt offline mot artefaktene den betalte Q5=B-kjøringen
etterlot (`scratchpad/q5b/live/`, økt 99) og mot MAF-kilden i `.venv/`.
MAF: `agent-framework-core` **1.16.0**, `agent-framework-orchestrations` **1.1.1** (`uv.lock`).
Remote målt: `git ls-remote origin main` = `4f23fa2` = HEAD → **UPUSHET = 0** (STATE-ens «3» var
foreldet og er rettet).
---
## 1. Målingen
### 1.1 De tre feilende `quick_validate`-kallene, ORDRETT
Fra `scratchpad/q5b/live/outbox-Bseed/q5b-Bseed-exploration.json` (`tool_calls`, i kall-rekkefølge)
og fra argument-blobbene i `Bseed-records.json` record 7:
| # | `bundle_id` | `proposal_json` | Hva MAF la tilbake |
|---|---|---|---|
| 1 | `renholdstekniske_funksjonskrav` | `{"cost_saving_direction":"Implement a comprehensive system integration and coordination strategy under a dedicated Responsible for IT and Building (RITB) role …"}` | `Error: Function failed.` |
| 2 | `renholdstekniske_funksjonskrav` | `{"cost_saving_direction":"Implement a dedicated Responsible for IT and Building (RITB) role …","rationale":"General technical requirements highlight …"}` | `Error: Function failed.` |
| 3 | `renholdstekniske_funksjonskrav` | `{"cost_saving_direction":"Assign a dedicated RITB role to coordinate system integration …","rationale":"General technical requirements advise dedicated roles (RITB) …"}` | `Error: Function failed.` |
**Roten: ukjent base-id.** Basens ekte id er `k2-trinn1-20260903`;
`renholdstekniske_funksjonskrav` er et KONSEPT-navn modellen gjettet ut av det seedede kuttet.
Begge argumentene var strenger og parset mot signaturen `quick_validate(bundle_id: str,
proposal_json: str)` — MAF feilet altså **ikke** på argumentformen. Det som feilet var
`_resolve_bundle`s raise (`src/portfolio_optimiser/explore.py:898-900`, `ExplorationError`).
**Beviset på at det var raisen og ikke et verdikt:** `quick_validations` i artefaktet er **tom**,
mens alle tre kallene står i `tool_calls`. Sinken appendes på HVER verdikt-gren
(`unparseable` / `rejected` / `validated`), og bare raisen gikk utenom. Ordrens premiss (ii)
er dermed bekreftet.
**Nevner:** 12 verktøykall totalt i kjøringen —
`read_file` 5 · **`quick_validate` 3** · `read_dir` 2 · `list_bundles` 1 · `read_bundle` 1.
Alle tre `quick_validate`-kallene er kall **0, 1 og 2**, altså FØR `list_bundles` (kall 3):
modellen gjettet en base-id før den noen gang spurte hvilke som fantes.
### 1.2 MAF-tellingen
- `.venv/…/agent_framework/_tools.py:1410-1432``_function_execution_error_result`: en raise i
verktøyet blir `Content.from_function_result(call_id=…, result="Error: Function failed.",
exception=str(exception))`. Detaljen er **undertrykt** med mindre `include_detailed_errors`
(linje 1427). **Et raise TELLER altså som «error» — svar på ordrens spørsmål.**
- `_tools.py:1895-1900``had_errors` er sann når et `function_result` bærer `exception`.
- `_tools.py:2718-2731``_update_consecutive_error_count`; grensen er
`DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST = 3` (`_tools.py:96`).
- `_tools.py:2856-2869` — ved grensen appendes `Message(role="tool", contents=execution_results)`
og `action="stop"`.
- `_tools.py:3303-3305` — «stop» betyr **`options["tool_choice"] = "none"` og så ett kall til**;
det er dét MAF sender etter grensen.
### 1.3 Ordrens ÅRSAKSKJEDE er FELT av målingen
Ordren leste kjeden som «tre `quick_validate`-feil på rad → grensen → resultat uten kall → 400».
**Records viser noe annet.** `quick_validate`-trippelen er records 4-6; kjøringen fortsatte
etterpå gjennom 13 modellkall og 9 verktøykall til. Trippelen som ligger UMIDDELBART foran 400-en
er en helt annen — navigatørens tre siste kall, ordrett fra record 19s prompt:
```
ok {"bundle_id":"k2-trinn1-20260903","path":"…/1-1"}
ok {"bundle_id":"k2-trinn1-20260903","path":"…/1-1/03-01-2023-vask-av-layout.md"}
ERR {"bundle_id":"k2-trinn1-20260903","path":"inbox-del-ii-bilag-7-prisskjema.md"}
ERR {"bundle_id":"k2-trinn1-20260903","path":"del-ii-bilag-7-prisskjema"}
ERR {"bundle_id":"k2-trinn1-20260903","path":"del-ii-bilag-7-prisskjema.md"}
```
Grensen fyrte altså **to ganger** i kjøringen, og 400-en kom på det NESTE agent-kallet
(record 19, hypothesiser), ikke på kallet rett etter `quick_validate`-trippelen.
### 1.4 «Resultat uten kall» — MAF-defekt eller po?
**IKKE VERIFISERT, rapportert som hypotese med linjer.** Feilen er
`400 … 'No tool call found for function call output with call_id call_AyeuYJmqvmmej9utu361Phtt.'`
— Responses-APIet fant et `function_call_output` uten sitt `function_call` i input.
Den ene mekanismen jeg KAN peke på i kilden er
`agent_framework_openai/_chat_client.py:1557-1559`: under service-side storage DROPPES
`function_call`-items fra inline input (`case "function_call": if request_uses_service_side_storage:
continue`), mens `function_result` sendes videre (kommentar :1540-1542, «plain function_call_output
pairs by call_id and is safe under storage»). Den parringen holder bare så lenge serveren HAR det
matchende kallet.
Hvorvidt det er dét som brast her er **ikke målt** — det ville krevd en ny betalt kjøring, som
ordren forbyr. Ingenting i `po` konstruerer meldinger på denne stien; po leverer et verktøy som
raiser, og MAF eier både konverteringen og transporten. **Fikset ALDRI i MAF.** Rapportert her.
---
## 2. Beslutningen
### (A) CLI-døren — JA, og på TO sømmer
`ChatClientException` (fra `agent_framework.exceptions`, ALDRI bar `Exception`) fanges nå
1. i utforsknings-dispatchen (`run.py`, samme blokk `BudgetExceeded` ble lagt til for), og
2. i fullkjørings-dispatchen (`run_project`) — debattens egne modellkall går gjennom samme
leverandør, så en fiks på bare den ene lar en helt vanlig `run.main([...])` tracebacke.
**AVVIK fra ordren, uttalt:** ordren ber om linjen `run refused:`. Levert er **`run stopped:`**.
Grunnen er repoets egen etablerte kontrakt, sitert ordrett fra `run.py`s MAJOR-2-arm:
«the argv was fine and the run had already spent tokens, so "refused" would mislabel it».
En leverandør-400 midt i en utforskning er ikke en argv-feil; `run refused:` ville sagt at po
avviste bestillingen. Klassen er dét som ruter den, akkurat som for `ProposalReviewInputError`.
Er PM uenig, er endringen én streng på to steder.
Artefaktet overlever: armen står **INNE i** try/finally, så `finally`-en fortsatt skriver
`{run_id}-exploration.json` med `completed: false`. Målt, ikke antatt (T3).
### (B) Verktøyet — JA, og kun for `quick_validate`
Målingen i 1.1 viser at det VAR `_resolve_bundle`s raise som ble telt, altså er ordrens betingelse
oppfylt. `quick_validate` returnerer nå
`{"decision": "refused", "reason": "unknown knowledge base 'X'; configured: Y", "anchored": false}`.
Hvorfor: MAF gjør raisen om til den ugjennomsiktige `"Error: Function failed."`, så **den ene
tingen refusalen visste og modellen ikke — hvilke id-er som finnes — nådde den aldri.** Følgen står
i modellens egne ord (record 5): «It appears the quick validation function is failing, possibly due
to format or content expectations» — den gjettet på JSON-formatet, som var riktig hele veien.
`anchored: false` er et faktum, ikke en default: ingen base ble resolvert, så ingen
`cost-baseline.json` ble lest.
**«Ikke i sinken» er OPPHEVET, med begrunnelse.** Kommentaren i koden var skrevet for en RAISE, som
etterlot null verdikt. Nå FINNES det et verdikt, og sinkens egen regel er «hver gren». Å holde det
ute ville gjort en refusert forespørsel til det ene `quick_validate`-utfallet som er usynlig i
`quick_validations` — og det er nøyaktig hvorfor denne økta måtte lese TO artefakter for å finne
roten.
**Scopet er smalt med vilje:** `read_file` / `read_dir` / `read_bundle` raiser fortsatt, og deres
raises telles på nøyaktig samme måte (se 1.3 — det var DEN trippelen som lå foran 400-en). Å endre
dem er en egen beslutning, ikke en jeg tar under en ordre som scoper (B) til `quick_validate`.
**MÅLT, RAPPORTERT, IKKE FIKSET.**
### Ikke gjort, og hvorfor
- **Ingen retry-logikk** (ordren forbyr).
- **Ingen ny `max_consecutive_errors_per_request`** — jeg har ingen måling som viser at den hjelper.
- **Ingen skjuling av andre unntakstyper** — begge armene er nøklet på klassen, og hver av dem har
en kjent-negativ arm som blir rød når den utvides til `Exception`.
- **`include_detailed_errors`** er IKKE skrudd på. Det ville sendt po sine feiltekster videre til
modellen på alle fire verktøy og er en flate-endring, ikke en fiks.
- **Hostet flate BEVISST urørt** — feltet er ikke i noen av hostings tre sett, og Fase 4es to
halvdeler står (MAJOR-4/S7bs eget valg gjentatt).
---
## 3. Load-bearing MÅLT
Ny fil: `tests/test_run_cli_chatclient_refusal.py`, 8 armer.
Grønn kontroll **1529 passed / 5 skipped**, golden `demo-transcript.stdout` BYTE-UENDRET
(`shasum -a 1` av INNHOLDET = `ea8c534773acdbe41ae68f2c55724d69aaf8be4f`, aldri git-blob-id-en).
Sju mutasjoner, ALLE RØDE mot HELE suiten:
| # | Mutasjon | Røde |
|---|---|---|
| M1 | detach utforsknings-armen | 2 (T1 + T3) |
| M2 | utvid utforsknings-armen til `Exception` | 5 — hvorav **4 i tester eldre enn dette arbeidet** |
| M3 | detach fullkjørings-armen | 1 (T4 ALENE — T1 grønn, altså er sømmene uavhengig gatet) |
| M4 | utvid fullkjørings-armen til `Exception` | 4 — hvorav 3 eksisterende, uavhengige vitner |
| M5 | gjeninnfør raisen i `quick_validate` | 3 |
| M6 | refuser ubetinget | 3 — hvorav 2 eksisterende (kontrollen er ekte) |
| M7 | dropp sink-appenden på den refuserte grenen | 1 (T8 ALENE) |
**T3 har ingen egen mutasjon, og det står som en ærlighets-grense.** Den pinner ordrens krav om at
allerede skrevne records overlever nekten, men `finally` kjører også ved `return` fra en `except`,
så enhver plassering av armen inne i funksjonen ville bestått den. Den er rød sammen med T1 under
M1 og ellers ikke separabel.
**En eksisterende gate ble SKREVET OM, ikke slettet:**
`test_explore_loadbearing::test_an_unknown_knowledge_base_is_refused_by_name` pinnet raisen.
Egenskapen den ble skrevet for — «refuserer, og sier hva som ER konfigurert» — er urørt og
assertert i begge halvdeler; den nye andre halvdelen driver `read_file` og er dét som holder
(B) SCOPET til `quick_validate`.
**Testdoblen fikk INGEN ny `_inner_get_response`-kropp.** Første utkast gjorde det og felte
S2.5-konsolideringsvakten (`test_scripted_client_consolidation`). Raisen bor nå i
`reply_selector`-sømmen, som den kanoniske kroppen kaller SYNKRONT (`simulation.py:443`) før den
bygger noen koroutine — samme punkt en ekte leverandørfeil ville truffet, og vakten forblir
urørt i stedet for utvidet.
---
## 4. Kommandoen som viser nekten (gratis)
```
uv run pytest tests/test_run_cli_chatclient_refusal.py -q
```

View file

@ -1114,6 +1114,27 @@ def navigator_tools(
return [list_bundles, read_bundle, read_dir, read_file]
def _refused(
reason: str,
*,
sink: list[QuickValidation] | None,
bundle_id: str,
proposal_json: str,
) -> dict[str, Any]:
"""The refused verdict, recorded on the same rule every other branch is (see below).
``anchored`` is ``False`` and that is a statement of fact, not a default: no base was
resolved, so no ``cost-baseline.json`` was read and this verdict was reached without the
project's own cost lines — exactly what the field says everywhere else it appears.
"""
verdict: dict[str, Any] = {"decision": "refused", "reason": reason, "anchored": False}
if sink is not None:
sink.append(
QuickValidation(bundle_id=bundle_id, proposal_json=proposal_json, verdict=verdict)
)
return verdict
def quick_validate_tool(
bundle_dirs: Sequence[str], *, sink: list[QuickValidation] | None = None
) -> FunctionTool:
@ -1148,9 +1169,24 @@ def quick_validate_tool(
),
)
def quick_validate(bundle_id: str, proposal_json: str) -> dict[str, Any]:
bundle_dir = _resolve_bundle(index, bundle_id)
baseline = okf.load_optional_cost_baseline(bundle_dir)
verdict: dict[str, Any]
try:
bundle_dir = _resolve_bundle(index, bundle_id)
except ExplorationError as exc:
# A base id the model guessed wrong is a thing it can CORRECT — so it comes back as a
# verdict naming the configured ids, never as a raise. MEASURED (funn 99, Q5=B on K2):
# three consecutive calls carried ``bundle_id="renholdstekniske_funksjonskrav"``, a
# concept name guessed out of the seeded cut, and MAF turned each raise into the opaque
# ``"Error: Function failed."`` (``_tools.py:1426`` — the detail is suppressed unless
# ``include_detailed_errors``), so the ONE thing this refusal knows and the model did
# not — which ids exist — never reached it. The replies show the consequence: it went
# on guessing at the JSON format. Three in a row is
# ``DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST`` (``_tools.py:96``), after which MAF
# stops all function calling for the request. This is NOT a general softening of the
# navigator's refusals: ``read_file``/``read_dir``/``read_bundle`` still raise, and
# their raises are counted the same way (measured, reported, out of this order's scope).
return _refused(str(exc), sink=sink, bundle_id=bundle_id, proposal_json=proposal_json)
baseline = okf.load_optional_cost_baseline(bundle_dir)
try:
proposal = SavingsProposal.model_validate_json(proposal_json)
except ValidationError as exc:
@ -1177,8 +1213,13 @@ def quick_validate_tool(
"p90": outcome.p90,
}
# Recorded AFTER the verdict is decided and on EVERY branch — an unparseable candidate is
# as much a thing the hypothesiser asked about as a validated one. A refused bundle id
# raises above and is deliberately not recorded: nothing was validated.
# as much a thing the hypothesiser asked about as a validated one. A refused bundle id is
# recorded too, on that same rule: the comment that used to stand here ("nothing was
# validated") was written for a RAISE, which left no verdict at all. Now that there IS one,
# keeping it out would make a refused call the single quick_validate outcome invisible in
# ``quick_validations`` — and an operator reading that list could not tell "never called"
# from "called three times with an id that does not exist", which is exactly the read this
# defect needed two artefacts to reconstruct.
if sink is not None:
sink.append(
QuickValidation(bundle_id=bundle_id, proposal_json=proposal_json, verdict=verdict)

View file

@ -34,6 +34,7 @@ from pathlib import Path
from typing import Any, Literal, cast
from agent_framework import BaseChatClient, SessionContext
from agent_framework.exceptions import ChatClientException
from pydantic import ValidationError
from portfolio_optimiser.backends import Profile, get_backend, resolve_model
@ -3496,6 +3497,7 @@ def main(argv: list[str] | None = None) -> int:
exploration: ExplorationResult | None = None
parked_now: PlanReviewParked | None = None
budget_now: BudgetExceeded | None = None
provider_now: ChatClientException | None = None
try:
if resumed is not None:
# The parked state, not argv, is what rebuilds the workflow: the graph has to match
@ -3543,6 +3545,17 @@ def main(argv: list[str] | None = None) -> int:
# than left to escape, because parking is what the operator ASKED for by giving
# --checkpoint-dir; the artefact is where a machine reads that it happened.
parked_now = parked_exc
except ChatClientException as provider_exc:
# Funn 99. The model endpoint rejected the request, so the run ENDED — it was never
# refused. ``run stopped:``, not ``run refused:``, and the class is what routes it here
# (the MAJOR-2 precedent, verbatim: "the argv was fine and the run had already spent
# tokens, so 'refused' would mislabel it"). MEASURED on the paid Q5=B run: an Azure 400
# ("No tool call found for function call output with call_id ...") left ``main()`` as a
# traceback, because ``ChatClientException`` is in none of this function's refusal
# tuples — the same gap ``BudgetExceeded`` was added to this very block for. Caught
# INSIDE the try/finally so the ``finally`` still writes ``{run_id}-exploration.json``:
# the run that most needs the evidence is the one a provider cut short.
provider_now = provider_exc
except BudgetExceeded as budget_exc:
# A cap that fired is a refusal at this door, not a programming error — the CLI's
# existing contract for every other loader mistake below (stderr + rc 1, never a
@ -3574,6 +3587,11 @@ def main(argv: list[str] | None = None) -> int:
),
),
)
if provider_now is not None:
# One line, rc 1, no traceback — and reported BEFORE the budget arm only because the
# two are mutually exclusive by construction (a single exception left the block).
print(f"run stopped: {provider_now}", file=sys.stderr)
return 1
if budget_now is not None:
# Same shape as every other refusal in this function: one line on stderr, rc 1, no
# traceback. The artefact was already written by the ``finally`` above (``completed``
@ -3858,6 +3876,14 @@ def main(argv: list[str] | None = None) -> int:
)
),
)
except ChatClientException as exc:
# Funn 99, the SECOND seam: the debate's own model calls go through the same provider, so
# an arm on the exploration block alone leaves an ordinary ``run.main([...])`` tracebacking.
# Same channel and same reason as ``ProposalReviewInputError`` below — the argv was fine and
# tokens were already spent — and a distinct class from every ``ValueError``-shaped refusal,
# so a reader can tell "the request was wrong" from "the endpoint rejected it".
print(f"run stopped: {exc}", file=sys.stderr)
return 1
except ProposalReviewInputError as exc:
# A DISTINCT channel from ``run refused:`` below, and the class is what routes it here.
# The argv was fine and the run had already spent tokens, so "refused" would mislabel it;

View file

@ -752,10 +752,23 @@ def test_an_unknown_knowledge_base_is_refused_by_name() -> None:
Model-chosen arguments are untrusted input. Answering an unknown id with an empty result would
let the manager conclude the base is empty rather than absent the fourth face of the
verification law, arrived at through a tool rather than a query.
REWRITTEN, not weakened (funn 99): ``quick_validate`` now answers with a ``refused`` VERDICT
rather than a raise, because MAF converts a tool raise into the opaque ``"Error: Function
failed."`` and counts three of them as grounds to stop function calling for the whole request —
so the one thing this refusal knows and the model does not (which ids exist) never reached it.
The property T20 was written for is untouched and asserted here in both halves: the answer is
still a refusal, and it still names what IS configured. The ``navigator_tools`` half is what
keeps the change SCOPED those three tools still raise.
"""
validate = explore.quick_validate_tool(("/tmp/base-a",))
verdict = validate.func(bundle_id="base-b", proposal_json="{}")
assert verdict["decision"] == "refused"
assert "base-b" in verdict["reason"] and "base-a" in verdict["reason"]
read_file = next(t for t in explore.navigator_tools(("/tmp/base-a",)) if t.name == "read_file")
with pytest.raises(explore.ExplorationError) as excinfo:
validate.func(bundle_id="base-b", proposal_json="{}")
read_file.func(bundle_id="base-b", path="x.md")
assert "base-a" in str(excinfo.value)

View file

@ -0,0 +1,268 @@
"""Funn 99 — a provider failure must LEAVE the CLI as one line, not as a traceback.
MEASURED FIRST, on the artefacts the paid Q5=B run left behind (``scratchpad/q5b/live/``,
never re-run here): the exploration died on
agent_framework.exceptions.ChatClientException:
<class 'FoundryChatClient'> service failed to complete the prompt: Error code: 400 -
{'error': {'message': 'No tool call found for function call output with call_id
call_AyeuYJmqvmmej9utu361Phtt.', ...}}
and that class is in NONE of ``main()``'s refusal tuples (``run.py`` catches
``FileNotFoundError, ValidationError, ValueError`` plus ``BudgetExceeded``/``PlanReviewParked``),
so the process tracebacked the same defect class ``BudgetExceeded`` was added to that block for.
TWO seams are closed here and each one has its OWN arm, because each can regress alone:
(A) the EXPLORATION dispatch (``run.py``'s ``explore()``/``resume_exploration()`` block), which
is where the measured failure happened; and
(A2) the FULL-RUN dispatch (``run_project``), which the debate's own model calls go through —
a fix on only one of the two leaves the other tracebacking.
The KNOWN-NEGATIVE is the point of the arm, not decoration: a ``RuntimeError`` from the same seam
must still propagate. We are closing ONE named provider channel, never hiding unknown failures.
Arm (B) is the tool side, and it is authorised by the measurement rather than by symmetry: the
three failing ``quick_validate`` calls in ``Bseed-records.json`` were NOT verdicts. All three sent
``bundle_id="renholdstekniske_funksjonskrav"`` a concept name, guessed out of the seeded cut,
never a base id with a well-formed ``proposal_json``, so the arguments parsed against the
signature and it was ``_resolve_bundle``'s raise that MAF counted. Proof it was the raise and not
a verdict: ``q5b-Bseed-exploration.json`` records all three in ``tool_calls`` while
``quick_validations`` is EMPTY, and the sink is appended on every verdict branch.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from agent_framework import BaseChatClient
from agent_framework.exceptions import ChatClientException
from portfolio_optimiser import explore, run
from portfolio_optimiser.simulation import ScriptedChatClient
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_PID = "BYGG-KONTOR-NORD"
#: The provider text VERBATIM from the measured run — truncated only where the records truncate it.
_PROVIDER_TEXT = (
"<class 'agent_framework_foundry._chat_client.FoundryChatClient'> service failed to complete "
"the prompt: Error code: 400 - {'error': {'message': 'No tool call found for function call "
"output with call_id call_AyeuYJmqvmmej9utu361Phtt.', 'type': 'invalid_request_error'}}"
)
_CONTRACT_JSON: dict[str, Any] = {
"max_rounds": 2,
"max_tokens": 50_000,
"max_stall_count": 2,
"max_reset_count": 1,
"max_plan_revisions": 0,
"enable_plan_review": False,
}
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_OTEL", raising=False)
def _raising_factory(exc: BaseException) -> Callable[[str], Callable[[str], BaseChatClient]]:
"""A factory whose clients raise ``exc`` on the FIRST model call, before any reply exists.
The raise lives in the ``reply_selector`` seam rather than in an ``_inner_get_response``
override, and that is deliberate: the canonical body calls the selector SYNCHRONOUSLY
(``simulation.py:443``) before it builds any coroutine, so a raise there leaves the client at
exactly the point a provider's would — and the S2.5 consolidation guard keeps its property
that there is no second copy of the scripted body anywhere in the tree.
"""
def _raise(_prompt: str, _role: str) -> str:
raise exc
def outer(_profile: Any) -> Callable[[str], BaseChatClient]:
def factory(role: str) -> BaseChatClient:
return ScriptedChatClient(reply_selector=_raise, role=role)
return factory
return outer
def _config_file(tmp_path: Path) -> str:
path = tmp_path / "exploration.json"
path.write_text(json.dumps(_CONTRACT_JSON), encoding="utf-8")
return str(path)
def _explore_argv(tmp_path: Path, *extra: str) -> list[str]:
return [
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"Find the cheapest saving.",
"--explore-config",
_config_file(tmp_path),
*extra,
]
# ---------------------------------------------------------------------------------------------
# (A) the exploration dispatch
# ---------------------------------------------------------------------------------------------
def test_a_provider_failure_in_the_exploration_leaves_the_cli_as_one_line(
tmp_path, monkeypatch, capsys
) -> None:
"""T1 — POSITIVE. ``--explore`` against a client that raises ``ChatClientException`` must give
rc 1 and ONE stderr line, never a traceback.
Detach point: remove the ``except ChatClientException`` arm from the exploration block the
exception escapes ``main()`` and pytest reports the raise instead of an rc RED.
"""
monkeypatch.setattr(
"portfolio_optimiser.run._default_factory",
_raising_factory(ChatClientException(_PROVIDER_TEXT)),
)
rc = run.main(_explore_argv(tmp_path))
err = capsys.readouterr().err
assert rc == 1
assert "Traceback" not in err
assert len([line for line in err.splitlines() if line.strip()]) == 1
assert err.startswith("run stopped:")
assert "No tool call found for function call output" in err
def test_an_unknown_failure_from_the_same_seam_still_propagates(tmp_path, monkeypatch) -> None:
"""T2 — KNOWN-NEGATIVE. The arm is ONE named provider channel, not a blanket ``except``.
Without this, a fix written as ``except Exception`` would pass T1 and hide every programming
error the exploration can make. Detach point: widen the arm to ``Exception`` RED.
"""
monkeypatch.setattr(
"portfolio_optimiser.run._default_factory",
_raising_factory(RuntimeError("a defect, not a provider")),
)
with pytest.raises(RuntimeError, match="a defect, not a provider"):
run.main(_explore_argv(tmp_path))
def test_the_exploration_artefact_survives_the_refusal(tmp_path, monkeypatch, capsys) -> None:
"""T3 — the evidence written BEFORE the failure must not disappear with it.
``run.py``'s ``finally`` writes ``{run_id}-exploration.json`` whatever ended the block; an arm
placed so the ``finally`` is skipped (or one that returns before it) would take the one record
of what the run had already spent with it. Detach point: catch the exception OUTSIDE the
try/finally RED.
"""
outbox = tmp_path / "outbox"
monkeypatch.setattr(
"portfolio_optimiser.run._default_factory",
_raising_factory(ChatClientException(_PROVIDER_TEXT)),
)
rc = run.main(_explore_argv(tmp_path, "--outbox-dir", str(outbox), "--run-id", "funn99"))
assert rc == 1
assert capsys.readouterr().err.startswith("run stopped:")
artefact = outbox / "funn99-exploration.json"
assert artefact.exists()
payload = json.loads(artefact.read_text(encoding="utf-8"))
assert payload["completed"] is False
# ---------------------------------------------------------------------------------------------
# (A2) the full-run dispatch — the debate's own model calls
# ---------------------------------------------------------------------------------------------
def test_a_provider_failure_in_the_full_run_leaves_the_cli_as_one_line(monkeypatch, capsys) -> None:
"""T4 — the SECOND seam. The debate calls the same provider, and a fix on the exploration
block alone leaves an ordinary ``run.main([...])`` tracebacking.
Detach point: remove the ``except ChatClientException`` arm from the full-run dispatch RED,
and T1 stays green which is exactly why this is its own arm.
"""
monkeypatch.setattr(
"portfolio_optimiser.run._default_factory",
_raising_factory(ChatClientException(_PROVIDER_TEXT)),
)
rc = run.main([_PID, "--docs-dir", str(_BUNDLE_DIR), "--bundle-dir", str(_BUNDLE_DIR)])
err = capsys.readouterr().err
assert rc == 1
assert "Traceback" not in err
assert len([line for line in err.splitlines() if line.strip()]) == 1
assert err.startswith("run stopped:")
def test_an_unknown_failure_in_the_full_run_still_propagates(monkeypatch) -> None:
"""T5 — KNOWN-NEGATIVE for the second seam, for T2's reason."""
monkeypatch.setattr(
"portfolio_optimiser.run._default_factory",
_raising_factory(RuntimeError("a defect, not a provider")),
)
with pytest.raises(RuntimeError, match="a defect, not a provider"):
run.main([_PID, "--docs-dir", str(_BUNDLE_DIR), "--bundle-dir", str(_BUNDLE_DIR)])
# ---------------------------------------------------------------------------------------------
# (B) the tool — a base id the model guessed wrong is a thing it can CORRECT, not a run-ender
# ---------------------------------------------------------------------------------------------
def test_an_unknown_base_id_comes_back_as_a_refused_verdict() -> None:
"""T6 — POSITIVE. ``quick_validate`` against an id no configured base carries must RETURN a
``refused`` verdict naming the configured ids, not raise.
MEASURED root, verbatim from ``Bseed-records.json``: three calls with
``bundle_id="renholdstekniske_funksjonskrav"``. Each raise became MAF's opaque
``"Error: Function failed."`` (``_tools.py:1426``; details are suppressed unless
``include_detailed_errors``), so the reason po had written *which* ids exist never reached
the model, and the replies show it guessing at the JSON format instead. Three in a row hit
``DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST`` (``_tools.py:96``, value 3).
Detach point: restore the raise RED.
"""
sink: list[explore.QuickValidation] = []
tool = explore.quick_validate_tool((str(_BUNDLE_DIR),), sink=sink)
verdict = tool.func(bundle_id="renholdstekniske_funksjonskrav", proposal_json="{}")
assert verdict["decision"] == "refused"
assert "renholdstekniske_funksjonskrav" in verdict["reason"]
assert _BUNDLE_DIR.name in verdict["reason"] or "bygg" in verdict["reason"]
assert verdict["anchored"] is False
def test_a_known_base_id_still_reaches_the_validator() -> None:
"""T7 — CONTROL. Without it, an implementation that answered ``refused`` to EVERY call would
pass T6 while destroying the tool. Detach point: return ``refused`` unconditionally RED."""
sink: list[explore.QuickValidation] = []
tool = explore.quick_validate_tool((str(_BUNDLE_DIR),), sink=sink)
verdict = tool.func(bundle_id=_bundle_id(), proposal_json="{}")
assert verdict["decision"] == "unparseable"
def test_the_refusal_is_recorded_in_the_sink() -> None:
"""T8 — a refused call is a thing the hypothesiser ASKED for, and the artefact is where an
operator reads that it happened.
While it raised, the call left ``quick_validations`` empty and was visible only in
``tool_calls`` which is precisely why this session had to read two artefacts to find the
root. Detach point: skip the sink append on the refused branch RED.
"""
sink: list[explore.QuickValidation] = []
tool = explore.quick_validate_tool((str(_BUNDLE_DIR),), sink=sink)
tool.func(bundle_id="renholdstekniske_funksjonskrav", proposal_json="{}")
assert [entry.verdict["decision"] for entry in sink] == ["refused"]
assert sink[0].bundle_id == "renholdstekniske_funksjonskrav"
def _bundle_id() -> str:
from portfolio_optimiser import okf
return okf.reconcile_bundle_id(str(_BUNDLE_DIR)).id