9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
308 lines
10 KiB
Python
308 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Patch ODS's Hermes config without clobbering user sections.
|
|
|
|
Hermes copies extensions/services/hermes/cli-config.yaml.template to
|
|
data/hermes/config.yaml only on first start. Installer migrations need to
|
|
repair the small set of ODS-managed defaults in both files while preserving
|
|
the rest of the user's config.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
def _top_level_block(lines: list[str], name: str) -> tuple[int, int] | None:
|
|
start = None
|
|
pattern = re.compile(rf"^{re.escape(name)}:\s*(?:#.*)?$")
|
|
for idx, line in enumerate(lines):
|
|
if start is None:
|
|
if pattern.match(line):
|
|
start = idx
|
|
continue
|
|
if line and not line.startswith((" ", "\t")) and not line.lstrip().startswith("#"):
|
|
return start, idx
|
|
if start is None:
|
|
return None
|
|
return start, len(lines)
|
|
|
|
|
|
def _child_block(lines: list[str], parent: tuple[int, int], name: str, indent: int) -> tuple[int, int] | None:
|
|
start = None
|
|
prefix = " " * indent
|
|
pattern = re.compile(rf"^{prefix}{re.escape(name)}:\s*(?:#.*)?$")
|
|
for idx in range(parent[0] + 1, parent[1]):
|
|
line = lines[idx]
|
|
if start is None:
|
|
if pattern.match(line):
|
|
start = idx
|
|
continue
|
|
if line.startswith(prefix) and line.strip() and not line.startswith(prefix + " ") and not line.lstrip().startswith("#"):
|
|
return start, idx
|
|
if start is None:
|
|
return None
|
|
return start, parent[1]
|
|
|
|
|
|
def _set_key(lines: list[str], block: tuple[int, int], key: str, value: str, indent: int) -> tuple[int, int]:
|
|
prefix = " " * indent
|
|
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
|
|
for idx in range(block[0] + 1, block[1]):
|
|
if pattern.match(lines[idx]):
|
|
lines[idx] = f"{prefix}{key}: {value}"
|
|
return block
|
|
|
|
insert_at = block[1]
|
|
lines.insert(insert_at, f"{prefix}{key}: {value}")
|
|
return block[0], block[1] + 1
|
|
|
|
|
|
def _has_key(lines: list[str], block: tuple[int, int], key: str, indent: int) -> bool:
|
|
prefix = " " * indent
|
|
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
|
|
return any(pattern.match(lines[idx]) for idx in range(block[0] + 1, block[1]))
|
|
|
|
|
|
def _key_value(lines: list[str], block: tuple[int, int], key: str, indent: int) -> str | None:
|
|
prefix = " " * indent
|
|
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*(.*?)\s*(?:#.*)?$")
|
|
for idx in range(block[0] + 1, block[1]):
|
|
match = pattern.match(lines[idx])
|
|
if match:
|
|
return match.group(1).strip()
|
|
return None
|
|
|
|
|
|
def _ensure_model(lines: list[str], model: str | None, base_url: str | None, context_length: int | None, api_key: str | None = None) -> None:
|
|
block = _top_level_block(lines, "model")
|
|
if block is None:
|
|
insert = ["model:"]
|
|
if model:
|
|
insert.append(f' default: "{model}"')
|
|
if base_url:
|
|
insert.append(' provider: "custom"')
|
|
insert.append(f' base_url: "{base_url}"')
|
|
if api_key:
|
|
insert.append(f' api_key: "{api_key}"')
|
|
if context_length:
|
|
insert.append(f" context_length: {context_length}")
|
|
lines[:0] = insert + [""]
|
|
return
|
|
|
|
if model:
|
|
block = _set_key(lines, block, "default", f'"{model}"', 2)
|
|
if base_url:
|
|
block = _set_key(lines, block, "base_url", f'"{base_url}"', 2)
|
|
if api_key:
|
|
block = _set_key(lines, block, "api_key", f'"{api_key}"', 2)
|
|
if context_length:
|
|
_set_key(lines, block, "context_length", str(context_length), 2)
|
|
|
|
|
|
def _ensure_provider_timeout(lines: list[str], provider: str = "custom", timeout_seconds: int = 180) -> None:
|
|
"""Add ODS's local-provider timeout default without clobbering operators.
|
|
|
|
Hermes can spend a long time in local prefill before the first token on
|
|
35B-class models. Existing configs from older ODS installs are missing
|
|
providers.custom.request_timeout_seconds, so add it on migration. If the
|
|
operator already tuned the provider timeout, leave their value untouched.
|
|
Passing a non-default timeout updates only ODS's shipped 180s default,
|
|
which lets platform installers tune slow local backends without replacing
|
|
an operator's custom value.
|
|
"""
|
|
|
|
block = _top_level_block(lines, "providers")
|
|
if block is None:
|
|
auxiliary = _top_level_block(lines, "auxiliary")
|
|
model = _top_level_block(lines, "model")
|
|
insert_at = auxiliary[0] if auxiliary else (model[1] if model else len(lines))
|
|
payload = [
|
|
"providers:",
|
|
f" {provider}:",
|
|
f" request_timeout_seconds: {timeout_seconds}",
|
|
"",
|
|
]
|
|
lines[insert_at:insert_at] = payload
|
|
return
|
|
|
|
provider_block = _child_block(lines, block, provider, 2)
|
|
if provider_block is None:
|
|
insert_at = block[1]
|
|
lines[insert_at:insert_at] = [
|
|
f" {provider}:",
|
|
f" request_timeout_seconds: {timeout_seconds}",
|
|
]
|
|
return
|
|
|
|
existing = _key_value(lines, provider_block, "request_timeout_seconds", 4)
|
|
if existing is None:
|
|
_set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)
|
|
elif timeout_seconds != 180 and existing == "180":
|
|
_set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)
|
|
|
|
|
|
def _ensure_auxiliary(lines: list[str], context_length: int | None) -> None:
|
|
if not context_length:
|
|
return
|
|
|
|
block = _top_level_block(lines, "auxiliary")
|
|
if block is None:
|
|
terminal = _top_level_block(lines, "terminal")
|
|
insert_at = terminal[0] if terminal else len(lines)
|
|
payload = [
|
|
"auxiliary:",
|
|
" compression:",
|
|
f" context_length: {context_length}",
|
|
"",
|
|
]
|
|
lines[insert_at:insert_at] = payload
|
|
return
|
|
|
|
compression = _child_block(lines, block, "compression", 2)
|
|
if compression is None:
|
|
insert_at = block[1]
|
|
lines[insert_at:insert_at] = [
|
|
" compression:",
|
|
f" context_length: {context_length}",
|
|
]
|
|
return
|
|
|
|
_set_key(lines, compression, "context_length", str(context_length), 4)
|
|
|
|
|
|
def _ensure_compression(lines: list[str]) -> None:
|
|
"""Set the compression block to ODS Talk's tuned values.
|
|
|
|
Previous defaults (0.50 / 0.20 / 20) caused the agent to lose granular
|
|
context mid-conversation when a single tool result briefly spiked
|
|
context past the 50% threshold. Bumped per ODS Talk live-testing —
|
|
see cli-config.yaml.template for the full reasoning.
|
|
|
|
Idempotent: every install (fresh or upgrade) that runs this patcher
|
|
converges /opt/data/config.yaml to these values, automatically
|
|
migrating existing operator installs on the next bootstrap-upgrade.
|
|
"""
|
|
block = _top_level_block(lines, "compression")
|
|
if block is None:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"compression:",
|
|
" enabled: true",
|
|
" threshold: 0.75",
|
|
" target_ratio: 0.50",
|
|
" protect_last_n: 40",
|
|
]
|
|
)
|
|
return
|
|
|
|
block = _set_key(lines, block, "enabled", "true", 2)
|
|
block = _set_key(lines, block, "threshold", "0.75", 2)
|
|
block = _set_key(lines, block, "target_ratio", "0.50", 2)
|
|
_set_key(lines, block, "protect_last_n", "40", 2)
|
|
|
|
|
|
def _ensure_whatsapp_bridge(lines: list[str]) -> None:
|
|
"""Keep WhatsApp off by default while avoiding upstream's port 3000 bridge.
|
|
|
|
Existing operator choices win: if a WhatsApp block already exists we do not
|
|
change its enabled state, and if it already has extra.bridge_port we leave
|
|
that value alone.
|
|
"""
|
|
|
|
platforms = _top_level_block(lines, "platforms")
|
|
if platforms is None:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"platforms:",
|
|
" whatsapp:",
|
|
" enabled: false",
|
|
" extra:",
|
|
" bridge_port: 3010",
|
|
]
|
|
)
|
|
return
|
|
|
|
whatsapp = _child_block(lines, platforms, "whatsapp", 2)
|
|
if whatsapp is None:
|
|
insert_at = platforms[1]
|
|
lines[insert_at:insert_at] = [
|
|
" whatsapp:",
|
|
" enabled: false",
|
|
" extra:",
|
|
" bridge_port: 3010",
|
|
]
|
|
return
|
|
|
|
extra = _child_block(lines, whatsapp, "extra", 4)
|
|
if extra is None:
|
|
insert_at = whatsapp[1]
|
|
lines[insert_at:insert_at] = [
|
|
" extra:",
|
|
" bridge_port: 3010",
|
|
]
|
|
return
|
|
|
|
for idx in range(extra[0] + 1, extra[1]):
|
|
if re.match(r"^\s{6}bridge_port:\s*.*$", lines[idx]):
|
|
return
|
|
_set_key(lines, extra, "bridge_port", "3010", 6)
|
|
|
|
|
|
def patch_config(
|
|
path: Path,
|
|
model: str | None,
|
|
base_url: str | None,
|
|
context_length: int | None,
|
|
api_key: str | None = None,
|
|
request_timeout_seconds: int = 180,
|
|
) -> bool:
|
|
original = path.read_text(encoding="utf-8")
|
|
trailing_newline = original.endswith("\n")
|
|
lines = original.splitlines()
|
|
|
|
_ensure_model(lines, model, base_url, context_length, api_key)
|
|
_ensure_provider_timeout(lines, timeout_seconds=request_timeout_seconds)
|
|
_ensure_auxiliary(lines, context_length)
|
|
_ensure_whatsapp_bridge(lines)
|
|
_ensure_compression(lines)
|
|
|
|
updated = "\n".join(lines)
|
|
if trailing_newline:
|
|
updated += "\n"
|
|
if updated == original:
|
|
return False
|
|
path.write_text(updated, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Patch ODS Hermes config defaults.")
|
|
parser.add_argument("path", type=Path)
|
|
parser.add_argument("--model")
|
|
parser.add_argument("--base-url")
|
|
parser.add_argument("--api-key", help="Bearer token Hermes uses to call the LLM (needed when routing through litellm)")
|
|
parser.add_argument("--context-length", type=int)
|
|
parser.add_argument("--request-timeout-seconds", type=int, default=180)
|
|
args = parser.parse_args()
|
|
|
|
if not args.path.exists():
|
|
return 0
|
|
changed = patch_config(
|
|
args.path,
|
|
args.model,
|
|
args.base_url,
|
|
args.context_length,
|
|
args.api_key,
|
|
args.request_timeout_seconds,
|
|
)
|
|
print("changed" if changed else "unchanged")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|