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
7 changes: 5 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -9920,9 +9920,12 @@ def claim_unseen_events_for_sub(

Callers should send the claimed events, then either leave the cursor at
``new_cursor`` on success or call :func:`rewind_notify_cursor` if delivery
failed before any terminal unsubscribe removed the row.
failed before any terminal unsubscribe removed the row. If the connection
is already in a transaction, the claim joins it so the caller can commit or
roll back the cursor with a larger delivery acceptance boundary.
"""
with write_txn(conn):
txn = contextlib.nullcontext(conn) if conn.in_transaction else write_txn(conn)
with txn:
row = conn.execute(
"SELECT last_event_id FROM kanban_notify_subs "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
Expand Down
149 changes: 149 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16184,3 +16184,152 @@ def _inspect_trim_frame(**_kwargs):
assert cleanup_order == ["trim", "reset_home"]
finally:
server._sessions.pop("sid_trim", None)


def test_prompt_submit_rejects_when_finalization_wins_before_acceptance(monkeypatch):
"""A resolved session reference cannot be accepted after finalization."""
session = _session(agent=None, attached_images=["still-attached.png"])
server._sessions["finalize-race"] = session
resolved = threading.Event()
finalized = threading.Event()
response = []
errors = []
side_effects = []
real_thread = threading.Thread

def _pause_after_session_lookup():
resolved.set()
if not finalized.wait(5):
raise TimeoutError("timed out waiting for finalization")
return {
"turn_isolation": False,
"compute_host_heartbeat_secs": 15,
"compute_host_respawn_max": 3,
}

def _submit():
try:
response.append(
server.handle_request(
{
"id": "submit",
"method": "prompt.submit",
"params": {
"session_id": "finalize-race",
"text": "must not be accepted",
},
}
)
)
except BaseException as exc:
errors.append(exc)

class _CapturedRunThread:
def __init__(self, *args, **kwargs):
side_effects.append("run-thread-created")

def start(self):
side_effects.append("run-thread-started")

monkeypatch.setattr(
server, "_load_dashboard_process_isolation_config", _pause_after_session_lookup
)
monkeypatch.setattr(
server,
"_ensure_session_db_row",
lambda *_args: side_effects.append("db-row"),
)
monkeypatch.setattr(
server,
"_persist_branch_seed",
lambda *_args: side_effects.append("branch-seed"),
)
monkeypatch.setattr(
server,
"_start_agent_build",
lambda *_args: side_effects.append("agent-build"),
)
monkeypatch.setattr(server, "_emit", lambda event, *_args: side_effects.append(event))
monkeypatch.setattr(server.threading, "Thread", _CapturedRunThread)
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args: None)
monkeypatch.setattr(
"tools.async_delegation.interrupt_for_session", lambda **_kwargs: None
)

submitter = real_thread(target=_submit, name="prompt-finalize-race")
try:
submitter.start()
assert resolved.wait(5)
assert server._close_session_by_id("finalize-race") is True
finalized.set()
submitter.join(5)
assert not submitter.is_alive()

assert errors == []
assert response and "error" in response[0]
assert response[0].get("result") != {"status": "streaming"}
assert side_effects == []
assert session.get("_finalized") is True
assert session["running"] is False
assert "inflight_turn" not in session
assert "_run_thread" not in session
assert session["attached_images"] == ["still-attached.png"]
finally:
finalized.set()
submitter.join(5)
server._sessions.pop("finalize-race", None)


def test_busy_prompt_submit_rejects_when_finalization_wins_before_queue(monkeypatch):
session = _session(running=True)
server._sessions["busy-finalize-race"] = session
entered = threading.Event()
finalized = threading.Event()
response = []
real_handle_busy_submit = server._handle_busy_submit

def _pause_before_busy_acceptance(*args, **kwargs):
entered.set()
if not finalized.wait(5):
raise TimeoutError("timed out waiting for finalization")
return real_handle_busy_submit(*args, **kwargs)

monkeypatch.setattr(server, "_handle_busy_submit", _pause_before_busy_acceptance)
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "queue")
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args: None)
monkeypatch.setattr(
"tools.async_delegation.interrupt_for_session", lambda **_kwargs: None
)

submitter = threading.Thread(
target=lambda: response.append(
server.handle_request(
{
"id": "submit",
"method": "prompt.submit",
"params": {
"session_id": "busy-finalize-race",
"text": "must not be queued",
},
}
)
),
name="busy-prompt-finalize-race",
)
try:
submitter.start()
assert entered.wait(5)
server._finalize_session(session)
finalized.set()
submitter.join(5)
assert not submitter.is_alive()

assert response and "error" in response[0]
assert session.get("queued_prompt") is None
assert session.get("_finalized") is True
finally:
finalized.set()
submitter.join(5)
server._sessions.pop("busy-finalize-race", None)
10 changes: 9 additions & 1 deletion tui_gateway/methods_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def _(rid, params: dict) -> dict:
while True:
busy_transport = None
with session["history_lock"]:
if session.get("_finalized"):
return _err(rid, 4001, "session not found")
Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release leases before rejecting finalized submissions

When session.close finalizes immediately after _sess_nowait() but before this new guard, _ensure_active_session_slot() at line 114 can acquire and store a lease after _finalize_session() has already performed its only release. This return then leaves that lease in active_sessions.json; ActiveSessionLease has no destructor and the finalized session is no longer registered for later teardown, so repeated close/submit races permanently consume the configured session cap and eventually block new CLI, desktop, or gateway sessions. Check finalization before acquiring the slot and make the acquisition/finalization boundary atomic, or explicitly release the lease on this rejection path.

AGENTS.md reference: AGENTS.md:L54-L57

Useful? React with 👍 / 👎.

if session.get("running"):
# Don't reject a mid-turn prompt — queue it (and, by default,
# interrupt the live turn) so it runs as the next turn. The
Expand All @@ -150,6 +152,8 @@ def _(rid, params: dict) -> dict:
# queue whose drain already ran.

with session["history_lock"]:
if session.get("_finalized"):
return _err(rid, 4001, "session not found")
# A watch session's run lives in the PARENT turn, so its own running
# flag is False — without this, typing mid-run builds a second agent
# racing the in-flight child on the same stored session (interleaved
Expand Down Expand Up @@ -304,7 +308,11 @@ def run_after_agent_ready() -> None:
_emit("session.info", sid, _session_info(session.get("agent"), session))
return
with session["history_lock"]:
if session.get("_turn_cancel_requested") or not session.get("running"):
if (
session.get("_finalized")
or session.get("_turn_cancel_requested")
or not session.get("running")
):
session["running"] = False
_clear_inflight_turn(session)
# Surface the cancellation to the client. Without this emit the
Expand Down
32 changes: 21 additions & 11 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,21 +658,21 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
force-quit (double Ctrl‑C, terminal‑close, SIGHUP) while the agent
is mid‑turn.
"""
if not session or session.get("_finalized"):
if not session:
return
session["_finalized"] = True
lock = session.get("history_lock")
guard = lock if lock is not None else contextlib.nullcontext()
with guard:
if session.get("_finalized"):
return
session["_finalized"] = True
history = list(session.get("history", []))
_release_active_session_slot(session)
stop_event = session.get("_notif_stop")
if stop_event is not None:
stop_event.set()

agent = session.get("agent")
lock = session.get("history_lock")
if lock is not None:
with lock:
history = list(session.get("history", []))
else:
history = list(session.get("history", []))

# ── Persist unflushed messages to SQLite ──────────────────────────
# Flush ``agent._session_messages`` via ``_persist_session``'s marker-based
Expand Down Expand Up @@ -7403,11 +7403,14 @@ def _handle_busy_submit(
mode = "queue" if queued else _load_busy_input_mode()
agent = session.get("agent")
with session["history_lock"]:
if session.get("_finalized"):
return _err(rid, 4001, "session not found")
if not session.get("running"):
# The turn ended between prompt.submit's first busy check and this
# helper. Let the caller retry and claim the now-idle session.
return None
with session["history_lock"]:
if session.get("_finalized"):
return _err(rid, 4001, "session not found")
if not session.get("running"):
return None
image_paths = list(session.get("attached_images", []))
Expand Down Expand Up @@ -7448,6 +7451,8 @@ def _handle_busy_submit(
# provider or compute-host method while holding history_lock: an interrupt
# can wait behind the very operation it is trying to cancel.
with session["history_lock"]:
if session.get("_finalized"):
return _err(rid, 4001, "session not found")
if not session.get("running"):
if image_paths:
session["attached_images"] = image_paths + list(session.get("attached_images", []))
Expand Down Expand Up @@ -10113,7 +10118,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
# we check that guard before re-firing.
if goal_followup:
with session["history_lock"]:
if session.get("running"):
if session.get("_finalized") or session.get("running"):
# User already sent something — their turn wins,
# the judge will re-run on the next turn anyway.
return
Expand Down Expand Up @@ -10146,14 +10151,17 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
# adopt another session's addressed notification while a
# post-compression session still claims its own pre-compression
# dispatches (#55578).
with session["history_lock"]:
if session.get("_finalized"):
return
drained = process_registry.drain_notifications(
session_key=session.get("session_key", ""),
owns_event=lambda e: _session_owns_notification_event(sid, session, e),
skip_poll_observed=False,
)
for index, (_evt, synth) in enumerate(drained):
with session["history_lock"]:
if session.get("running"):
if session.get("_finalized") or session.get("running"):
for pending_evt, _pending_synth in drained[index:]:
process_registry.completion_queue.put(pending_evt)
break
Expand All @@ -10177,6 +10185,8 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
)
with session["history_lock"]:
session["running"] = False
if session.get("_finalized"):
process_registry.completion_queue.put(_evt)
except Exception as _drain_exc:
print(
f"[tui_gateway] completion queue drain failed: "
Expand Down
4 changes: 2 additions & 2 deletions website/docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/config` | Show current configuration |
| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Flags: `--global` persists the change to config.yaml; `--session` forces session-only; `--once` applies to the next turn only; `--refresh` re-fetches the provider's model list; `--provider <name>` switches backend (session-only unless `--global`). A plain `/model <name>` is session-only unless `model.persist_switch_by_default: true` is set. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. **Cost note:** switching models mid-conversation resets the prompt cache — the cache key includes the model, so your next turn re-reads the entire conversation at full input price instead of the ~75%-discounted cached rate. Expected and unavoidable, but worth knowing on long sessions. |
| `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) for OpenAI/Codex models. `auto` (default) uses Hermes' standard chat completions; `codex_app_server` hands turns to a `codex app-server` subprocess for native shell, apply_patch, ChatGPT subscription auth, and migrated Codex plugins. Effective on next session. |
| `/personality` | Set a predefined personality |
| `/personality` | Set a predefined personality. `/personality none` (or `default` / `neutral`) clears the overlay and returns to base behavior. |
| `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |
| `/focus [on\|off\|status]` | Toggle **focus view** — a display-only reduced-output mode showing just your prompt and the final response. Composes with `/verbose`: turning it on snaps tool progress to `off` and remembers your previous mode, and `/focus off` restores it. Each turn ends with a dim recovery line (`⋯ 7 tool lines hidden · /focus off to show`) and a persistent `◉ focus` badge sits in the status bar so you always know you're in the reduced view. Nothing is sent differently to the model — detail is hidden, never discarded. |
| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. |
Expand Down Expand Up @@ -226,7 +226,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/stop` | Kill all running background processes and interrupt the running agent. |
| `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), auto-detect (`/model custom`), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). **Cost note:** a mid-session model switch resets the prompt cache (the cache key includes the model), so the next message re-reads the whole conversation at full input price. |
| `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime). Persists to `model.openai_runtime` in config.yaml and evicts the cached agent so the next message picks up the new runtime. Effective on next session. |
| `/personality [name]` | Set a personality overlay for the session. |
| `/personality [name]` | Set a personality overlay for the session. `/personality none` (or `default` / `neutral`) clears it. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle messaging resets before requiring presets

This new messaging documentation is false when agent.personalities is empty, which is the raw gateway-config default. In gateway/slash_commands.py, _handle_personality_command() returns gateway.personality.none_configured at line 2507 before reaching the none/default/neutral reset branch at line 2532, so a user who previously selected a built-in personality through the CLI cannot clear that saved agent.system_prompt from Telegram, Discord, or another messaging surface as documented. Process reset aliases before the empty-personalities guard, or restrict the documentation to surfaces where reset currently works.

Useful? React with 👍 / 👎.

| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. |
| `/retry` | Retry the last message. |
| `/undo` | Remove the last exchange. |
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ Set a predefined personality to change the agent's tone:

Built-in personalities include: `helpful`, `concise`, `technical`, `creative`, `teacher`, `kawaii`, `catgirl`, `pirate`, `shakespeare`, `surfer`, `noir`, `uwu`, `philosopher`, `hype`.

To go back to the default (no overlay), use `/personality none` — `default` and `neutral` work too.

You can also define custom personalities in `~/.hermes/config.yaml`:

```yaml
Expand Down
12 changes: 12 additions & 0 deletions website/docs/user-guide/features/personality.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ Then switch to it with:
/personality codereviewer
```

## Resetting to the default

To cancel the active personality overlay and return to base behavior (your `SOUL.md` persona), use any of:

```text
/personality none
/personality default
/personality neutral
```

All three clear the overlay: the saved `agent.system_prompt` is emptied and the change takes effect on your next message. Running `/personality` with no arguments also lists `none` alongside the available presets.

## Recommended workflow

A strong default setup is:
Expand Down
2 changes: 1 addition & 1 deletion website/docs/user-guide/messaging/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ platform network disconnect as an event-loop failure.
|---------|-------------|
| `/new` or `/reset` | Start a fresh conversation |
| `/model [provider:model]` | Show or change the model (supports `provider:model` syntax) |
| `/personality [name]` | Set a personality |
| `/personality [name]` | Set a personality (`none` to reset) |
| `/retry` | Retry the last message |
| `/undo` | Remove the last exchange |
| `/status` | Show session info |
Expand Down