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:
|
limits:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
memory: 128Mi
|
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
|
- name: install-kubectl
|
||||||
image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
|
image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
@ -282,6 +307,10 @@ spec:
|
|||||||
value: https://metrics.bstein.dev
|
value: https://metrics.bstein.dev
|
||||||
- name: HERMES_AUTO_ROUTER_PROFILE
|
- name: HERMES_AUTO_ROUTER_PROFILE
|
||||||
value: triage
|
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
|
# Claude subscription OAuth token (sk-ant-oat01...). The anthropic
|
||||||
# provider accepts ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, or this, in
|
# provider accepts ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, or this, in
|
||||||
# that order; an OAuth token is not an API key, so it must arrive
|
# that order; an OAuth token is not an API key, so it must arrive
|
||||||
@ -307,6 +336,9 @@ spec:
|
|||||||
- name: auth-patch
|
- name: auth-patch
|
||||||
mountPath: /opt/hermes/hermes_cli/auth.py
|
mountPath: /opt/hermes/hermes_cli/auth.py
|
||||||
subPath: auth.py
|
subPath: auth.py
|
||||||
|
- name: api-server-patch
|
||||||
|
mountPath: /opt/hermes/gateway/platforms/api_server.py
|
||||||
|
subPath: api_server.py
|
||||||
- name: tools
|
- name: tools
|
||||||
mountPath: /usr/local/bin/kubectl
|
mountPath: /usr/local/bin/kubectl
|
||||||
subPath: kubectl
|
subPath: kubectl
|
||||||
@ -432,6 +464,8 @@ spec:
|
|||||||
name: hermes-auto-router-plugin
|
name: hermes-auto-router-plugin
|
||||||
- name: auth-patch
|
- name: auth-patch
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: api-server-patch
|
||||||
|
emptyDir: {}
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir:
|
emptyDir:
|
||||||
sizeLimit: 256Mi
|
sizeLimit: 256Mi
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@ -22,9 +25,63 @@ LEGACY_CASSANDRA_WORKERS = {
|
|||||||
LEGACY_ORPHANED_SMOKE_SESSIONS = {
|
LEGACY_ORPHANED_SMOKE_SESSIONS = {
|
||||||
"e17f2d888689": "Archived agent workspace smoke test",
|
"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."""
|
"""Apply idempotent, transcript-preserving lineage corrections."""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return 0
|
return 0
|
||||||
@ -61,8 +118,11 @@ def migrate(path: Path = STATE_DB) -> int:
|
|||||||
(title, session_id),
|
(title, session_id),
|
||||||
)
|
)
|
||||||
changed += cursor.rowcount
|
changed += cursor.rowcount
|
||||||
|
if group_triage:
|
||||||
|
changed += migrate_triage_group(connection)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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:
|
def patch(source: Path, destination: Path) -> None:
|
||||||
"""Apply the narrow session-lineage extension and fail on upstream drift."""
|
"""Apply the narrow session-lineage extension and fail on upstream drift."""
|
||||||
content = source.read_text(encoding="utf-8")
|
content = source.read_text(encoding="utf-8")
|
||||||
if BEFORE not in content:
|
if BEFORE not in content:
|
||||||
raise RuntimeError("Hermes API session patch context changed")
|
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.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:
|
def main() -> int:
|
||||||
|
|||||||
@ -986,7 +986,10 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
|
|||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
source = tmp_path / "api_server.py"
|
source = tmp_path / "api_server.py"
|
||||||
destination = tmp_path / "patched.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)
|
module.patch(source, destination)
|
||||||
patched = destination.read_text(encoding="utf-8")
|
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 "X-Hermes-Parent-Session-Id" in patched
|
||||||
assert "parent_session_id=parent_session_id" in patched
|
assert "parent_session_id=parent_session_id" in patched
|
||||||
assert "Parent session not found" 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):
|
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():
|
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
|
||||||
"""Thin brokers stay small while native Claude runs beside owner auth."""
|
"""Thin brokers stay small while native Claude runs beside owner auth."""
|
||||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()
|
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user