portfolio-optimiser/src/portfolio_optimiser/hosting.py
Kjell Tore Guttormsen 56f4f6d084 feat(hitl): ekspertdommen kan ikke oppstaa av stillhet (F2, ORDRE 20260825T214801Z)
run_project KREVDE verdict_input og kjorte capture_verdict ubetinget; CLI-en
defaultet det til {"approved", "reviewed by expert"} og hosting listet det som
PAAKREVD. Netto: hver flaggloes kjoering myntet en ekspertgodkjenning ingen ga,
den gikk inn i den delte storen, og run_portfolio bar den inn i neste prosjekts
hypotese-prompt som en prior expert verdict -- paa flaten som ble overlevert
14.08. Non-goal 3, brutt i en soem.

RunResult.verdict er naa Verdict | None, og None er hva stillhet produserer:
ingenting myntes, ingenting lagres, ingenting varsles. Prinsippet sto allerede i
repoet -- RunFailure sin docstring: aa fylle et felt med en dummy legger
FABRIKKERT proveniens inn i aggregatet.

Traceability koster ingenting: RunResult.verdict_key (property, derivert fra
kandidaten) er verdicts.verdict_key sitt alt dokumenterte formaal -- identisk
med verdict.id naar en dom BLE gitt, og fortsatt meningsfull naar ingen ble det.
Det er den outboxen og den hostede responsen stempler.

Halv dom NEKTES paa begge doerer (FeedbackContract er eneste sted formen
valideres; CLI-en nekter ved navn FOER enhver mode-dispatch). Validering, aldri
reparasjon. De to mode-partisjonene fikk --decision/--rationale inn: kommentarene
sa ordrett at en aerlig nekt var uimplementerbar fordi de non-None
argparse-defaultene gjorde en eksplisitt verdi uskillbar fra defaulten -- med
defaultene borte er den implementerbar.

Hosting er WIDENING, ikke bryting: verdict_input flyttet fra _REQUIRED_FIELDS
til _OPTIONAL_FIELDS. Ingen ekstern kaller brekker.

AERLIGHETS-GRENSE: referanse-fixturens SYNTETISKE verdict_input-rader staar
uroert -- de er merket SYNTETISK paa fire steder og er reviewens F5 (maaling av
misjonspaastanden), ikke F2. Project.verdict_input er naa valgfri.

Load-bearing MAALT (tests/test_ungiven_verdict_loadbearing.py, 15 armer), aatte
mutasjoner alle roede mot HELE suiten + gronn kontroll 1080/5 og golden
demo-transcript.stdout BYTE-UENDRET (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
En mutasjon falsifiserte testen foerst (vakuoes-gate-klassen, ellevte gang):
--report-armen brukte et bart --report, som nekter rc 1 uansett fordi --ledger
mangler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 01:22:07 +02:00

358 lines
18 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)
_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")
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``, 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())