Compare commits
3 Commits
burn/conte
...
burn/379-1
| Author | SHA1 | Date | |
|---|---|---|---|
| 57418dae07 | |||
| 5180c172fa | |||
|
|
b62fa0ec13 |
@@ -37,6 +37,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_time import now as _hermes_now
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -777,6 +778,29 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
},
|
||||
)
|
||||
|
||||
# Build disabled toolsets — always exclude cronjob/messaging/clarify
|
||||
# for cron sessions. When the runtime endpoint is cloud (not local),
|
||||
# also disable terminal so the agent does not attempt SSH or shell
|
||||
# commands that require local infrastructure (keys, filesystem).
|
||||
# Jobs that declare requires_local_infra=true also get terminal
|
||||
# disabled on cloud endpoints regardless of this check. #379
|
||||
_cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
_runtime_base_url = turn_route["runtime"].get("base_url", "")
|
||||
_is_cloud = not is_local_endpoint(_runtime_base_url)
|
||||
if _is_cloud:
|
||||
_cron_disabled.append("terminal")
|
||||
logger.info(
|
||||
"Job '%s': cloud provider detected (%s), disabling terminal toolset",
|
||||
job_name,
|
||||
turn_route["runtime"].get("provider", "unknown"),
|
||||
)
|
||||
if job.get("requires_local_infra") and _is_cloud:
|
||||
logger.warning(
|
||||
"Job '%s': requires_local_infra=true but running on cloud provider — "
|
||||
"terminal-dependent steps will fail gracefully",
|
||||
job_name,
|
||||
)
|
||||
|
||||
_agent_kwargs = _safe_agent_kwargs({
|
||||
"model": turn_route["model"],
|
||||
"api_key": turn_route["runtime"].get("api_key"),
|
||||
@@ -784,7 +808,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"provider": turn_route["runtime"].get("provider"),
|
||||
"api_mode": turn_route["runtime"].get("api_mode"),
|
||||
"acp_command": turn_route["runtime"].get("command"),
|
||||
"acp_args": turn_route["runtime"].get("args"),
|
||||
"acp_args": list(turn_route["runtime"].get("args") or []),
|
||||
"max_iterations": max_iterations,
|
||||
"reasoning_config": reasoning_config,
|
||||
"prefill_messages": prefill_messages,
|
||||
@@ -792,7 +816,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"providers_ignored": pr.get("ignore"),
|
||||
"providers_order": pr.get("order"),
|
||||
"provider_sort": pr.get("sort"),
|
||||
"disabled_toolsets": ["cronjob", "messaging", "clarify"],
|
||||
"disabled_toolsets": _cron_disabled,
|
||||
"tool_choice": "required",
|
||||
"quiet_mode": True,
|
||||
"skip_memory": True, # Cron system prompts would corrupt user representations
|
||||
|
||||
104
hermes_state.py
104
hermes_state.py
@@ -32,7 +32,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 6
|
||||
SCHEMA_VERSION = 7
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
profile TEXT,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -86,6 +87,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_profile ON sessions(profile);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
||||
@@ -330,6 +332,19 @@ class SessionDB:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: add profile column to sessions for profile isolation (#323)
|
||||
try:
|
||||
cursor.execute('ALTER TABLE sessions ADD COLUMN "profile" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_profile ON sessions(profile)"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
@@ -362,13 +377,19 @@ class SessionDB:
|
||||
system_prompt: str = None,
|
||||
user_id: str = None,
|
||||
parent_session_id: str = None,
|
||||
profile: str = None,
|
||||
) -> str:
|
||||
"""Create a new session record. Returns the session_id."""
|
||||
"""Create a new session record. Returns the session_id.
|
||||
|
||||
Args:
|
||||
profile: Profile name for session isolation. When set, sessions
|
||||
are tagged so queries can filter by profile. (#323)
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
|
||||
system_prompt, parent_session_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
system_prompt, parent_session_id, profile, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
source,
|
||||
@@ -377,6 +398,7 @@ class SessionDB:
|
||||
json.dumps(model_config) if model_config else None,
|
||||
system_prompt,
|
||||
parent_session_id,
|
||||
profile,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
@@ -505,19 +527,23 @@ class SessionDB:
|
||||
session_id: str,
|
||||
source: str = "unknown",
|
||||
model: str = None,
|
||||
profile: str = None,
|
||||
) -> None:
|
||||
"""Ensure a session row exists, creating it with minimal metadata if absent.
|
||||
|
||||
Used by _flush_messages_to_session_db to recover from a failed
|
||||
create_session() call (e.g. transient SQLite lock at agent startup).
|
||||
INSERT OR IGNORE is safe to call even when the row already exists.
|
||||
|
||||
Args:
|
||||
profile: Profile name for session isolation. (#323)
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO sessions
|
||||
(id, source, model, started_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(session_id, source, model, time.time()),
|
||||
(id, source, model, profile, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(session_id, source, model, profile, time.time()),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
@@ -788,6 +814,7 @@ class SessionDB:
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
profile: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@@ -799,6 +826,10 @@ class SessionDB:
|
||||
|
||||
By default, child sessions (subagent runs, compression continuations)
|
||||
are excluded. Pass ``include_children=True`` to include them.
|
||||
|
||||
Args:
|
||||
profile: Filter sessions to this profile name. Pass None to see all.
|
||||
(#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -813,6 +844,9 @@ class SessionDB:
|
||||
placeholders = ",".join("?" for _ in exclude_sources)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(exclude_sources)
|
||||
if profile:
|
||||
where_clauses.append("s.profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
query = f"""
|
||||
@@ -1158,34 +1192,52 @@ class SessionDB:
|
||||
source: str = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
profile: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions, optionally filtered by source."""
|
||||
"""List sessions, optionally filtered by source and profile.
|
||||
|
||||
Args:
|
||||
profile: Filter sessions to this profile name. Pass None to see all.
|
||||
(#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if profile:
|
||||
where_clauses.append("profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
query = f"SELECT * FROM sessions {where_sql} ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
with self._lock:
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(source, limit, offset),
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
cursor = self._conn.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# =========================================================================
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
def session_count(self, source: str = None, profile: str = None) -> int:
|
||||
"""Count sessions, optionally filtered by source and profile.
|
||||
|
||||
Args:
|
||||
profile: Filter to this profile name. Pass None to count all. (#323)
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if profile:
|
||||
where_clauses.append("profile = ?")
|
||||
params.append(profile)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
with self._lock:
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions {where_sql}", params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def message_count(self, session_id: str = None) -> int:
|
||||
|
||||
73
tests/test_cron_cloud_terminal.py
Normal file
73
tests/test_cron_cloud_terminal.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for cron scheduler cloud-provider terminal disabling (#379).
|
||||
|
||||
When a cron job runs on a cloud inference endpoint (Nous, OpenRouter, etc.),
|
||||
the terminal toolset must be disabled because SSH keys don't exist on cloud
|
||||
servers. Only local endpoints (localhost, 127.0.0.1, RFC-1918) retain
|
||||
terminal access.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
class TestIsLocalEndpoint:
|
||||
"""Verify is_local_endpoint correctly classifies endpoints."""
|
||||
|
||||
def test_localhost(self):
|
||||
assert is_local_endpoint("http://localhost:11434/v1") is True
|
||||
|
||||
def test_127_loopback(self):
|
||||
assert is_local_endpoint("http://127.0.0.1:8080/v1") is True
|
||||
|
||||
def test_0_0_0_0(self):
|
||||
assert is_local_endpoint("http://0.0.0.0:11434/v1") is True
|
||||
|
||||
def test_rfc1918_10(self):
|
||||
assert is_local_endpoint("http://10.0.0.5:8080/v1") is True
|
||||
|
||||
def test_rfc1918_192(self):
|
||||
assert is_local_endpoint("http://192.168.1.100:11434/v1") is True
|
||||
|
||||
def test_rfc1918_172(self):
|
||||
assert is_local_endpoint("http://172.16.0.1:8080/v1") is True
|
||||
|
||||
def test_cloud_openrouter(self):
|
||||
assert is_local_endpoint("https://openrouter.ai/api/v1") is False
|
||||
|
||||
def test_cloud_nous(self):
|
||||
assert is_local_endpoint("https://inference-api.nousresearch.com/v1") is False
|
||||
|
||||
def test_cloud_anthropic(self):
|
||||
assert is_local_endpoint("https://api.anthropic.com") is False
|
||||
|
||||
def test_empty_url(self):
|
||||
assert is_local_endpoint("") is False
|
||||
|
||||
def test_none_url(self):
|
||||
assert is_local_endpoint(None) is False
|
||||
|
||||
|
||||
class TestCronDisabledToolsetsLogic:
|
||||
"""Verify the disabled_toolsets logic matches scheduler expectations."""
|
||||
|
||||
def _build_disabled(self, base_url, job=None):
|
||||
"""Mirror the scheduler's disabled_toolsets logic."""
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
if not is_local_endpoint(base_url):
|
||||
cron_disabled.append("terminal")
|
||||
return cron_disabled
|
||||
|
||||
def test_local_keeps_terminal(self):
|
||||
disabled = self._build_disabled("http://localhost:11434/v1")
|
||||
assert "terminal" not in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_cloud_disables_terminal(self):
|
||||
disabled = self._build_disabled("https://openrouter.ai/api/v1")
|
||||
assert "terminal" in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_empty_url_disables_terminal(self):
|
||||
disabled = self._build_disabled("")
|
||||
assert "terminal" in disabled
|
||||
Reference in New Issue
Block a user