Skip to content
Open
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
1 change: 1 addition & 0 deletions gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ def _collect():
message_type=MessageType.TEXT,
source=_source,
internal=True,
metadata={"unattended_session": True},
)
await adapter.handle_message(_synth_event)
logger.info(
Expand Down
10 changes: 8 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -11843,7 +11843,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
context = build_session_context(source, self.config, session_entry)

# Set session context variables for tools (task-local, concurrency-safe)
_session_env_tokens = self._set_session_env(context)
_session_env_tokens = self._set_session_env(context, event=event)
Comment thread
izumi0uu marked this conversation as resolved.

# Read privacy.redact_pii from config (re-read per message)
_redact_pii = False
Expand Down Expand Up @@ -15994,7 +15994,7 @@ async def _send_home_channel_startup_notifications(

return delivered

def _set_session_env(self, context: SessionContext) -> list:
def _set_session_env(self, context: SessionContext, event: Optional[MessageEvent] = None) -> list:
"""Set session context variables for the current async task.

Uses ``contextvars`` instead of ``os.environ`` so that concurrent
Expand All @@ -16014,6 +16014,11 @@ def _set_session_env(self, context: SessionContext) -> list:
_adapters = getattr(self, "adapters", None) or {}
_adapter = _adapters.get(context.source.platform)
_async_delivery = getattr(_adapter, "supports_async_delivery", True)
_event_metadata = getattr(event, "metadata", None) if event is not None else None
_unattended = bool(
isinstance(_event_metadata, dict)
and _event_metadata.get("unattended_session")
)
return set_session_vars(
platform=context.source.platform.value,
chat_id=context.source.chat_id,
Expand All @@ -16025,6 +16030,7 @@ def _set_session_env(self, context: SessionContext) -> list:
message_id=str(context.source.message_id) if context.source.message_id else "",
profile=getattr(context.source, "profile", "") or "",
async_delivery=_async_delivery,
unattended=_unattended,
)

def _clear_session_env(self, tokens: list) -> None:
Expand Down
9 changes: 9 additions & 0 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def session_context_engaged() -> bool:
_SESSION_MESSAGE_ID: ContextVar = ContextVar("HERMES_SESSION_MESSAGE_ID", default=_UNSET)

_SESSION_PROFILE: ContextVar = ContextVar("HERMES_SESSION_PROFILE", default=_UNSET)
_UNATTENDED_SESSION: ContextVar = ContextVar("HERMES_UNATTENDED_SESSION", default=_UNSET)

# Whether the current session's delivery channel can route an ASYNC completion
# back to the agent AFTER the current turn ends (i.e. wake a fresh turn).
Expand Down Expand Up @@ -133,6 +134,7 @@ def session_context_engaged() -> bool:
"HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID,
"HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
"HERMES_SESSION_PROFILE": _SESSION_PROFILE,
"HERMES_UNATTENDED_SESSION": _UNATTENDED_SESSION,
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID,
"HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID,
Expand Down Expand Up @@ -169,6 +171,7 @@ def set_session_vars(
cwd: str = "",
async_delivery: bool = True,
ui_session_id: str = "",
unattended: bool = False,
) -> list:
"""Set all session context variables and return reset tokens.

Expand All @@ -184,6 +187,10 @@ def set_session_vars(
background completion back to the agent after the turn ends (see
``_SESSION_ASYNC_DELIVERY`` / ``async_delivery_supported``). Stateless
request/response adapters (the API server) pass ``False``.

``unattended`` marks synthetic no-human turns, such as kanban notification
wakeups, so approval guards fail closed instead of queueing prompts no one
can answer.
"""
# Mark the session-context machinery engaged for this process. The
# subprocess-env bridge uses this to switch from "os.environ fallback" to
Expand All @@ -203,6 +210,7 @@ def set_session_vars(
_SESSION_UI_SESSION_ID.set(ui_session_id),
_SESSION_MESSAGE_ID.set(message_id),
_SESSION_PROFILE.set(profile),
_UNATTENDED_SESSION.set("1" if unattended else ""),
_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
]
try:
Expand Down Expand Up @@ -238,6 +246,7 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_UI_SESSION_ID,
_SESSION_MESSAGE_ID,
_SESSION_PROFILE,
_UNATTENDED_SESSION,
):
var.set("")
# Reset async-delivery capability to the "never set" sentinel rather than a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ Next: Run significance tests on these results.
| **Process crash** | PID gone, log stops mid-problem | Re-run script (resumes from last checkpoint) |
| **Wrong model ID** | Model not found errors | Fix ID (e.g., `claude-opus-4-6` not `claude-opus-4.6`) |
| **Parallel slowdown** | Each experiment taking 2x longer | Reduce parallel experiments to 2-3 max |
| **Security scan blocks** | Commands blocked by security | Use `execute_code` instead of piped `terminal` commands |
| **Security scan blocks** | Commands blocked by security | Rewrite the command to avoid pipe-to-interpreter patterns, split fetch and parsing into separate approved steps, or ask for explicit approval |
| **Delegation failures** | `delegate_task` returns errors | Fall back to doing work directly |
| **Timeout on hard problems** | Process stuck, no log progress | Kill, skip problem, note in results |
| **Dataset path mismatch** | File not found errors | Verify paths before launching |
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_42039_duplicate_user_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _bootstrap(monkeypatch, tmp_path):
runner._pending_messages = {}
runner._pending_approvals = {}
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._handle_active_session_busy_message = AsyncMock(return_value=False)
runner._session_db = MagicMock()
runner._recover_telegram_topic_thread_id = lambda _source: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_approve_deny_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _make_runner():
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
return runner


Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_first_turn_session_meta_rebaseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _bootstrap(monkeypatch, tmp_path, db):
runner._pending_messages = {}
runner._pending_approvals = {}
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._handle_active_session_busy_message = AsyncMock(return_value=False)
# REAL SessionDB behind the async facade the gateway holds β€” the
# production re-baseline does ``await self._session_db.get_session(...)``,
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_footer_command_mid_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _make_runner(session_entry: SessionEntry):
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_gateway_command_dispatch_minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _make_runner():
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_gateway_silence_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _runner(monkeypatch, tmp_path):
runner._pending_messages = {}
runner._pending_approvals = {}
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._handle_active_session_busy_message = AsyncMock(return_value=False)
runner._session_db = MagicMock()
runner._recover_telegram_topic_thread_id = lambda _source: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_incomplete_gateway_turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _make_runner(adapter: CaptureSlackAdapter) -> gateway_run.GatewayRunner:
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(return_value=_make_incomplete_result())
return runner

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _make_runner(*, compression_in_flight: bool):
runner._show_reasoning = False
runner._service_tier = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_queue_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def _make_runner(session_entry: SessionEntry):
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_reload_skills_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _make_runner():
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
# Use the real _session_key_for_source binding so the key matches what
# the agent-loop consumer will look up later.
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_running_agent_session_toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def _make_runner():
runner._show_reasoning = False
runner._service_tier = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
12 changes: 12 additions & 0 deletions tests/gateway/test_session_context_inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ def test_reset_session_vars_restores_unset_not_empty():
assert var.get() is _UNSET, f"{name} is {var.get()!r}, expected _UNSET"


def test_unattended_session_var_is_context_local_and_bridgeable():
set_session_vars(**MINE, unattended=True)

assert sc.get_session_env("HERMES_UNATTENDED_SESSION") == "1"
env = _make_run_env({})
assert env["HERMES_UNATTENDED_SESSION"] == "1"

reset_session_vars()
assert sc.get_session_env("HERMES_UNATTENDED_SESSION") == ""
assert "HERMES_UNATTENDED_SESSION" not in _make_run_env({})


# ---------------------------------------------------------------------------
# Async-delivery capability inheritance (the sibling var outside _VAR_MAP)
# ---------------------------------------------------------------------------
Expand Down
25 changes: 24 additions & 1 deletion tests/gateway/test_session_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionContext, SessionSource
from gateway.session_context import (
Expand Down Expand Up @@ -169,6 +170,29 @@ def test_set_session_env_handles_missing_optional_fields():
runner._clear_session_env(tokens)


def test_set_session_env_marks_synthetic_event_as_unattended():
runner = object.__new__(GatewayRunner)
source = SessionSource(
platform=Platform.DISCORD,
chat_id="kanban-notifications",
chat_type="channel",
)
context = SessionContext(source=source, connected_platforms=[], home_channels={})
event = MessageEvent(
text="Kanban task completed",
source=source,
metadata={"unattended_session": True},
)

tokens = runner._set_session_env(context, event=event)
try:
assert get_session_env("HERMES_UNATTENDED_SESSION") == "1"
finally:
runner._clear_session_env(tokens)

assert get_session_env("HERMES_UNATTENDED_SESSION") == ""


# ---------------------------------------------------------------------------
# SESSION_KEY contextvars tests
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -393,4 +417,3 @@ async def test_gateway_executor_refuses_resurrection_after_shutdown():
await runner._run_in_executor_with_context(lambda: "second")
finally:
runner._shutdown_executor()

16 changes: 8 additions & 8 deletions tests/gateway/test_session_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -456,7 +456,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -554,7 +554,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -660,7 +660,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -780,7 +780,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -913,7 +913,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = SimpleNamespace(_db=fake_db)
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -1030,7 +1030,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down Expand Up @@ -1133,7 +1133,7 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
Expand Down
4 changes: 2 additions & 2 deletions tests/gateway/test_slash_access_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _make_runner(*, platform_extra: dict | None = None,
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down Expand Up @@ -611,7 +611,7 @@ async def test_gating_isolated_per_platform():
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_stacked_skill_platform_disabled.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _make_runner():
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
from gateway.run import GatewayRunner as _GR
runner._session_key_for_source = _GR._session_key_for_source.__get__(runner, _GR)
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_status_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.T
runner._agent_cache_lock = MagicMock()
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_steer_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def _make_runner(session_entry: SessionEntry):
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_telegram_topic_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _switch_session(session_key, target_session_id):
group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True),
thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False),
)
runner._set_session_env = lambda _context: None
runner._set_session_env = lambda _context, **_kwargs: None
runner._should_send_voice_reply = lambda *_args, **_kwargs: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None
Expand Down
Loading
Loading