hermes: group automated triage sessions
All checks were successful
Tests / Declarative: Post Actions passed: 265
All checks were successful
Tests / Declarative: Post Actions passed: 265
This commit is contained in:
parent
15b6711a7b
commit
37ac5ff11f
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user