portfolio-optimiser/src/portfolio_optimiser/hosting.py
Kjell Tore Guttormsen 938a1ca30e feat(row6): a proposal whose approach declared no requirement is unsupported
Stress round 6 validated three falsification arms, and every validated
approach rested only on run-level declarations nobody can attribute to one
approach. declare_requirement now takes a required approach_id (a mandate
id or own-proposal; an unknown id is refused naming the valid ones), and a
ValidatedProposal whose approach has neither a mandate requirement nor a
declaration under its own id becomes validator.Unsupported - a Rejection
subclass carrying the validator's own ruling, reported as `unsupported` in
coverage, the outcome artefact, the settlement and the judge, and never
counted or summed. The rule is active whenever the debate held the
declaration tool, the micro base included; the road and pre-pass paths are
untouched. Declaration quality is not judged, so the rule can be satisfied
by declaring any document the run read.

The v1 gate's row 6 probes pass; its artefact half reads IKKE MÅLT because
stress round 6 predates approach-addressed declarations, and IKKE MÅLT is
never green - it fails the exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:40:54 +02:00

379 lines
19 KiB
Python

"""Hosted entrypoint (Fase 4d): the Foundry hosted-agent runtime contract implemented
DIRECTLY around ``run_project`` — a wrapper, never ``Workflow.as_agent()``.
The form was decided by TWO measurements (13.08), not preference:
* ``agent-framework-foundry-hosting``'s ``InvocationsHostServer`` exists only in builds
requiring ``agent-framework-core>=1.13.0`` (this tree locks 1.9.0); the sole
1.9-compatible build (``1.0.0a260618``) ships broken metadata — it imports ``mcp``
without declaring it — and is superseded. With no usable protocol library, the runtime
contract (port 8088/``PORT``, ``GET /readiness``, ``POST /invocations``, SIGTERM
shutdown — spike §1.1) is served HERE, including ``/readiness``, which a protocol
library would otherwise have provided.
* A BUILT workflow is single-use on core 1.9.0 (measured: client-call series [2, 0, 0]
across three ``.run()`` calls on ONE object — the round cap persists in the object, so
reuse yields EMPTY runs, not just contaminated ones). A long-lived hosted process must
therefore never hold a workflow; every invocation goes through ``run_project``, which
builds a fresh one per call (the B7 factory).
``as_agent()`` alone would also serve UNGATED proposals: the deterministic validator,
baseline anchoring, checker gate, ledger and learning loop all live OUTSIDE the Workflow
graph (spike §5) — wrapping the graph wraps the wrong boundary.
**The server is asyncio on the ONE loop — no threads, by NG1.** ``http.server``'s
threading variant would put concurrent ``run_project`` calls on OS threads, where none of
S3.3's determinism reasoning holds and MAF's thread-safety is undocumented — exactly what
``test_no_thread_or_process_path_exists_under_src`` ratchets against. ``asyncio.start_server``
plus ~40 lines of HTTP/1.1 parsing keeps liveness (``/readiness`` answers while an
invocation awaits model I/O) and stays inside the sanctioned concurrency model: concurrent
invocations interleave as coroutines, the same way ``run_portfolio``'s waves do. Honest
limit: during a CPU-bound stretch (the CBC solve) the loop — and thus readiness — stalls
for that stretch; chunked request bodies are not supported (``Content-Length`` only).
Surface: ``POST /invocations`` takes a JSON object whitelisted onto ``run_project``'s
signature. ``profile`` defaults to ``"azure"`` on THIS surface only (``run_project``'s own
default stays LOCAL): a hosted container has no local OpenAI-compatible endpoint, and the
AZURE profile reads its environment at call time (Fase 4b). Validation, never repair: an
unknown field is a 400 naming the field — the permissive-schema trap (valg-doc §0) applied
to our own surface. Error mapping is honest: ``ValueError`` (pydantic contract violations
subclass it) → 400; any other failure → 500 ``{error_type, error}`` (mirrors
``RunFailure``); a ``Rejection`` is a SUCCESSFUL run → 200 with ``outcome_type:
"rejected"`` — the negative outcome belongs to the payload, never to the transport.
``BudgetExceeded`` gets its OWN arm → 429, for the same reason ``BudgetStop`` is kept out of
``stop_reason`` (S3.4): a cap that fires is the feature working (``Budget`` exists so a run can
never hang unbounded), and answering it on the crash channel makes "it did not work"
unreadable — the first live run died exactly here and the surface said 500, the same thing it
says when the endpoint falls over. It is NOT 200 either: unlike a ``Rejection``, which is a run
that CONCLUDED, an exhausted budget produced no proposal, and a 2xx would let an automated
caller record "analysed" for a run that analysed nothing. 429 because the condition arises from
an ALLOWANCE — ``max_rounds``/``max_tokens`` are whitelisted request fields and raising them is
the caller's own remedy — never from a server fault. The ``kind``/``limit``/``observed`` triple
is carried as STRUCTURE, not flattened into ``str(exc)`` (kø-(y): it describes one ledger and
answering "which cap bound, and by how much" is the operational question), and ``error_type``
is deliberately absent — that key belongs to the failure channel. Honesty limit, stated: no
``Retry-After``. Retrying an unchanged body hits the same cap; the remedy is a larger allowance
or accepting the stop, and a header promising time would be a lie. The
platform's injected headers (``x-agent-user-id``/``x-agent-foundry-call-id``) are absent
locally by contract and unused here; forwarding the call-id on outgoing Foundry calls has
no seam in ``backends.py`` today and is deliberately not built (90 %-prinsippet).
"""
from __future__ import annotations
import asyncio
import json
import os
import signal
import sys
from collections.abc import Mapping
from typing import Any
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.explore import ExplorationContract, explore
from portfolio_optimiser.outbox import outcome_payload
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.tracing import configure_tracing, tracing_notice
DEFAULT_PORT = 8088
_HOSTED_DEFAULT_PROFILE = "azure"
_REQUIRED_FIELDS = ("project_id", "docs_dir")
#: ``verdict_input`` is OPTIONAL since F2 (non-goal 3). It used to be required, which forced an
#: external caller to invent an expert verdict just to get a run at all — a field that could not be
#: filled honestly, on the surface handed over 2026-08-14. The move is a pure WIDENING: a caller
#: that still sends it is unaffected, and one that omits it now gets a run whose verdict is
#: honestly absent.
_OPTIONAL_FIELDS = (
"bundle_dir",
"profile",
"max_rounds",
"max_tokens",
"top_k",
"verdict_input",
)
#: Fields this surface CONSUMES rather than forwards (U4). They are not ``run_project``
#: parameters — the exploration runs first and hands ``run_project`` a ``Mandate`` — so passing one
#: through would be a ``TypeError`` answered as a 500. The whitelist is therefore a THREE-way
#: partition, and the Fase 4e proof gained a negative half to match: every forwarded field must be
#: a real ``run_project`` parameter, and every consumed field must not be.
_CONSUMED_FIELDS = ("explore_prompt", "explore_contract")
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS + _CONSUMED_FIELDS)
#: Names refused BY NAME on the raw payload, before the generic unknown-field check (MAJOR-2).
#:
#: A pre-whitelist check rather than a fourth list entry, and the placement is MEASURED.
#: ``_CONSUMED_FIELDS`` must be disjoint from ``run_project``'s parameters (Fase 4e's negative
#: half) while ``proposal_reviewer`` IS one; and the generic ``unknown field(s)`` refusal below
#: fires FIRST, so a name left to it would return the same 400 with a message that says nothing
#: about where the door actually is. The F4 precedent is NOT analogous: ``enable_plan_review`` is
#: a NESTED key inside the whitelisted ``explore_contract``, which is why it can be named there.
_REFUSED_BY_NAME = ("proposal_review",)
_REASONS = {
200: "OK",
400: "Bad Request",
404: "Not Found",
429: "Too Many Requests",
500: "Internal Server Error",
}
class InvocationRefused(ValueError):
"""A request the invocations contract refuses — unknown, missing or non-object input.
Validation, never repair (``write_concept_file`` precedent): nothing is dropped,
defaulted or corrected on the caller's behalf."""
def resolve_port() -> int:
"""PORT on truthiness, not presence (the 4b rule): an exported-empty PORT is a shell
accident, not a bind instruction. 8088 is the hosted-agent contract's port."""
return int(os.environ.get("PORT") or DEFAULT_PORT)
def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any], dict[str, Any]]:
"""Whitelist the JSON payload onto ``run_project``'s signature. Everything not named in
the whitelist — including server-side seams like ``outbox_dir``, ``client_factory`` or
``verdict_dir`` — is refused by name, never silently dropped.
Returns ``(project_id, forwarded_kwargs, consumed)``. The consumed half is split out HERE
rather than filtered at the call site so there is one place that decides which fields reach
``run_project``: a consumed field left in ``kwargs`` is an argument the signature does not have,
which the container answers as a 500 for what is really a wiring mistake."""
if not isinstance(payload, dict):
raise InvocationRefused("body must be a JSON object")
# AFTER the object guard (a non-object body must keep its 400 rather than become a 500) and
# BEFORE the generic unknown-field check, which would otherwise answer this one first.
for name in _REFUSED_BY_NAME:
if name in payload:
if name == "proposal_review":
raise InvocationRefused(
"proposal_review: this surface has no terminal to answer a proposal review "
"— the synchronous door would block the request on nobody, and would block "
"the event loop that answers /readiness while doing it. The operator door is "
"the CLI's --proposal-review"
)
raise InvocationRefused(f"{name}: this field is refused by name on this surface")
unknown = sorted(set(payload) - _ALLOWED_FIELDS)
if unknown:
raise InvocationRefused(f"unknown field(s): {', '.join(unknown)}")
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
if missing:
raise InvocationRefused(f"missing required field(s): {', '.join(missing)}")
consumed = {k: payload[k] for k in _CONSUMED_FIELDS if k in payload}
kwargs: dict[str, Any] = {
k: payload[k] for k in payload if k != "project_id" and k not in _CONSUMED_FIELDS
}
kwargs.setdefault("profile", _HOSTED_DEFAULT_PROFILE)
return payload["project_id"], kwargs, consumed
async def _shaped_mandate(consumed: Mapping[str, Any], kwargs: Mapping[str, Any]) -> Any:
"""Run the U4 exploration this invocation asked for and return the mandate it shaped.
Every refusal here is the CALLER's error and therefore a ``ValueError`` (the 400 arm), by name.
That placement is deliberate rather than incidental: ``explore()`` refuses two of these itself,
but ``ExplorationError`` is a ``RuntimeError``, so leaving them to the loop would answer a
caller's configuration mistake on the crash channel — the same conflation ``BudgetExceeded``
was given its own 429 to end.
``enable_plan_review`` is refused outright. The U13 door is SYNCHRONOUS: it blocks the loop on
a human or persona, and an HTTP request has neither — the invocation would hang rather than
answer. The library API is where that door opens."""
prompt = consumed.get("explore_prompt")
raw_contract = consumed.get("explore_contract")
if prompt is None:
raise InvocationRefused(
"explore_contract without explore_prompt: the bounds describe an exploration that "
"would never run"
)
if raw_contract is None:
raise InvocationRefused(
"explore_prompt without explore_contract: an exploration's bounds are never defaulted "
"(an omitted cap falls back to an unbounded loop)"
)
if not kwargs.get("bundle_dir"):
raise InvocationRefused(
"explore_prompt without bundle_dir: the exploration navigates knowledge bases, and "
"with none configured it would spend its budget reading nothing"
)
if not isinstance(raw_contract, dict):
raise InvocationRefused("explore_contract must be a JSON object")
contract = ExplorationContract(**raw_contract) # ValidationError subclasses ValueError -> 400
if contract.enable_plan_review:
raise InvocationRefused(
"explore_contract sets enable_plan_review, but this surface has no reviewer to answer "
"it: the synchronous plan review would block the request on nobody, and would block "
"the event loop that answers /readiness while doing it. The operator door is the CLI's "
"--plan-review (or explore(..., plan_reviewer=...) in-process)"
)
result = await explore(
str(prompt),
contract=contract,
bundle_dirs=(kwargs["bundle_dir"],),
profile=kwargs["profile"],
)
return result.mandate
def _response_payload(result: RunResult) -> dict[str, Any]:
return {
**outcome_payload(
result.outcome,
checker_verdict=result.checker_verdict,
# The candidate's KEY, not evidence that anybody decided (F2): identical to the
# captured verdict's id whenever one was given, and still the id under which a later
# expert verdict on this candidate will arrive when none was.
verdict_id=result.verdict_key,
),
"proposal": result.outcome.proposal.model_dump(),
"provenance": result.provenance.model_dump(),
"refinements": [rejection.reason for rejection in result.refinements],
}
async def invoke(payload: Any) -> dict[str, Any]:
"""One invocation: validate → ``run_project`` → outbox-shaped response payload.
``run_project`` is resolved through this module's namespace at call time (the test
seam). ``live_dry_run`` is not on the whitelist, so the union narrows to RunResult.
With ``explore_prompt`` the exploration runs FIRST and its mandate is what the pipeline then
evaluates — level 2 and 3 of the guarantee table are unchanged, and the exploration itself
still writes nothing."""
project_id, kwargs, consumed = _run_kwargs(payload)
if consumed:
kwargs["mandate"] = await _shaped_mandate(consumed, kwargs)
result = await run_project(project_id, **kwargs)
assert isinstance(result, RunResult)
return _response_payload(result)
def _budget_payload(exc: BudgetExceeded) -> dict[str, Any]:
"""The exhausted-budget body: the ledger's own triple, plus the human line for the log.
The ``budget_exhausted`` key's PRESENCE is the discriminator — it is not folded into
``outcome_type`` (whose values, ``validated``/``rejected``/``unsupported``, mean "the run concluded and
here is the verdict") for the same reason ``BudgetStop`` was given its own field instead of
widening ``stop_reason``. Nor could it be: ``outcome_payload`` is the ONE copy of that fork
and takes a ``ValidatedProposal | Rejection``, neither of which an exhausted run has."""
return {
"budget_exhausted": {"kind": exc.kind, "limit": exc.limit, "observed": exc.observed},
"error": str(exc),
}
def _http_response(status: int, content_type: str, body: bytes) -> bytes:
head = (
f"HTTP/1.1 {status} {_REASONS[status]}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
)
return head.encode("latin-1") + body
def _json_response(status: int, payload: dict[str, Any]) -> bytes:
body = (json.dumps(payload, sort_keys=True) + "\n").encode("utf-8")
return _http_response(status, "application/json", body)
async def _read_request(
reader: asyncio.StreamReader,
) -> tuple[str, str, bytes] | None:
"""Parse one HTTP/1.1 request: (method, path, body), or None when unparseable. The
platform terminates TLS and speaks plain HTTP/1.1 (spike §1.1); ``Content-Length`` is
required for a body — chunked transfer is not supported (an honest MVP limit)."""
request_line = await reader.readline()
parts = request_line.decode("latin-1", errors="replace").split()
if len(parts) != 3:
return None
method, path = parts[0], parts[1]
content_length = 0
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
name, _, value = line.decode("latin-1", errors="replace").partition(":")
if name.strip().lower() == "content-length":
try:
content_length = int(value.strip())
except ValueError:
return None
body = b""
if content_length > 0:
try:
body = await reader.readexactly(content_length)
except asyncio.IncompleteReadError:
return None
return method, path, body
async def _respond(method: str, path: str, body: bytes) -> bytes:
if method == "GET" and path == "/readiness":
return _http_response(200, "text/plain", b"ok\n")
if method == "POST" and path == "/invocations":
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return _json_response(400, {"error": "body is not valid JSON"})
try:
return _json_response(200, await invoke(payload))
except BudgetExceeded as exc:
# A cap that fired, not a failure — its own channel, and the triple kept as
# structure rather than re-parsed out of the message by whoever reads this.
return _json_response(429, _budget_payload(exc))
except ValueError as exc:
# The caller's error: InvocationRefused + run_project's fail-fast contract
# violations (pydantic ValidationError subclasses ValueError).
return _json_response(400, {"error": str(exc)})
except Exception as exc:
# The run's failure, answered rather than dropped — RunFailure's honest shape
# (error_type + text), so BudgetExceeded reads as what it is, not as a 400.
return _json_response(500, {"error_type": type(exc).__name__, "error": str(exc)})
return _json_response(404, {"error": f"no such path: {path}"})
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request = await _read_request(reader)
if request is None:
writer.write(_json_response(400, {"error": "malformed HTTP request"}))
else:
writer.write(await _respond(*request))
await writer.drain()
except (ConnectionError, asyncio.CancelledError):
pass # client went away / server shutting down — nothing to answer
finally:
writer.close()
try:
await writer.wait_closed()
except ConnectionError:
pass
async def start_server(host: str, port: int) -> asyncio.AbstractServer:
"""The contract server on the CURRENT loop, bindable to port 0 for tests."""
return await asyncio.start_server(_handle, host, port)
async def _serve_until_sigterm() -> None:
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
server = await start_server("0.0.0.0", resolve_port())
async with server:
await stop.wait()
# Leaving the context closes the listening socket; in-flight handlers already hold
# their connections and finish on the loop before asyncio.run() tears it down.
def main() -> None:
"""Serve the hosted-agent contract until SIGTERM (bind 0.0.0.0 — the platform
terminates TLS in front of us), then exit 0.
U14: the tracing seam is installed before the loop starts and announced on stderr, which in a
container IS the log. This is the entry where "an organisation must be able to see what the run
did" is actually cashed — the demo is a scripted proof, not the product. A malformed
``PORTFOLIO_OTEL`` propagates: a server whose telemetry cannot be configured as asked must not
start and then look healthy on ``/readiness``."""
setup = configure_tracing()
notice = tracing_notice(setup)
if notice is not None:
print(notice, file=sys.stderr)
asyncio.run(_serve_until_sigterm())