Compare commits

...
Author SHA1 Message Date
root e42b970dec fix(audit-hermes): handle fallback_providers as list or dict
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 17s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
The audit assumed fallback_providers was always a dict (single provider).
Two live agents (koby, koonimo) carry it as a LIST of dicts (one entry per
fallback), so the script crashed with:

    File "audit-hermes-config.py", line 211, in audit
        fb.get("provider") == "deepseek",
    AttributeError: 'list' object has no attribute 'get'

Both are REAL agent configs, so this is not a malformed-input case — the
script simply could not audit two of the four agents it exists to audit.

Fix:
- Normalize fallback_providers to a list of entries (dict → [dict], list → list)
- Apply the existing checks to each entry
- A malformed entry (not a mapping) produces a reported VIOLATION naming the
  offending entry, NOT an uncaught exception

Adds regression test using the real failing shape (list of dicts) and proves
it bites against the pre-fix revision.

Real audit results after fix:
- mumuni: FAIL — 7 violations
- tanko: FAIL — 21 violations
- koby: FAIL — 16 violations (previously crashed)
- koonimo: FAIL — 10 violations (previously crashed)

No agent configs were changed. No existing rules were relaxed.
2026-09-27 11:17:18 +00:00
abiba-bot eadb927ec1 Merge pull request 'fix(hermes): clarify auxiliary model policy to match audit' (#140) from fix/hermes-aux-model-policy-20260927 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 1s
2026-09-27 10:05:44 +00:00
root d4e238047d fix(hermes): clarify auxiliary model policy to match audit
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
The 'Auxiliary Tasks (CONSISTENCY RULE)' header claimed ALL auxiliary
services must use an identical model (gpu-vision) and that syslog-auto
must never be used for auxiliary tasks. Both are false for compression,
which the script (and Rule 7/8) require to be syslog-auto. An agent
following the template produced a config the audit then failed.

- Split auxiliary into two classes: light (vision, web_extract/browsing)
  -> gpu-vision (RTX 5070); context-heavy (compression) -> syslog-auto,
  citing the existing 2026-07-23 OPERATIONAL DECISION in Rule 7.
- Remove the absolute 'Do NOT use syslog-auto for auxiliary tasks' line.
- State gpu-dense + strix-moe are the reasoning hosts; do not pin aux to them.
- Note the change in the frontmatter UPDATED log.

audit-hermes-config.py unchanged (it is the enforcement contract); prose
now matches it line-for-line on vision/web_extract/compression.

Verified: prose-lint.sh PASS, secret-scan.sh clean, 14/14 tests in
tests/test_audit_hermes_config_alias.py pass, audit PASSES on the
template's stated policy.
2026-09-27 09:46:37 +00:00
mumuni-bot 73d5097555 Merge pull request 'feat(memory-fixer): add duplicate-detection phase; correct stale canonical pointer + reporting model (v2.2.0)' (#139) from fix/memory-fixer-duplicate-detection-20260926 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 2s
Merge PR #139 — memory-fixer duplicate-detection phase (v2.2.0). All CI green; authorized by AGENTS.md step 4 (all green -> merge) and the authorization table (memory-fixer = normal sensitivity, any registered agent).
2026-09-27 02:16:43 +00:00
Mumuni c460ef905c feat(memory-fixer): add duplicate-detection phase; correct stale canonical pointer + reporting model (v2.2.0)
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
Level 1 gains phase 5, duplicate-node detection: runs
memory_dup_detect.py (read-only) and reports clusters by verdict.

- WRITER-DEFECT (run_family): one task creating a node per run -> writer
  fix, never merge (per-run nodes are the audit trail)
- SAFE-MERGE: identical bodies, still needs an explicit Kwame decision
- HUMAN-DECISION: same subject, bodies differ -> connect, never merge

Replaces the naive '>70% title overlap' duplicate rule, which false-positives
on distinct work: four client workflows (#357-#361) and two machines'
migrations (#1792/#1793) score high on titles with bodies 0.1-0.3 apart.

Also corrected in the same pass:
- the 'canonical copy' pointer named /root/.hermes/contracts/memory-fixer-v3.md,
  which does not exist on kagentz (no /root access); the job's instruction set is
  inline in ~/.hermes/cron/jobs.json
- 'reports to Kwame via this Zulip DM' described the pre-2026-09-21 model; the
  report is DROPped to the gate (comms_drop.py) and exit 0 means QUEUED, not sent
- added a Checks line so a skipped phase 5 is visible in the report
2026-09-26 19:32:36 +00:00
abiba-bot d99b552448 Merge pull request 'feat(search): agent-consumption layer — dedupe, filter, rerank, extract content' (#138) from feat/search-agent-consumption-20260926 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 7s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 10s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 0s
2026-09-26 16:06:47 +00:00
root ba38efcd75 test(search): quality guard so the ranking layer cannot silently rot
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 4s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 13s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 11s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s
Extends search-stack-visibility with a ranking assertion: for the fixed query
set, no config demote_domains host may appear in the top 3, and the known
non-answers (bestbuy.com, merriam-webster.com) must not be returned at all.

Without this the layer could rot back to raw engine ordering unnoticed - the same
way the endpoint colours silently rotted before 2026-09-26. It reads the demote
list from the SAME config the layer uses, so the guard cannot drift from the
policy it is guarding.

Live: 'ok: no demoted host in the top 3; no banned non-answer returned';
visibility contract still PASSES end to end.
2026-09-26 15:44:10 +00:00
root 0d30091f62 feat(search): agent-consumption layer - dedupe, filter, rerank, extract
Raw multi-engine aggregation had no dedupe, no filtering and no reranking.
Measured 2026-09-26: 'best practices agent context management' returned
bestbuy.com and merriam-webster.com, plus 4 content farms, with medium.com twice;
'proxmox thin pool metadata exhaustion recovery' put four SEO blogs ABOVE the
real Proxmox forum threads. Identical queries also ranked DIFFERENTLY between
runs, which is why the fix is deterministic rather than trusting the engines.

scripts/search-agent-consume.py:
  1. dedupe by normalised URL (tracking params and fragments stripped)
  2. drop non-answers - shopping/dictionary hosts, navigational host roots,
     search/shopping/cart/login paths and query keys
  3. demote content farms and promote primary sources
  4. STABLE sort (score desc, then original position) so runs are reproducible
  5. extract page text for the top N via Firecrawl POST /v1/scrape under an
     explicit character budget, so an agent gets usable material in ONE call
  6. emit stable JSON with engine provenance and source_type

Policy is config, not code: config/search-ranking.yaml holds demote_domains,
prefer_domains, non_answer rules and the extraction budget, so it is reviewable
and changeable without touching the module. Content farms are DEMOTED rather
than dropped so a useful hit is not lost, it just cannot outrank a primary.

A '/products/' path rule was REMOVED after the before/after run caught it
dropping docs.digitalocean.com/products/inference/... - a legitimate docs page.
Shopping is caught by the host list instead, which has no such false positive.

Measured: 'best practices...' top 8 becomes anthropic, langchain, jetbrains,
blog.jetbrains, docs.langchain, reddit, cursor, reddit - no content farm.
'proxmox thin pool...' moves the forum threads from positions 5-9 to 1-4.
Extraction: 5 items, 12000 chars of 12000 budget, 0 failures, 5.28s; whole run
6.4s wall.

Contract: search-agent-consumption.prose.md, including the honest reachability
gap - the pi MCP search server's shape is not ours to change, so this layer is
NOT wired into it.
2026-09-26 15:44:09 +00:00
8 changed files with 1117 additions and 28 deletions
+36 -17
View File
@@ -94,7 +94,16 @@ def audit(path):
cfg = yaml.safe_load(f)
model = cfg.get("model", {})
fb = cfg.get("fallback_providers", {})
fb_raw = cfg.get("fallback_providers", {})
# Normalize: fallback_providers may be a dict (single provider) or a list of dicts
# (one entry per fallback). Both shapes are valid; we must handle both without crashing.
if isinstance(fb_raw, dict):
fb_entries = [fb_raw]
elif isinstance(fb_raw, list):
fb_entries = fb_raw
else:
fb_entries = [fb_raw] # Let it fail the check below as malformed
fb = fb_entries[0] if fb_entries else {}
comp = cfg.get("compression", {})
aux = cfg.get("auxiliary", {})
deleg = cfg.get("delegation", {})
@@ -207,22 +216,32 @@ def audit(path):
"Rule 14",
f"delegation.provider must be 'harness' (got {deleg.get('provider')!r})",
)
check(
fb.get("provider") == "deepseek",
"Rule 14",
f"fallback_providers.provider must be 'deepseek' (got {fb.get('provider')!r}) — "
f"true fallback diversity, not same endpoint as primary",
)
check(
fb.get("model") == "deepseek-v4-flash",
"Rule 14",
f"fallback_providers.model must be 'deepseek-v4-flash' (got {fb.get('model')!r})",
)
check(
fb.get("api_key_env") == "DEEPSEEK_API_KEY",
"Rule 14",
f"fallback_providers.api_key_env must be DEEPSEEK_API_KEY (got {fb.get('api_key_env')!r})",
)
# Check each fallback entry. A malformed entry (not a mapping) is a VIOLATION, not a crash.
for idx, entry in enumerate(fb_entries):
prefix = f"fallback_providers[{idx}]"
if not isinstance(entry, dict):
check(
False,
"Rule 14",
f"{prefix} must be a mapping (got {type(entry).__name__})",
)
continue
check(
entry.get("provider") == "deepseek",
"Rule 14",
f"{prefix}.provider must be 'deepseek' (got {entry.get('provider')!r}) — "
f"true fallback diversity, not same endpoint as primary",
)
check(
entry.get("model") == "deepseek-v4-flash",
"Rule 14",
f"{prefix}.model must be 'deepseek-v4-flash' (got {entry.get('model')!r})",
)
check(
entry.get("api_key_env") == "DEEPSEEK_API_KEY",
"Rule 14",
f"{prefix}.api_key_env must be DEEPSEEK_API_KEY (got {entry.get('api_key_env')!r})",
)
# --- custom_providers sanity ---
check(
+173
View File
@@ -0,0 +1,173 @@
# Search ranking policy for the agent-consumption layer.
#
# Everything here is CONFIG, not code, so it is reviewable and changeable without
# touching the module. Read by scripts/search-agent-consume.py.
#
# Why this file exists: multi-engine aggregation returns results with no
# filtering, no dedupe and no reranking. On 2026-09-26 that put a shopping page
# and a dictionary definition into "best practices agent context management",
# and put four SEO blogs ABOVE the actual Proxmox forum threads on a precise
# technical query. Identical queries also ranked differently between runs, which
# is the strongest argument for a deterministic layer rather than hoping the
# engines behave.
version: 1
# ── Non-answers: dropped outright, never returned ────────────────────────────
# These are pages that cannot answer a question: navigational homepages,
# shopping/product pages, dictionary definitions, and login walls.
non_answer:
# URL path is empty -> it is a site's front door, not an answer. Still allowed
# when the host is explicitly preferred (see prefer_domains), because some
# docs/repo front doors ARE the answer.
host_root: true
path_patterns:
- '/dictionary/'
- '/dictionary?'
- '/wiki/Wiktionary:'
- '/search?'
- '/cart'
- '/checkout'
- '/login'
- '/signin'
- '/sign-in'
- '/account/login'
- '/shop/'
- '/store/'
- '/dp/' # Amazon-style product URL
- '/gp/product/'
- '/add-to-cart'
- '/checkout'
# NOTE: '/products/' and '/product/' were REMOVED as path patterns. They fired
# on docs.digitalocean.com/products/inference/... — a legitimate documentation
# page — which the 2026-09-26 before/after run caught. Shopping is caught by
# the shopping HOST list instead, which does not have that false positive.
# Query strings that betray a search/shopping surface rather than an article.
query_keys:
- 'q'
- 'query'
- 's'
- 'search'
- 'add-to-cart'
# Hosts that are shopping/retail and never answer a technical question.
hosts:
- bestbuy.com
- amazon.com
- ebay.com
- walmart.com
- etsy.com
- aliexpress.com
- merriam-webster.com
- dictionary.com
- thesaurus.com
- vocabulary.com
- collinsdictionary.com
# ── Demotion: ranked below everything else, never dropped ────────────────────
# Low-authority content farms / SEO aggregators. Demoted rather than dropped so
# a genuinely useful hit is not lost, but it can never outrank a primary source.
# Reviewable: add or remove hosts here, no code change required.
demote_domains:
- medium.com
- sparkco.ai
- mindstudio.ai
- aitechmonk.com
- stackai.com
- agentic-design.ai
- voxfor.com
- bigiron.cc
- linuxoperatingsystem.net
- riparazioneserver.com
- rossmanngroup.com
- dev.to
- hashnode.dev
- substack.com
- towardsdatascience.com
- analyticsvidhya.com
- geeksforgeeks.org
- tutorialspoint.com
- javatpoint.com
- w3schools.com
- scaler.com
- simplilearn.com
- udemy.com
- coursera.org
# ── Preference: promoted above the default rank ──────────────────────────────
# Primary sources: upstream repositories, official docs, Q&A, vendor
# engineering blogs. These are what an agent should be reading.
prefer_domains:
# upstream repositories and code hosting
- github.com
- gitlab.com
- codeberg.org
- sourceforge.net
- kernel.org
- git.kernel.org
# Q&A
- stackoverflow.com
- stackexchange.com
- superuser.com
- serverfault.com
- askubuntu.com
- discourse.org
# vendor / project documentation and forums
- proxmox.com
- forum.proxmox.com
- pve.proxmox.com
- docs.python.org
- developer.mozilla.org
- kernelnewbies.org
- man7.org
- gnu.org
- debian.org
- ubuntu.com
- redhat.com
- kernel.dk # io_uring / Jens Axboe
- github.io # project pages (docs, papers) — promoted, not authoritative by itself
# vendor engineering blogs
- anthropic.com
- openai.com
- googleblog.com
- developers.googleblog.com
- engineering.fb.com
- netflixtechblog.com
- aws.amazon.com
- cloud.google.com
- microsoft.com
- learn.microsoft.com
- apple.com
- nvidia.com
- intel.com
- amd.com
- redislabs.com
- cloudflare.com
- langchain.com
- jetbrains.com
- cursor.com
# community discussion with high signal
- news.ycombinator.com
- lobste.rs
- reddit.com
# ── Ranking weights ──────────────────────────────────────────────────────────
# Final score = engine_score - demote_penalty + prefer_bonus, then a stable
# tiebreak on original position so ordering is reproducible run to run.
ranking:
demote_penalty: 1000
prefer_bonus: 100
# Results that several engines independently returned are more likely real.
multi_engine_bonus: 25
# Shallow paths (e.g. /blog/x) are slightly less likely to be primary docs.
host_root_allowed_when_preferred: true
# ── Extraction budget (criterion 4) ──────────────────────────────────────────
# Return CONTENT, not just links, so an agent gets usable material in ONE call.
extraction:
top_n: 5 # how many results get page text extracted
total_chars: 12000 # global budget across all extracted items
per_item_chars: 4000 # cap for any single item, so one page cannot eat the budget
timeout_seconds: 45 # per scrape
# If extraction fails, the result is still returned with an empty excerpt —
# a link is better than nothing, but the failure is recorded in the output.
on_failure: keep_with_empty_excerpt
+13 -5
View File
@@ -5,6 +5,11 @@ description: >
Standard Hermes configuration template for Syslog Solution LLC agents.
Enforces shared infrastructure setup (Firecrawl, SearXNG, local models,
RA-H OS MCP) while keeping agent-specific API keys and model choices.
UPDATED 2026-09-27: Clarified the Auxiliary Tasks policy — light aux (vision,
web_extract/browsing) -> gpu-vision (RTX 5070); context-heavy aux (compression) ->
syslog-auto (2026-07-23 decision, Rule 7). Removed the false "one model for all
auxiliary" / "never syslog-auto" claim; stated gpu-dense + strix-moe are the reasoning
hosts and aux should not be pinned to them. Now matches audit-hermes-config.py line-for-line.
UPDATED 2026-08-07: Added litellm MCP server entry; updated Rule 15 (MCP Validation)
to enforce REAL key headers (not env-vars) from the 2026-08-07 keyless-MCP incident.
Added Rule 12 (Context-Issue Diagnostic) + Rule 13 (.env fallback enforcement) from the
@@ -167,13 +172,16 @@ compression:
abort_on_summary_failure: false
# ─── Auxiliary Tasks (CONSISTENCY RULE) ───
# All auxiliary services MUST use identical model, base_url, and api_key_env:
# model: gpu-vision # stable alias (NOT a raw model name)
# Auxiliary tasks split into TWO model classes — do NOT assume one model for all:
# Light auxiliary (vision, web_extract/browsing) -> model: gpu-vision # RTX 5070
# Keeps the reasoning hosts (gpu-dense / strix-moe) free for agent prompts.
# Context-heavy auxiliary (compression) -> model: syslog-auto # weighted pool
# Deliberate per the 2026-07-23 OPERATIONAL DECISION in Rule 7: summarization
# runs against long histories and must be able to use the pool.
# Do NOT pin auxiliary work to the reasoning hosts (gpu-dense / strix-moe).
# All auxiliary services share identical ROUTING (base_url + api_key_env), not model:
# base_url: http://192.168.68.116/litellm/v1 # Rule 5 (2026-08-09): canonical authenticated; /v1 also OK
# api_key_env: LITELLM_API_KEY
# Do NOT use syslog-auto for auxiliary tasks — it routes to the primary GPU.
# gpu-vision = RTX 5070 (12B), freeing the Strix Halo for agent reasoning.
# Heavy aux (delegation, x_search) use gpu-dense (RTX 3090) instead.
# NEVER use retired model names (qwen3.6-27B-code, qwen3.6-35B-udq4; gemma-4-12b is retired
# and no longer resolves) in agent configs — use the stable aliases so model swaps don't break agents.
auxiliary:
+44 -6
View File
@@ -6,13 +6,19 @@ name: memory-fixer
description: >
Auto-fix low-hanging fruit in the RA-H OS knowledge graph. No judgment calls — only deterministic Level 1 operations.
Escalate anything that needs Kwame's input. Executes confirmed Kwame decisions to completion (state + updated_at).
version: 2.1.0
version: 2.2.0
---
---
# Memory Fixer
> **Canonical copy:** `/root/.hermes/contracts/memory-fixer-v3.md` (used by the `memory-fixer-daily` cron job). This file is the institutional record of the same contract. When the two diverge, treat the v3 source in `/root/.hermes/contracts/` as executable truth.
> **Executable copy:** the `okyeame-memory-fixer` cron job on kagentz (`hermes cron list`) holds its instruction
> set **inline in `~/.hermes/cron/jobs.json`** (`hermes cron edit <id> --prompt …`; there is no `--prompt-file`, and
> `~/.hermes/cron/memory-fixer-prompt.md` is a synced draft, not the live instruction). This file is the institutional
> record of the same contract; when the two diverge, the job prompt is what actually runs — diff it against this file
> before claiming a prompt change landed.
> ⚠️ Corrected 2026-09-26: the previous pointer (`/root/.hermes/contracts/memory-fixer-v3.md`) does not exist on
> kagentz — no `/root` access from this container — and was verified unreachable, not merely stale.
## Purpose
Auto-fix low-hanging fruit in the graph. No judgment calls — only deterministic Level 1 operations. Escalate anything that needs Kwame's input. When Kwame replies to an escalation, **execute the decision to completion** (update state and timestamps), never leaving a node in review-pending forever.
@@ -125,15 +131,44 @@ updateNode(id, {
**Archive candidates are identified by the fix 3 query's `suggested_action = 'archive'` branch** (the `ELSE 'archive'` case: anything not an infrastructure/skill/documentation/strategic/audit type).
### 5. Duplicate-Node Detection (Level 1 — read-only, every run)
The graph's duplicate problem is rarely an agent mistyping a title: it is **recurring writers creating a new
node per run instead of updating one**. This phase detects that class and reports it. It is read-only and
**never merges**.
```bash
python3 /home/hermes/.hermes/scripts/memory_dup_detect.py --json
```
Read-only, ~15s over the whole graph, exit 0. That script is the source of truth for the clustering logic —
do not re-implement it in the prompt or hand-count "duplicates" from titles.
Consume each `items[]` entry's `verdict` field; do not invent your own:
| `verdict` | Meaning | Required action |
|---|---|---|
| `WRITER-DEFECT` (`run_family: true`) | ONE scheduled task writes a new node per run | Report the ids, the `agents` (the writer) and `span_days`. **Never merge** — each node is that run's audit record. If the family grew since the last report, say `UNFIXED` and name the writer. |
| `SAFE-MERGE` | Bodies identical | Still requires an explicit `merge #A into #B` decision from Kwame. |
| `HUMAN-DECISION` | Same subject, bodies differ | Propose **connect (an edge)**, never merge. |
- **Title overlap alone is not duplication.** Four distinct client workflows of one family (#357-#361) and two
different machines' migrations (#1792/#1793) both score high on title tokens while their bodies sit 0.1-0.3
apart. Confirm against body similarity before calling anything a duplicate.
- Report clusters as **candidates for Kwame's decision**, never as established duplicates — a wrong auto-merge
destroys distinct content irrecoverably.
- Per-run history nodes are kept deliberately. Bulk-merging a run family destroys the audit trail the family exists for.
## Level 2 Escalations (Kwame Decision Required)
1. **Refresh-suggested stale nodes** flagged with `[REVIEW: refresh]` — refresh or keep? (Archive-suggested nodes are auto-archived under fix 4 and are not escalated.)
2. **Duplicate Nodes** (same title or >70% title overlap) — Merge or keep?
2. **Duplicate Nodes** — as detected by fix 5, by `verdict`, never by raw title overlap. `WRITER-DEFECT` is a writer fix (update one canonical node), not a merge decision; `SAFE-MERGE` and `HUMAN-DECISION` clusters are escalated for merge-or-connect.
3. **Orphan Nodes >90 days old** — Archive or connect?
## Reporting Format
The fixer reports to Kwame via this Zulip DM:
The fixer does **not** send anything. Under the single-egress model (2026-09-21) every report leaves the node
through Mumuni's gate (`comms_drop.py` for the queue, `comms_gate.py` to release and read-back verify), so
exit 0 means QUEUED, never delivered. A report body is written to a file and handed to the outbox helper:
```
🦅 Memory Fixer — [HH:MM UTC]
@@ -147,8 +182,10 @@ Stale nodes needing review (max 10):
2. [Node #YYY] Title — Y days stale, SUGGEST: archive
...
Duplicates needing decision:
1. [Node #AAA] vs [Node #BBB] — Same title
Duplicate clusters (candidates — Kwame decides; the fixer never merges unilaterally):
1. [WRITER-DEFECT] #AAA/#BBB/#CCC — writer <agent>, N nodes, span Nd (UNFIXED if it grew since the last report)
2. [HUMAN-DECISION] #DDD/#EEE — same subject, bodies differ, SUGGEST: connect
3. "none" when the scan returned no clusters
Orphans >90 days:
1. [Node #EEE] Title — X days stale, orphaned
@@ -195,6 +232,7 @@ The result must be 0 rows when all decisions are executed. Report what was done.
- **State integrity:** archived nodes have `state: archived` + `[ARCHIVED]` prefix; kept nodes are `state: active` without a `[REVIEW:]` tag.
- **Auto-archive applied:** no node should ever be left tagged `[REVIEW: archive]` — that tag is retired. Any `[REVIEW: archive]` found means fix 4 was skipped; archive it and report.
- **No review-pending forever:** after executing Kwame's decisions, `[REVIEW:%` node count must be 0.
- **Duplicate scan ran:** every report carries the fix 5 block (`none` when there were no clusters). A report with no duplicate section means phase 5 was skipped — a silently skipped detection phase is the failure this phase exists to prevent.
- **Timestamps:** every executed decision (and every auto-archive) bumps `updated_at`, so the node exits the stale window on the next run.
## Logging
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""Agent-consumption layer in front of SearXNG + Firecrawl.
Multi-engine aggregation returns results with no dedupe, no filtering and no
reranking. Measured 2026-09-26 that put bestbuy.com and merriam-webster.com into
"best practices agent context management", and put four SEO blogs ABOVE the real
Proxmox forum threads on a precise technical query. Identical queries also ranked
differently between runs, so the fix has to be deterministic rather than
dependent on engine mood.
This module turns the raw result list into something an agent can actually use:
1. DEDUPE the same page arriving from several engines
2. DROP clear non-answers (homepages, shopping, dictionaries, logins)
3. DEMOTE config-listed low-authority hosts; PROMOTE primary sources
4. STABLE SORT so ordering is reproducible run to run
5. EXTRACT page text for the top N under an explicit character budget,
so one call returns usable material instead of a snippet
6. EMIT stable JSON with engine provenance
Policy lives in config/search-ranking.yaml, not in this file.
Usage:
search-agent-consume.py "query text" # JSON to stdout
search-agent-consume.py --no-extract "query" # ranking only, no Firecrawl
search-agent-consume.py --explain "query" # include drop/demote reasons
Exit: 0 ok, 1 no results survived filtering, 2 the layer could not run.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://192.168.68.7:8888").rstrip("/")
FIRECRAWL_URL = os.environ.get("FIRECRAWL_URL", "http://192.168.68.7:3002").rstrip("/")
CONFIG_PATH = os.environ.get(
"SEARCH_RANKING_CONFIG",
str(Path(__file__).resolve().parent.parent / "config" / "search-ranking.yaml"),
)
HTTP_TIMEOUT = float(os.environ.get("SEARCH_CONSUME_TIMEOUT", "25"))
def _load_config() -> dict:
"""Load the ranking policy.
PyYAML is used when present; otherwise a tiny built-in parser handles the
flat lists in this specific file, so the layer never hard-fails on a host
without PyYAML.
"""
text = Path(CONFIG_PATH).read_text()
try:
import yaml # type: ignore
return yaml.safe_load(text)
except ImportError:
return _parse_flat_yaml(text)
def _parse_flat_yaml(text: str) -> dict:
"""Minimal fallback parser: top-level keys, nested one level, flat lists."""
import re
out: dict = {}
stack: list[tuple[int, dict]] = [(-1, out)]
section: dict | None = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].rstrip()
if not line.strip():
continue
indent = len(line) - len(line.lstrip())
body = line.strip()
if body.startswith("- "):
if section is not None:
section.setdefault("_list", []).append(
body[2:].strip().strip("'\"")
)
continue
if ":" in body:
key, _, val = body.partition(":")
key, val = key.strip(), val.strip()
if val:
# write to the INNERMOST open section, not the document root
stack[-1][1][key] = _scalar(val)
section = None
else:
while stack and indent <= stack[-1][0]:
stack.pop()
parent = stack[-1][1]
new: dict = {}
parent[key] = new
stack.append((indent, new))
section = new
# flatten "_list" holders back into their parent as plain lists
def fix(node):
if isinstance(node, dict):
if set(node.keys()) == {"_list"}:
return node["_list"]
return {k: fix(v) for k, v in node.items()}
return node
return fix(out)
def _scalar(v: str):
if v.lower() in ("true", "false"):
return v.lower() == "true"
try:
return int(v)
except ValueError:
pass
try:
return float(v)
except ValueError:
pass
return v.strip("'\"")
# ── filtering ────────────────────────────────────────────────────────────────
def _host(url: str) -> str:
return (urllib.parse.urlparse(url).netloc or "").lower().split(":")[0]
def _registrable(host: str) -> str:
"""Best-effort registrable domain so sub.forum.proxmox.com matches proxmox.com."""
parts = host.split(".")
if len(parts) <= 2:
return host
# handle common two-label public suffixes
two = ".".join(parts[-2:])
if parts[-2] in ("co", "com", "org", "net", "ac", "gov") and len(parts) >= 3:
return ".".join(parts[-3:])
return two
def _host_in(host: str, domains) -> bool:
if not domains:
return False
reg = _registrable(host)
for d in domains:
d = str(d).lower()
if host == d or host.endswith("." + d) or reg == d:
return True
return False
def _normalise_url(url: str) -> str:
"""Strip tracking params and fragments so the same page dedupes."""
p = urllib.parse.urlparse(url)
q = [
(k, v)
for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True)
if not k.lower().startswith(("utm_", "fbclid", "gclid", "mc_", "ref"))
]
path = p.path.rstrip("/") or "/"
return urllib.parse.urlunparse(
(p.scheme.lower(), p.netloc.lower(), path, "", urllib.parse.urlencode(q), "")
)
def non_answer_reason(result: dict, cfg: dict) -> str | None:
"""Return why this result is a non-answer, or None if it may be returned."""
na = cfg.get("non_answer", {}) or {}
url = result.get("url", "")
p = urllib.parse.urlparse(url)
host = _host(url)
path = p.path or ""
if _host_in(host, na.get("hosts")):
return "shopping_or_dictionary_host"
if na.get("host_root", True) and path in ("", "/"):
# A preferred host's front door may legitimately be the answer
# (a repo, a docs site). Everything else is navigational.
if not _host_in(host, cfg.get("prefer_domains")):
return "navigational_host_root"
low = url.lower()
for pat in na.get("path_patterns", []) or []:
if pat.lower() in low:
return f"path_pattern:{pat}"
qkeys = {k.lower() for k in (na.get("query_keys") or [])}
if qkeys & {k.lower() for k, _ in urllib.parse.parse_qsl(p.query)}:
return "search_or_shopping_query"
return None
def source_type(url: str, cfg: dict) -> str:
host = _host(url)
if _host_in(host, ["github.com", "gitlab.com", "codeberg.org", "sourceforge.net"]):
return "code"
if _host_in(host, ["stackoverflow.com", "stackexchange.com", "superuser.com",
"serverfault.com", "askubuntu.com"]):
return "qa"
if _host_in(host, ["forum.proxmox.com", "forum.", "discourse"]) or "forum." in host:
return "forum"
if _host_in(host, ["news.ycombinator.com", "lobste.rs", "reddit.com"]):
return "discussion"
if _host_in(host, cfg.get("prefer_domains")):
return "official"
if _host_in(host, cfg.get("demote_domains")):
return "content-farm"
return "web"
def rank(results: list[dict], cfg: dict) -> tuple[list[dict], list[dict]]:
"""Dedupe, drop non-answers, demote/ promote, stable sort.
Returns (kept, dropped) where dropped carries the reason, because a filter
nobody can audit is a filter nobody should trust.
"""
rank_cfg = cfg.get("ranking", {}) or {}
demote_pen = float(rank_cfg.get("demote_penalty", 1000))
prefer_bonus = float(rank_cfg.get("prefer_bonus", 100))
multi_bonus = float(rank_cfg.get("multi_engine_bonus", 25))
seen: dict[str, dict] = {}
dropped: list[dict] = []
for pos, r in enumerate(results):
url = r.get("url")
if not url:
continue
key = _normalise_url(url)
engine = r.get("engine", "?")
# 1. dedupe: same normalised URL from several engines
if key in seen:
seen[key].setdefault("engines", []).append(engine)
seen[key]["duplicate_of"] = True
continue
reason = non_answer_reason(r, cfg)
if reason:
dropped.append({"url": url, "reason": reason, "position": pos + 1})
continue
seen[key] = {
"title": (r.get("title") or "").strip(),
"url": url,
"engines": [engine],
"position": pos,
"score": 0.0,
}
kept = []
for item in seen.values():
host = _host(item["url"])
score = -float(item["position"]) # original order is the base signal
if _host_in(host, cfg.get("demote_domains")):
score -= demote_pen
if _host_in(host, cfg.get("prefer_domains")):
score += prefer_bonus
if len(item["engines"]) > 1:
score += multi_bonus * (len(item["engines"]) - 1)
item["score"] = round(score, 2)
item["host"] = host
item["source_type"] = source_type(item["url"], cfg)
kept.append(item)
# stable: score desc, then original position asc => reproducible run to run
kept.sort(key=lambda i: (-i["score"], i["position"]))
return kept, dropped
# ── extraction ───────────────────────────────────────────────────────────────
def _post_json(url: str, payload: dict, timeout: float) -> dict:
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", "replace"))
def extract(items: list[dict], cfg: dict) -> dict:
"""Fetch page text for the top N under a global character budget."""
ex = cfg.get("extraction", {}) or {}
top_n = int(ex.get("top_n", 5))
total_budget = int(ex.get("total_chars", 12000))
per_item = int(ex.get("per_item_chars", 4000))
timeout = float(ex.get("timeout_seconds", 45))
used = 0
failures = 0
t0 = time.time()
for item in items[:top_n]:
remaining = total_budget - used
if remaining <= 200:
item["excerpt"] = ""
item["extraction"] = "skipped_budget_exhausted"
continue
cap = min(per_item, remaining)
try:
data = _post_json(
f"{FIRECRAWL_URL}/v1/scrape",
{"url": item["url"], "formats": ["markdown"]},
timeout,
)
md = ((data.get("data") or {}).get("markdown") or "").strip()
if not md:
item["excerpt"] = ""
item["extraction"] = "empty"
failures += 1
continue
item["excerpt"] = md[:cap]
item["extraction"] = "ok" if len(md) <= cap else "truncated"
used += len(item["excerpt"])
except Exception as exc: # noqa: BLE001
item["excerpt"] = ""
item["extraction"] = f"failed:{type(exc).__name__}"
failures += 1
return {
"extracted": min(top_n, len(items)),
"chars_used": used,
"budget": total_budget,
"failures": failures,
"seconds": round(time.time() - t0, 2),
}
# ── entry point ──────────────────────────────────────────────────────────────
def consume(query: str, do_extract: bool = True, explain: bool = False) -> dict:
cfg = _load_config()
url = f"{SEARXNG_URL}/search?" + urllib.parse.urlencode(
{"q": query, "format": "json"}
)
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as resp:
raw = json.loads(resp.read().decode("utf-8", "replace"))
results = raw.get("results", [])
kept, dropped = rank(results, cfg)
extraction = extract(kept, cfg) if do_extract else None
out = {
"query": query,
"raw_result_count": len(results),
"returned_count": len(kept),
"dropped_count": len(dropped),
"engines": sorted({r.get("engine", "?") for r in results}),
"results": [
{
"rank": i + 1,
"title": it["title"],
"url": it["url"],
"host": it["host"],
"source_type": it["source_type"],
"engines": sorted(set(it["engines"])),
"score": it["score"],
"excerpt": it.get("excerpt", ""),
"extraction": it.get("extraction", "not_attempted"),
}
for i, it in enumerate(kept)
],
"extraction": extraction,
}
if explain:
out["dropped"] = dropped
return out
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
do_extract = "--no-extract" not in sys.argv
explain = "--explain" in sys.argv
if not args:
print(__doc__)
return 2
query = " ".join(args)
try:
out = consume(query, do_extract=do_extract, explain=explain)
except Exception as exc: # noqa: BLE001
print(f"LAYER FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
print(json.dumps(out, indent=2))
return 0 if out["returned_count"] else 1
if __name__ == "__main__":
sys.exit(main())
+83
View File
@@ -114,6 +114,79 @@ def unresponsive_names(pairs: list) -> dict[str, str]:
return out
# ── QUALITY GUARD (search-agent-consumption) ─────────────────────────────────
# The agent-consumption layer applies a deterministic demote/drop policy. Without
# an assertion here it could silently rot back to raw engine ordering - the same
# way the endpoint colours silently rotted before 2026-09-26.
QUALITY_QUERIES = [
"best practices agent context management",
"proxmox thin pool metadata exhaustion recovery",
]
# A demoted (content-farm) host must never occupy the top 3 for these queries.
QUALITY_TOP_N = 3
# Non-answers that must never be returned for these queries at all.
QUALITY_BANNED_HOSTS = ["bestbuy.com", "merriam-webster.com"]
def _consumption_layer_path():
here = os.path.dirname(os.path.abspath(__file__))
return os.path.join(here, "search-agent-consume.py")
def check_ranking_quality() -> list[str]:
"""Return a list of quality failures; empty means healthy."""
import subprocess as _sp
layer = _consumption_layer_path()
if not os.path.exists(layer):
return [f"agent-consumption layer missing: {layer}"]
failures: list[str] = []
for query in QUALITY_QUERIES:
r = _sp.run([sys.executable, layer, "--no-extract", "--explain", query],
capture_output=True, text=True, timeout=120)
if r.returncode != 0:
failures.append(f"{query!r}: layer exited {r.returncode} ({r.stderr[:120]})")
continue
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
failures.append(f"{query!r}: layer returned unparseable JSON")
continue
results = data.get("results", [])
if len(results) < QUALITY_TOP_N:
failures.append(f"{query!r}: only {len(results)} results returned")
continue
# load the demote list from the SAME config the layer uses
cfg_path = os.path.join(os.path.dirname(layer), "..", "config", "search-ranking.yaml")
demoted: set[str] = set()
try:
sys.path.insert(0, os.path.dirname(layer))
import importlib.util as _iu
spec = _iu.spec_from_file_location("_sac_cfg", layer)
mod = _iu.module_from_spec(spec)
spec.loader.exec_module(mod)
demoted = set(mod._load_config().get("demote_domains", []) or [])
except Exception: # noqa: BLE001
failures.append(f"{query!r}: could not load demote_domains from config")
for item in results[:QUALITY_TOP_N]:
host = (item.get("host") or "")
for d in demoted:
if host == d or host.endswith("." + d):
failures.append(
f"{query!r}: demoted host {host} in top {QUALITY_TOP_N}"
)
for item in results:
host = (item.get("host") or "")
for b in QUALITY_BANNED_HOSTS:
if host == b or host.endswith("." + b):
failures.append(f"{query!r}: non-answer host {host} returned")
return failures
def main() -> int:
failures: list[str] = []
print(f"Search stack check -- {SEARXNG_URL}")
@@ -216,6 +289,16 @@ def main() -> int:
print(f" FAIL: {msg}")
failures.append(msg)
print("-" * 72)
print("RANKING QUALITY (agent-consumption layer)")
quality = check_ranking_quality()
if quality:
for q in quality:
print(f" FAIL: {q}")
failures.extend(quality)
else:
print(" ok: no demoted host in the top 3; no banned non-answer returned")
print("=" * 72)
if failures:
print("VERDICT: FAIL")
+137
View File
@@ -0,0 +1,137 @@
---
kind: function
name: search-agent-consumption
description: >
Agent-consumption layer in front of SearXNG + Firecrawl. Raw multi-engine
aggregation returns results with no dedupe, no filtering and no reranking;
measured 2026-09-26 that put bestbuy.com and merriam-webster.com into "best
practices agent context management", and put four SEO blogs above the real
Proxmox forum threads on a precise technical query. Identical queries also
ranked DIFFERENTLY between runs, which is why the layer is deterministic
rather than dependent on engine behaviour.
Pipeline: dedupe -> drop non-answers -> demote content farms / promote primary
sources -> stable sort -> extract page text for the top N under an explicit
character budget -> stable JSON. Policy lives in config, not code.
Call it when an agent needs search RESULTS rather than links: it returns usable
page text in one call instead of a snippet plus a second fetch.
version: 1.0.0
---
## Where the policy lives
`config/search-ranking.yaml` — reviewable, no code change needed to adjust:
| key | effect |
| --- | --- |
| `non_answer.hosts` / `path_patterns` / `query_keys` / `host_root` | dropped outright |
| `demote_domains` | ranked below everything, never dropped |
| `prefer_domains` | promoted above default rank |
| `ranking.*` | `demote_penalty`, `prefer_bonus`, `multi_engine_bonus` |
| `extraction.*` | `top_n`, `total_chars`, `per_item_chars`, `timeout_seconds` |
**Demotion, not deletion, for content farms**: a genuinely useful hit is not lost,
it simply cannot outrank a primary source. Non-answers are dropped because they
cannot answer a question at all.
## Usage
```bash
python3 scripts/search-agent-consume.py "query text" # JSON
python3 scripts/search-agent-consume.py --no-extract "query" # ranking only
python3 scripts/search-agent-consume.py --explain "query" # + drop reasons
```
Exit `0` ok, `1` nothing survived filtering, `2` the layer could not run.
## Output shape
Stable JSON:
```json
{
"query": "...",
"raw_result_count": 46,
"returned_count": 44,
"dropped_count": 2,
"engines": ["bing", "brave", "duckduckgo", "yandex"],
"results": [
{"rank": 1, "title": "...", "url": "...", "host": "...",
"source_type": "official|code|qa|forum|discussion|web|content-farm",
"engines": ["bing"], "score": 100.0,
"excerpt": "...", "extraction": "ok|truncated|skipped_budget_exhausted|empty|failed:<Type>"}
],
"extraction": {"extracted": 5, "chars_used": 12000, "budget": 12000,
"failures": 0, "seconds": 5.28}
}
```
`--explain` adds `dropped: [{url, reason, position}]` so the filter is auditable
rather than magic.
## Measured before/after (2026-09-26)
Fixed query set. Relevance judged per query, not by impression.
**`best practices agent context management`**
| | before (raw SearXNG) | after (layer) |
| --- | --- | --- |
| 1-2 | anthropic, stackai | anthropic, langchain |
| 3-4 | aitechmonk, agentic-design | jetbrains, blog.jetbrains |
| 5-6 | mindstudio, sparkco | docs.langchain, reddit |
| 7-8 | langchain, medium | cursor, reddit |
| verdict | 4 relevant of 10; 4 content farms; medium.com twice | top 8 all primary/discussion; no content farm in the top 8 |
**`proxmox thin pool metadata exhaustion recovery`**
| | before | after |
| --- | --- | --- |
| 1-4 | vormox, linuxoperatingsystem, riparazioneserver, bigiron (all SEO/thin) | forum.proxmox.com, forum.proxmox.com, gist.github, github |
| 5-9 | forum.proxmox.com x2, voxfor, github, gist | forum.proxmox.com, serverfault, forum.proxmox.com, reddit |
The primary sources moved from positions 5-9 to 1-4.
**Rule proof** (`--explain`, and a direct check of the classifier):
```
DigitalOcean docs -> KEEP (a '/products/' path rule was REMOVED after the
before/after run caught it dropping this page)
Best Buy -> DROP shopping_or_dictionary_host
Merriam-Webster -> DROP shopping_or_dictionary_host
bare homepage -> DROP navigational_host_root
proxmox.com home -> KEEP (preferred host root: a repo/docs front door is
legitimately the answer)
github repo -> KEEP
```
**Extraction cost (criterion 4):**
```
extracted 5 items, 12000 chars used of 12000 budget, 0 failures, 5.28s
whole run end-to-end: 6.4s wall
```
## Regression guard
`search-stack-visibility` asserts the layer still ranks correctly: for the fixed
query set, no `demote_domains` host may appear in the top 3, and the two known
non-answers must not be returned. Without it this layer could silently rot back
to raw ordering, which is exactly what happened to the endpoint colours.
## Reachability, and one honest gap
- **Hermes agents** reach it directly: it reads the same `SEARXNG_URL` and
`FIRECRAWL_URL` they already use.
- **pi agents (MCP search server)**: the MCP server's request/response shape is
**not ours to change**, so this layer is **NOT** wired into it. That is a real
gap, stated rather than claimed as coverage. Closing it would require a change
on the MCP side, which is outside this repo.
## Constraints
Does not touch the live SearXNG or Firecrawl service paths. Third-party
`google cse` is not a hard requirement of this layer — if it 429s, ranking still
works from the remaining engines. No credential is added or required.
@@ -0,0 +1,236 @@
"""Regression test for the fallback_providers list-shape crash in audit-hermes-config.py.
WHY THIS FILE EXISTS: audit-hermes-config.py assumed `fallback_providers` was always a dict
(single provider). Two live agents (koby, koonimo) carry it as a LIST of dicts (one entry per
fallback), so the script crashed with:
File "audit-hermes-config.py", line 211, in audit
fb.get("provider") == "deepseek",
AttributeError: 'list' object has no attribute 'get'
Both are REAL agent configs, so this is not a malformed-input case — the script simply could not
audit two of the four agents it exists to audit. Until fixed, the key-hygiene check had no
coverage for half the fleet while appearing to run.
These tests execute the real CLI (`python3 audit-hermes-config.py <config>`) and assert:
1. A config whose `fallback_providers` is a LIST of valid dicts does NOT crash (exit code is 0 or 1,
never a traceback/AttributeError).
2. A config whose `fallback_providers` contains a MALFORMED entry (a list element that is not a
mapping) reports a VIOLATION naming the offending entry, NOT an uncaught exception.
3. The dict shape still works (existing tests must stay green).
No network, vault, or SSH access is required.
"""
from __future__ import annotations
import pathlib
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
AUDIT = ROOT / "audit-hermes-config.py"
# A valid config where fallback_providers is a LIST of dicts (the real koby/koonimo shape).
# One entry, well-formed: provider=deepseek, model=deepseek-v4-flash, api_key_env=DEEPSEEK_API_KEY.
# This must produce a real verdict (PASS or FAIL) without crashing.
LIST_SHAPE_VALID = """
model:
api_key: ""
api_key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
max_tokens: 4096
default: syslog-auto
provider: harness
fallback_providers:
- provider: deepseek
model: deepseek-v4-flash
api_key_env: DEEPSEEK_API_KEY
compression:
model: syslog-auto
provider: harness
threshold: 0.65
max_context_window: 131072
auxiliary:
vision:
model: gpu-vision
provider: harness
web_extract:
model: gpu-vision
provider: harness
compression:
model: syslog-auto
provider: harness
delegation:
provider: harness
custom_providers:
- name: harness
key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
"""
# A valid config where fallback_providers is a LIST with TWO entries (multiple fallbacks).
# Both entries well-formed. Must not crash and should produce a real verdict.
LIST_SHAPE_MULTI = """
model:
api_key: ""
api_key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
max_tokens: 4096
default: syslog-auto
provider: harness
fallback_providers:
- provider: deepseek
model: deepseek-v4-flash
api_key_env: DEEPSEEK_API_KEY
- provider: deepseek
model: deepseek-v4-flash
api_key_env: DEEPSEEK_API_KEY
compression:
model: syslog-auto
provider: harness
threshold: 0.65
max_context_window: 131072
auxiliary:
vision:
model: gpu-vision
provider: harness
web_extract:
model: gpu-vision
provider: harness
compression:
model: syslog-auto
provider: harness
delegation:
provider: harness
custom_providers:
- name: harness
key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
"""
# A config where fallback_providers is a LIST containing a MALFORMED entry:
# one element is a plain string, not a mapping. The checker must report a VIOLATION
# naming the offending entry (fallback_providers[1]) and NOT crash.
LIST_SHAPE_MALFORMED = """
model:
api_key: ""
api_key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
max_tokens: 4096
default: syslog-auto
provider: harness
fallback_providers:
- provider: deepseek
model: deepseek-v4-flash
api_key_env: DEEPSEEK_API_KEY
- "not-a-mapping"
compression:
model: syslog-auto
provider: harness
threshold: 0.65
max_context_window: 131072
auxiliary:
vision:
model: gpu-vision
provider: harness
web_extract:
model: gpu-vision
provider: harness
compression:
model: syslog-auto
provider: harness
delegation:
provider: harness
custom_providers:
- name: harness
key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
"""
# The original DICT shape (single provider) must still work — existing behaviour preserved.
DICT_SHAPE_VALID = """
model:
api_key: ""
api_key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
max_tokens: 4096
default: syslog-auto
provider: harness
fallback_providers:
provider: deepseek
model: deepseek-v4-flash
api_key_env: DEEPSEEK_API_KEY
compression:
model: syslog-auto
provider: harness
threshold: 0.65
max_context_window: 131072
auxiliary:
vision:
model: gpu-vision
provider: harness
web_extract:
model: gpu-vision
provider: harness
compression:
model: syslog-auto
provider: harness
delegation:
provider: harness
custom_providers:
- name: harness
key_env: LITELLM_API_KEY
base_url: http://192.168.68.116/litellm/v1
"""
def _run_config(tmp_path, name, text):
cfg = tmp_path / name
cfg.write_text(text)
proc = subprocess.run(
[sys.executable, str(AUDIT), str(cfg)],
capture_output=True, text=True,
)
return proc.returncode, proc.stdout, proc.stderr
def test_list_shape_single_entry_does_not_crash(tmp_path):
"""A LIST with one valid dict must not raise AttributeError; exit 0 (PASS)."""
code, out, err = _run_config(tmp_path, "list-single.yaml", LIST_SHAPE_VALID)
# Must NOT be a crash (traceback). A clean run exits 0 (PASS) or 1 (FAIL), never 2+ (exception).
assert code in (0, 1), f"Expected clean exit 0 or 1, got {code}\nSTDOUT:\n{out}\nSTDERR:\n{err}"
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
# The valid single-entry list should PASS (all rules satisfied).
assert code == 0, f"Expected PASS but got {code}\n{out}"
assert "RESULT: PASS" in out
def test_list_shape_multiple_entries_does_not_crash(tmp_path):
"""A LIST with two valid dicts must not raise AttributeError; exit 0 (PASS)."""
code, out, err = _run_config(tmp_path, "list-multi.yaml", LIST_SHAPE_MULTI)
assert code in (0, 1), f"Expected clean exit 0 or 1, got {code}\nSTDOUT:\n{out}\nSTDERR:\n{err}"
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
assert code == 0, f"Expected PASS but got {code}\n{out}"
assert "RESULT: PASS" in out
def test_list_shape_malformed_entry_reports_violation_not_crash(tmp_path):
"""A LIST containing a non-mapping element must be a reported VIOLATION, not a crash."""
code, out, err = _run_config(tmp_path, "list-malformed.yaml", LIST_SHAPE_MALFORMED)
# Must NOT be a crash.
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
# Should be a FAIL (exit 1) because the malformed entry is a violation.
assert code == 1, f"Expected FAIL (exit 1) but got {code}\n{out}"
assert "RESULT: FAIL" in out
# The violation must name the offending entry (fallback_providers[1]).
assert "fallback_providers[1]" in out, f"Violation did not name the offending entry:\n{out}"
def test_dict_shape_still_passes(tmp_path):
"""The original DICT shape (single provider) must still PASS — existing behaviour preserved."""
code, out, err = _run_config(tmp_path, "dict-valid.yaml", DICT_SHAPE_VALID)
assert code == 0, f"Expected PASS but got {code}\n{out}\nSTDERR:\n{err}"
assert "RESULT: PASS" in out