From 9a6c7a9314fd525c2106ff7af4ffed0cf3d6eb16 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 14 Sep 2026 20:11:57 +0700 Subject: [PATCH] fix(bot-mode): resume nested one-shot DMs on the recipient's session Quiet chat -Q inherited the dispatcher's HERMES_SESSION_KEY, so a nested message_agent notify was addressed to the grandparent and B never woke. Bind this session's key, strip inherited session identity from the delivery child env, and continue owned notify completions in-process before stdout. --- cli.py | 59 ++++++++++++++++++++++++++++++++++++---------- tools/bot_relay.py | 8 ++++++- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/cli.py b/cli.py index c330455608..0d904afe04 100644 --- a/cli.py +++ b/cli.py @@ -4073,24 +4073,59 @@ def _sync_cli_session_id_from_agent(cli) -> None: def _run_quiet_single_query(cli, effective_query): """Quiet (-Q) one-shot turn: run, print the response (stderr for errors/session_id), then sys.exit with the automation exit code. - HERMES_TURN_AUTHOR (set only by a bot-to-bot dispatcher) is consumed here so tool subprocesses do not inherit it.""" + HERMES_TURN_AUTHOR (set only by a bot-to-bot dispatcher) is consumed here so tool subprocesses do not inherit it. + Nested Bot Mode notifies bind this session's key (not the dispatcher's) and resume in-process + before stdout is printed, so a teammate reply is the quiet run's final answer rather than a + stranded receipt.""" from agent.interrupt_compat import _accepts_keyword from agent.turn_author import take_turn_author_from_env + from hermes_cli.quiet_single_query import bind_quiet_session_key, continue_quiet_notify_completions author = take_turn_author_from_env() author_kwargs = {"turn_author": author} if author is not None and _accepts_keyword(cli.agent.run_conversation, "turn_author") else {} + key_token, reset_session_key = None, None try: - result = cli.agent.run_conversation( - user_message=effective_query, conversation_history=cli.conversation_history, **author_kwargs, - ) - except KeyboardInterrupt: - _emit_interrupted_session_end(cli, reason="keyboard_interrupt") - print(f"\nsession_id: {cli.session_id}", file=sys.stderr) - sys.exit(130) - # The exit line below reports session_id to stderr for automation wrappers; - # without this sync it would point at the ended parent after compression. - _sync_cli_session_id_from_agent(cli) - response = result.get("final_response", "") if isinstance(result, dict) else str(result) + key_token, reset_session_key = bind_quiet_session_key(getattr(cli, "session_id", "") or "default") + except Exception: + key_token, reset_session_key = None, None + try: + try: + result = cli.agent.run_conversation( + user_message=effective_query, conversation_history=cli.conversation_history, **author_kwargs, + ) + except KeyboardInterrupt: + _emit_interrupted_session_end(cli, reason="keyboard_interrupt") + print(f"\nsession_id: {cli.session_id}", file=sys.stderr) + sys.exit(130) + # The exit line below reports session_id to stderr for automation wrappers; + # without this sync it would point at the ended parent after compression. + _sync_cli_session_id_from_agent(cli) + if isinstance(result, dict) and not result.get("failed"): + history = result.get("messages") or cli.conversation_history + + def _follow_up(text): + nonlocal history + follow = cli.agent.run_conversation( + user_message=text, conversation_history=history, **author_kwargs, + ) + if isinstance(follow, dict) and follow.get("messages"): + history = follow["messages"] + return follow + + continued = continue_quiet_notify_completions( + getattr(cli, "session_id", "") or "", + _follow_up, + owns_event=getattr(cli, "_owns_process_notification", None), + ) + if isinstance(continued, dict): + result = continued + response = result.get("final_response", "") if isinstance(result, dict) else str(result) + finally: + if key_token is not None and reset_session_key is not None: + try: + reset_session_key(key_token) + except Exception: + pass # Surface backend errors that produced no visible output (e.g. invalid model slug # -> provider 4xx) on stderr so piped stdout stays clean. if ( diff --git a/tools/bot_relay.py b/tools/bot_relay.py index eb1fcc4a67..bb5f34d9a7 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -406,11 +406,17 @@ def delivery_turn_author(from_profile: Any, from_handle: Any, from_connection: A def delivery_env(author: Optional[dict]) -> dict[str, str]: """Environment for one delivery turn's ``hermes`` child. The dispatcher's own HERMES_TURN_AUTHOR is - dropped first so a delivery without an author never inherits the author of the turn that sent it.""" + dropped first so a delivery without an author never inherits the author of the turn that sent it. + Dispatcher session identity (``HERMES_SESSION_*``, ``HERMES_UI_SESSION_ID``) is dropped too: a + nested recipient that ``message_agent``s onward must not stamp that grandchild notify with the + grandparent's key, or the live recipient never resumes.""" from agent.turn_author import TURN_AUTHOR_ENV, turn_author_env env = dict(os.environ) env.pop(TURN_AUTHOR_ENV, None) + for key in list(env): + if key.startswith("HERMES_SESSION_") or key == "HERMES_UI_SESSION_ID": + env.pop(key, None) if author: env.update(turn_author_env(author)) return env