honcho/sandbox/seed.py

345 lines
13 KiB
Python

"""Populate the sandbox with a known fixture and verify it actually landed.
Run through sandbox.sh, which supplies the base URL and owns the Docker and
Postgres side. This script only talks to the API:
uv run --no-project --with-editable ./sdks/python python sandbox/seed.py verify
Two phases, because seeded conclusions are written between them by
inject_conclusions.py, which sandbox.sh runs inside the api container:
seed.py seed peers, messages, derivation
seed.py verify the whole round trip, seeded conclusions included
The split is what keeps the deriver honest. The check that proves derivation ran
at all is "some conclusion exists", and once conclusions are seeded that would
pass against a completely dead deriver - so it runs in the seed phase, before
anything is injected.
It is provider-agnostic. Under the mock provider the *text* of a derived
conclusion is synthetic and unrelated to the messages, so nothing here asserts on
derived content. Seeded conclusions are the opposite: their text is committed, so
they are asserted exactly, in both modes. What is asserted strictly either way is
the round trip that harness integrations actually get wrong: which messages
landed, on which peers, with which observation topology.
Exits non-zero on any failure. An empty sandbox that reports success is the exact
failure mode this whole thing exists to prevent.
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
from honcho import Honcho
from honcho.api_types import MessageCreateParams, SessionPeerConfig
FIXTURE = Path(__file__).with_name("fixture.json")
# Generous: a cold deriver on a real provider is slow, and the failure we care
# about (nothing is processing at all) shows up as a timeout either way.
DRAIN_TIMEOUT_SECONDS = float(os.environ.get("SANDBOX_DRAIN_TIMEOUT", "300"))
DRAIN_POLL_SECONDS = 0.5
class SeedError(RuntimeError):
"""Seeding did not reach a usable state."""
def log(message: str) -> None:
print(f"[seed] {message}", flush=True)
LEVELS = ("explicit", "deductive", "inductive")
def seeded_conclusions(
fixture: dict[str, Any],
) -> dict[tuple[str, str], dict[str, list[str]]]:
"""What inject_conclusions.py should have written, keyed by (observer, observed).
Mirrors the injector's resolution rules so verification is stated in terms of the
fixture rather than of whatever happens to be in the database.
"""
peers = fixture["peers"]
planned: dict[tuple[str, str], dict[str, list[str]]] = {}
for spec in peers:
by_level = {
level: [
item if isinstance(item, str) else item["content"]
for item in spec.get(level, [])
]
for level in LEVELS
}
if not any(by_level.values()):
continue
override = spec.get("observer")
observers = (
[override]
if override
else [
peer["id"]
for peer in peers
if peer.get("observe_others") and peer["id"] != spec["id"]
]
)
for observer in observers:
planned[(observer, spec["id"])] = by_level
return planned
def cited_premise_texts(spec: dict[str, Any], explicit: list[str]) -> set[str]:
"""The explicit conclusions this peer's derived conclusions name as premises."""
return {
explicit[index]
for level in ("deductive", "inductive")
for item in spec.get(level, [])
if isinstance(item, dict)
for index in item.get("premises", [])
}
def count_conclusions(peers: dict[str, Any]) -> int:
total = 0
for observer in peers.values():
for observed_id in peers:
total += len(list(observer.conclusions_of(observed_id).list(size=100)))
return total
def drain(honcho: Honcho, what: str) -> None:
"""Block until the deriver queue is empty, or fail loudly.
Silence is the dangerous outcome here. A queue that never drains and a queue
that never had anything queued look identical from the outside, so the timeout
reports the last status it saw rather than just giving up.
"""
deadline = time.monotonic() + DRAIN_TIMEOUT_SECONDS
last: Any = None
while time.monotonic() < deadline:
last = honcho.queue_status()
outstanding = last.pending_work_units + last.in_progress_work_units
if outstanding == 0 and last.completed_work_units > 0:
log(f"drained after {what} ({last.completed_work_units} work units)")
return
time.sleep(DRAIN_POLL_SECONDS)
raise SeedError(
f"queue did not drain after {what} within {DRAIN_TIMEOUT_SECONDS:.0f}s. "
f"Last status: {last}. "
"If completed_work_units is 0, nothing was ever enqueued - check that the "
"deriver is running and that the provider base URL resolves."
)
def main() -> int:
phase = sys.argv[1] if len(sys.argv) > 1 else "seed"
if phase not in ("seed", "verify"):
raise SeedError(f"unknown phase {phase!r} (expected 'seed' or 'verify')")
fixture = json.loads(FIXTURE.read_text())
base_url = os.environ.get("SANDBOX_BASE_URL", "http://127.0.0.1:18000")
workspace_id = fixture["workspace"]
honcho = Honcho(base_url=base_url, workspace_id=workspace_id, api_key="sandbox")
peers = {spec["id"]: honcho.peer(spec["id"]) for spec in fixture["peers"]}
session = honcho.session(fixture["session"])
if phase == "verify":
verify(honcho, session, peers, fixture)
log("verify complete")
return 0
log(f"seeding workspace {workspace_id!r} at {base_url}")
session.add_peers(
[
(
peers[spec["id"]],
SessionPeerConfig(
observe_me=spec["observe_me"],
observe_others=spec["observe_others"],
),
)
for spec in fixture["peers"]
]
)
log(f"created {len(peers)} peers with explicit observation topology")
session.add_messages(
[
MessageCreateParams(peer_id=msg["peer"], content=msg["content"])
for msg in fixture["messages"]
]
)
log(f"posted {len(fixture['messages'])} messages")
drain(honcho, "messages")
for dream in fixture.get("dreams", []):
honcho.schedule_dream(
observer=dream["observer"],
observed=dream["observed"],
session=session,
)
log(f"scheduled dream: {dream['observer']} -> {dream['observed']}")
if fixture.get("dreams"):
drain(honcho, "dream")
# The one check that proves derivation happened, made here rather than in verify
# because seeded conclusions land afterwards and would satisfy it on their own.
derived = count_conclusions(peers)
if derived == 0:
raise SeedError(
"no conclusions were derived. The queue drained, so the deriver ran and "
"produced nothing - check the provider wiring "
"(LLM_OPENAI_BASE_URL / EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL) "
"and the deriver logs."
)
log(f"verified {derived} derived conclusions present")
log("seed complete")
return 0
def verify(
honcho: Honcho,
session: Any,
peers: dict[str, Any],
fixture: dict[str, Any],
) -> None:
"""Assert the fixture round-tripped. Raises SeedError on any mismatch."""
stored = list(session.messages(size=100))
if len(stored) != len(fixture["messages"]):
raise SeedError(
f"expected {len(fixture['messages'])} messages, found {len(stored)}"
)
expected_authors = sorted(msg["peer"] for msg in fixture["messages"])
actual_authors = sorted(msg.peer_id for msg in stored)
if actual_authors != expected_authors:
raise SeedError(
f"messages landed on the wrong peers: expected {expected_authors}, "
f"got {actual_authors}"
)
log(f"verified {len(stored)} messages on the expected peers")
# Topology is the thing that silently breaks, so it is checked against the
# server's view rather than assumed from the create call.
for spec in fixture["peers"]:
config = session.get_peer_configuration(spec["id"])
if (config.observe_me, config.observe_others) != (
spec["observe_me"],
spec["observe_others"],
):
raise SeedError(
f"peer {spec['id']!r} topology mismatch: expected "
f"observe_me={spec['observe_me']} "
f"observe_others={spec['observe_others']}, got {config}"
)
log("verified observation topology")
# Derived conclusions get presence only. Under the mock provider their text is
# synthetic and says nothing about the messages, and the level mix is
# explicit-only because the Dreamer specialists write via tool calls the mock
# never emits. Asserting on either would pass in real mode and fail in mock mode.
total = 0
for observer_id, observer in peers.items():
for observed_id in peers:
found = len(list(observer.conclusions_of(observed_id).list(size=100)))
if found:
log(f" {observer_id} -> {observed_id}: {found} conclusions")
total += found
if total == 0:
raise SeedError(
"no conclusions at all. Both derivation and injection produced nothing - "
"check the provider wiring "
"(LLM_OPENAI_BASE_URL / EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL) "
"and the deriver logs."
)
log(f"verified {total} conclusions present")
verify_seeded(peers, fixture, total)
def verify_seeded(
peers: dict[str, Any],
fixture: dict[str, Any],
total: int,
) -> None:
"""Assert the seeded conclusions landed exactly, level by level.
Seeded text is committed, so unlike derived conclusions it is asserted by content
in both modes - which is the whole reason the fixture carries these keys.
"""
planned = seeded_conclusions(fixture)
if not planned:
log("fixture declares no conclusions to seed")
return
seeded_total = 0
for (observer_id, observed_id), by_level in planned.items():
stored = list(peers[observer_id].conclusions_of(observed_id).list(size=100))
for level, expected in by_level.items():
if not expected:
continue
actual = {c.content for c in stored if c.level == level}
missing = [content for content in expected if content not in actual]
if missing:
raise SeedError(
f"{observer_id} -> {observed_id}: {len(missing)} seeded {level} "
f"conclusion(s) missing, first is {missing[0]!r}. Honcho collapses "
"conclusions whose content matches something already stored, so "
"check for near-duplicate text in the fixture."
)
seeded_total += len(expected)
summary = " ".join(f"{level}={len(by_level[level])}" for level in LEVELS)
log(f"verified seeded {observer_id} -> {observed_id}: {summary}")
# Derived and seeded conclusions must both be present. The seed phase already
# proved derivation ran against an un-injected database; this catches the reverse
# error of an injection that somehow replaced the derived rows.
if total <= seeded_total:
raise SeedError(
f"found {total} conclusions but {seeded_total} were seeded, leaving none "
"derived. Injection should add to the deriver's output, not replace it."
)
log(
f"verified {total - seeded_total} derived conclusions alongside {seeded_total} seeded"
)
# Premise text is what the working representation renders for a derived
# conclusion, and it is the only part of the reasoning tree an API client can
# see - schemas.Conclusion does not expose source_ids.
by_peer = {spec["id"]: spec for spec in fixture["peers"]}
for (observer_id, observed_id), by_level in planned.items():
premise_texts = cited_premise_texts(by_peer[observed_id], by_level["explicit"])
if not premise_texts:
continue
rendered = peers[observer_id].representation(
target=observed_id, max_conclusions=100
)
absent = [text for text in premise_texts if text not in rendered]
if absent:
raise SeedError(
f"{observer_id} -> {observed_id}: premise text missing from the "
f"representation, first is {absent[0]!r}. Premises render only for "
"deductive conclusions and sources only for inductive ones, so a "
"premise stored under the wrong metadata key renders as nothing."
)
log(
f"verified premise text renders in {observer_id}'s representation of {observed_id}"
)
if __name__ == "__main__":
try:
sys.exit(main())
except SeedError as exc:
print(f"[seed] FAILED: {exc}", file=sys.stderr)
sys.exit(1)