diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml index 26e215e82..2dc71e668 100644 --- a/services/hermes/deployment.yaml +++ b/services/hermes/deployment.yaml @@ -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 diff --git a/services/hermes/scripts/migrate_api_session_lineage.py b/services/hermes/scripts/migrate_api_session_lineage.py index 34c7a220c..d9bf56df9 100644 --- a/services/hermes/scripts/migrate_api_session_lineage.py +++ b/services/hermes/scripts/migrate_api_session_lineage.py @@ -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") diff --git a/services/hermes/scripts/patch_api_server_sessions.py b/services/hermes/scripts/patch_api_server_sessions.py index eb5596a40..aaabbd5ee 100644 --- a/services/hermes/scripts/patch_api_server_sessions.py +++ b/services/hermes/scripts/patch_api_server_sessions.py @@ -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: diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 14c0f0b74..4301abda5 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -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()