Magentic legges OVER den normative sloeyfa, aldri inni Steg 3: prompt + kunnskapsbaser -> Mandate -> run_project(mandate=...) UENDRET. Manageren velger VEI; det som forlater friheten er et Mandate, aldri et forslag. explore() skriver ingenting - niva 3 (skriverettigheter) tilhoerer pipelinen alene. Levert i denne oekten (kjernen; kallstedene staar til oekt 57): - ExplorationContract: seks paakrevde felt uten default. max_reset_count=0 nektes paa en MAALING - reset_count >= max_reset_count mot en teller som starter paa 0 terminerer kjoeringen FOER foerste runde med null ledger-events, altsaa en utforskning som utforsket ingenting, forkledd som en stall som aldri skjedde. - explore() + fresh_exploration_workflow(): fersk builder per utforskning, BudgetMiddleware paa HVER agent inkl. manageren, synkron plan review via request_info, og max_plan_revisions som binder den ubundne revise-loekka. - Tre kanaler: tokens OG runder raiser BudgetExceeded (rundene oversatt av vaart lag som kind="exploration_rounds", fordi orkestreringen maalt ikke raiser ved sitt eget rundetak); alt semantisk er en VERDI i stop. - quick_validate (niva 1, raadgivende) + navigator-verktoey over safe_resolve. - U14s tre utsatte events landet som span-events paa EN exploration-span. Load-bearing MAALT mot HELE suiten, groenn kontroll 975/5, golden ea8c534 uendret: tolv mutasjoner alle roede. TO av dem falsifiserte testen foerst - skrivefrihets-testen naadde aldri en verktoeykropp (ScriptedChatClient emitterer ingen verktoeykall), og stdout-testens capsys er blind for ConsoleSpanExporter, hvis out-default bindes ved modulimport. Begge er rettet; stdout-armen er naa en subprosess, som er P4-presedensen. [skip-docs] fordi flaten ikke er naabar for en bruker enna: --explore, det whitelistede hosting-feltet og sim-scenarioet bygges i oekt 57, og en README-oppfoering naa ville vaert en paastand om en inngang som ikke finnes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
990 lines
45 KiB
Python
990 lines
45 KiB
Python
"""U4 + U13-synchronous (økt 56) — the Magentic exploration loop as a MANDATE-FORMER.
|
||
|
||
**What this loop is, and what it deliberately is not.** ``explore()`` puts a Magentic manager
|
||
*over* the normative pipeline, never inside it: the manager is free to choose which knowledge base
|
||
to open and which hypothesis to shape next, and what leaves that freedom is a
|
||
``mandate.Mandate`` — a list of approaches worth *testing*. It is never a proposal. Every number
|
||
that survives is still gated by ``validate_proposal`` inside ``run_project``, in the same blocking
|
||
gate as today, and the exploration itself can write to neither the outbox nor the wiki. Step 3's
|
||
maker-checker debate is untouched (``shared/method-spec.md`` §3 is commons-owned and normative).
|
||
|
||
**Everything asserted here was measured before it was built** (plan
|
||
``docs/plan/2026-08-23-magentic-utforskningssloeyfe.md`` § F, spikes S0–S6 in økt 54, plus three
|
||
probes run at the head of økt 56):
|
||
|
||
* a plan-review ``revise`` costs two manager calls, **zero** rounds, and asks *again* — so an
|
||
always-revising expert is unbounded spend under a round cap that never ticks. That is the whole
|
||
reason ``max_plan_revisions`` is a required contract field rather than a nicety.
|
||
* the round cap and the reset cap **raise nothing**. Both end the run with a canonical assistant
|
||
message and a normal-looking result (measured: ``max_round_count=2`` → two ledger events and
|
||
``'Workflow terminated due to reaching maximum round count.'``; a stalling ledger with
|
||
``max_reset_count=1`` → one ``REPLANNED`` event and ``'…maximum reset count.'``). At the
|
||
transport both are indistinguishable from success, so this layer produces the typed stop itself.
|
||
* a ``next_speaker`` naming nobody produces a **silent final answer with zero participant work**
|
||
(``_magentic.py:1128-1131``) — a plausible answer produced by no work at all, which is the
|
||
hazard class E2 was retired for. The names are therefore validated, never assumed.
|
||
|
||
**The client is the repo's own ``ScriptedChatClient``.** A bare ``BaseChatClient`` silently no-ops
|
||
``BudgetMiddleware`` (measured, ``simulation.py:373-375``), so a budget claim proved against one
|
||
would prove nothing.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from agent_framework import BaseChatClient
|
||
from opentelemetry.sdk.trace import TracerProvider
|
||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||
from pydantic import ValidationError
|
||
|
||
import portfolio_optimiser
|
||
from portfolio_optimiser import explore, okf
|
||
from portfolio_optimiser.budget import Budget, BudgetExceeded, TokenMeter
|
||
from portfolio_optimiser.explore import ExplorationContract
|
||
from portfolio_optimiser.mandate import Approach
|
||
from portfolio_optimiser.simulation import ScriptedChatClient
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.3 — the contract: an exploration without stated bounds refuses to start
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
_FULL_CONTRACT = {
|
||
"max_rounds": 4,
|
||
"max_tokens": 5_000,
|
||
"max_stall_count": 2,
|
||
"max_reset_count": 1,
|
||
"max_plan_revisions": 1,
|
||
"enable_plan_review": True,
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("omitted", sorted(_FULL_CONTRACT))
|
||
def test_every_bound_is_required_with_no_default(omitted: str) -> None:
|
||
"""T1: each of the six fields is REQUIRED — dropping any one refuses construction.
|
||
|
||
Not a style point. ``MagenticBuilder`` defaults ``max_round_count`` to ``None`` (unbounded)
|
||
and ``max_reset_count`` to ``None`` (unlimited), and inheriting either would give this repo
|
||
the one thing ``shared/method-spec.md`` §8 forbids outright: a loop with no stated end. A
|
||
default here would also be a claim about the operator's intent that nobody made — the same
|
||
ground on which ``ProvenanceStamp.cost_baseline_anchored`` is required without one.
|
||
"""
|
||
payload = {k: v for k, v in _FULL_CONTRACT.items() if k != omitted}
|
||
with pytest.raises(ValidationError):
|
||
ExplorationContract(**payload)
|
||
|
||
|
||
def test_full_contract_constructs() -> None:
|
||
"""T2: the control for T1 — the complete payload IS valid.
|
||
|
||
Without it, T1 would pass on a model that refuses everything, which is the vacuous-gate class
|
||
this repo has paid for six times.
|
||
"""
|
||
contract = ExplorationContract(**_FULL_CONTRACT)
|
||
assert contract.max_rounds == 4
|
||
assert contract.enable_plan_review is True
|
||
|
||
|
||
def test_a_revision_cap_without_plan_review_is_refused_not_ignored() -> None:
|
||
"""T3: ``max_plan_revisions > 0`` with ``enable_plan_review=False`` refuses.
|
||
|
||
A plan revision can only arise from a plan review — with the review off, the cap bounds an
|
||
event that cannot occur, and a caller who set it believes they bounded something. This repo
|
||
refuses a setting that cannot take effect rather than dropping it silently (the same partition
|
||
``--embedder-config requires --semantic-retrieval`` enforces on the CLI).
|
||
"""
|
||
with pytest.raises(ValidationError):
|
||
ExplorationContract(**{**_FULL_CONTRACT, "enable_plan_review": False})
|
||
|
||
|
||
def test_review_off_with_zero_revisions_is_the_coherent_form() -> None:
|
||
"""T4: the control for T3 — review off and the cap at ``0`` is a consistent statement, and
|
||
must construct. Without this arm T3 would pass on a model that simply forbade
|
||
``enable_plan_review=False`` outright, which is a different (and wrong) rule.
|
||
"""
|
||
contract = ExplorationContract(
|
||
**{**_FULL_CONTRACT, "enable_plan_review": False, "max_plan_revisions": 0}
|
||
)
|
||
assert contract.enable_plan_review is False
|
||
assert contract.max_plan_revisions == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# The scripted stand-ins. ScriptedChatClient, never a bare BaseChatClient: the latter no-ops
|
||
# BudgetMiddleware (measured, simulation.py:373-375), so a budget assertion made against one
|
||
# would assert nothing.
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
PROMPT = "Find the cheapest saving available in the energy bundle."
|
||
|
||
|
||
def _ledger_json(
|
||
*, satisfied: bool, speaker: str, instruction: str = "Shape one hypothesis."
|
||
) -> str:
|
||
"""A progress ledger naming ``speaker``.
|
||
|
||
The name is a PARAMETER, never a literal, because a ``next_speaker`` matching no participant
|
||
is the measured footgun this module defends against: the orchestrator does not error, it
|
||
quietly emits a final answer having asked nobody (``_magentic.py:1128-1131``).
|
||
"""
|
||
return json.dumps(
|
||
{
|
||
"is_request_satisfied": {"reason": "r", "answer": satisfied},
|
||
"is_in_loop": {"reason": "r", "answer": False},
|
||
"is_progress_being_made": {"reason": "r", "answer": True},
|
||
"next_speaker": {"reason": "r", "answer": speaker},
|
||
"instruction_or_question": {"reason": "r", "answer": instruction},
|
||
}
|
||
)
|
||
|
||
|
||
def _stalling_ledger_json(speaker: str) -> str:
|
||
"""A ledger reporting NO progress and a loop — the two flags that drive ``stall_count`` up."""
|
||
return json.dumps(
|
||
{
|
||
"is_request_satisfied": {"reason": "r", "answer": False},
|
||
"is_in_loop": {"reason": "circles", "answer": True},
|
||
"is_progress_being_made": {"reason": "none", "answer": False},
|
||
"next_speaker": {"reason": "r", "answer": speaker},
|
||
"instruction_or_question": {"reason": "r", "answer": "Try again."},
|
||
}
|
||
)
|
||
|
||
|
||
def _manager_script(
|
||
ledgers: list[str], calls: list[str] | None = None
|
||
) -> Callable[[str, str], str]:
|
||
"""Route a manager prompt blob to its scripted reply, consuming ``ledgers`` in order.
|
||
|
||
The ORDER of these tests is load-bearing and was measured (§ F, A6): the selector receives the
|
||
CONCATENATION of every message in the call, so a later-stage prompt still carries the earlier
|
||
stage's text — one manager call in five carries two markers. Testing the later stage FIRST is
|
||
what resolves it; reversing two of these silently reattributes a reply to the wrong stage.
|
||
"""
|
||
|
||
def _select(blob: str, _role: str) -> str:
|
||
if calls is not None:
|
||
calls.append(blob[:40])
|
||
if "provide the final answer" in blob:
|
||
return "FINAL: exploration done."
|
||
if "pure JSON format" in blob:
|
||
return ledgers.pop(0) if ledgers else _ledger_json(satisfied=True, speaker="navigator")
|
||
if "went wrong on this last run" in blob:
|
||
return "PLAN-UPDATE: revised plan."
|
||
if "rewrite the following fact sheet" in blob:
|
||
return "FACTS-UPDATE: revised facts."
|
||
if "bullet-point plan" in blob:
|
||
return "PLAN: - ask the hypothesiser"
|
||
if "pre-survey" in blob:
|
||
return "FACTS: the bundle is anchored."
|
||
return "{}"
|
||
|
||
return _select
|
||
|
||
|
||
def _factory(
|
||
*, ledgers: list[str], hypothesiser: list[str], navigator: str = "NAVIGATOR: index read."
|
||
) -> Callable[[str], BaseChatClient]:
|
||
"""One fresh ``ScriptedChatClient`` per role, exactly as the real factory hands out one per
|
||
role. ``hypothesiser`` is a list consumed in order, so a run can shape several candidates."""
|
||
|
||
def factory(role: str) -> BaseChatClient:
|
||
if role == explore.MANAGER_ROLE:
|
||
return ScriptedChatClient(reply_selector=_manager_script(ledgers), role=role)
|
||
if role == explore.HYPOTHESISER_ROLE:
|
||
replies = list(hypothesiser)
|
||
|
||
def _hyp(_blob: str, _role: str) -> str:
|
||
return replies.pop(0) if replies else "nothing further."
|
||
|
||
return ScriptedChatClient(reply_selector=_hyp, role=role)
|
||
return ScriptedChatClient(navigator, role=role)
|
||
|
||
return factory
|
||
|
||
|
||
def _hypothesis_line(label: str, rationale: str) -> str:
|
||
return f"{explore.HYPOTHESIS_MARKER} " + json.dumps({"label": label, "rationale": rationale})
|
||
|
||
|
||
#: The no-review base every stop test derives from. ``enable_plan_review`` and
|
||
#: ``max_plan_revisions`` move together — ``ExplorationContract`` refuses them apart — so a test
|
||
#: about round or stall behaviour has to say so explicitly rather than inherit ``_FULL_CONTRACT``.
|
||
_NO_REVIEW = {**_FULL_CONTRACT, "enable_plan_review": False, "max_plan_revisions": 0}
|
||
|
||
_CONTRACT = ExplorationContract(
|
||
max_rounds=6,
|
||
max_tokens=100_000,
|
||
max_stall_count=2,
|
||
max_reset_count=1,
|
||
max_plan_revisions=0,
|
||
enable_plan_review=False,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.0 / C.6 — the exploration is a MANDATE-FORMER, and a seed never disappears
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_hypotheses_become_the_mandate_in_the_order_they_were_shaped() -> None:
|
||
"""T5: what the hypothesiser MARKED becomes ``Mandate.approaches``, rationale VERBATIM.
|
||
|
||
The rationale is the half a model cannot re-derive from cost data — ``mandate.Approach``
|
||
already feeds ``description`` to the proposer verbatim (``generate._build_messages``), so
|
||
paraphrasing it here would drop precisely the part the exploration exists to carry forward.
|
||
"""
|
||
ledgers = [
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
]
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=_CONTRACT,
|
||
bundle_dirs=(),
|
||
client_factory=_factory(
|
||
ledgers=ledgers,
|
||
hypothesiser=[
|
||
"Looking at the bundle.\n"
|
||
+ _hypothesis_line("LED retrofit", "the fixtures are 1990s fluorescent")
|
||
],
|
||
),
|
||
)
|
||
|
||
assert [a.label for a in result.mandate.approaches] == ["LED retrofit"]
|
||
assert result.mandate.approaches[0].description == "the fixtures are 1990s fluorescent"
|
||
assert result.mandate.objective == PROMPT
|
||
assert result.stop is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_seed_approach_survives_whatever_the_manager_found() -> None:
|
||
"""T6: an expert's own hypothesis is in the output mandate, FIRST, untouched.
|
||
|
||
Door 1 of § C.6, and the ``not_evaluated`` rule applied one stage earlier: a direction the
|
||
domain expert asked for may never vanish because an autonomous loop preferred its own. Seeds
|
||
lead so the pipeline reaches them before spending its budget on discovered ones.
|
||
"""
|
||
seed = Approach(id="fagperson-1", label="Night setback", description="the expert's own words")
|
||
ledgers = [
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
]
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=_CONTRACT,
|
||
bundle_dirs=(),
|
||
seed_approaches=(seed,),
|
||
client_factory=_factory(
|
||
ledgers=ledgers,
|
||
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
|
||
),
|
||
)
|
||
|
||
assert [a.id for a in result.mandate.approaches] == ["fagperson-1", "hypothesis-1"]
|
||
assert result.mandate.approaches[0] == seed
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_an_exploration_that_shaped_nothing_still_returns_the_seeds() -> None:
|
||
"""T7: the control for T6 — with the hypothesiser silent, the seed is still the mandate.
|
||
|
||
This is what makes T6 a statement about PRESERVATION rather than about ordering: a test that
|
||
only ever saw seeds alongside discoveries could not tell "seeds are kept" from "seeds sort
|
||
first".
|
||
"""
|
||
seed = Approach(id="fagperson-1", label="Night setback", description="the expert's own words")
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=_CONTRACT,
|
||
bundle_dirs=(),
|
||
seed_approaches=(seed,),
|
||
client_factory=_factory(
|
||
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
|
||
hypothesiser=[],
|
||
),
|
||
)
|
||
|
||
assert result.mandate.approaches == (seed,)
|
||
assert result.mandate.allow_own_proposals is True
|
||
|
||
|
||
def test_zero_resets_is_refused_because_it_silently_explores_nothing() -> None:
|
||
"""T8: ``max_reset_count=0`` refuses — MEASURED, not reasoned.
|
||
|
||
The orchestrator's limit check is ``reset_count >= max_reset_count`` (``_magentic.py:1243``)
|
||
and ``reset_count`` starts at zero, so a cap of zero is already met before the first round.
|
||
Measured against the installed stack: the run makes only the ``facts`` and ``plan`` manager
|
||
calls, emits **zero** progress-ledger events, and returns
|
||
``'Workflow terminated due to reaching maximum reset count.'`` — an exploration that explored
|
||
nothing, reported as a stall that never happened. An operator writing "allow no resets" would
|
||
get "do no work", quietly. So it is refused at construction, where the reason can be said.
|
||
"""
|
||
with pytest.raises(ValidationError):
|
||
ExplorationContract(**{**_FULL_CONTRACT, "max_reset_count": 0})
|
||
|
||
|
||
def test_zero_stalls_is_allowed_because_it_means_something() -> None:
|
||
"""T9: the control for T8 — ``max_stall_count=0`` is a real setting and must construct.
|
||
|
||
The stall check is STRICT (``stall_count > max_stall_count``, ``:1118``) and the counter is
|
||
incremented before it, so zero means "reset on the first round that reports no progress".
|
||
That is strictness, not self-defeat, and refusing both zeroes on symmetry would have banned a
|
||
usable configuration on the strength of a measurement about a different field.
|
||
"""
|
||
contract = ExplorationContract(**{**_FULL_CONTRACT, "max_stall_count": 0})
|
||
assert contract.max_stall_count == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.2 / C.3 — three endings the orchestration reports as if they were success
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_the_round_cap_leaves_as_a_typed_budget_stop() -> None:
|
||
"""T10: the round cap becomes ``BudgetExceeded(kind="exploration_rounds")``.
|
||
|
||
Measured (§ F, E5, re-measured at the head of this økt): ``max_round_count`` raises NOTHING.
|
||
The run ends with the assistant message ``'Workflow terminated due to reaching maximum round
|
||
count.'`` and a result that ``get_outputs()`` answers like any other — at the transport it is
|
||
indistinguishable from a finished exploration. Left alone, a caller would read a run that
|
||
explored two rounds of a six-round question as a completed answer. The triple is the one
|
||
kø-(y) defends: WHICH cap bound, what it was, and how far the run actually got.
|
||
"""
|
||
contract = ExplorationContract(**{**_NO_REVIEW, "max_rounds": 2})
|
||
with pytest.raises(BudgetExceeded) as excinfo:
|
||
await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
client_factory=_factory(
|
||
ledgers=[_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE)] * 4,
|
||
hypothesiser=[_hypothesis_line("LED", "worth a look")] * 4,
|
||
),
|
||
)
|
||
|
||
assert excinfo.value.kind == "exploration_rounds"
|
||
assert excinfo.value.limit == 2
|
||
assert excinfo.value.observed == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_request_satisfied_on_the_last_allowed_round_is_success() -> None:
|
||
"""T11: the discriminator for T10 — reaching the cap is not the same as being cut off by it.
|
||
|
||
Both runs end with exactly ``max_rounds`` progress-ledger events, so a check written on the
|
||
count alone would raise on this one too and turn a completed exploration into a budget error.
|
||
What separates them is the LAST ledger's ``is_request_satisfied``, which is also what the
|
||
orchestrator itself branches on (``:1106``). Without this arm, T10 would pass on an
|
||
implementation that refuses every exploration that uses its whole allowance.
|
||
"""
|
||
contract = ExplorationContract(**{**_NO_REVIEW, "max_rounds": 2})
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
client_factory=_factory(
|
||
ledgers=[
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
],
|
||
hypothesiser=[_hypothesis_line("LED", "worth a look")],
|
||
),
|
||
)
|
||
|
||
assert len(result.ledger_log) == contract.max_rounds
|
||
assert result.stop is None
|
||
assert [a.label for a in result.mandate.approaches] == ["LED"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stalling_out_is_a_typed_value_never_an_exception() -> None:
|
||
"""T12: stall → reset → out of resets is ``stop="stalled"``, and the run still returns.
|
||
|
||
Kept as a VALUE while the round cap RAISES, and the split is S3.4's, not a preference: a
|
||
stalled exploration is an outcome (the manager tried and got nowhere), whereas an exhausted
|
||
round or token cap is resource exhaustion. Fusing them would leave a caller unable to tell
|
||
"there was nothing here" from "we could not afford to look".
|
||
"""
|
||
contract = ExplorationContract(
|
||
**{**_NO_REVIEW, "max_rounds": 6, "max_stall_count": 1, "max_reset_count": 1}
|
||
)
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
client_factory=_factory(
|
||
ledgers=[_stalling_ledger_json(explore.HYPOTHESISER_ROLE)] * 6,
|
||
hypothesiser=["still nothing."] * 6,
|
||
),
|
||
)
|
||
|
||
assert result.stop == "stalled"
|
||
assert len(result.ledger_log) < contract.max_rounds
|
||
assert all(entry.is_in_loop for entry in result.ledger_log)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_ledger_naming_nobody_withholds_what_the_run_produced() -> None:
|
||
"""T13: a ``next_speaker`` matching no participant stops the exploration and drops its finds.
|
||
|
||
The measured footgun (``_magentic.py:1128-1131``): the orchestrator neither raises nor retries
|
||
on an unknown speaker — it logs a warning and jumps to ``_prepare_final_answer``. The run
|
||
therefore returns a plausible answer that no participant was asked for. This is the shape E2
|
||
was retired over ("a plausible verdict produced by zero work"), so the mandate is NOT built
|
||
from what such a run said it found.
|
||
|
||
The scripted run reaches the bad ledger on round TWO, after a good round in which the
|
||
hypothesiser really did commit to a direction. That ordering is what makes the assertion
|
||
sharp: with the bad ledger first, nobody would ever have spoken and "nothing was carried
|
||
forward" would be true of any implementation at all.
|
||
"""
|
||
seed = Approach(id="fagperson-1", label="Night setback", description="expert's own")
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=_CONTRACT,
|
||
bundle_dirs=(),
|
||
seed_approaches=(seed,),
|
||
client_factory=_factory(
|
||
ledgers=[
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=False, speaker="a-name-nobody-answers-to"),
|
||
],
|
||
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
|
||
),
|
||
)
|
||
|
||
assert result.stop == "unknown_speaker"
|
||
assert result.ledger_log[0].speaker_known is True
|
||
assert result.ledger_log[-1].speaker_known is False
|
||
# The seed survives — preservation is unconditional (§ C.6 door 1) — while the loop's own
|
||
# find does not, because nothing stands behind the turn that ended the run.
|
||
assert result.mandate.approaches == (seed,)
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.2 — the token cap covers the MANAGER, which is the loop's most talkative agent
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_the_token_cap_binds_the_manager_before_any_participant_speaks() -> None:
|
||
"""T14: a one-token budget stops the exploration on the MANAGER's own first call.
|
||
|
||
Agent-level ``ChatMiddleware`` does fire on the manager's calls (§ F, A1, measured green), and
|
||
the manager talks more than anyone else in a Magentic loop — it extracts facts, writes the
|
||
plan, and writes a progress ledger every single round. A cap fastened only to the participants
|
||
would be a cap in name.
|
||
|
||
The assertion is deliberately not "something raised". ``kind == "tokens"`` separates it from
|
||
the round-cap stop, ``meter.tokens == 8`` shows the charge came from a call that was actually
|
||
made and metered, and the EMPTY ledger log shows it landed before the loop had run a single
|
||
round — which is exactly what a manager-attached middleware does and a participant-only one
|
||
cannot.
|
||
"""
|
||
meter = TokenMeter(Budget(max_tokens=1, max_rounds=6))
|
||
contract = ExplorationContract(**{**_NO_REVIEW, "max_tokens": 1})
|
||
|
||
with pytest.raises(BudgetExceeded) as excinfo:
|
||
await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
meter=meter,
|
||
client_factory=_factory(
|
||
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
|
||
hypothesiser=[],
|
||
),
|
||
)
|
||
|
||
assert excinfo.value.kind == "tokens"
|
||
assert meter.tokens == 8, "the manager's own call must have been charged to the meter"
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.5 / U13 — the synchronous plan review, and the cap the measurement forced
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
def _reviewer(script: list[explore.PlanReviewDecision], seen: list[explore.PlanReviewRequest]):
|
||
def review(request: explore.PlanReviewRequest) -> explore.PlanReviewDecision:
|
||
seen.append(request)
|
||
return script.pop(0) if script else explore.PlanReviewDecision.approve()
|
||
|
||
return review
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_revision_reaches_the_manager_and_the_review_is_asked_again() -> None:
|
||
"""T15: revise → replan → asked AGAIN → approve → the loop runs.
|
||
|
||
This is målbilde's "ask the question, use the answer, carry on" on the installed stack: the
|
||
expert's words go into the manager's history, the manager replans, and the human is asked to
|
||
sign off on the NEW plan rather than the old one. Both round trips are recorded, in order,
|
||
with the feedback verbatim — an audit of what a human actually told an autonomous loop is
|
||
worth nothing paraphrased.
|
||
"""
|
||
seen: list[explore.PlanReviewRequest] = []
|
||
contract = ExplorationContract(
|
||
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 2}
|
||
)
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
plan_reviewer=_reviewer(
|
||
[explore.PlanReviewDecision.revise("Also test night setback.")], seen
|
||
),
|
||
client_factory=_factory(
|
||
ledgers=[
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
],
|
||
hypothesiser=[_hypothesis_line("Night setback", "the expert asked for it")],
|
||
),
|
||
)
|
||
|
||
assert [r.decision for r in result.plan_reviews] == ["revise", "approve"]
|
||
assert result.plan_reviews[0].feedback == "Also test night setback."
|
||
assert len(seen) == 2, "a revision must produce a SECOND review, not resume silently"
|
||
assert seen[1].plan != "", "the second review must show the revised plan"
|
||
assert result.stop is None
|
||
assert [a.label for a in result.mandate.approaches] == ["Night setback"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_an_always_revising_reviewer_is_stopped_by_the_cap() -> None:
|
||
"""T16: the cap terminates a reviewer that never signs off — the reason it exists.
|
||
|
||
Measured (§ F, A3): a revise costs two manager calls, emits NO progress ledger and consumes
|
||
NO round, then asks again. The round cap therefore never ticks, and without
|
||
``max_plan_revisions`` this is an unbounded spend under caps that all look satisfied —
|
||
precisely what ``shared/method-spec.md`` §8 forbids. The stop is typed and the exploration
|
||
still returns; the reviewer's last (refused) revision is recorded, because the record is of
|
||
what the human decided and ``stop`` is what says it was not applied.
|
||
"""
|
||
seen: list[explore.PlanReviewRequest] = []
|
||
always_revise = [explore.PlanReviewDecision.revise(f"Again #{n}.") for n in range(10)]
|
||
contract = ExplorationContract(
|
||
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 1}
|
||
)
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
plan_reviewer=_reviewer(always_revise, seen),
|
||
client_factory=_factory(
|
||
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
|
||
hypothesiser=[],
|
||
),
|
||
)
|
||
|
||
assert result.stop == "plan_revisions_exhausted"
|
||
assert [r.decision for r in result.plan_reviews] == ["revise", "revise"]
|
||
assert result.ledger_log == (), "the loop must never have run: the plan was never approved"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_a_reviewer_that_signs_off_at_once_is_not_capped() -> None:
|
||
"""T17: the control for T16 — the same cap, a reviewer that approves, and no stop.
|
||
|
||
Without it, T16 would pass on an implementation that refuses every plan review it is given,
|
||
which would stop the runaway loop and every legitimate one with it.
|
||
"""
|
||
seen: list[explore.PlanReviewRequest] = []
|
||
contract = ExplorationContract(
|
||
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 1}
|
||
)
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
plan_reviewer=_reviewer([], seen),
|
||
client_factory=_factory(
|
||
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
|
||
hypothesiser=[],
|
||
),
|
||
)
|
||
|
||
assert result.stop is None
|
||
assert [r.decision for r in result.plan_reviews] == ["approve"]
|
||
assert len(result.ledger_log) >= 1, "an approved plan must let the loop actually run"
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# C.0 level 3 — the exploration has no write access, and level 1 is advisory
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
def _tree(root: Path) -> dict[str, bytes]:
|
||
return {
|
||
str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_an_exploration_leaves_the_knowledge_base_byte_identical(tmp_path: Path) -> None:
|
||
"""T18: ``explore()`` writes NOTHING — not to the base, not anywhere under it.
|
||
|
||
Level 3 of the guarantee table: only the pipeline may write an outbox artefact, and only the
|
||
gated ``promote_verdict`` may write to the wiki. An exploration that could write would be a
|
||
route around the gate that makes an answer checkable — and, promoting into the base it reads,
|
||
the self-contamination loop the Step-8 gate exists to prevent.
|
||
|
||
Compared BYTE for byte over the whole subtree rather than by listing names, so a rewritten
|
||
``index.md`` of the same length would still fail.
|
||
|
||
**The tools are exercised DIRECTLY, and that is a correction, not thoroughness.** A first
|
||
version of this test drove only ``explore()`` — and a mutation that made ``read_bundle`` write
|
||
a file into the base it reads left the WHOLE suite green (measured: 974 passed). A
|
||
``ScriptedChatClient`` returns text and never emits a tool call, so no scripted run reaches a
|
||
tool body: the read surface, which is the only place a write could plausibly come from, was
|
||
outside the gate entirely.
|
||
"""
|
||
base = tmp_path / "bygg-energi-baseline-mikro"
|
||
shutil.copytree(
|
||
Path(portfolio_optimiser.__file__).parent / "data" / "bundles" / base.name, base
|
||
)
|
||
before = _tree(tmp_path)
|
||
|
||
result = await explore.explore(
|
||
PROMPT,
|
||
contract=_CONTRACT,
|
||
bundle_dirs=(str(base),),
|
||
client_factory=_factory(
|
||
# Three ledgers, and the third is what makes the second one matter: the orchestrator
|
||
# tests ``is_request_satisfied`` BEFORE it reads ``next_speaker`` (``:1106``), so a
|
||
# satisfied ledger naming the hypothesiser never actually asks it anything.
|
||
ledgers=[
|
||
_ledger_json(satisfied=False, speaker=explore.NAVIGATOR_ROLE),
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
],
|
||
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
|
||
),
|
||
)
|
||
|
||
assert result.mandate.approaches[0].label == "LED retrofit"
|
||
|
||
# Every read tool, called on the same base, with model-shaped arguments.
|
||
tools = {t.name: t for t in explore.navigator_tools((str(base),))}
|
||
assert tools["list_bundles"].func()[0]["id"] == base.name
|
||
assert tools["read_bundle"].func(bundle_id=base.name) != ""
|
||
assert tools["read_file"].func(bundle_id=base.name, path="index.md") != ""
|
||
explore.quick_validate_tool((str(base),)).func(
|
||
bundle_id=base.name, proposal_json=json.dumps(_micro_projection())
|
||
)
|
||
|
||
assert _tree(tmp_path) == before
|
||
|
||
|
||
def _micro_bundle_dir() -> str:
|
||
return str(
|
||
Path(portfolio_optimiser.__file__).parent
|
||
/ "data"
|
||
/ "bundles"
|
||
/ "bygg-energi-baseline-mikro"
|
||
)
|
||
|
||
|
||
def _micro_projection() -> dict[str, Any]:
|
||
projection = dict(okf.load_ir_projection(_micro_bundle_dir()))
|
||
projection.pop("_note", None)
|
||
return projection
|
||
|
||
|
||
def test_quick_validate_reports_the_real_verdict_and_says_whether_it_was_anchored() -> None:
|
||
"""T19: the in-loop check is the SAME validator, and it declares its own anchoring.
|
||
|
||
Level 1 is advisory but never fake: it runs ``validate_proposal`` against the base's own
|
||
``cost-baseline.json``, so stage 0 reconciliation is live and a fabricated cost line is caught
|
||
in the loop rather than three steps later. ``anchored`` rides along for the reason
|
||
``ProvenanceStamp.cost_baseline_anchored`` is a required field — a verdict reached without the
|
||
project's real cost lines is a weaker claim, and one that does not say so is a silence.
|
||
"""
|
||
base = _micro_bundle_dir()
|
||
projection = _micro_projection()
|
||
validate = explore.quick_validate_tool((base,))
|
||
|
||
honest = validate.func(
|
||
bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(projection)
|
||
)
|
||
assert honest["decision"] == "validated"
|
||
assert honest["anchored"] is True
|
||
assert honest["p90"] >= honest["p50"] >= honest["p10"]
|
||
|
||
# A cost code the project does not have is refused by stage 0 — the one stage that can tell a
|
||
# fabricated line from a real one, and the reason `anchored` is worth reporting at all.
|
||
invented = dict(projection)
|
||
invented["affected_items"] = [
|
||
{**dict(projection["affected_items"][0]), "code": "CODE-THAT-DOES-NOT-EXIST"}
|
||
]
|
||
fabricated = validate.func(
|
||
bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(invented)
|
||
)
|
||
assert fabricated["decision"] == "rejected"
|
||
assert "CODE-THAT-DOES-NOT-EXIST" in fabricated["reason"]
|
||
|
||
|
||
def test_an_unknown_knowledge_base_is_refused_by_name() -> None:
|
||
"""T20: a tool call naming a base nobody configured refuses, and says what IS configured.
|
||
|
||
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.
|
||
"""
|
||
validate = explore.quick_validate_tool(("/tmp/base-a",))
|
||
with pytest.raises(explore.ExplorationError) as excinfo:
|
||
validate.func(bundle_id="base-b", proposal_json="{}")
|
||
assert "base-a" in str(excinfo.value)
|
||
|
||
|
||
def test_two_bases_with_the_same_name_are_refused() -> None:
|
||
"""T21: duplicate ids refuse at construction — the S3.2 key-collision class, one layer up.
|
||
|
||
The id is how the manager names a base. Two bases answering to one name would let it read A
|
||
while believing it read B, and every quotation it produced afterwards would be attributed to
|
||
the wrong project.
|
||
"""
|
||
with pytest.raises(explore.ExplorationError):
|
||
explore.navigator_tools(("/tmp/one/shared-name", "/tmp/two/shared-name"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------
|
||
# U14 — the three events the tracing seam was landed for, now that they have a call site
|
||
# ---------------------------------------------------------------------------------------------
|
||
|
||
|
||
def _recording_tracer() -> tuple[Any, InMemorySpanExporter]:
|
||
"""A REAL OpenTelemetry tracer over an in-memory exporter — not a spy.
|
||
|
||
A recorder standing in for ``add_event`` would prove that this module calls something shaped
|
||
like OTel; this proves the events survive the actual SDK, with the attribute types it will
|
||
accept. The provider is LOCAL and is never installed globally, so the pytest process keeps
|
||
whatever tracing configuration it had (the same restraint U14's own tests exercise).
|
||
"""
|
||
provider = TracerProvider()
|
||
exporter = InMemorySpanExporter()
|
||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||
return provider.get_tracer("test"), exporter
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_the_three_orchestrator_events_reach_the_trace(monkeypatch: Any) -> None:
|
||
"""T22: ``plan_created``, ``replanned`` and ``progress_ledger_updated`` are recorded.
|
||
|
||
These are the events U14 deliberately did NOT build in økt 55 — "an emitter with no call site
|
||
is a shape guessed instead of measured". This is the call site. A Magentic manager decides
|
||
which base to open and who speaks next; without these, the only trace of that reasoning is
|
||
MAF's own ``invoke_agent`` spans, which say a call happened and nothing about what it decided.
|
||
|
||
The ledger event carries the decision fields rather than a rendered sentence, for the reason
|
||
``SkippedLink`` is structured and ``BudgetExceeded`` carries three fields: "who was asked" and
|
||
"was the request satisfied" are separate operative questions, and a reader who has to re-parse
|
||
prose to tell them apart has a trace they cannot query.
|
||
"""
|
||
tracer, exporter = _recording_tracer()
|
||
# Patched where the name is BOUND (the ``hosting.run_project`` precedent): ``explore``
|
||
# imports it by name, so patching ``tracing`` would leave that binding untouched and this
|
||
# test would quietly measure nothing.
|
||
monkeypatch.setattr(explore, "exploration_tracer", lambda: tracer)
|
||
|
||
seen: list[explore.PlanReviewRequest] = []
|
||
contract = ExplorationContract(
|
||
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 2}
|
||
)
|
||
await explore.explore(
|
||
PROMPT,
|
||
contract=contract,
|
||
bundle_dirs=(),
|
||
plan_reviewer=_reviewer([explore.PlanReviewDecision.revise("Test night setback.")], seen),
|
||
client_factory=_factory(
|
||
ledgers=[
|
||
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
|
||
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
|
||
],
|
||
hypothesiser=[_hypothesis_line("Night setback", "the expert asked")],
|
||
),
|
||
)
|
||
|
||
spans = exporter.get_finished_spans()
|
||
assert [s.name for s in spans] == [explore.EXPLORATION_SPAN]
|
||
events = [(e.name, dict(e.attributes or {})) for e in spans[0].events]
|
||
names = [name for name, _ in events]
|
||
assert names.count("plan_created") == 1
|
||
assert names.count("replanned") == 1, "the human's revision must be visible in the trace"
|
||
assert names.count("progress_ledger_updated") == 2
|
||
|
||
ledger_events = [attrs for name, attrs in events if name == "progress_ledger_updated"]
|
||
assert [a["round_index"] for a in ledger_events] == [1, 2]
|
||
assert [a["next_speaker"] for a in ledger_events] == [explore.HYPOTHESISER_ROLE] * 2
|
||
assert [a["is_request_satisfied"] for a in ledger_events] == [False, True]
|
||
assert all(a["speaker_known"] for a in ledger_events)
|
||
|
||
|
||
#: A complete exploration in a CHILD interpreter. The stdout/stderr question cannot be answered
|
||
#: in-process: ``ConsoleSpanExporter``'s ``out`` default is bound when
|
||
#: ``opentelemetry.sdk.trace.export`` is first imported, so under pytest it is whatever stdout was
|
||
#: at COLLECTION time — and ``capsys``, which replaces ``sys.stdout`` later, never sees it. That is
|
||
#: not a testing quirk to work around; it is precisely the fact U14 exists for, and the reason
|
||
#: ``configure_tracing`` passes ``out=`` explicitly instead of trusting the default. Measured: a
|
||
#: mutation routing exploration spans to that default left an in-process ``capsys`` assertion
|
||
#: GREEN while the spans really were on stdout.
|
||
_CHILD_EXPLORATION = """
|
||
import asyncio, json, sys
|
||
from portfolio_optimiser import explore
|
||
from portfolio_optimiser.simulation import ScriptedChatClient
|
||
from portfolio_optimiser.tracing import configure_tracing
|
||
|
||
configure_tracing()
|
||
|
||
LEDGER = json.dumps({
|
||
"is_request_satisfied": {"reason": "r", "answer": True},
|
||
"is_in_loop": {"reason": "r", "answer": False},
|
||
"is_progress_being_made": {"reason": "r", "answer": True},
|
||
"next_speaker": {"reason": "r", "answer": "navigator"},
|
||
"instruction_or_question": {"reason": "r", "answer": "none"},
|
||
})
|
||
|
||
def _select(blob, _role):
|
||
if "provide the final answer" in blob:
|
||
return "FINAL: done."
|
||
if "pure JSON format" in blob:
|
||
return LEDGER
|
||
if "bullet-point plan" in blob:
|
||
return "PLAN: - ask the navigator"
|
||
if "pre-survey" in blob:
|
||
return "FACTS: none."
|
||
return "{}"
|
||
|
||
def factory(role):
|
||
if role == explore.MANAGER_ROLE:
|
||
return ScriptedChatClient(reply_selector=_select, role=role)
|
||
return ScriptedChatClient("ok", role=role)
|
||
|
||
contract = explore.ExplorationContract(
|
||
max_rounds=4, max_tokens=100000, max_stall_count=2,
|
||
max_reset_count=1, max_plan_revisions=0, enable_plan_review=False,
|
||
)
|
||
result = asyncio.run(
|
||
explore.explore("probe", contract=contract, bundle_dirs=(), client_factory=factory)
|
||
)
|
||
assert result.stop is None, result.stop
|
||
print("EXPLORATION-OK", file=sys.stderr)
|
||
"""
|
||
|
||
|
||
def _run_child(**env: str) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(
|
||
[sys.executable, "-c", _CHILD_EXPLORATION],
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=str(Path(__file__).resolve().parent.parent),
|
||
env={**os.environ, **env},
|
||
)
|
||
|
||
|
||
def test_an_untraced_exploration_writes_nothing_to_stdout_or_stderr() -> None:
|
||
"""T23: in a real process, with tracing off, an exploration prints NOTHING.
|
||
|
||
A subprocess and not ``capsys``, for the reason recorded above ``_CHILD_EXPLORATION`` — and the
|
||
stakes are the pinned artefacts: ``tests/golden/demo-transcript.stdout`` is byte-fixed and the
|
||
demo's stderr is fixed at four lines, so one stray span dump would break both.
|
||
|
||
``EXPLORATION-OK`` on stderr is the control. Without it, "stdout was empty" would be equally
|
||
true of a child that crashed on import, which is the fourth face of the verification law: an
|
||
absence is only evidence once you have shown the measurement could have found something.
|
||
"""
|
||
proc = _run_child(PORTFOLIO_OTEL="")
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert "EXPLORATION-OK" in proc.stderr, "the child must really have run an exploration"
|
||
assert proc.stdout == ""
|
||
# Not an exact-equality assertion on stderr: MAF emits two ``ExperimentalWarning`` lines while
|
||
# importing, under every run form, and they are the same pair the demo's pinned stderr already
|
||
# carries. What must be absent is TRACE data, so that is what is asserted.
|
||
assert '"name": "exploration"' not in proc.stderr
|
||
assert "progress_ledger_updated" not in proc.stderr
|
||
|
||
|
||
def test_a_traced_exploration_puts_its_span_on_stderr_and_leaves_stdout_clean() -> None:
|
||
"""T24: the positive arm — ``PORTFOLIO_OTEL=console`` and the exploration span is on STDERR.
|
||
|
||
This is what the whole U14 seam was landed for, now carrying the events U4 gave it a call site
|
||
for. Both halves are asserted: the span and its ``progress_ledger_updated`` event ARE exported
|
||
(so tracing is real), and stdout is STILL empty (so the byte-pinned transcript survives a
|
||
traced run). Asserting only the first would pass on an exporter writing to stdout — which is
|
||
OpenTelemetry's own default, and therefore the mistake actually available to make.
|
||
"""
|
||
proc = _run_child(PORTFOLIO_OTEL="console")
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert "EXPLORATION-OK" in proc.stderr
|
||
assert proc.stdout == "", "a traced run must not put one byte on stdout"
|
||
assert '"name": "exploration"' in proc.stderr
|
||
assert "progress_ledger_updated" in proc.stderr
|
||
|
||
|
||
def test_a_marked_line_that_will_not_parse_is_a_hard_error() -> None:
|
||
"""T24: the marker is what makes fail-closed affordable here.
|
||
|
||
Most hypothesiser turns legitimately are not hypotheses — the agent reasons out loud — so
|
||
"parse every turn or fail" would refuse a normal exploration. The marker separates a turn that
|
||
is not a claim from a claim that cannot be read. The second is the run's own product coming
|
||
back unreadable, so it raises (``write_concept_file``'s rule: validation, never repair) rather
|
||
than following the tolerant RAW-inbox rule, which belongs to folders anyone may drop files in.
|
||
"""
|
||
with pytest.raises(explore.HypothesisParseError):
|
||
explore._parse_hypotheses([f"{explore.HYPOTHESIS_MARKER} not json at all"])
|
||
with pytest.raises(explore.HypothesisParseError):
|
||
explore._parse_hypotheses([f'{explore.HYPOTHESIS_MARKER} {{"label": "no rationale"}}'])
|
||
|
||
|
||
def test_unmarked_prose_is_not_a_failure() -> None:
|
||
"""T25: the control for T24 — ordinary reasoning yields no hypothesis and no error.
|
||
|
||
Without it, T24 would pass on an implementation that refused every hypothesiser turn that was
|
||
not a hypothesis, which would make the loop unusable and the strictness meaningless.
|
||
"""
|
||
assert explore._parse_hypotheses(["I looked at the index and nothing stands out yet."]) == []
|
||
|
||
|
||
def test_a_review_nobody_can_answer_is_refused_before_the_first_model_call() -> None:
|
||
"""T26: plan review without a reviewer refuses; a reviewer without plan review refuses too.
|
||
|
||
The first would hang: the workflow stops at a ``request_info`` and nothing ever answers it, and
|
||
a hang is the one failure mode that reports nothing at all. The second is the silent-ignore the
|
||
repo's flag partition forbids — a caller who supplied a reviewer believes a human is in the
|
||
loop. Both are refused BEFORE anything is built, so neither costs a model call.
|
||
"""
|
||
with pytest.raises(explore.ExplorationError):
|
||
asyncio.run(
|
||
explore.explore(
|
||
PROMPT,
|
||
contract=ExplorationContract(**_FULL_CONTRACT),
|
||
bundle_dirs=(),
|
||
client_factory=_factory(ledgers=[], hypothesiser=[]),
|
||
)
|
||
)
|
||
with pytest.raises(explore.ExplorationError):
|
||
asyncio.run(
|
||
explore.explore(
|
||
PROMPT,
|
||
contract=ExplorationContract(**_NO_REVIEW),
|
||
bundle_dirs=(),
|
||
plan_reviewer=lambda _r: explore.PlanReviewDecision.approve(),
|
||
client_factory=_factory(ledgers=[], hypothesiser=[]),
|
||
)
|
||
)
|