Skip to content

Hooks can show the user a notice that never reaches the model (port of mcode#376) - #132584

Open
teknium1 wants to merge 1 commit into
mainfrom
minimax-port/hook-system-message
Open

teknium1 wants to merge 1 commit into
mainfrom
minimax-port/hook-system-message

Conversation

@teknium1

@teknium1 teknium1 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

A plugin or shell hook can now tell the user something without telling the model: any hook result may carry notice (Hermes) or systemMessage (the field existing third-party hook scripts already emit, so they work unchanged), and the text is shown on the session's surface while never entering model context. Port of MiniMax-AI/minimax-code#376.

Changes

  • agent/hook_notices.py (new, 70 LOC): extracts the notice from a hook result (ANSI-stripped, 2,000-char cap), routes it to the live AIAgent whose session_id the hook payload names (WeakSet registry; agents register at construction).
  • hermes_cli/plugins_dispatch.py: both invoke_hook paths deliver notices after collecting results — every event, plugin and shell hooks alike, one seam.
  • agent/status_output.py::_emit_hook_notice: CLI prints ⚑ hook <event>: … on the status lane; TUI/Desktop get a 30 s AgentNotice toast; the messaging gateway a one-shot line (all three drivers already render AgentNotice).
  • agent/shell_hooks.py: _parse_response carries notice/systemMessage through every event's parser. A notice alone is never a decision: a fail_closed gate that exits non-zero with only a notice still blocks, and an exit-2 block keeps its notice.
  • Docs: hooks.md (plugin hooks intro + shell stdout block), observer-hooks.md return table.

Validation

Live repro — real hermes chat -q on gpt-5-mini, isolated HOME, a post_tool_call shell hook printing {"systemMessage": "LIVE-NOTICE after <tool> …"}:

tree CLI output rows mentioning the notice in the session's persisted messages
origin/main hook runs, nothing shown 0
this PR ⚑ hook post_tool_call: LIVE-NOTICE after terminal (never for the model) 0

Tests (2 invariants, red on base): a plugin hook's systemMessage/notice reaches the Desktop agent as an info toast and the CLI agent as a hook_notice status line, with ANSI stripped, nothing in messages, and nothing for an agent whose session is not named; a real shell-hook subprocess's systemMessage surfaces from post_tool_call while the same script on a fail_closed pre_tool_call exiting 3 still blocks. 163 tests across shell_hooks, plugins, plugin_host, status_output green.

Root cause: hook return values could only carry model-facing directives; no key reached the user, so hook authors either stayed silent or spent model context on status text.

Not reached live: TUI/Desktop toast rendering (unit-tested via the AgentNotice path all three drivers share).

Infographic

Hook notices: tell the user, not the model

…ce that never reaches the model

A plugin or shell hook could only speak to the model: a `context` string, a
block message, a transformed result. A formatter that wanted to say
"reformatted 3 files" either stayed silent or spent model context on it.

Any hook result may now carry `notice` (Hermes) or `systemMessage` (the field
existing third-party hook scripts already emit, so they work unchanged).
The dispatcher routes it to the agent whose session the payload names:
CLI prints a `⚑ hook <event>: …` status line, TUI/Desktop get a 30 s
AgentNotice toast, the messaging gateway a one-shot line. Nothing is appended
to `messages`, so history, compaction and export never see it. The text is
ANSI-stripped and capped at 2000 chars.

A notice alone is never a decision: a fail_closed pre_tool_call gate that
exits non-zero with only a notice still blocks, and a block directive keeps
its notice.

Live repro (real `hermes chat -q` on gpt-5-mini with a post_tool_call shell
hook, isolated HOME): origin/main prints nothing; this branch prints the hook
line and the session's persisted messages contain 0 rows of the notice text.
@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on c026324 — Port from MiniMax-AI/minimax-code#376: hooks can show the us

debug info

CI timings

CI timings · View report · View job

Wall time 6m37s vs 23m22s (-71.7%). 9 job(s) slower, 8 faster,

  • OS-specific tests / macOS-only tests (2/2): +88.0s
  • Python tests / Run tests (1/2): -52.0s
  • Check no case-colliding filenames / check-case-collisions: +50.0s
  • Python tests / Run tests (2/2): +36.0s
  • Check contributors / check-attribution: -27.0s

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Oct 4, 2026
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

Reviewed at head c0263242. The core idea holds up — routing notice / systemMessage onto the AgentNotice / status channel does keep it out of messages, compaction and export. All four in-context consumers take only a context key or a bare str, so a {"notice": ...} dict falls through them.

Two gaps in the delivery path, same shape: a value reaches _evaluate_result and doesn't come back out.

1. agent/shell_hooks.py:433 — a fail-closed gate that dies with only a notice loses the notice.

_parse_response returns {"notice": ...} for that stdout, so no_directive is true and this return discards it — _fail_closed_block carries none. The notice then reaches neither the model nor the user.

blk = _fail_closed_block(spec, f"hook exited {r['returncode']} with no directive")
notice = parsed.get("notice") if isinstance(parsed, dict) else None
return {**blk, **({"notice": notice} if notice else {})}

The exit-2 branch 10 lines above already does exactly this merge, as do the docs ({"action": "block", "message": "…", "notice": "…"} blocks and notifies) — so this reads as an oversight, not a deliberate difference. The new test misses the cell: test_hook_notices.py:303 asserts [0] of the notices list, which is the post_tool_call delivery; the pre_tool_call leg only asserts the block message.

2. agent/hook_notices.py:72 — the background-review fork shares its parent's session_id, and _agent_for_session takes candidates[0].

register_agent fires for every agent at agent/agent_init.py:1216; _agent_for_session scans the WeakSet for a session_id match and takes the first hit, unordered.

background_review.py:1025 sets review_agent.session_id = agent.session_id, and the fork also goes through register_agent. So during a review there are two entries with the same session_id and the winner is arbitrary. When the fork wins, every notice for that session goes to it — and the fork has suppress_status_output = True (background_review.py:1022) on a background thread, so the user sees nothing. Window: any session with background review on, turn end to fork exit.

live = [a for a in candidates if not getattr(a, "_persist_disabled", False)]
return (live or candidates)[0] if candidates else None

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-reviewed applied to manually approve dangerous changes comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants