feat(explore): U4 kallsted 2 - hostet explore_prompt, og whitelisten blir en TREDELING (ORDRE 20260823T204216Z) [skip-docs]
Kallsted (2) av oerkt 57s fire. explore_prompt + explore_contract whitelistes paa den hostede flaten. De er ikke run_project-parametre - utforskningen kjoerer FOERST og gir run_project et Mandate - saa de KONSUMERES i stedet for aa videresendes, og whitelisten er dermed en TREDELING (_REQUIRED / _OPTIONAL / _CONSUMED). Ett sted (_run_kwargs) avgjoer hvilke felt som naar signaturen: et konsumert felt som blir liggende i kwargs er et argument run_project ikke har, som containeren svarer 500 paa for det som egentlig er en wiring-feil. FASE 4e-REGELEN UTVIDET MED EN NEGATIV HALVDEL, og det var paakrevd: 4e-testen sender HVERT whitelistet felt gjennom den EKTE run_project og asserterer dekning mot _ALLOWED_FIELDS - en assert et konsumert felt ALDRI kan oppfylle. Den positive halvdelen dekker naa _REQUIRED|_OPTIONAL (hvert felt maalt mot inspect.signature(run_project)), og den negative at _CONSUMED er DISJUNKT fra samme signatur. Uten den ville et felt som glir fra konsumert til videresendt vaere nettopp driften 4e finnes for. Fire nekter, alle ved navn og alle paa KALLERENS kanal (400): explore_contract uten explore_prompt - explore_prompt uten explore_contract - explore_prompt uten bundle_dir - enable_plan_review=true. Den siste er nektet HER og ikke i explore(), som ogsaa nekter den: ExplorationError er en RuntimeError, saa aa overlate den til sloeyfa ville svart en kallers konfigurasjonsfeil paa KRASJ-kanalen (500) - samme sammenblanding BudgetExceeded fikk sin egen 429 for aa avslutte. U13-doera er dessuten SYNKRON: den blokkerer sloeyfa paa et menneske, og en HTTP-forespoersel har ingen - invocationen ville hengt i stedet for aa svare. TracingConfigError er derimot en ValueError, saa 400- armen dekket den alt (verifisert, ikke antatt). Load-bearing MAALT (tests/test_explore_callsites_loadbearing.py, 6 nye tester), fire mutasjoner alle roede mot HELE suiten + groenn kontroll 996/5: videresend de konsumerte feltene igjen (2 roede) - detach mandate=-wiringen (1 roed) - detach enable_plan_review-nekten (1) - detach bundle_dir-kravet (1). Golden-transkriptet byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f). mypy + ruff rene. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
This commit is contained in:
parent
8ba824c96f
commit
234c8d138a
3 changed files with 242 additions and 14 deletions
|
|
@ -37,7 +37,7 @@ import pytest
|
|||
from agent_framework import BaseChatClient
|
||||
|
||||
import portfolio_optimiser
|
||||
from portfolio_optimiser import explore, okf, run
|
||||
from portfolio_optimiser import explore, hosting, okf, run
|
||||
from portfolio_optimiser.budget import BudgetExceeded
|
||||
from portfolio_optimiser.explore import ExplorationContract, ExplorationTrace
|
||||
from portfolio_optimiser.mandate import Approach, Mandate
|
||||
|
|
@ -115,7 +115,11 @@ def _hypothesis_line(label: str, rationale: str) -> str:
|
|||
|
||||
|
||||
def _factory(
|
||||
*, ledgers: list[str], hypothesiser: list[str], fallback: str = "ok"
|
||||
*,
|
||||
ledgers: list[str],
|
||||
hypothesiser: list[str],
|
||||
fallback: str = "ok",
|
||||
sink: list[str] | None = None,
|
||||
) -> Callable[[str], BaseChatClient]:
|
||||
"""One fresh ``ScriptedChatClient`` per role — the exploration's three plus everyone else.
|
||||
|
||||
|
|
@ -136,7 +140,7 @@ def _factory(
|
|||
return ScriptedChatClient(reply_selector=_hyp, role=role)
|
||||
if role == explore.NAVIGATOR_ROLE:
|
||||
return ScriptedChatClient("NAVIGATOR: index read.", role=role)
|
||||
return ScriptedChatClient(fallback, role=role)
|
||||
return ScriptedChatClient(fallback, sink, role=role)
|
||||
|
||||
return factory
|
||||
|
||||
|
|
@ -536,3 +540,148 @@ def test_a_seeded_mandate_still_leads_the_shaped_one() -> None:
|
|||
minted = explore._mint_approaches((seed,), [(_LABEL, "shaped in the loop")])
|
||||
assert [a.id for a in minted] == ["expert-1", "hypothesis-1"]
|
||||
assert isinstance(Mandate(objective="o", approaches=minted, allow_own_proposals=True), Mandate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 4. The hosted surface — a THREE-way whitelist, and the Fase 4e rule extended to cover it
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hosted_payload(**extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"project_id": _PID,
|
||||
"docs_dir": str(_BUNDLE_DIR),
|
||||
"verdict_input": {"decision": "approved", "rationale": "expert reviewed (explore)"},
|
||||
# LOCAL, never the hosted AZURE default: the AZURE arm resolves a Foundry deployment name
|
||||
# from the model map before any client is built, so it cannot complete offline.
|
||||
"profile": "local",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _hosted_backend(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""The scripted backend behind a hosted invocation, plus the prompt sink that proves it ran.
|
||||
|
||||
``client_factory`` is refused by the invocations whitelist on purpose — the caller of a hosted
|
||||
agent never chooses the server's model client — so ``run._default_factory`` is the only
|
||||
injection point the surface leaves, and ``explore()`` resolves through the same one.
|
||||
"""
|
||||
sink: list[str] = []
|
||||
factory = _factory(
|
||||
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=True)],
|
||||
hypothesiser=[_hypothesis_line(_LABEL, "the index says the fittings are old")],
|
||||
fallback=_ENERGY_REPLY,
|
||||
sink=sink,
|
||||
)
|
||||
monkeypatch.setattr("portfolio_optimiser.run._default_factory", lambda profile: factory)
|
||||
return sink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_hosted_exploration_shapes_the_mandate_the_run_evaluates(_hosted_backend) -> None:
|
||||
"""H1: ``explore_prompt`` over the hosted surface reaches the pipeline as a mandate.
|
||||
|
||||
Driven through ``hosting.invoke`` and the REAL ``run_project`` (Fase 4e): every other
|
||||
invocations test hands ``invoke`` a recorder that swallows ``**kwargs`` and therefore cannot
|
||||
see whether a new field composes with the signature at all.
|
||||
|
||||
The proof is the PROMPT, not the status code: an ``Approach``'s description reaches the
|
||||
proposer VERBATIM, and this label exists nowhere in the bundle or the reference projects — so
|
||||
finding it in a generation prompt means it travelled prompt → ``explore()`` → ``Mandate`` →
|
||||
``run_project(mandate=…)``.
|
||||
|
||||
Detach point: stop passing the shaped mandate into ``run_project`` → RED.
|
||||
"""
|
||||
body = await hosting.invoke(
|
||||
_hosted_payload(
|
||||
bundle_dir=str(_BUNDLE_DIR),
|
||||
explore_prompt="Find the cheapest saving.",
|
||||
explore_contract=dict(_CONTRACT_JSON),
|
||||
)
|
||||
)
|
||||
|
||||
assert body["outcome_type"] in {"validated", "rejected"}
|
||||
assert any(_LABEL in prompt for prompt in _hosted_backend), (
|
||||
"the shaped approach never reached a prompt — the hosted door does not wire the mandate"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_consumed_field_is_never_forwarded_to_run_project() -> None:
|
||||
"""H2: the whitelist is a THREE-way partition, and the consumed half is proved NEGATIVELY.
|
||||
|
||||
``explore_prompt``/``explore_contract`` are accepted by the surface and consumed BY it — they
|
||||
are not ``run_project`` parameters, and forwarding one would be a ``TypeError`` answered as a
|
||||
500. The positive half of Fase 4e (every forwarded field reaches the real signature) cannot
|
||||
see that; without this arm a field sliding from consumed to forwarded is exactly the drift 4e
|
||||
exists to catch.
|
||||
|
||||
Detach point: build ``kwargs`` from the whole payload again → RED.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
_, kwargs, consumed = hosting._run_kwargs(
|
||||
_hosted_payload(
|
||||
bundle_dir=str(_BUNDLE_DIR),
|
||||
explore_prompt="p",
|
||||
explore_contract=dict(_CONTRACT_JSON),
|
||||
)
|
||||
)
|
||||
assert set(hosting._CONSUMED_FIELDS).isdisjoint(kwargs), (
|
||||
"a consumed field was forwarded to run_project, which does not take it"
|
||||
)
|
||||
assert set(consumed) == set(hosting._CONSUMED_FIELDS)
|
||||
|
||||
parameters = inspect.signature(run.run_project).parameters
|
||||
assert set(hosting._CONSUMED_FIELDS).isdisjoint(parameters), (
|
||||
"a CONSUMED field is a run_project parameter — it belongs in the forwarded half"
|
||||
)
|
||||
for name in (*hosting._REQUIRED_FIELDS, *hosting._OPTIONAL_FIELDS):
|
||||
# project_id is positional; every other forwarded field must be a real keyword.
|
||||
assert name in parameters, f"whitelisted field {name!r} is not a run_project parameter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"bundle_dir": str(_BUNDLE_DIR), "explore_contract": dict(_CONTRACT_JSON)},
|
||||
"explore_prompt",
|
||||
id="bounds-without-an-exploration",
|
||||
),
|
||||
pytest.param(
|
||||
{"bundle_dir": str(_BUNDLE_DIR), "explore_prompt": "p"},
|
||||
"explore_contract",
|
||||
id="exploration-without-bounds",
|
||||
),
|
||||
pytest.param(
|
||||
{"explore_prompt": "p", "explore_contract": dict(_CONTRACT_JSON)},
|
||||
"bundle_dir",
|
||||
id="exploration-without-a-knowledge-base",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"bundle_dir": str(_BUNDLE_DIR),
|
||||
"explore_prompt": "p",
|
||||
"explore_contract": {**_CONTRACT_JSON, "enable_plan_review": True},
|
||||
},
|
||||
"enable_plan_review",
|
||||
id="a-review-nobody-can-answer",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_the_hosted_door_refuses_by_name_on_the_callers_channel(payload, expected) -> None:
|
||||
"""H3: each hosted refusal names the field, and each is a ``ValueError`` — the 400 arm.
|
||||
|
||||
``enable_plan_review`` is the one that had to be refused HERE rather than in ``explore()``:
|
||||
``ExplorationError`` is a ``RuntimeError``, so leaving it to the loop would answer a caller's
|
||||
configuration mistake on the crash channel (500), which is where a fallen-over endpoint lives.
|
||||
A synchronous plan review would also block the HTTP request on a reviewer that does not exist.
|
||||
|
||||
Detach point: drop any one of the four guards → RED.
|
||||
"""
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await hosting.invoke(_hosted_payload(**payload))
|
||||
assert expected in str(excinfo.value)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue