hermes: group automated triage sessions
All checks were successful
Tests / Declarative: Post Actions passed: 265

This commit is contained in:
jenkins 2026-08-13 00:50:34 -03:00
parent 15b6711a7b
commit 37ac5ff11f
4 changed files with 208 additions and 4 deletions

View File

@ -212,6 +212,31 @@ spec:
limits:
cpu: 100m
memory: 128Mi
- name: patch-api-server-sessions
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
/opt/hermes/.venv/bin/python /opt/coordinator/patch_api_server_sessions.py \
/opt/hermes/gateway/platforms/api_server.py /patched/api_server.py
/opt/hermes/.venv/bin/python /opt/coordinator/migrate_api_session_lineage.py
env:
- {name: HERMES_API_DEFAULT_PARENT_SESSION_ID, value: automated-triage}
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: api-server-patch, mountPath: /patched}
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: install-kubectl
image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
imagePullPolicy: IfNotPresent
@ -282,6 +307,10 @@ spec:
value: https://metrics.bstein.dev
- name: HERMES_AUTO_ROUTER_PROFILE
value: triage
- name: HERMES_API_DEFAULT_PARENT_SESSION_ID
value: automated-triage
- name: HERMES_API_DEFAULT_PARENT_MATCH_PREFIX
value: A static-analysis finding, not a build failure.
# Claude subscription OAuth token (sk-ant-oat01...). The anthropic
# provider accepts ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, or this, in
# that order; an OAuth token is not an API key, so it must arrive
@ -307,6 +336,9 @@ spec:
- name: auth-patch
mountPath: /opt/hermes/hermes_cli/auth.py
subPath: auth.py
- name: api-server-patch
mountPath: /opt/hermes/gateway/platforms/api_server.py
subPath: api_server.py
- name: tools
mountPath: /usr/local/bin/kubectl
subPath: kubectl
@ -432,6 +464,8 @@ spec:
name: hermes-auto-router-plugin
- name: auth-patch
emptyDir: {}
- name: api-server-patch
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 256Mi

View File

@ -3,7 +3,10 @@
from __future__ import annotations
import os
import re
import sqlite3
import time
from pathlib import Path
@ -22,9 +25,63 @@ LEGACY_CASSANDRA_WORKERS = {
LEGACY_ORPHANED_SMOKE_SESSIONS = {
"e17f2d888689": "Archived agent workspace smoke test",
}
TRIAGE_PARENT = "automated-triage"
TRIAGE_PARENT_TITLE = "Automated triage"
TRIAGE_MESSAGE_PREFIX = "A static-analysis finding, not a build failure."
def migrate(path: Path = STATE_DB) -> int:
def _triage_title(message: str) -> str:
"""Create a concise label from Ariadne's stable incident contract."""
match = re.search(r"for incident ([^\s]+)\.", message)
if not match:
return "Automated triage run"
parts = match.group(1).split("/")
label = " · ".join(parts[1:3]) if len(parts) >= 3 else match.group(1)
return f"Sonar · {label}"
def migrate_triage_group(connection: sqlite3.Connection) -> int:
"""Nest only recognized automated Sonar runs under one durable parent."""
rows = connection.execute(
"""
SELECT s.id, s.started_at, m.content
FROM sessions AS s
JOIN messages AS m ON m.session_id = s.id
WHERE s.source = 'api_server'
AND s.parent_session_id IS NULL
AND s.archived = 0
AND m.role = 'user'
AND m.content LIKE ?
ORDER BY s.started_at
""",
(f"{TRIAGE_MESSAGE_PREFIX}%",),
).fetchall()
if not rows:
return 0
started_at = min(float(row[1] or time.time()) for row in rows)
connection.execute(
"""
INSERT OR IGNORE INTO sessions (id, source, started_at, title, archived)
VALUES (?, 'api_server', ?, ?, 0)
""",
(TRIAGE_PARENT, started_at, TRIAGE_PARENT_TITLE),
)
changed = 0
for session_id, _, message in rows:
cursor = connection.execute(
"""
UPDATE sessions
SET parent_session_id = ?, title = ?
WHERE id = ?
AND parent_session_id IS NULL
""",
(TRIAGE_PARENT, _triage_title(str(message or "")), session_id),
)
changed += cursor.rowcount
return changed
def migrate(path: Path = STATE_DB, *, group_triage: bool = False) -> int:
"""Apply idempotent, transcript-preserving lineage corrections."""
if not path.is_file():
return 0
@ -61,8 +118,11 @@ def migrate(path: Path = STATE_DB) -> int:
(title, session_id),
)
changed += cursor.rowcount
if group_triage:
changed += migrate_triage_group(connection)
return changed
if __name__ == "__main__":
print(f"migrated {migrate()} legacy API sessions")
group_triage = os.environ.get("HERMES_API_DEFAULT_PARENT_SESSION_ID") == TRIAGE_PARENT
print(f"migrated {migrate(group_triage=group_triage)} legacy API sessions")

View File

@ -48,14 +48,70 @@ AFTER = ''' model = body.get("model") or self._model_name
)
'''
RUNS_BEFORE = ''' run_id = f"run_{uuid.uuid4().hex}"
session_id = body.get("session_id") or stored_session_id or run_id
# Approval queues gate host-side tool execution and must be isolated
'''
RUNS_AFTER = ''' run_id = f"run_{uuid.uuid4().hex}"
session_id = body.get("session_id") or stored_session_id or run_id
# Persist API-run lineage before the agent starts. Automated callers
# may omit a parent, so a deployment can provide a narrowly matched
# default without grouping ordinary interactive conversations.
metadata = body.get("metadata")
metadata_parent = metadata.get("parent_session_id") if isinstance(metadata, dict) else None
raw_parent = body.get("parent_session_id") or metadata_parent or request.headers.get(
"X-Hermes-Parent-Session-Id"
)
default_parent = os.environ.get("HERMES_API_DEFAULT_PARENT_SESSION_ID", "").strip()
default_prefix = os.environ.get("HERMES_API_DEFAULT_PARENT_MATCH_PREFIX", "").strip()
if not raw_parent and default_parent and default_prefix and user_message.startswith(default_prefix):
raw_parent = default_parent
parent_session_id = str(raw_parent).strip() if raw_parent else None
from gateway.session import _is_path_unsafe
if parent_session_id:
if (
len(parent_session_id) > self._MAX_SESSION_HEADER_LEN
or re.search(r'[\\r\\n\\x00]', parent_session_id)
or _is_path_unsafe(parent_session_id)
or parent_session_id == session_id
):
return web.json_response(_openai_error("Invalid parent session ID", code="invalid_parent_session_id"), status=400)
db = self._ensure_session_db()
if db is None:
return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503)
if not db.get_session(parent_session_id):
return web.json_response(_openai_error(f"Parent session not found: {parent_session_id}", code="parent_session_not_found"), status=404)
if not db.get_session(session_id):
db.create_session(
session_id,
"api_server",
model=str(body.get("model") or self._model_name or ""),
system_prompt=instructions if isinstance(instructions, str) else None,
parent_session_id=parent_session_id,
)
incident = re.search(r"for incident ([^\\s]+)\\.", user_message)
if incident and parent_session_id == default_parent:
parts = incident.group(1).split("/")
label = " · ".join(parts[1:3]) if len(parts) >= 3 else incident.group(1)
db.set_session_title(session_id, f"Sonar · {label}")
# Approval queues gate host-side tool execution and must be isolated
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the narrow session-lineage extension and fail on upstream drift."""
content = source.read_text(encoding="utf-8")
if BEFORE not in content:
raise RuntimeError("Hermes API session patch context changed")
if RUNS_BEFORE not in content:
raise RuntimeError("Hermes API runs patch context changed")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content.replace(BEFORE, AFTER, 1), encoding="utf-8")
content = content.replace(BEFORE, AFTER, 1)
destination.write_text(content.replace(RUNS_BEFORE, RUNS_AFTER, 1), encoding="utf-8")
def main() -> int:

View File

@ -986,7 +986,10 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
spec.loader.exec_module(module)
source = tmp_path / "api_server.py"
destination = tmp_path / "patched.py"
source.write_text("prefix\n" + module.BEFORE + "suffix\n", encoding="utf-8")
source.write_text(
"prefix\n" + module.BEFORE + "middle\n" + module.RUNS_BEFORE + "suffix\n",
encoding="utf-8",
)
module.patch(source, destination)
patched = destination.read_text(encoding="utf-8")
@ -994,6 +997,8 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
assert "X-Hermes-Parent-Session-Id" in patched
assert "parent_session_id=parent_session_id" in patched
assert "Parent session not found" in patched
assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIX" in patched
assert "user_message.startswith(default_prefix)" in patched
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
@ -1051,6 +1056,55 @@ def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
)
def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs(tmp_path: Path):
"""Only the stable Ariadne contract moves below the triage parent."""
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
spec = importlib.util.spec_from_file_location("migrate_triage_sessions", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
database = tmp_path / "state.db"
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"parent_session_id TEXT, title TEXT, started_at REAL, archived INTEGER DEFAULT 0)"
)
connection.execute(
"CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT)"
)
connection.executemany(
"INSERT INTO sessions (id, source, started_at) VALUES (?, 'api_server', ?)",
(("triage-run", 1.0), ("interactive-run", 2.0)),
)
connection.executemany(
"INSERT INTO messages (session_id, role, content) VALUES (?, 'user', ?)",
(
(
"triage-run",
module.TRIAGE_MESSAGE_PREFIX
+ " Fix for incident sonar/bstein_home/python:S2208/finding-key.",
),
("interactive-run", "Please explain this alert to me."),
),
)
assert module.migrate(database, group_triage=True) == 1
assert module.migrate(database, group_triage=True) == 0
with sqlite3.connect(database) as connection:
parent = connection.execute(
"SELECT title FROM sessions WHERE id = ?", (module.TRIAGE_PARENT,)
).fetchone()
triage = connection.execute(
"SELECT parent_session_id, title FROM sessions WHERE id = 'triage-run'"
).fetchone()
interactive = connection.execute(
"SELECT parent_session_id FROM sessions WHERE id = 'interactive-run'"
).fetchone()
assert parent == (module.TRIAGE_PARENT_TITLE,)
assert triage == (module.TRIAGE_PARENT, "Sonar · bstein_home · python:S2208")
assert interactive == (None,)
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
"""Thin brokers stay small while native Claude runs beside owner auth."""
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()