diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdd059..ed708df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,12 +53,15 @@ jobs: ops/verify_gcp_cloudsql_restore_readback.py \ ops/verify_postgres_parity_manifest.py \ telegram/approvals.py \ + hermes-agent/leoclean-bin/kb_tool.py \ scripts/check_crabbox_ci_contract.py \ scripts/check_llm_refinement_contract.py \ scripts/build_working_leo_m3taversal_outcome_sandbox.py \ + scripts/leo_behavior_manifest.py \ scripts/replay_decision_engine_eval.py \ scripts/prove_phase1b_local.py \ scripts/run_gcp_generated_db_direct_claim_suite.py \ + scripts/run_leo_direct_claim_handler_suite.py \ scripts/run_leo_m3taversal_oos_handler_suite.py \ scripts/run_leo_clone_bound_handler_checkpoint.py \ scripts/working_leo_m3taversal_oos_benchmark.py \ @@ -80,6 +83,10 @@ jobs: tests/test_gcp_iam_split_plan.py \ tests/test_gcp_readiness_workflow.py \ tests/test_gcp_generated_db_direct_claim_suite.py \ + tests/test_hermes_leoclean_kb_bridge_source.py \ + tests/test_hermes_leoclean_skill_surfaces.py \ + tests/test_leo_behavior_manifest.py \ + tests/test_compile_kb_source_packet.py \ tests/test_verify_postgres_parity_manifest.py \ tests/test_working_leo_m3taversal_oos_benchmark.py \ tests/test_working_leo_open_ended_benchmark.py \ diff --git a/deploy/auto-deploy.sh b/deploy/auto-deploy.sh index c276bda..6468513 100755 --- a/deploy/auto-deploy.sh +++ b/deploy/auto-deploy.sh @@ -103,7 +103,7 @@ fi # Syntax check all Python files before copying ERRORS=0 -for f in lib/*.py *.py diagnostics/*.py telegram/*.py tests/*.py hermes-agent/leoclean-plugins/vps/*/*.py hermes-agent/patches/*.py scripts/compile_kb_source_packet.py scripts/kb_proposal_normalize.py scripts/prepare_kb_source_manifest.py; do +for f in lib/*.py *.py diagnostics/*.py telegram/*.py tests/*.py hermes-agent/leoclean-bin/*.py hermes-agent/leoclean-plugins/vps/*/*.py hermes-agent/patches/*.py scripts/compile_kb_source_packet.py scripts/kb_proposal_normalize.py scripts/prepare_kb_source_manifest.py scripts/leo_behavior_manifest.py; do [ -f "$f" ] || continue if ! python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" "$f" 2>&1; then log "SYNTAX ERROR: $f" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 6bbed3b..ccde590 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -48,7 +48,7 @@ echo "" # Syntax check all Python files before deploying echo "=== Pre-deploy syntax check ===" ERRORS=0 -for f in "$REPO_ROOT/lib/"*.py "$REPO_ROOT/"*.py "$REPO_ROOT/diagnostics/"*.py "$REPO_ROOT/telegram/"*.py "$REPO_ROOT/hermes-agent/leoclean-plugins/vps/"*/*.py "$REPO_ROOT/hermes-agent/patches/"*.py "$REPO_ROOT/scripts/compile_kb_source_packet.py" "$REPO_ROOT/scripts/kb_proposal_normalize.py" "$REPO_ROOT/scripts/prepare_kb_source_manifest.py"; do +for f in "$REPO_ROOT/lib/"*.py "$REPO_ROOT/"*.py "$REPO_ROOT/diagnostics/"*.py "$REPO_ROOT/telegram/"*.py "$REPO_ROOT/hermes-agent/leoclean-bin/"*.py "$REPO_ROOT/hermes-agent/leoclean-plugins/vps/"*/*.py "$REPO_ROOT/hermes-agent/patches/"*.py "$REPO_ROOT/scripts/compile_kb_source_packet.py" "$REPO_ROOT/scripts/kb_proposal_normalize.py" "$REPO_ROOT/scripts/prepare_kb_source_manifest.py" "$REPO_ROOT/scripts/leo_behavior_manifest.py"; do [ -f "$f" ] || continue if ! python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" "$f" 2>/dev/null; then echo "SYNTAX ERROR: $f" diff --git a/docs/reports/leo-working-state-20260709/db-first-source-verification-canary-20260714.json b/docs/reports/leo-working-state-20260709/db-first-source-verification-canary-20260714.json new file mode 100644 index 0000000..18b927d --- /dev/null +++ b/docs/reports/leo-working-state-20260709/db-first-source-verification-canary-20260714.json @@ -0,0 +1,86 @@ +{ + "schema": "livingip.leoDbFirstRetrievalCanary.v1", + "mode": "read_only_candidate_tool_on_live_vps", + "recorded_at": "2026-07-14", + "query": "AI sandbagging M&A liability product liability consumer protection securities fraud", + "candidate_tool_sha256": "73de3cc641a72c00d39de7703b139daeecb562472eaf57e5f72ff1a4cd38a76b", + "target_claim_id": "2a7ae257-d01d-46f4-b813-63f81bb9c7c7", + "canonical_counts_before": { + "claims": 1837, + "sources": 4145, + "claim_evidence": 4670, + "claim_edges": 4916, + "kb_proposals": 29 + }, + "canonical_counts_after": { + "claims": 1837, + "sources": 4145, + "claim_evidence": 4670, + "claim_edges": 4916, + "kb_proposals": 29 + }, + "first_receipt": { + "schema": "livingip.teleoKbRetrievalReceipt.v1", + "semantic_context_sha256": "8333bc7a9dc2effe7d6fd135a95d9ab7822f9332f8b4019245c67c14efa38d9c", + "artifact_state_sha256": "47473661b1baa182d7fa2f73516a0e64415697d7ebc4a1cdeb2b59cbeb2356fc", + "counts": { + "claims": 4, + "context_rows": 0, + "evidence_rows": 14, + "edge_rows": 9 + }, + "read_consistency": { + "status": "stable_wal_marker", + "attempts": 1, + "wal_lsn_before": "0/49BC4478", + "wal_lsn_after": "0/49BC4478" + } + }, + "second_receipt": { + "schema": "livingip.teleoKbRetrievalReceipt.v1", + "semantic_context_sha256": "8333bc7a9dc2effe7d6fd135a95d9ab7822f9332f8b4019245c67c14efa38d9c", + "artifact_state_sha256": "47473661b1baa182d7fa2f73516a0e64415697d7ebc4a1cdeb2b59cbeb2356fc", + "counts": { + "claims": 4, + "context_rows": 0, + "evidence_rows": 14, + "edge_rows": 9 + }, + "read_consistency": { + "status": "stable_wal_marker", + "attempts": 1, + "wal_lsn_before": "0/49BC4478", + "wal_lsn_after": "0/49BC4478" + } + }, + "source_verification": [ + { + "source_id": "261c3532-fa32-47d8-a5b5-6cc45035c267", + "role": "grounds", + "storage_path": null, + "expected_sha256": "9fcd40191449fe0843178e08120fb3c86fc2b35633594aee60e747ca3918cfa5", + "status": "no_source_pointer", + "artifact_exists": null, + "hash_matches_db": null + }, + { + "source_id": "15740795-ecc6-40fa-9a01-3d6bc7c54f79", + "role": "illustrates", + "storage_path": "domains/ai-alignment/ai-sandbagging-creates-m-and-a-liability-exposure-across-product-liability-consumer-protection-and-securities-fraud.md", + "resolved_storage_path": "/opt/teleo-eval/workspaces/main/domains/ai-alignment/ai-sandbagging-creates-m-and-a-liability-exposure-across-product-liability-consumer-protection-and-securities-fraud.md", + "expected_sha256": "341a7685cd2909ae821d8e464fe0d4fdfbb0cadccf546ccfd4ccaf037aa9f646", + "artifact_sha256": "341a7685cd2909ae821d8e464fe0d4fdfbb0cadccf546ccfd4ccaf037aa9f646", + "status": "verified", + "artifact_exists": true, + "artifact_bytes": 2701, + "hash_matches_db": true + } + ], + "semantic_receipt_repeated": true, + "artifact_receipt_repeated": true, + "production_mutation_performed": false, + "gateway_pid_unchanged": true, + "gateway_restart_count_before": 0, + "gateway_restart_count_after": 0, + "temp_candidate_removed": true +} diff --git a/docs/reports/leo-working-state-20260709/working-leo-db-first-behavior-model-20260714.md b/docs/reports/leo-working-state-20260709/working-leo-db-first-behavior-model-20260714.md new file mode 100644 index 0000000..830214f --- /dev/null +++ b/docs/reports/leo-working-state-20260709/working-leo-db-first-behavior-model-20260714.md @@ -0,0 +1,151 @@ +# What Makes Leo Leo: DB-First Behavior Model + +Date: `2026-07-14` + +## Definition Of Working + +**Working target:** Leo forms durable knowledge through a replayable database lifecycle, and answers through consistent, traceable database retrieval. A conversation may become a source candidate; it does not silently train Leo or become truth. + +**Operator path:** retain source -> extract candidates -> deduplicate/contrast -> stage proposal -> review -> guarded apply -> canonical readback -> retrieve with a source receipt. + +**Done means:** the same retained inputs, compiler version, approved proposal, database snapshot, retrieval query, and runtime manifest reproduce the same rows and retrieval receipt. The benchmark can measure the outcome without changing Leo's live profile or canonical database. + +**Not done:** a chat answer looks better because `SOUL.md`, `USER.md`, a skill, a plugin response template, old session state, or benchmark-specific text changed without a reviewed database change. + +**Required tier:** live VPS database/tool lifecycle first; GCP reproduces the same manifest and receipts before promotion. + +## One-Line Model + +Leo is an external language model run by Hermes, wrapped in the `leoclean` profile, given static identity and procedural layers, dynamic session context, and database retrieval. Only reviewed canonical database changes should count as durable learning. + +```mermaid +flowchart LR + U["Person or agent discussion"] --> A["Immutable source artifact + stable message locator"] + A --> X["Deterministic extraction and canonical dedupe"] + X --> P["Pending review proposal"] + P --> R["Human or governed review"] + R --> C["Canonical Postgres rows"] + C --> T["Deterministic retrieval tool + receipt"] + T --> H["Hermes reasoning turn"] + S["SOUL + config + skills"] --> H + M["Session continuity"] --> H + G["Generic safety middleware"] --> H + B["External benchmark"] -. "measure only" .-> H + B -. "must not write" .-> C + B -. "must not edit" .-> S +``` + +## Current Live Layers + +Fresh readback on `2026-07-14` found the VPS service active with `NRestarts=0` and Hermes Agent `v0.7.0` at commit `b2f477a30b3c05d0f383c543af98496ae8a96070`. + +| Layer | What it does | Change rate | How visible/replayable it is | Current assessment | +| --- | --- | --- | --- | --- | +| Base-model weights | Supply general language and reasoning capability | Provider-controlled | Opaque; no local weight hash | No evidence Leo was fine-tuned. The configured model name is not a weight-level identity. | +| Model routing/config | Selects provider/model, routing, token/reasoning defaults, tools, and memory | Deployment-static | File-hashable; actual per-turn model still needs a receipt | OpenRouter, default `anthropic/claude-sonnet-4-6`, smart routing enabled through `google/gemini-2.5-flash`. Temperature/reasoning effort are not explicitly pinned in the observed config. | +| Hermes runtime | Builds prompts, sessions, tools, gateway calls, and plugin hooks | Deployment-static | Source and commit are inspectable | Generic upstream runtime, but locally patched to allow post-model response replacement before persistence. | +| `SOUL.md` | Primary runtime identity text loaded into the system prompt | Static until manually edited or rendered | Fully hashable; current SHA-256 `5973a54c...32a` | Important runtime input, but not canonical truth. No active general DB-to-SOUL renderer is proven. | +| Skills | Procedural instructions discoverable by Hermes | Deployment-static | Source-controlled and hashable | Useful for tool use, but detailed answer rules can become hidden prompt training. | +| `leo-db-context` plugin | Injects pre-turn DB contracts, validates drafts, and can replace the final answer | Request-dynamic over static code | Source-controlled but invisible in normal chat | The most behaviorally powerful and obscure layer. Current query-specific compiled answers are a temporary guardrail, not database reasoning. | +| KB bridge/tool code | Queries claims/context/evidence/edges and stages proposals | Deployment-static code over dynamic DB | Source-controlled; calls can emit receipts | Correct architectural control point. This branch adds allowlisted artifact resolution, hash verification, and deterministic context receipts on VPS. | +| Canonical Postgres | Stores applied claims, sources, evidence, graph, and identity/strategy rows | Governed dynamic | Row IDs, hashes, manifests, and apply receipts | Intended source of durable knowledge. | +| `kb_stage` proposals | Holds candidates awaiting review/apply | Dynamic, review-gated | Queryable and replayable | Correct place for discussion-derived candidates before approval. | +| `MEMORY.md` / `USER.md` | Persistent Hermes facts/preferences injected outside Postgres | Conversation-dynamic | File-hashable but provenance-poor | Noncanonical. Live `USER.md` contains a stale naming rule that conflicts with the exact visible-handle rule. | +| Sessions / `state.db` | Preserve conversation continuity | Turn-dynamic | Hashable but highly stateful | Appropriate for “what did we just discuss,” not durable collective knowledge. | +| Benchmark harness | Sends test turns and scores replies | External | Fully inspectable if isolated | Must remain measurement-only. The prior temp-profile harness copied live memories and sessions, weakening causal attribution. | + +## What “Weights” Can Mean + +1. **Base-model weights:** opaque parameters hosted by the model provider. We do not inspect or modify them. +2. **Database weights:** explicit `claim_evidence.weight`, `claim_edges.weight`, and strategy-anchor weights. These are reviewable rows and belong in database receipts. +3. **Retrieval scores:** deterministic query-term matching currently ranks claims using term hits, evidence counts, edge counts, confidence, and text length. These are code-level ranking rules, not learning. +4. **Benchmark scores:** regex/rubric checks outside Leo decide whether a response passed. They must never feed edits back into the runtime automatically. +5. **Plugin contracts:** not weights at all. They are hard rules that can replace a response. They currently explain part of the measured improvement and must be separated from raw model-plus-database capability. + +## Desired Static/Dynamic Boundary + +### Keep static and small + +- Hermes runtime version and patches. +- Model routing and explicit inference parameters. +- A minimal `SOUL.md`: stable identity, mission, voice, and immutable safety boundaries. +- Generic skills describing tools and schemas. +- Generic middleware: fail closed when live retrieval fails, prevent unauthorized writes, validate receipts. + +### Keep dynamic and canonical + +- Claims and claim state. +- Sources, content hashes, exact excerpts, and source classification. +- Claim-to-evidence and claim-to-claim relationships, including explicit weights. +- Agent beliefs, strategies, identity graph rows, governance rules, and reasoning tools where supported by schema/apply contracts. +- Candidate proposals, reviews, apply receipts, and supersession history. + +### Keep dynamic but noncanonical + +- The current conversation and same-session references. +- Delivery metadata and Telegram message IDs. +- Temporary plans/tool traces. + +These may help the current turn but must not become evidence or durable identity without source capture and review. + +## Conversation Learning Contract + +1. A discussion receives a stable locator such as a Telegram chat/message ID plus capture time and content hash. +2. The retained artifact is immutable. A display name or chat label alone is rejected as provenance. +3. Extraction produces typed candidate claims, evidence excerpts, relationships, and explicit uncertainty. +4. Existing canonical rows are retrieved first to identify duplicates, conflicts, and missing evidence. +5. The result enters `kb_stage.kb_proposals` as `pending_review`; no canonical row and no runtime identity file changes yet. +6. Review may approve, revise, split, or reject each candidate. +7. Guarded apply writes the supported canonical rows and emits row-level postflight proof. +8. Later retrieval verifies the linked artifact and database hash, then emits a deterministic receipt for the context Leo received. + +An agent can act as the reviewing interlocutor, but its review identity, policy, and decision must be recorded. Agent discussion is not self-authenticating approval. + +## Harness Contract + +The harness must: + +- start from a fresh session and empty persistent memory unless memory itself is the tested dimension; +- record the model route, Hermes commit, config/SOUL/skill/plugin/tool hashes, and database fingerprint; +- compare live profile and database fingerprints before and after; +- write only inside an isolated temporary profile or disposable database; +- never update `SOUL.md`, `MEMORY.md`, `USER.md`, skills, plugins, or live Postgres as a consequence of a score; +- keep a frozen regression set and a separate blind set so passing known prompts cannot masquerade as general reasoning; +- score raw reasoning separately from middleware-composed or replaced responses. + +## Delivery Status And Repair Order + +1. **Implemented in this branch - remove causal ambiguity from tests:** exclude prior memories/sessions from temporary profiles and fingerprint every behavior-bearing layer. +2. **Implemented and live-canary proven on VPS - complete retrieval receipts:** resolve source paths, verify content hashes, stabilize row ordering, and hash the exact context bundle. +3. **Bounded compiler behavior proven locally - make discussion ingestion real:** stable message-level locators can produce pending-review proposals without profile edits, but automatic Telegram capture is not shipped. +4. **Remaining - reduce query-specific middleware:** keep generic truth and authorization guards, then measure raw DB reasoning separately from compiled response replacement. +5. **Remaining - make identity composition deterministic:** implement and receipt a DB-to-`SOUL.md` renderer before calling identity updates live. +6. **Remaining - pin the execution identity:** record the actual provider/model and explicit inference settings for each benchmark turn. +7. **Remaining - reproduce on GCP:** restore the same canonical manifest, deploy the same behavior manifest, replay the same lifecycle, and only then consider promotion. + +This design makes a behavior change attributable: either a reviewed DB delta, a versioned runtime delta, a model-route delta, or current-session context. “Leo learned it somehow” is not an acceptable state. + +## Live VPS Read-Only Canary + +The candidate retrieval path was exercised twice against the live VPS database +without deploying it, restarting Leo, or writing to Postgres. An unfamiliar +query selected claim `2a7ae257-d01d-46f4-b813-63f81bb9c7c7` and its two evidence +rows. + +- One source has a database hash but no URL or storage pointer. The receipt + reports `no_source_pointer`; it does not misstate that the canonical evidence + row or raw artifact is missing. +- The second source uses a relative storage path. The tool resolved the path + under an allowlisted workspace root, found the artifact, calculated SHA-256 + `341a7685...9f646`, and matched the database hash exactly. +- Both reads emitted semantic context hash `8333bc7a...38d9c` and artifact-state + hash `47473661...356fc` with a stable WAL marker. Each read returned four + claims, fourteen evidence rows, and nine edges in the same order. +- Before/after counts stayed at 1,837 claims, 4,145 sources, 4,670 evidence + links, 4,916 claim edges, and 29 proposals. The gateway PID and restart count + were unchanged, and the temporary candidate was removed. + +The machine-readable receipt is +[`db-first-source-verification-canary-20260714.json`](./db-first-source-verification-canary-20260714.json). +This proves deterministic read and artifact verification on VPS. It does not +yet prove a Telegram-visible answer, GCP parity, or an approved canonical write. diff --git a/hermes-agent/leoclean-bin/kb_tool.py b/hermes-agent/leoclean-bin/kb_tool.py index c875d7a..15f2090 100755 --- a/hermes-agent/leoclean-bin/kb_tool.py +++ b/hermes-agent/leoclean-bin/kb_tool.py @@ -24,6 +24,7 @@ import uuid from collections import defaultdict from pathlib import Path from typing import Any +from urllib.parse import urlsplit DEFAULT_CLAIM_BASE_URL = "https://leo.livingip.xyz" SOURCE_COMPILER_PATH = Path("/opt/teleo-eval/workspaces/deploy-infra/scripts/compile_kb_source_packet.py") @@ -33,8 +34,17 @@ SOURCE_PREPARATION_ROOT = Path("/home/teleo/.hermes/profiles/leoclean/state/kb-s SOURCE_PROPOSAL_RECEIPT_SCHEMA = "livingip.teleoKbSourceProposalReceipt.v1" SOURCE_PREPARATION_RECEIPT_SCHEMA = "livingip.kbSourcePreparationReceipt.v1" SOURCE_COMPILER_BUNDLE_SCHEMA = "livingip.kbSourceProposalBundle.v1" +RETRIEVAL_RECEIPT_SCHEMA = "livingip.teleoKbRetrievalReceipt.v1" CANONICAL_COUNT_LABELS = ("claims", "sources", "claim_edges", "claim_evidence") SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +DEFAULT_MAX_SOURCE_ARTIFACT_BYTES = 64 * 1024 * 1024 +DEFAULT_SOURCE_ARTIFACT_ROOTS = ( + Path("/opt/teleo-eval/workspaces/main"), + Path("/opt/teleo-eval/workspaces/deploy-infra"), + Path("/home/teleo/.hermes/profiles/leoclean/workspace/ingested"), + Path("/home/teleo/.hermes/profiles/leoclean/state/kb-source-inbox"), + Path("/home/teleo/.hermes/profiles/leoclean/state/kb-source-preparation"), +) WORKER_SUPPORTED_PROPOSAL_TYPES = frozenset({"revise_strategy", "add_edge", "attach_evidence", "approve_claim"}) DIRECT_READBACK_CONTRACTS = frozenset( { @@ -1703,7 +1713,7 @@ def find_claims(args: argparse.Namespace, query: str, limit: int) -> list[dict[s )::text from ranked where score >= {min_score} - order by score desc, evidence_count desc, edge_count desc, coalesce(confidence, 0) desc, length(text) + order by score desc, evidence_count desc, edge_count desc, coalesce(confidence, 0) desc, length(text), text, id limit {limit}; """ return psql_json(args, sql) @@ -1812,7 +1822,7 @@ def find_context_rows(args: argparse.Namespace, query: str, limit: int) -> list[ )::text from ranked where score >= {min_score} - order by score desc, source, owner, title + order by score desc, source, owner, title, body limit {limit}; """ return psql_json(args, sql) @@ -1843,6 +1853,166 @@ def canonical_json_sha256(value: Any) -> str: return hashlib.sha256(encoded).hexdigest() +def source_artifact_roots() -> tuple[Path, ...]: + raw = os.environ.get("TELEO_KB_SOURCE_ROOTS", "").strip() + configured = tuple(Path(item).expanduser() for item in raw.split(os.pathsep) if item.strip()) + roots = configured or DEFAULT_SOURCE_ARTIFACT_ROOTS + resolved: list[Path] = [] + for root in roots: + if not root.is_absolute(): + raise SystemExit(f"TELEO_KB_SOURCE_ROOTS entries must be absolute: {root}") + candidate = root.resolve(strict=False) + if candidate not in resolved: + resolved.append(candidate) + return tuple(resolved) + + +def _normalized_sha256(value: Any) -> str | None: + text = str(value or "").strip().lower() + if text.startswith("sha256:"): + text = text.removeprefix("sha256:") + return text if SHA256_RE.fullmatch(text) else None + + +def source_artifact_max_bytes() -> int: + raw = os.environ.get("TELEO_KB_MAX_ARTIFACT_BYTES", str(DEFAULT_MAX_SOURCE_ARTIFACT_BYTES)) + try: + value = int(raw) + except ValueError as exc: + raise SystemExit("TELEO_KB_MAX_ARTIFACT_BYTES must be an integer") from exc + if not 1 <= value <= 1024 * 1024 * 1024: + raise SystemExit("TELEO_KB_MAX_ARTIFACT_BYTES must be between 1 byte and 1 GiB") + return value + + +def _stable_file_sha256(path: Path) -> tuple[str, bool]: + before = path.stat() + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + after = path.stat() + stable = (before.st_ino, before.st_size, before.st_mtime_ns) == (after.st_ino, after.st_size, after.st_mtime_ns) + return digest.hexdigest(), stable + + +def verify_source_artifact(row: dict[str, Any], roots: tuple[Path, ...] | None = None) -> dict[str, Any]: + """Resolve one source pointer under explicit roots and verify its content hash.""" + + storage_path = str(row.get("storage_path") or "").strip() + url = str(row.get("url") or "").strip() + expected_sha256 = _normalized_sha256(row.get("source_hash")) + base: dict[str, Any] = { + "path_kind": "none", + "artifact_exists": None, + "artifact_sha256": None, + "artifact_bytes": None, + "expected_sha256": expected_sha256, + "hash_matches_db": None, + "resolved_storage_path": None, + "matched_root": None, + } + if not storage_path: + return {**base, "status": "remote_locator_only" if url else "no_source_pointer"} + + scheme = urlsplit(storage_path).scheme.lower() + if scheme: + return {**base, "status": "locator_only", "path_kind": "uri", "locator_scheme": scheme} + + allowed_roots = tuple(path.resolve(strict=False) for path in (roots or source_artifact_roots())) + pointer = Path(storage_path) + candidates: list[tuple[Path, Path]] = [] + if pointer.is_absolute(): + resolved = pointer.resolve(strict=False) + for root in allowed_roots: + if resolved.is_relative_to(root): + candidates.append((resolved, root)) + break + path_kind = "absolute" + else: + path_kind = "relative" + for root in allowed_roots: + resolved = (root / pointer).resolve(strict=False) + if resolved.is_relative_to(root): + candidates.append((resolved, root)) + + if not candidates: + return { + **base, + "status": "rejected_outside_allowlisted_roots", + "path_kind": path_kind, + "artifact_exists": False, + } + + existing = sorted( + ((candidate, root) for candidate, root in candidates if candidate.is_file()), key=lambda pair: str(pair[0]) + ) + if not existing: + return { + **base, + "status": "missing", + "path_kind": path_kind, + "artifact_exists": False, + "candidate_count": len(candidates), + } + if len(existing) > 1: + return { + **base, + "status": "ambiguous_multiple_files", + "path_kind": path_kind, + "artifact_exists": True, + "candidate_count": len(existing), + } + + resolved, matched_root = existing[0] + try: + artifact_bytes = resolved.stat().st_size + max_artifact_bytes = source_artifact_max_bytes() + if artifact_bytes > max_artifact_bytes: + return { + **base, + "status": "too_large_to_verify", + "path_kind": path_kind, + "artifact_exists": True, + "artifact_bytes": artifact_bytes, + "max_artifact_bytes": max_artifact_bytes, + "resolved_storage_path": str(resolved), + "matched_root": str(matched_root), + } + artifact_sha256, stable = _stable_file_sha256(resolved) + except OSError as exc: + return { + **base, + "status": "unreadable", + "path_kind": path_kind, + "artifact_exists": True, + "verification_error": type(exc).__name__, + } + if not stable: + status = "changed_during_verification" + hash_matches = None + elif expected_sha256 is None: + status = "verified_hash_unavailable" + hash_matches = None + elif artifact_sha256 == expected_sha256: + status = "verified" + hash_matches = True + else: + status = "hash_mismatch" + hash_matches = False + return { + **base, + "status": status, + "path_kind": path_kind, + "artifact_exists": True, + "artifact_sha256": artifact_sha256, + "artifact_bytes": artifact_bytes, + "hash_matches_db": hash_matches, + "resolved_storage_path": str(resolved), + "matched_root": str(matched_root), + } + + def _validated_uuid(value: Any, label: str) -> str: try: return str(uuid.UUID(str(value))) @@ -2643,16 +2813,20 @@ def load_evidence(args: argparse.Namespace, claim_ids: list[str], limit: int) -> select ce.claim_id, ce.role::text as role, ce.weight, + s.id as source_id, s.source_type, s.url, s.storage_path, + s.hash as source_hash, + s.captured_at, left(coalesce(s.excerpt, ''), 800) as excerpt, row_number() over ( partition by ce.claim_id order by (s.storage_path like 'inbox/archive/%') desc, ce.role::text, s.storage_path nulls last, - s.url nulls last + s.url nulls last, + s.id ) as rn from claim_evidence ce join sources s on s.id = ce.source_id @@ -2662,9 +2836,12 @@ def load_evidence(args: argparse.Namespace, claim_ids: list[str], limit: int) -> 'claim_id', claim_id::text, 'role', role, 'weight', weight, + 'source_id', source_id::text, 'source_type', source_type, 'url', url, 'storage_path', storage_path, + 'source_hash', source_hash, + 'captured_at', captured_at::text, 'excerpt', excerpt )::text from ranked @@ -2672,7 +2849,12 @@ def load_evidence(args: argparse.Namespace, claim_ids: list[str], limit: int) -> order by claim_id, rn; """ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + verification_cache: dict[tuple[Any, ...], dict[str, Any]] = {} for row in psql_json(args, sql): + cache_key = (row.get("source_id"), row.get("storage_path"), row.get("url"), row.get("source_hash")) + if cache_key not in verification_cache: + verification_cache[cache_key] = verify_source_artifact(row) + row["artifact_verification"] = verification_cache[cache_key] grouped[row["claim_id"]].append(row) return grouped @@ -2705,7 +2887,8 @@ def load_edges(args: argparse.Namespace, claim_ids: list[str], limit: int) -> di when 'accelerates' then 8 else 9 end, - other.text + other.text, + other.id ) as rn from base join claim_edges e on e.from_claim = base.id or e.to_claim = base.id @@ -2729,7 +2912,95 @@ def load_edges(args: argparse.Namespace, claim_ids: list[str], limit: int) -> di return grouped -def bundle(args: argparse.Namespace, query: str, claim_limit: int, context_limit: int) -> dict[str, Any]: +def database_read_marker(args: argparse.Namespace) -> dict[str, Any]: + sql = """ + select jsonb_build_object( + 'database', current_database(), + 'database_user', current_user, + 'system_identifier', (select system_identifier::text from pg_control_system()), + 'wal_lsn', pg_current_wal_lsn()::text, + 'transaction_snapshot', txid_current_snapshot()::text + )::text; + """ + rows = psql_json(args, sql) + if not rows: + raise SystemExit("No database read marker returned.") + return rows[0] + + +def _read_marker_stable(before: dict[str, Any], after: dict[str, Any]) -> bool: + keys = ("database", "database_user", "system_identifier", "wal_lsn") + return all(before.get(key) == after.get(key) for key in keys) + + +def _stable_receipt_value(value: Any) -> Any: + volatile_or_environmental = { + "claim_url", + "matched_root", + "resolved_storage_path", + "snapshot_id", + "statement_timestamp", + } + if isinstance(value, dict): + return { + key: _stable_receipt_value(item) + for key, item in sorted(value.items()) + if key not in volatile_or_environmental + } + if isinstance(value, list): + return [_stable_receipt_value(item) for item in value] + return value + + +def build_retrieval_receipt( + data: dict[str, Any], + *, + before: dict[str, Any], + after: dict[str, Any], + attempts: int, + consistency_status: str, +) -> dict[str, Any]: + semantic_context = _stable_receipt_value(data) + evidence_rows = [evidence for claim in data.get("claims", []) for evidence in claim.get("evidence", [])] + source_ids = sorted({str(row["source_id"]) for row in evidence_rows if row.get("source_id")}) + artifact_states = sorted( + ( + { + "source_id": row.get("source_id"), + "source_hash": row.get("source_hash"), + "storage_path": row.get("storage_path"), + "artifact_verification": _stable_receipt_value(row.get("artifact_verification") or {}), + } + for row in evidence_rows + ), + key=lambda row: (str(row.get("source_id") or ""), str(row.get("storage_path") or "")), + ) + return { + "schema": RETRIEVAL_RECEIPT_SCHEMA, + "query_sha256": hashlib.sha256(str(data.get("query") or "").encode("utf-8")).hexdigest(), + "semantic_context_sha256": canonical_json_sha256(semantic_context), + "artifact_state_sha256": canonical_json_sha256(artifact_states), + "claim_ids": [str(claim.get("id")) for claim in data.get("claims", []) if claim.get("id")], + "source_ids": source_ids, + "counts": { + "claims": len(data.get("claims", [])), + "context_rows": len(data.get("context_rows", [])), + "evidence_rows": len(evidence_rows), + "edge_rows": sum(len(claim.get("edges", [])) for claim in data.get("claims", [])), + }, + "read_consistency": { + "status": consistency_status, + "attempts": attempts, + "database": before.get("database"), + "database_user": before.get("database_user"), + "system_identifier": before.get("system_identifier"), + "wal_lsn_before": before.get("wal_lsn"), + "wal_lsn_after": after.get("wal_lsn"), + }, + } + + +def _bundle_once(args: argparse.Namespace, query: str, claim_limit: int, context_limit: int) -> dict[str, Any]: contracts = context_operational_contracts(args, query) claims = find_claims(args, query, claim_limit) claim_ids = [claim["id"] for claim in claims] @@ -2753,6 +3024,43 @@ def bundle(args: argparse.Namespace, query: str, claim_limit: int, context_limit } +def bundle(args: argparse.Namespace, query: str, claim_limit: int, context_limit: int) -> dict[str, Any]: + try: + max_attempts = max(1, min(int(os.environ.get("TELEO_KB_READ_ATTEMPTS", "3")), 5)) + except ValueError as exc: + raise SystemExit("TELEO_KB_READ_ATTEMPTS must be an integer") from exc + previous_semantic_sha256: str | None = None + last_markers: tuple[dict[str, Any], dict[str, Any]] | None = None + for attempt in range(1, max_attempts + 1): + before = database_read_marker(args) + data = _bundle_once(args, query, claim_limit, context_limit) + after = database_read_marker(args) + last_markers = (before, after) + consistency_status = "stable_wal_marker" if _read_marker_stable(before, after) else "wal_changed_during_read" + receipt = build_retrieval_receipt( + data, + before=before, + after=after, + attempts=attempt, + consistency_status=consistency_status, + ) + semantic_sha256 = receipt["semantic_context_sha256"] + if consistency_status == "stable_wal_marker": + data["retrieval_receipt"] = receipt + return data + if previous_semantic_sha256 == semantic_sha256: + receipt["read_consistency"]["status"] = "stable_content_across_wal_change_retry" + data["retrieval_receipt"] = receipt + return data + previous_semantic_sha256 = semantic_sha256 + assert last_markers is not None + before, after = last_markers + raise SystemExit( + "Canonical DB changed during context retrieval and no identical retry was observed " + f"(wal_lsn {before.get('wal_lsn')} -> {after.get('wal_lsn')})." + ) + + def print_json(data: Any) -> None: print(json.dumps(data, indent=2, sort_keys=True)) @@ -2772,6 +3080,14 @@ def print_status(data: dict[str, Any]) -> None: def print_claim_bundle(data: dict[str, Any]) -> None: print(f"# Teleo KB Context\n\nQuery: {data['query']}\n") + receipt = data.get("retrieval_receipt") or {} + if receipt: + consistency = receipt.get("read_consistency") or {} + print("## Retrieval Receipt\n") + print(f"- schema: `{receipt.get('schema')}`") + print(f"- semantic context SHA-256: `{receipt.get('semantic_context_sha256')}`") + print(f"- artifact state SHA-256: `{receipt.get('artifact_state_sha256')}`") + print(f"- DB read consistency: `{consistency.get('status')}`; attempts: `{consistency.get('attempts')}`\n") contracts = data.get("operational_contracts") or [] if contracts: print("## Current Runtime Contracts\n") @@ -2804,6 +3120,12 @@ def print_claim_bundle(data: dict[str, Any]) -> None: source = ev.get("storage_path") or ev.get("url") or "(no source pointer)" excerpt = f" - {truncate(ev.get('excerpt'), 260)}" if ev.get("excerpt") else "" print(f"- `{ev['role']}` / `{ev.get('source_type')}` / `{source}`{excerpt}") + verification = ev.get("artifact_verification") or {} + print( + f" artifact verification: `{verification.get('status', 'not_checked')}`; " + f"hash matches DB: `{verification.get('hash_matches_db')}`; " + f"source id: `{ev.get('source_id') or '-'}`" + ) if claim.get("edges"): print("\nEdges:") for edge in claim["edges"]: @@ -2930,15 +3252,7 @@ def main() -> int: args = parse_args() if args.command == "status": data = status(args) - elif args.command == "search": - data = { - "query": args.query, - "terms": query_terms(args.query), - "operational_contracts": context_operational_contracts(args, args.query), - "claims": find_claims(args, args.query, args.limit), - "context_rows": find_context_rows(args, args.query, args.context_limit), - } - elif args.command == "context": + elif args.command in {"search", "context"}: data = bundle(args, args.query, args.limit, args.context_limit) elif args.command == "show": claim = get_claim(args, args.claim_id) diff --git a/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/REFERENCE.md b/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/REFERENCE.md index dc245c8..1ef10e4 100644 --- a/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/REFERENCE.md +++ b/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/REFERENCE.md @@ -48,12 +48,16 @@ Apply these gates before drafting the answer or proposing a next action: payload and review that new packet; never tell the operator to apply the legacy row. `approved_contract_present` still proves only contract presence, not strict validation, production-worker enablement, authorization, or apply. -2. **Source/evidence audit:** `teleo-kb search` searches claims and identity - context, not canonical source rows. Use `evidence ` or a bounded - read-only `public.claim_evidence` to `public.sources` join. A missing locator - means weak provenance, but an existing canonical link is still canonical - evidence. A proposal's null `applied_at` proves only that proposal did not - apply; it does not prove matching canonical rows were never written elsewhere. +2. **Source/evidence audit:** `teleo-kb search` and `teleo-kb context` emit the + selected claims, evidence, edges, and a deterministic retrieval receipt. Read + each evidence row's `artifact_verification` before describing provenance. + Never infer that an artifact is missing from a raw `storage_path`; use the + reported status. `no_source_pointer` means the canonical source row has no + resolvable locator, while `missing`, `hash_mismatch`, and `verified` describe + distinct artifact states. An existing claim-evidence link remains canonical + evidence when raw-artifact verification is unavailable. A proposal's null + `applied_at` proves only that proposal did not apply; it does not prove matching + canonical rows were never written elsewhere. 3. **Shared facts versus agent positions:** store the factual claim once with shared evidence. Current agent-owned positions live in `public.beliefs` with `id`, `agent_id`, `level`, `statement`, `confidence`, `falsifier`, `rank`, @@ -109,6 +113,13 @@ Treat the emitted `Current Runtime Contracts` / `operational_contracts` block as binding deployed-code readback. Follow its `required_lead`, schema guards, capability tier, and reply budget. It overrides recalled or generic architecture. +For `search` and `context`, cite the receipt's `semantic_context_sha256` when an +exact repeatable read matters. `read_consistency.status=stable_wal_marker` means +the read had a stable WAL marker: the database position did not move during the +read. If it did move, the bridge +retries and accepts only an identical semantic context; otherwise it fails rather +than combining rows from ambiguous database states. + The profile's `leo-db-context` plugin automatically uses one immutable contract snapshot for generation and pre-delivery validation. When a covered draft violates the snapshot, `kb_tool.py` compiles the delivered response from the @@ -255,6 +266,14 @@ Describe a missing locator as weak or citation-only provenance, or say the evidence is not traceable to the raw artifact. Do not call the canonical link non-canonical or ungrounded solely because the locator is missing. +The bridge's `artifact_verification.status` distinguishes `verified`, +`verified_hash_unavailable`, `hash_mismatch`, `missing`, `locator_only`, +`no_source_pointer`, `remote_locator_only`, +`rejected_outside_allowlisted_roots`, `ambiguous_multiple_files`, +`too_large_to_verify`, `unreadable`, and `changed_during_verification`. Never infer `missing` from a raw +`storage_path` without this verification result. A verified database link and a +verified raw artifact are related but separate proof statements. + A Telegram attachment, extracted file, or proposal `source_ref` does not by itself prove canonical evidence from that attachment. That proof requires a `public.sources` row representing the attachment and a `public.claim_evidence` diff --git a/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md b/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md index c49465d..636e0c3 100644 --- a/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md +++ b/hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md @@ -42,12 +42,16 @@ Apply these before drafting the answer or proposing a next action: that new packet; never tell the operator to apply the legacy row. `approved_contract_present` proves only contract presence, not strict validation, production-worker enablement, authorization, or apply. -2. **Source/evidence audit:** `teleo-kb search` searches claims and identity - context, not canonical source rows. Use `evidence ` or a bounded - read-only `public.claim_evidence` to `public.sources` join. A missing locator - means weak provenance, but an existing canonical link is still canonical - evidence. Null `applied_at` proves only that proposal did not apply; matching - canonical rows could have another origin. +2. **Source/evidence audit:** `teleo-kb search` and `teleo-kb context` emit the + selected claims, evidence, edges, and a deterministic retrieval receipt. Read + each evidence row's `artifact_verification` before describing provenance. + Never infer that an artifact is missing from a raw `storage_path`; use the + reported status. `no_source_pointer` means the canonical source row has no + resolvable locator, while `missing`, `hash_mismatch`, and `verified` describe + distinct artifact states. An existing claim-evidence link remains canonical + evidence even when raw-artifact verification is unavailable. Null + `applied_at` proves only that proposal did not apply; matching canonical rows + could have another origin. 3. **Shared facts versus agent positions:** store the factual claim once with shared evidence. Current agent-owned positions live in `public.beliefs` with `id`, `agent_id`, `level`, `statement`, `confidence`, `falsifier`, `rank`, @@ -105,6 +109,13 @@ Treat the emitted `Current Runtime Contracts` / `operational_contracts` block as binding deployed-code readback. Follow its `required_lead`, schema guards, capability tier, and reply budget. It overrides recalled or generic architecture. +For `search` and `context`, cite the receipt's `semantic_context_sha256` when an +exact repeatable read matters. `read_consistency.status=stable_wal_marker` means +the read had a stable WAL marker: the database position did not move during the +read. If it did move, the bridge +retries and accepts only an identical semantic context; otherwise it fails rather +than combining rows from ambiguous database states. + The live leoclean profile injects that exact read-only contract automatically before the model turn. Before delivery, the same snapshot validates the draft. For contract-covered composition and source-intake questions, an invalid draft @@ -205,6 +216,12 @@ one bounded read-only Postgres inspection and label it as such. original probability and call resolution ambiguous when criteria were absent. - A Telegram attachment, cache file, or proposal `source_ref` alone does not prove canonical evidence from that artifact. +- `artifact_verification.status` distinguishes `verified`, + `verified_hash_unavailable`, `hash_mismatch`, `missing`, `locator_only`, + `no_source_pointer`, `remote_locator_only`, + `rejected_outside_allowlisted_roots`, `ambiguous_multiple_files`, + `too_large_to_verify`, `unreadable`, and `changed_during_verification`. Never infer `missing` from a raw + `storage_path` without this verification result. - Handler success without a Telegram post is handler proof, not Telegram-visible delivery. A visible reply proves delivery, not DB mutation. diff --git a/scripts/leo_behavior_manifest.py b/scripts/leo_behavior_manifest.py new file mode 100644 index 0000000..39bae4e --- /dev/null +++ b/scripts/leo_behavior_manifest.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Fingerprint every local runtime layer that can make Leo answer differently. + +The manifest deliberately records hashes and a small allowlist of non-secret +configuration fields. It never emits profile file contents or credentials. +Canonical Postgres state is outside this manifest and must be fingerprinted by +the database harness that owns the connection. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +SCHEMA = "livingip.leoBehaviorManifest.v1" +DEFAULT_PROFILE = Path("/home/teleo/.hermes/profiles/leoclean") +DEFAULT_HERMES_ROOT = Path("/home/teleo/.hermes/hermes-agent") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def canonical_sha256(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return sha256_bytes(encoded) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _safe_relative(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return str(path) + + +def _resolved_node_fingerprint(path: Path, seen_directories: frozenset[tuple[int, int]] = frozenset()) -> dict[str, Any]: + """Hash a symlink target, following nested links while stopping directory cycles.""" + + try: + if path.is_symlink(): + target = os.readlink(path) + resolved = path.resolve(strict=True) + return { + "kind": "symlink", + "target": target, + "resolved": _resolved_node_fingerprint(resolved, seen_directories), + } + stat = path.stat() + if path.is_file(): + return { + "kind": "file", + "bytes": stat.st_size, + "mode": oct(stat.st_mode & 0o777), + "sha256": file_sha256(path), + } + if path.is_dir(): + identity = (stat.st_dev, stat.st_ino) + if identity in seen_directories: + return {"kind": "directory_cycle"} + child_seen = seen_directories | {identity} + children = [ + {"name": child.name, "fingerprint": _resolved_node_fingerprint(child, child_seen)} + for child in sorted(path.iterdir(), key=lambda item: item.name) + ] + return { + "kind": "directory", + "entry_count": len(children), + "sha256": canonical_sha256(children), + } + return {"kind": "other", "mode": oct(stat.st_mode & 0o777)} + except (OSError, RuntimeError) as exc: + return {"kind": "unavailable", "error": type(exc).__name__} + + +def path_manifest(profile: Path, paths: tuple[Path, ...]) -> dict[str, Any]: + files: list[dict[str, Any]] = [] + missing: list[str] = [] + symlinks: list[dict[str, Any]] = [] + for requested in paths: + if not requested.exists() and not requested.is_symlink(): + missing.append(_safe_relative(requested, profile)) + continue + candidates = [requested] if requested.is_file() or requested.is_symlink() else sorted(requested.rglob("*")) + for path in candidates: + relative = _safe_relative(path, profile) + if path.is_symlink(): + symlinks.append( + { + "path": relative, + "target": os.readlink(path), + "target_fingerprint": _resolved_node_fingerprint(path), + } + ) + continue + if not path.is_file(): + continue + try: + stat = path.stat() + files.append( + { + "path": relative, + "bytes": stat.st_size, + "mode": oct(stat.st_mode & 0o777), + "sha256": file_sha256(path), + } + ) + except OSError as exc: + files.append({"path": relative, "status": "unavailable", "error": type(exc).__name__}) + files.sort(key=lambda item: item["path"]) + symlinks.sort(key=lambda item: item["path"]) + stable = {"files": files, "missing": sorted(missing), "symlinks": symlinks} + return { + **stable, + "file_count": len(files), + "total_bytes": sum(int(item.get("bytes", 0)) for item in files), + "sha256": canonical_sha256(stable), + } + + +def _nested(config: dict[str, Any], *path: str) -> Any: + value: Any = config + for key in path: + value = value.get(key) if isinstance(value, dict) else None + return value + + +def safe_model_config(config_path: Path) -> dict[str, Any]: + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (OSError, TypeError, yaml.YAMLError) as exc: + return {"status": "unavailable", "error": type(exc).__name__} + return { + "status": "observed", + "provider": _nested(raw, "model", "provider"), + "default_model": _nested(raw, "model", "default"), + "smart_routing": _nested(raw, "model", "smart_routing"), + "smart_routing_model": _nested(raw, "model", "smart_routing_model"), + "max_tokens": _nested(raw, "model", "max_tokens"), + "reasoning_effort": _nested(raw, "agent", "reasoning_effort"), + "memory_enabled": _nested(raw, "memory", "enabled"), + "memory_search": _nested(raw, "memory", "search"), + } + + +def git_head(repo: Path) -> str | None: + try: + completed = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ) + except (OSError, subprocess.TimeoutExpired): + return None + value = completed.stdout.strip() + return value if completed.returncode == 0 and len(value) == 40 else None + + +def component( + profile: Path, + *, + role: str, + mutability: str, + replayability: str, + paths: tuple[Path, ...], +) -> dict[str, Any]: + return { + "role": role, + "mutability": mutability, + "replayability": replayability, + "content": path_manifest(profile, paths), + } + + +def build_manifest(profile: Path = DEFAULT_PROFILE, hermes_root: Path = DEFAULT_HERMES_ROOT) -> dict[str, Any]: + profile = profile.resolve() + hermes_root = hermes_root.resolve() + config = safe_model_config(profile / "config.yaml") + components = { + "profile_config": component( + profile, + role="Selects model routing, tools, memory, and gateway behavior.", + mutability="deployment_static", + replayability="hashable_but_may_contain_unpinned_provider_defaults", + paths=(profile / "config.yaml",), + ), + "runtime_identity": component( + profile, + role="SOUL.md identity text injected into the Hermes system prompt.", + mutability="file_static_until_edit_or_render", + replayability="fully_hashable", + paths=(profile / "SOUL.md",), + ), + "procedural_skills": component( + profile, + role="On-demand operating instructions available to the model.", + mutability="deployment_static", + replayability="fully_hashable", + paths=(profile / "skills",), + ), + "runtime_middleware": component( + profile, + role="Pre-LLM context injection and post-LLM validation or response replacement.", + mutability="deployment_static", + replayability="fully_hashable", + paths=(profile / "plugins", hermes_root / "run_agent.py"), + ), + "database_tools": component( + profile, + role="Commands that query Postgres, stage proposals, and return receipts.", + mutability="deployment_static", + replayability="fully_hashable", + paths=(profile / "bin",), + ), + "persistent_memory": component( + profile, + role="Hermes MEMORY.md and USER.md content injected independently of canonical Postgres.", + mutability="conversation_dynamic_noncanonical", + replayability="hashable_but_should_not_define_durable_knowledge", + paths=(profile / "memories",), + ), + "conversation_sessions": component( + profile, + role="Session transcripts and local state used for conversation continuity.", + mutability="request_dynamic_noncanonical", + replayability="hashable_but_not_a_knowledge_source", + paths=( + profile / "sessions", + profile / "state", + profile / "state.db", + profile / "state.db-wal", + profile / "state.db-shm", + ), + ), + "generated_prompt_cache": component( + profile, + role="Hermes-generated cache of the skills prompt.", + mutability="generated_cache", + replayability="discard_and_rebuild", + paths=(profile / ".skills_prompt_snapshot.json",), + ), + } + stable = { + "schema": SCHEMA, + "model_runtime": { + **config, + "base_model_weights": "external_provider_managed_not_locally_hashable", + "actual_per_turn_model_must_be_recorded_by_the_call_receipt": True, + }, + "hermes_runtime": { + "git_head": git_head(hermes_root), + "source_tree": path_manifest( + profile, (hermes_root / "run_agent.py", hermes_root / "agent" / "prompt_builder.py") + ), + }, + "components": components, + "canonical_database": { + "included": False, + "reason": "Fingerprint through a read-only Postgres manifest in the database-owning harness.", + }, + } + return { + **stable, + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "profile_root": str(profile), + "hermes_root": str(hermes_root), + "behavior_sha256": canonical_sha256(stable), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE) + parser.add_argument("--hermes-root", type=Path, default=DEFAULT_HERMES_ROOT) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + manifest = build_manifest(args.profile, args.hermes_root) + encoded = json.dumps(manifest, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded, encoding="utf-8") + print(encoded, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_leo_direct_claim_handler_suite.py b/scripts/run_leo_direct_claim_handler_suite.py index 80361cb..1185882 100755 --- a/scripts/run_leo_direct_claim_handler_suite.py +++ b/scripts/run_leo_direct_claim_handler_suite.py @@ -2,9 +2,11 @@ """Run the m3taversal-style direct-claim suite through live VPS GatewayRunner. The harness does not post to Telegram and does not write to the production KB. -It copies the live leoclean profile to a temporary profile on the VPS, invokes -GatewayRunner._handle_message with Telegram-shaped MessageEvents, and removes -the temporary profile after the run. +It copies only static behavior surfaces from the live leoclean profile to a +temporary profile on the VPS, invokes GatewayRunner._handle_message with +Telegram-shaped MessageEvents, and removes the temporary profile after the +run. Prior sessions, memory, state databases, and generated prompt caches are +excluded so the result can be attributed to the declared runtime inputs. """ from __future__ import annotations @@ -53,6 +55,7 @@ from pathlib import Path LIVE_PROFILE = Path("/home/teleo/.hermes/profiles/leoclean") AGENT_ROOT = Path("/home/teleo/.hermes/hermes-agent") +DEPLOY_ROOT = Path("/opt/teleo-eval/workspaces/deploy-infra") CHAT_ID = "__CHAT_ID__" USER_ID = "__USER_ID__" USER_NAME = "__USER_NAME__" @@ -121,10 +124,17 @@ def copy_profile() -> Path: "cache", "image_cache", "memory_backups", + "memories", + "sessions", + "state", ".pytest_cache", "__pycache__", "gateway.pid", "auth.lock", + ".skills_prompt_snapshot.json", + "state.db", + "state.db-shm", + "state.db-wal", } return { name @@ -137,6 +147,8 @@ def copy_profile() -> Path: try: shutil.copytree(LIVE_PROFILE, target, symlinks=True, ignore=ignore) + for directory in ("memories", "sessions", "state"): + (target / directory).mkdir(mode=0o700, exist_ok=True) except Exception: shutil.rmtree(tmp_root, ignore_errors=True) raise @@ -194,8 +206,12 @@ def read_database_context_trace(path): async def run_suite(): + sys.path.insert(0, str(DEPLOY_ROOT / "scripts")) + from leo_behavior_manifest import build_manifest as build_behavior_manifest + before_service = service_state() before_counts = db_counts() + behavior_before = build_behavior_manifest(LIVE_PROFILE, AGENT_ROOT) temp_profile = None context_trace_path = None temp_removed = False @@ -203,8 +219,21 @@ async def run_suite(): "generated_at_utc": datetime.now(timezone.utc).isoformat(), "mode": __SUITE_MODE_JSON__, "posted_to_telegram": False, - "changed_live_profile": False, "mutates_kb_by_harness": False, + "live_behavior_manifest_before": behavior_before, + "temp_profile_seed": { + "mode": "static_runtime_surfaces_only", + "excluded": [ + "memories", + "sessions", + "state", + "state.db", + "state.db-shm", + "state.db-wal", + ".skills_prompt_snapshot.json", + ], + "same_session_continuity_starts_fresh": True, + }, "source": { "platform": "telegram", "chat_id": CHAT_ID, @@ -302,8 +331,12 @@ async def run_suite(): finally: after_counts = db_counts() after_service = service_state() + behavior_after = build_behavior_manifest(LIVE_PROFILE, AGENT_ROOT) report["db_counts_after"] = after_counts report["db_counts_changed"] = after_counts != before_counts + report["live_behavior_manifest_after"] = behavior_after + report["changed_live_profile"] = behavior_before["behavior_sha256"] != behavior_after["behavior_sha256"] + report["live_behavior_manifest_unchanged"] = not report["changed_live_profile"] report["service_before_after"] = { "before": before_service, "after": after_service, diff --git a/scripts/run_leo_m3taversal_oos_handler_suite.py b/scripts/run_leo_m3taversal_oos_handler_suite.py index e2cd0dc..46fe739 100755 --- a/scripts/run_leo_m3taversal_oos_handler_suite.py +++ b/scripts/run_leo_m3taversal_oos_handler_suite.py @@ -29,6 +29,7 @@ def write_score_markdown(path: Path, report: dict[str, Any]) -> None: f"Pass: `{score['pass']}`", f"Prompts: `{score['passes']}/{score['expected_prompt_count']}`", f"DB counts changed: `{report['db_counts_changed']}`", + f"Live behavior manifest unchanged: `{report['live_behavior_manifest_unchanged']}`", f"Service unchanged: `{report['service_unchanged']}`", f"Temporary profile removed: `{report['temp_profile_removed']}`", f"Database context injections: `{report['database_context_injection_count']}`", @@ -67,6 +68,8 @@ def build_score_report(report: dict[str, Any], *, memory_token: str) -> dict[str "memory_token": memory_token, "score": score, "db_counts_changed": report.get("db_counts_changed"), + "changed_live_profile": report.get("changed_live_profile"), + "live_behavior_manifest_unchanged": report.get("live_behavior_manifest_unchanged"), "service_unchanged": service_unchanged, "temp_profile_removed": report.get("temp_profile_removed"), "database_context_injection_count": report.get("database_context_injection_count"), @@ -84,6 +87,8 @@ def score_report_passes(report: dict[str, Any], score_report: dict[str, Any]) -> report.get("remote_returncode") == 0 and report.get("pass_runtime") is True and report.get("db_counts_changed") is False + and report.get("changed_live_profile") is False + and report.get("live_behavior_manifest_unchanged") is True and score_report["service_unchanged"] and report.get("temp_profile_removed") is True and report.get("database_context_all_ok") is True diff --git a/tests/test_compile_kb_source_packet.py b/tests/test_compile_kb_source_packet.py index 842d11a..49adf02 100644 --- a/tests/test_compile_kb_source_packet.py +++ b/tests/test_compile_kb_source_packet.py @@ -399,6 +399,31 @@ def test_rejects_attempt_to_invent_source_from_chat_label(tmp_path: Path) -> Non _compile(tmp_path, manifest) +def test_compiles_stable_telegram_message_as_review_candidate_without_learning_from_chat(tmp_path: Path) -> None: + manifest = _manifest() + locator = "telegram://chat/-5146042086/message/12345" + manifest["source"].update( + { + "identity": locator, + "source_key": "telegram_message_12345", + "source_type": "transcript", + "title": "Telegram discussion message 12345", + "locator": locator, + } + ) + + bundle = _compile(tmp_path, manifest) + source = bundle["parent_proposal"]["payload"]["source_candidates"][0] + + assert bundle["status"] == "pending_review" + assert bundle["database_write_performed"] is False + assert bundle["canonical_apply_performed"] is False + assert bundle["compiler_contract"]["source_from_chat_labels"] is False + assert source["source_type"] == "transcript" + assert source["storage_path"] == locator + assert source["content_sha256"] == manifest["artifact_sha256"] + + def test_rejects_quotes_that_cannot_fit_the_reviewable_canonical_excerpt(tmp_path: Path) -> None: long_quote = "x" * 8100 text = f"prefix {long_quote} suffix" diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index c3ff5d8..e16b2b5 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -100,6 +100,191 @@ def _load_module(path: Path): return module +def test_vps_bridge_verifies_relative_source_artifact_against_canonical_hash(tmp_path: Path) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + root = tmp_path / "workspace" + artifact = root / "domains" / "research.md" + artifact.parent.mkdir(parents=True) + artifact.write_text("hash-bound evidence\n", encoding="utf-8") + digest = module.hashlib.sha256(artifact.read_bytes()).hexdigest() + + verification = module.verify_source_artifact( + { + "storage_path": "domains/research.md", + "url": None, + "source_hash": digest, + }, + roots=(root,), + ) + + assert verification["status"] == "verified" + assert verification["artifact_exists"] is True + assert verification["artifact_sha256"] == digest + assert verification["hash_matches_db"] is True + assert verification["resolved_storage_path"] == str(artifact) + + +def test_vps_bridge_does_not_invent_local_artifact_state(tmp_path: Path) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + root = tmp_path / "workspace" + root.mkdir() + outside = tmp_path / "outside.md" + outside.write_text("outside", encoding="utf-8") + + rejected = module.verify_source_artifact( + {"storage_path": str(outside), "source_hash": module.hashlib.sha256(outside.read_bytes()).hexdigest()}, + roots=(root,), + ) + missing = module.verify_source_artifact( + {"storage_path": "domains/missing.md", "source_hash": "0" * 64}, + roots=(root,), + ) + locator = module.verify_source_artifact( + {"storage_path": "telegram://chat/-5146042086/message/12345", "source_hash": "0" * 64}, + roots=(root,), + ) + + assert rejected["status"] == "rejected_outside_allowlisted_roots" + assert rejected["artifact_exists"] is False + assert missing["status"] == "missing" + assert missing["artifact_exists"] is False + assert locator["status"] == "locator_only" + assert locator["artifact_exists"] is None + + +def test_vps_bridge_reports_source_hash_mismatch(tmp_path: Path) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + root = tmp_path / "workspace" + artifact = root / "evidence.txt" + artifact.parent.mkdir() + artifact.write_text("actual bytes", encoding="utf-8") + + verification = module.verify_source_artifact( + {"storage_path": "evidence.txt", "source_hash": "0" * 64}, + roots=(root,), + ) + + assert verification["status"] == "hash_mismatch" + assert verification["hash_matches_db"] is False + assert verification["artifact_sha256"] != verification["expected_sha256"] + + +def test_vps_bridge_source_artifact_verification_is_size_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + root = tmp_path / "sources" + artifact = root / "large.md" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"0123456789") + monkeypatch.setenv("TELEO_KB_MAX_ARTIFACT_BYTES", "4") + + result = module.verify_source_artifact( + {"storage_path": "large.md", "source_hash": module.hashlib.sha256(artifact.read_bytes()).hexdigest()}, + roots=(root,), + ) + + assert result["status"] == "too_large_to_verify" + assert result["artifact_exists"] is True + assert result["artifact_bytes"] == 10 + assert result["max_artifact_bytes"] == 4 + assert result["artifact_sha256"] is None + + +def test_vps_bridge_evidence_fetch_includes_source_identity_and_verification( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + root = tmp_path / "workspace" + artifact = root / "source.md" + artifact.parent.mkdir() + artifact.write_text("source bytes", encoding="utf-8") + digest = module.hashlib.sha256(artifact.read_bytes()).hexdigest() + claim_id = "11111111-1111-4111-8111-111111111111" + source_id = "22222222-2222-4222-8222-222222222222" + captured_sql: list[str] = [] + + def fake_psql_json(_args, sql): + captured_sql.append(sql) + return [ + { + "claim_id": claim_id, + "role": "grounds", + "weight": 1, + "source_id": source_id, + "source_type": "article", + "url": None, + "storage_path": "source.md", + "source_hash": digest, + "captured_at": "2026-07-14T00:00:00+00:00", + "excerpt": "source bytes", + } + ] + + monkeypatch.setenv("TELEO_KB_SOURCE_ROOTS", str(root)) + monkeypatch.setattr(module, "psql_json", fake_psql_json) + + rows = module.load_evidence(SimpleNamespace(), [claim_id], 4)[claim_id] + + assert rows[0]["source_id"] == source_id + assert rows[0]["source_hash"] == digest + assert rows[0]["artifact_verification"]["status"] == "verified" + assert "s.id as source_id" in captured_sql[0] + assert "s.hash as source_hash" in captured_sql[0] + assert "s.captured_at" in captured_sql[0] + + +def test_vps_bridge_context_receipt_is_deterministic_and_retries_moving_wal(monkeypatch) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + markers = iter( + [ + {"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/1"}, + {"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/2"}, + {"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/2"}, + {"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/3"}, + ] + ) + contract_calls = 0 + + def contracts(_args, _query): + nonlocal contract_calls + contract_calls += 1 + return [{"id": "reply_budget", "hard_max_words": 200, "statement_timestamp": f"volatile-{contract_calls}"}] + + monkeypatch.setattr(module, "database_read_marker", lambda _args: next(markers)) + monkeypatch.setattr(module, "context_operational_contracts", contracts) + monkeypatch.setattr(module, "compile_operational_response", lambda _contracts: None) + monkeypatch.setattr(module, "find_claims", lambda _args, _query, _limit: []) + monkeypatch.setattr(module, "load_evidence", lambda _args, _ids, _limit: {}) + monkeypatch.setattr(module, "load_edges", lambda _args, _ids, _limit: {}) + monkeypatch.setattr(module, "find_context_rows", lambda _args, _query, _limit: []) + + result = module.bundle(SimpleNamespace(), "same database question", 4, 6) + receipt = result["retrieval_receipt"] + + assert contract_calls == 2 + assert receipt["read_consistency"]["status"] == "stable_content_across_wal_change_retry" + assert receipt["read_consistency"]["attempts"] == 2 + stable_copy = {**result, "operational_contracts": [{"id": "reply_budget", "hard_max_words": 200}]} + rebuilt = module.build_retrieval_receipt( + {key: value for key, value in stable_copy.items() if key != "retrieval_receipt"}, + before={"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/9"}, + after={"database": "teleo", "database_user": "postgres", "system_identifier": "sys", "wal_lsn": "0/9"}, + attempts=1, + consistency_status="stable_wal_marker", + ) + assert rebuilt["semantic_context_sha256"] == receipt["semantic_context_sha256"] + + +def test_vps_bridge_retrieval_queries_have_deterministic_final_tie_breakers() -> None: + source = (BRIDGE_DIR / "kb_tool.py").read_text(encoding="utf-8") + + assert "length(text), text, id" in source + assert "order by score desc, source, owner, title, body" in source + assert "s.url nulls last,\n s.id" in source + assert "other.text,\n other.id" in source + + def test_vps_bridge_search_proposals_finds_approved_rows_by_payload(monkeypatch) -> None: module = _load_module(BRIDGE_DIR / "kb_tool.py") captured_sql: list[str] = [] diff --git a/tests/test_hermes_leoclean_skill_surfaces.py b/tests/test_hermes_leoclean_skill_surfaces.py index c1f03e7..1e49eb6 100644 --- a/tests/test_hermes_leoclean_skill_surfaces.py +++ b/tests/test_hermes_leoclean_skill_surfaces.py @@ -246,8 +246,8 @@ def test_vps_kb_skill_encodes_apply_readiness_beliefs_and_current_proof_boundari assert "approved_needs_apply_payload" in text assert "normalize into a strict payload and review that new packet" in squashed assert "never tell the operator to apply the legacy row" in squashed - assert "search` searches claims and identity context, not canonical source rows" in squashed - assert "an existing canonical link is still canonical evidence" in squashed + assert "`teleo-kb search` and `teleo-kb context` emit the selected claims" in squashed + assert "An existing claim-evidence link remains canonical evidence" in squashed assert "Current agent-owned positions live in `public.beliefs`" in squashed for field in ("`agent_id`", "`level`", "`statement`", "`falsifier`", "`rank`"): assert field in text @@ -265,6 +265,12 @@ def test_vps_kb_skill_encodes_apply_readiness_beliefs_and_current_proof_boundari assert "`applied_at` belongs to the proposal receipt, not each inserted row" in squashed assert "`state.db` and session JSONL can preserve continuity across restart" in squashed assert "It has no `author`, `title`, `publisher`, or publication-date column" in squashed + assert "`artifact_verification`" in text + assert "`semantic_context_sha256`" in text + assert "stable WAL marker" in text + assert "Never infer that an artifact is missing from a raw `storage_path`" in squashed + assert "Never infer `missing` from a raw `storage_path`" in squashed + assert "accepts only an identical semantic context" in squashed assert "Automatic Telegram attachment capture" in squashed assert "state/kb-source-inbox/source.md" in text assert "--receipt /home/teleo/.hermes/profiles/leoclean/state/kb-source-receipts/source.json" in text diff --git a/tests/test_leo_behavior_manifest.py b/tests/test_leo_behavior_manifest.py new file mode 100644 index 0000000..08287ad --- /dev/null +++ b/tests/test_leo_behavior_manifest.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from scripts import leo_behavior_manifest as manifest + +ROOT = Path(__file__).resolve().parents[1] + + +def _write(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + +def test_behavior_manifest_is_deterministic_and_never_emits_config_secrets(tmp_path: Path) -> None: + profile = tmp_path / "profile" + hermes = tmp_path / "hermes" + _write( + profile / "config.yaml", + """model: + provider: openrouter + default: anthropic/claude-sonnet-4-6 + smart_routing: true + smart_routing_model: google/gemini-2.5-flash +memory: + enabled: true + search: sqlite_fts5 +gateway: + telegram: + bot_token: super-secret-token +""", + ) + _write(profile / "SOUL.md", "# Leo\n") + _write(profile / "skills" / "teleo-kb-bridge" / "SKILL.md", "# Bridge\n") + _write(profile / "plugins" / "leo-db-context" / "__init__.py", "# plugin\n") + _write(profile / "bin" / "kb_tool.py", "# tool\n") + _write(profile / "memories" / "USER.md", "private participant fact\n") + _write(profile / "sessions" / "one.jsonl", '{"private":"conversation"}\n') + _write(profile / "state.db", "runtime-state") + _write(hermes / "run_agent.py", "# runtime\n") + _write(hermes / "agent" / "prompt_builder.py", "# prompts\n") + + first = manifest.build_manifest(profile, hermes) + second = manifest.build_manifest(profile, hermes) + + assert first["behavior_sha256"] == second["behavior_sha256"] + assert first["model_runtime"]["default_model"] == "anthropic/claude-sonnet-4-6" + assert first["components"]["persistent_memory"]["content"]["file_count"] == 1 + encoded = json.dumps(first) + assert "super-secret-token" not in encoded + assert "private participant fact" not in encoded + assert '{"private":"conversation"}' not in encoded + + +def test_behavior_manifest_changes_when_a_behavior_layer_changes(tmp_path: Path) -> None: + profile = tmp_path / "profile" + hermes = tmp_path / "hermes" + _write(profile / "config.yaml", "model:\n provider: openrouter\n default: model-a\n") + _write(profile / "SOUL.md", "identity-a\n") + _write(hermes / "run_agent.py", "runtime\n") + _write(hermes / "agent" / "prompt_builder.py", "prompts\n") + + before = manifest.build_manifest(profile, hermes) + _write(profile / "SOUL.md", "identity-b\n") + after = manifest.build_manifest(profile, hermes) + + assert before["behavior_sha256"] != after["behavior_sha256"] + assert ( + before["components"]["runtime_identity"]["content"]["sha256"] + != after["components"]["runtime_identity"]["content"]["sha256"] + ) + + +def test_behavior_manifest_hashes_content_behind_symlinks(tmp_path: Path) -> None: + profile = tmp_path / "profile" + hermes = tmp_path / "hermes" + target = tmp_path / "deployed-skill.md" + _write(profile / "config.yaml", "model:\n provider: test\n") + _write(profile / "SOUL.md", "identity\n") + _write(target, "version-a\n") + (profile / "skills").mkdir(parents=True) + (profile / "skills" / "SKILL.md").symlink_to(target) + _write(hermes / "run_agent.py", "runtime\n") + _write(hermes / "agent" / "prompt_builder.py", "prompts\n") + + before = manifest.build_manifest(profile, hermes) + _write(target, "version-b\n") + after = manifest.build_manifest(profile, hermes) + + assert before["behavior_sha256"] != after["behavior_sha256"] + symlinks = after["components"]["procedural_skills"]["content"]["symlinks"] + assert symlinks[0]["path"] == "skills/SKILL.md" + assert symlinks[0]["target_fingerprint"]["kind"] == "symlink" + assert symlinks[0]["target_fingerprint"]["resolved"]["kind"] == "file" + + +def test_live_handler_harness_excludes_prior_dynamic_state_and_compares_live_fingerprints() -> None: + source = (ROOT / "scripts" / "run_leo_direct_claim_handler_suite.py").read_text(encoding="utf-8") + + for directory in ("sessions", "state", "memories"): + assert f'"{directory}"' in source + assert '".skills_prompt_snapshot.json"' in source + assert "build_behavior_manifest" in source + assert 'report["changed_live_profile"] =' in source + assert '"changed_live_profile": False' not in source diff --git a/tests/test_teleo_agent_systemd.py b/tests/test_teleo_agent_systemd.py index 75c591a..154ac86 100644 --- a/tests/test_teleo_agent_systemd.py +++ b/tests/test_teleo_agent_systemd.py @@ -124,6 +124,16 @@ def test_deploy_scripts_sync_leoclean_skills_and_restart_gateway_for_runtime_cha assert 'systemctl is-active --quiet leoclean-gateway.service' in manual_deploy +def test_deploy_scripts_syntax_check_leoclean_database_runtime_before_restart(): + auto_deploy = (REPO_ROOT / "deploy" / "auto-deploy.sh").read_text() + manual_deploy = (REPO_ROOT / "deploy" / "deploy.sh").read_text() + + assert "hermes-agent/leoclean-bin/*.py" in auto_deploy + assert "scripts/leo_behavior_manifest.py" in auto_deploy + assert '"$REPO_ROOT/hermes-agent/leoclean-bin/"*.py' in manual_deploy + assert '"$REPO_ROOT/scripts/leo_behavior_manifest.py"' in manual_deploy + + def test_auto_deploy_hot_syncs_leoclean_markdown_without_gateway_restart(): assert not _leoclean_gateway_restart_required( "hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md"