Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,23 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
"Pre-call sanitizer: added %d stub tool result(s)",
len(missing_results),
)

# 3. Coerce non-string tool results to JSON strings (#29920).
# MCP tools and memory helpers may return Python dicts/lists as content.
# The OpenAI API rejects these with HTTP 400 "invalid message content type".
for msg in messages:
if msg.get("role") == "tool":
content = msg.get("content")
if content is not None and not isinstance(content, str):
try:
msg["content"] = json.dumps(content, ensure_ascii=False)
except (TypeError, ValueError):
_ra().logger.warning(
"Pre-call sanitizer: failed to JSON-serialize tool result for %s",
msg.get("name", "?"),
)
msg["content"] = repr(content)

return messages


Expand Down
15 changes: 15 additions & 0 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,21 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") ->
if ctx and isinstance(ctx, (int, float)):
return int(ctx)

# llama.cpp: /props reports the actual runtime n_ctx (what user set with -c/--ctx-size).
# This overrides the GGUF metadata which may be a smaller hardcoded value.
if server_type == "llamacpp":
try:
props_resp = client.get(f"{server_url}/v1/props")
if not props_resp.ok:
props_resp = client.get(f"{server_url}/props")
if props_resp.ok:
props = props_resp.json()
n_ctx = (props.get("default_generation_settings") or {}).get("n_ctx")
if n_ctx and isinstance(n_ctx, (int, float)) and n_ctx > 0:
return int(n_ctx)
except Exception:
pass

# Try /v1/models and find the model in the list.
# Use _model_id_matches to handle "publisher/slug" vs bare "slug".
resp = client.get(f"{server_url}/v1/models")
Expand Down
26 changes: 26 additions & 0 deletions agent/transports/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,32 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
if extra_body:
api_kwargs["extra_body"] = extra_body

# System-message guard (#29871): ensure persona/identity content reaches API.
# When a provider's hooks silently strip the role="system" message
# (known with some Ollama Cloud variants), re-inject from original input
# so SOUL.md is never lost mid-flight.
_had_system = (
len(params.get("messages", [])) > 0
and isinstance(params["messages"][0], dict)
and params["messages"][0].get("role") == "system"
)
_has_system = (
len(api_kwargs.get("messages", [])) > 0
and isinstance(api_kwargs["messages"][0], dict)
and api_kwargs["messages"][0].get("role") == "system"
)
if _had_system and not _has_system:
logger.debug(
"System-message guard (%s): profile/hooks stripped system role. "
"Re-injecting from input (input_msgs=%d, output_msgs=%d).",
profile.name,
len(params["messages"]),
len(api_kwargs["messages"]),
)
api_kwargs["messages"] = [
{"role": "system", "content": params["messages"][0]["content"]}
] + api_kwargs["messages"]

return api_kwargs

