Two paid rounds scored 0 of 26 fasit concepts opened -- the same number twice.
P18 closed the navigation side (a listing is a window, an invented path is
refused by name) and it did not move, which makes it a ROLE question: nothing
in the loop ever asked the model to say what requirement binds the direction it
committed to, so opening one was never on the critical path to an answer.
A PREMISE OF THE ORDER WAS FELLED BEFORE ANYTHING WAS BUILT ON IT. A1 places
the demand in _INSTRUCTIONS[HYPOTHESISER_ROLE] alone. Measured: the stress
command sends --mandate and NOT --explore, the two are refused together by
name, and none of the nine round-1/2 outboxes holds a {run_id}-exploration.json
-- the hypothesiser never runs in a stress round, so A3 would have been
unreachable in exactly the paid runs this order commissions.
A2's own sentence resolves it: the refusal goes to the model "som en tur den
kan rette (samme mekanisme som quick_validate's nekt), ikke som en raise" --
and quick_validate IS a tool. declare_requirement therefore lives in
navigator_tools, held by BOTH roles that navigate (the exploration, and since
S2c the debate). It EXISTS only when the caller offers both sinks, which keeps
every pre-P19 call site byte-identical; one sink without the other is refused
at construction. 'opened' is the SAME list ExplorationToolRecorder fills, so
the refusal reads the run's own read trace.
The marked hypothesis carries 'requirement' as a REQUIRED key: omitted is a
hard error, explicit null is legal and needs 'why_none', a half-named one is
refused. A minted approach carries it; a seed never acquires one. The proposer
prompt names it only when the field exists, and the judge counts a hit against
THIS approach's fasit concepts, never against the base.
Load-bearing measured (12 arms), four mutations all red against the whole
suite, green control 1711/5 and demo-transcript.stdout byte-unchanged.
A-iii's predicted signature was FALSIFIED: the golden stays green because the
demo runs without a mandate, so _build_messages' approach branch is never
taken there. A-iv was GREEN first -- the repo's vacuous-gate class, 24th time:
the arm drove _attributable while the hit is computed at the call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""Load-bearing: configured MCP servers become tools the AGENTS can call during a run, they are
|
|
opened and closed around the debate, and nothing is contacted that was not announced (Trekk B2/B3).
|
|
|
|
Krav 3 is only met if the external service is reachable *while the run works*. Three detach points,
|
|
each RED on its own:
|
|
|
|
* drop the MCP tools from ``debate_tools`` -> the agents never get the tool (on the bundle path
|
|
they are then left with the four navigator tools alone — before S2c that path had none);
|
|
* skip the ``AsyncExitStack`` entry -> the tools are constructed but never connected, so they are
|
|
present and useless — the failure mode that looks like success;
|
|
* let a dry run enter them -> ``--live-dry-run`` would contact a third party while claiming to stop
|
|
before the first call.
|
|
|
|
The control asserts an un-configured run's tool list is unchanged, so no assertion above can pass
|
|
on something the run does anyway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser import run as run_module
|
|
from portfolio_optimiser.mcp_tools import McpServerConfig
|
|
from portfolio_optimiser.run import run_project
|
|
from portfolio_optimiser.simulation import ScriptedChatClient
|
|
from portfolio_optimiser.verdicts import VerdictStore
|
|
|
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
|
|
_REPLY = (
|
|
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
|
|
)
|
|
|
|
_SERVER = McpServerConfig(
|
|
name="prisregister",
|
|
transport="http",
|
|
url="https://intern.example/mcp",
|
|
allowed_tools=("lookup_unit_price",),
|
|
timeout_seconds=15,
|
|
)
|
|
|
|
|
|
class _FakeMcpTool:
|
|
"""Stands in for a MAF ``MCPTool``: an async context manager that RECORDS its lifecycle.
|
|
|
|
A real one would open a connection, which is exactly what a test must not do — and the thing
|
|
worth asserting is the lifecycle itself, not the protocol MAF already owns.
|
|
"""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
self.entered = 0
|
|
self.exited = 0
|
|
self.entered_before_debate: bool | None = None
|
|
|
|
async def __aenter__(self) -> _FakeMcpTool:
|
|
self.entered += 1
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
self.exited += 1
|
|
|
|
|
|
@pytest.fixture()
|
|
def fake_tools(monkeypatch: pytest.MonkeyPatch) -> list[_FakeMcpTool]:
|
|
"""Replace the MAF client construction with recording doubles — the config still travels the
|
|
real path, only the socket-opening object is swapped."""
|
|
built: list[_FakeMcpTool] = []
|
|
|
|
def _build(configs: tuple[McpServerConfig, ...]) -> list[Any]:
|
|
# Return ONLY this call's tools. Accumulating across calls handed the second project both
|
|
# projects' tools under one name, and MAF refused it ("Duplicate tool name") — a defect in
|
|
# the double, but a useful reminder that each run builds its own clients.
|
|
fresh = [_FakeMcpTool(c.name) for c in configs]
|
|
built.extend(fresh)
|
|
return list(fresh)
|
|
|
|
monkeypatch.setattr(run_module, "build_mcp_tools", _build)
|
|
return built
|
|
|
|
|
|
@pytest.fixture()
|
|
def captured_tools(monkeypatch: pytest.MonkeyPatch) -> list[list[Any]]:
|
|
"""Capture what actually reaches the debate's ``tools=`` — the agents' real surface."""
|
|
seen: list[list[Any]] = []
|
|
original = run_module.fresh_workflow
|
|
|
|
def _spy(*args: Any, **kwargs: Any) -> Any:
|
|
seen.append(list(kwargs.get("tools") or []))
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(run_module, "fresh_workflow", _spy)
|
|
return seen
|
|
|
|
|
|
def _factory(_role: str) -> ScriptedChatClient:
|
|
return ScriptedChatClient(reply=_REPLY)
|
|
|
|
|
|
async def _run(**kwargs: Any):
|
|
return await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
store=VerdictStore(verdicts=[]),
|
|
client_factory=_factory,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
async def test_configured_server_becomes_a_tool_the_agents_have(fake_tools, captured_tools) -> None:
|
|
"""On the bundle path a configured server is the first EXTERNAL tool; since S2c the four
|
|
in-process navigator tools sit alongside it."""
|
|
await _run(mcp_servers=(_SERVER,))
|
|
assert captured_tools, "the debate was never built"
|
|
assert any(isinstance(t, _FakeMcpTool) for t in captured_tools[0])
|
|
|
|
|
|
async def test_tools_are_opened_and_closed_around_the_debate(fake_tools) -> None:
|
|
"""Constructed is not connected. An ``MCPTool`` must be entered to expose anything, and exited
|
|
or the process hangs — so both halves are asserted, not just the first."""
|
|
await _run(mcp_servers=(_SERVER,))
|
|
assert fake_tools, "no MCP tool was built"
|
|
assert fake_tools[0].entered == 1
|
|
assert fake_tools[0].exited == 1
|
|
|
|
|
|
async def test_dry_run_never_contacts_a_configured_server(fake_tools) -> None:
|
|
"""``--live-dry-run`` stops before the first model call, and that promise has to cover egress
|
|
too: a dry run that opened a connection to a third party would be lying by omission."""
|
|
await _run(mcp_servers=(_SERVER,), live_dry_run=True)
|
|
assert all(t.entered == 0 for t in fake_tools)
|
|
|
|
|
|
async def test_run_without_mcp_servers_keeps_the_tool_list_unchanged(captured_tools) -> None:
|
|
"""CONTROL: with nothing configured the bundle path hands the agents no EXTERNAL tool, so every
|
|
assertion above rests on the configuration and not on something the run does anyway.
|
|
|
|
Before S2c this read ``captured_tools[0] == []`` — the bundle path had no tools at all. It now
|
|
navigates its knowledge base, so the control asserts what it always meant: nothing here reaches
|
|
outside the process. Asserting the exact navigator set as well keeps it from degrading into
|
|
"some tools, whatever they are"."""
|
|
await _run()
|
|
assert not any(isinstance(t, _FakeMcpTool) for t in captured_tools[0])
|
|
assert {getattr(t, "name", "") for t in captured_tools[0]} == {
|
|
"list_bundles",
|
|
"read_bundle",
|
|
"read_dir",
|
|
"read_file",
|
|
# P19 DEL A: the declaration rung. It is in-process like the other four — the point of
|
|
# this arm is that NOTHING here reaches outside the process — and it is created by the two
|
|
# caller-owned sinks ``run_project`` passes, never by configuration.
|
|
"declare_requirement",
|
|
}
|
|
|
|
|
|
async def test_portfolio_mode_gives_every_project_the_configured_tools(
|
|
fake_tools, captured_tools
|
|
) -> None:
|
|
"""A configured server reaches EVERY project in a portfolio pass. Without the threading the
|
|
flag would be accepted and silently dropped in one of the two modes — the defect class this
|
|
repo refuses by name ('refused, never ignored')."""
|
|
result = await run_module.run_portfolio(
|
|
["FV42-GSV-E1", "RV13-RAS-TP"],
|
|
"local",
|
|
store=VerdictStore(verdicts=[]),
|
|
client_factory=_factory,
|
|
mcp_servers=(_SERVER,),
|
|
)
|
|
assert len(result.runs) == 2
|
|
assert len(captured_tools) == 2
|
|
assert all(any(isinstance(t, _FakeMcpTool) for t in tools) for tools in captured_tools)
|