def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
Expand Down
22 changes: 20 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11216,6 +11216,7 @@ def run_agent():
agent_message = _srn + "\n\n" + agent_message
self._pending_skills_reload_note = None
try:
self._cli_last_run_old_session_id = getattr(self.agent, "session_id", None)
result = self.agent.run_conversation(
user_message=agent_message,
conversation_history=self.conversation_history[:-1], # Exclude the message we just added
Expand Down Expand Up @@ -11359,8 +11360,25 @@ def run_agent():
sys.stdout.flush()
time.sleep(0.15)

# Update history with full conversation
self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history
# Update history with full conversation.
# If auto-compression rotated the session mid-turn, result["messages"]
# is inflated (compressed baseline + this turn's growth). Use the
# agent's internal _session_messages instead — it holds the actual
# post-loop state the agent used for its final API call(s). Mirrors
# the gateway path fix in PR #29505.
if result:
compressed = getattr(self.agent, "_session_messages", None)
session_rotated = (
self.agent
and self._cli_last_run_old_session_id is not None
and getattr(self.agent, "session_id", None) != self._cli_last_run_old_session_id
)
if session_rotated and compressed:
self.conversation_history = list(compressed)
else:
self.conversation_history = result.get("messages", self.conversation_history)
elif self.conversation_history:
pass # Keep existing history on error/null result

# If auto-compression fired mid-turn, the agent created a new
# continuation session and mutated self.agent.session_id. Sync
Expand Down
222 changes: 222 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,9 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs
(the `memory-watchdog.sh` pattern) without wrapping them in Python.

When a remote terminal backend (e.g. SSH) is configured, the script
executes on that remote host instead of the scheduler's local machine.

Args:
script_path: Path to the script. Relative paths are resolved
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
Expand All @@ -825,6 +828,20 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
(success, output) — on failure *output* contains the error message so the
LLM can report the problem to the user.
"""
# ------------------------------------------------------------------
# Remote terminal backend routing (#29849)
# ------------------------------------------------------------------
try:
config = load_config()
terminal_cfg = config.get("terminal", {})
backend = (terminal_cfg.get("backend") or "local").strip().lower()
except Exception:
backend = "local"

if backend != "local":
# Non-local terminal — run the script on the remote host.
return _run_job_script_remote(script_path, backend)

scripts_dir = _get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
scripts_dir_resolved = scripts_dir.resolve()
Expand Down Expand Up @@ -925,6 +942,211 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
return False, f"Script execution failed: {exc}"


def _run_job_script_remote(script_path: str, backend: str) -> tuple[bool, str]:
"""Execute a cron job's data-collection script on a remote terminal backend.

Reads SSH / Docker env var config to build the remote command, then runs
the script via that transport instead of local ``subprocess.run()``.

Args:
script_path: Path as stored in the cron job (relative or absolute).
backend: The terminal backend name (e.g. "ssh").

Returns:
(success, output) — on failure *output* contains the error message.
"""
try:
config = load_config()
terminal_cfg = config.get("terminal", {})
except Exception:
return False, "Failed to read terminal configuration"

# ------------------------------------------------------------------
# SSH transport (#29849)
# ------------------------------------------------------------------
if backend == "ssh":
host = os.getenv("TERMINAL_SSH_HOST") or ""
user = os.getenv("TERMINAL_SSH_USER") or ""
port = os.getenv("TERMINAL_SSH_PORT", "22")
key_path = os.getenv("TERMINAL_SSH_KEY") or ""

if not host or not user:
return False, (
f"SSH terminal backend requires TERMINAL_SSH_HOST and TERMINAL_SSH_USER env vars. "
f"(host={host!r}, user={user!r})"
)

# Validate script filename against path traversal — same guard as local.
raw = Path(script_path).expanduser()
if raw.is_absolute():
script_name = raw.name
else:
scripts_dir = _get_hermes_home() / "scripts"
script_name = (scripts_dir / raw).name

# Basic safety: reject absolute path overrides on remote.
if Path(script_path).is_absolute():
return False, f"Absolute paths not supported for remote execution: {script_path!r}"

scripts_dir_resolved = _get_hermes_home().resolve() / "scripts"

# Validate filename stays within scripts dir via resolved name check.
try:
test_path = (scripts_dir_resolved / script_name).resolve()
test_path.relative_to(scripts_dir_resolved)
except ValueError:
return False, (
f"Blocked: script path resolves outside the scripts directory "
f"({scripts_dir_resolved}): {script_path!r}"
)

# Build SSH command: ssh user@host bash /remote/.hermes/scripts/script.sh
ssh_args = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15"]
if key_path:
ssh_args.extend(["-i", key_path])
if port != "22":
ssh_args.extend(["-p", port])

# Detect remote HERMES_HOME to build the script path.
home_cmd = list(ssh_args) + [f"{user}@{host}", "echo $HOME"]
try:
home_result = subprocess.run(
home_cmd, capture_output=True, text=True, timeout=15
)
if home_result.returncode != 0:
stderr = (home_result.stderr or "").strip()
return False, f"SSH connection failed ({user}@{host}): {stderr or home_result.stdout.strip()}"
remote_home = home_result.stdout.strip()
except subprocess.TimeoutExpired:
return False, f"SSH connection to {user}@{host} timed out"

if not remote_home:
remote_home = f"/home/{user}"

# Resolve script path against REMOTE hermes_home/scripts.
remote_script_dir = Path(remote_home) / ".hermes" / "scripts"
remote_script_path = remote_script_dir / script_name

suffix = script_name.rsplit(".", 1)[-1].lower() if "." in script_name else ""
if suffix in ("sh", "bash"):
remote_cmd = f"bash {remote_script_path}"
else:
remote_cmd = f"{sys.executable} {remote_script_path}"

# Run the script on remote host.
full_cmd = list(ssh_args) + [f"{user}@{host}", remote_cmd]
try:
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=300, # generous timeout for remote execution
)
except subprocess.TimeoutExpired:
return False, f"Script timed out after 300s on remote ({user}@{host}): {script_path!r}"

stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()

# Redact secrets.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception:
pass

if result.returncode != 0:
parts = [f"Script exited with code {result.returncode} (remote: {user}@{host})"]
if stderr:
parts.append(f"stderr:\\n{stderr}")
if stdout:
parts.append(f"stdout:\\n{stdout}")
return False, "\\n".join(parts)

return True, stdout

# ------------------------------------------------------------------
# Fallback for unknown backends: log and run locally (backward compat).
# ------------------------------------------------------------------
logger.warning(
"Cron script: unknown terminal backend %r — falling back to local execution",
backend,
)
scripts_dir = _get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
scripts_dir_resolved = scripts_dir.resolve()

raw = Path(script_path).expanduser()
if raw.is_absolute():
path = raw.resolve()
else:
path = (scripts_dir / raw).resolve()

try:
path.relative_to(scripts_dir_resolved)
except ValueError:
return False, (
f"Blocked: script path resolves outside the scripts directory "
f"({scripts_dir_resolved}): {script_path!r}"
)

if not path.exists():
return False, f"Script not found on local host for remote backend: {path}"
if not path.is_file():
return False, f"Script path is not a file: {path}"

script_timeout = _get_script_timeout()
suffix = path.suffix.lower()
if suffix in {".sh", ".bash"}:
_bash = shutil.which("bash") or (
"/bin/bash" if os.path.isfile("/bin/bash") else None
)
if _bash is None:
return False, f"bash not found for remote fallback script execution."
argv = [_bash, str(path)]
else:
argv = [sys.executable, str(path)]

run_env = os.environ.copy()
run_env["HERMES_HOME"] = str(_get_hermes_home())
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
run_env["HOME"] = profile_home
except Exception:
pass

try:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
argv,
capture_output=True, text=True, timeout=script_timeout,
cwd=str(path.parent), env=run_env, **popen_kwargs,
)
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception:
pass
if result.returncode != 0:
parts = [f"Script exited with code {result.returncode} (fallback to local)"]
if stderr:
parts.append(f"stderr:\\n{stderr}")
if stdout:
parts.append(f"stdout:\\n{stdout}")
return False, "\\n".join(parts)
return True, stdout
except subprocess.TimeoutExpired:
return False, f"Script timed out after {script_timeout}s (fallback): {path}"
except Exception as exc:
return False, f"Script execution failed (fallback): {exc}"


def _parse_wake_gate(script_output: str) -> bool:
"""Parse the last non-empty stdout line of a cron job's pre-check script
as a wake gate.
Expand Down
Loading