Inspired by Claude Cowork: pre_llm_call block directive — veto a turn before inference

Claude Cowork / Claude Enterprise shipped 'inference hooks' (Aug 5 2026): a
policy layer that inspects every prompt before it reaches the model and
returns an allow/deny verdict, giving DLP/compliance middleware a single
enforcement point across surfaces.

Hermes' pre_llm_call hook could until now only inject context — plugins
doing privacy/redaction/policy work (e.g. #57364) had no supported way to
stop a prompt from reaching the provider and had to patch core.

A pre_llm_call callback (Python plugin or shell hook) may now return
{"action": "block", "message": "..."}. The turn is vetoed in the
prologue, before any provider request: zero API calls, the message becomes
the assistant response (alternation-safe, session-resume-safe), and the
result carries turn_exit_reason=blocked_by_plugin_pre_llm_call. First valid
block wins; block without a message is ignored; context returns from other
hooks are unaffected. Shell hooks accept both the Hermes-canonical and
Claude-Code-style block shapes, mirroring pre_tool_call.
This commit is contained in:
Teknium 2026-08-07 18:26:03 -07:00
parent b3aa561faf
commit dbaf473ae3
No known key found for this signature in database
7 changed files with 322 additions and 6 deletions

View File

@ -1466,6 +1466,37 @@ def run_conversation(
_plugin_user_context = _ctx.plugin_user_context
_ext_prefetch_cache = _ctx.ext_prefetch_cache
# ── Plugin pre-LLM veto (inspired by Claude Cowork inference hooks) ──
# A pre_llm_call hook returned {"action": "block", "message": "..."}:
# stop the turn before ANY provider request. The block message becomes
# the assistant response so message-role alternation stays valid for
# session resume, and no prompt bytes ever reach the provider.
if _ctx.plugin_block_message:
_block_response = _ctx.plugin_block_message
messages.append({"role": "assistant", "content": _block_response})
agent._delivered_interim_texts = set()
agent._incremental_persistence_failed = False
# Normally reset just before the loop below; reset here too so the
# finalizer's context-engine notification can't see a stale prior
# turn's usage on a vetoed turn.
agent._last_turn_usage = None
from agent.turn_finalizer import finalize_turn as _finalize_blocked_turn
return _finalize_blocked_turn(
agent,
final_response=_block_response,
api_call_count=0,
interrupted=False,
failed=False,
messages=messages,
conversation_history=conversation_history,
effective_task_id=effective_task_id,
turn_id=turn_id,
user_message=user_message,
original_user_message=original_user_message,
_should_review_memory=False,
_turn_exit_reason="blocked_by_plugin_pre_llm_call",
)
# Commentary deduplication spans all provider continuations and tool calls
# within one user turn, but must not suppress the same phrase next turn.
agent._delivered_interim_texts = set()

View File

@ -615,6 +615,16 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
return {"action": "continue", "message": message.strip()}
return None
if event == "pre_llm_call":
# Inference-hook-style veto (inspired by Claude Cowork / Claude
# Enterprise inference hooks): a shell hook may block the turn before
# any provider request. Both the Hermes-canonical and the
# Claude-Code-style shapes are accepted, mirroring pre_tool_call.
if data.get("action") == "block":
return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))}
if data.get("decision") == "block":
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
context = data.get("context")
if isinstance(context, str) and context.strip():
return {"context": context}

View File

@ -334,6 +334,12 @@ class TurnContext:
should_review_memory: bool = False
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
plugin_user_context: str = ""
# Block directive from a ``pre_llm_call`` plugin: when non-empty, the turn
# is vetoed BEFORE any provider request is made and this message is
# returned to the user. Inspired by Claude Cowork / Claude Enterprise
# "inference hooks" (Aug 2026): a policy layer inspects every prompt
# before it reaches the model and returns an allow/deny verdict.
plugin_block_message: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
# Turn-start preflight already proved an immediate retry ineffective.
@ -1064,6 +1070,7 @@ def build_turn_context(
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
plugin_block_message = ""
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
@ -1094,6 +1101,22 @@ def build_turn_context(
_spill_config_cached = None
for r in _pre_results:
_piece: str = ""
if isinstance(r, dict) and r.get("action") == "block":
# Inference-hook-style veto (inspired by Claude Cowork /
# Claude Enterprise inference hooks, Aug 2026): a plugin may
# return {"action": "block", "message": "..."} to stop the
# turn before ANY provider request is made. The message is
# returned to the user in place of a model response. First
# block wins; a block without a message is ignored (matching
# the pre_tool_call directive contract).
_block_msg = r.get("message")
if (
not plugin_block_message
and isinstance(_block_msg, str)
and _block_msg.strip()
):
plugin_block_message = _block_msg.strip()
continue
if isinstance(r, dict) and r.get("context"):
_piece = str(r["context"])
elif isinstance(r, str) and r.strip():
@ -1276,6 +1299,7 @@ def build_turn_context(
current_turn_user_idx=current_turn_user_idx,
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
plugin_block_message=plugin_block_message,
ext_prefetch_cache=ext_prefetch_cache,
preflight_compression_blocked=_preflight_compression_blocked,
)

View File

@ -2114,6 +2114,11 @@ class PluginManager:
{"context": "recalled text..."}
"recalled text..." # plain string, equivalent
or a block directive that vetoes the turn before any provider
request is made (inspired by Claude Cowork inference hooks)::
{"action": "block", "message": "Reason shown to the user"}
Context is ALWAYS injected into the user message, never the
system prompt. This preserves the prompt cache prefix the
system prompt stays identical across turns so cached tokens

View File

@ -0,0 +1,235 @@
"""Tests for the pre_llm_call block directive (Cowork-inspired inference veto).
A ``pre_llm_call`` hook may return ``{"action": "block", "message": "..."}``
to veto the turn BEFORE any provider request is made inspired by Claude
Cowork / Claude Enterprise "inference hooks" (Aug 2026), where a policy layer
inspects every prompt pre-inference and returns an allow/deny verdict.
Covers: prologue directive extraction (block wins, message required, first
block wins, context still collected), the wire invariant (zero provider
requests on a blocked turn), message-role alternation of the persisted
transcript, and recovery on the following unblocked turn. Also covers the
shell-hook stdout translation for the new event shape.
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_state import SessionDB
BLOCK_MSG = "⛔ Blocked by policy: prompt contains a customer SSN."
# ---------------------------------------------------------------------------
# Mock provider (counts every request that would have reached the model)
# ---------------------------------------------------------------------------
class _MockHandler(BaseHTTPRequestHandler):
captured_requests: list = []
def do_POST(self): # noqa: N802 (http.server API)
length = int(self.headers.get("Content-Length", 0))
req = json.loads(self.rfile.read(length).decode())
type(self).captured_requests.append(req)
if req.get("stream") is True:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
chunks = [
{"id": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]},
{"id": "m", "choices": [{"index": 0, "delta": {"content": "MODEL-ANSWER"}, "finish_reason": None}]},
{"id": "m", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
]
for c in chunks:
self.wfile.write(f"data: {json.dumps(c)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
return
resp = {
"id": "m",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "MODEL-ANSWER"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
body = json.dumps(resp).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a, **kw):
pass
@pytest.fixture()
def block_env():
"""Mock provider + isolated HERMES_HOME + a shared SessionDB.
Yields ``(make_agent, handler, db, sid, hook_state)`` where
``hook_state["results"]`` is what the patched ``pre_llm_call`` hook
returns on the next turn.
"""
_MockHandler.captured_requests = []
srv = HTTPServer(("127.0.0.1", 0), _MockHandler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
test_home = tempfile.mkdtemp(prefix="hermes_prellm_block_")
os.makedirs(os.path.join(test_home, ".hermes"))
prev_home = os.environ.get("HERMES_HOME")
os.environ["HERMES_HOME"] = os.path.join(test_home, ".hermes")
from run_agent import AIAgent
db = SessionDB(db_path=Path(test_home) / "state.db")
sid = "sess-block"
hook_state = {"results": []}
def make_agent():
agent = AIAgent(
api_key="test-key", base_url=f"http://127.0.0.1:{port}/v1",
provider="openai-compat", model="test-model",
max_iterations=10, enabled_toolsets=[],
quiet_mode=True, skip_context_files=True, skip_memory=True,
save_trajectories=False, platform="cli",
session_db=db, session_id=sid,
)
return agent
try:
with patch(
"hermes_cli.plugins.invoke_hook",
side_effect=lambda hook, **kw: (
list(hook_state["results"]) if hook == "pre_llm_call" else []
),
):
yield make_agent, _MockHandler, db, sid, hook_state
finally:
srv.shutdown()
db.close()
shutil.rmtree(test_home, ignore_errors=True)
if prev_home is None:
os.environ.pop("HERMES_HOME", None)
else:
os.environ["HERMES_HOME"] = prev_home
def _chat_requests(handler) -> list:
# The model context-length probe also hits the mock; keep only
# chat-completions payloads.
return [r for r in handler.captured_requests if "messages" in r]
# ---------------------------------------------------------------------------
# E2E: blocked turn never reaches the provider
# ---------------------------------------------------------------------------
class TestPreLlmBlock:
def test_block_prevents_provider_request(self, block_env):
make_agent, handler, db, sid, hook_state = block_env
hook_state["results"] = [{"action": "block", "message": BLOCK_MSG}]
agent = make_agent()
result = agent.run_conversation("here is an SSN 123-45-6789", conversation_history=[], task_id="t")
assert result["final_response"] == BLOCK_MSG
assert result["turn_exit_reason"] == "blocked_by_plugin_pre_llm_call"
assert result["api_calls"] == 0
assert result["failed"] is False
assert _chat_requests(handler) == [] # nothing reached the provider
def test_blocked_turn_keeps_alternation_and_next_turn_recovers(self, block_env):
make_agent, handler, db, sid, hook_state = block_env
hook_state["results"] = [{"action": "block", "message": BLOCK_MSG}]
agent = make_agent()
result = agent.run_conversation("secret stuff", conversation_history=[], task_id="t")
assert result["final_response"] == BLOCK_MSG
# Persisted transcript stays a valid user→assistant alternation.
rows = [r for r in db.get_messages(sid) if r["role"] in ("user", "assistant")]
assert [r["role"] for r in rows] == ["user", "assistant"]
assert rows[-1]["content"] == BLOCK_MSG
# Next turn (hook allows): normal provider round-trip on a fresh agent
# that reloads the blocked turn's history from the store.
hook_state["results"] = []
agent2 = make_agent()
history = db.get_messages_as_conversation(sid)
result2 = agent2.run_conversation("hello again", conversation_history=history, task_id="t")
assert result2["final_response"] == "MODEL-ANSWER"
reqs = _chat_requests(handler)
assert len(reqs) == 1
roles = [m["role"] for m in reqs[0]["messages"]]
# No two identical non-tool roles adjacent (alternation invariant).
for a, b in zip(roles, roles[1:]):
if a == b:
assert a == "tool"
def test_block_without_message_is_ignored(self, block_env):
make_agent, handler, db, sid, hook_state = block_env
hook_state["results"] = [{"action": "block"}]
agent = make_agent()
result = agent.run_conversation("hi", conversation_history=[], task_id="t")
assert result["final_response"] == "MODEL-ANSWER"
assert len(_chat_requests(handler)) == 1
def test_first_block_wins_and_context_hooks_unaffected(self, block_env):
make_agent, handler, db, sid, hook_state = block_env
hook_state["results"] = [
{"context": "some recalled context"},
{"action": "block", "message": "first"},
{"action": "block", "message": "second"},
]
agent = make_agent()
result = agent.run_conversation("hi", conversation_history=[], task_id="t")
assert result["final_response"] == "first"
assert _chat_requests(handler) == []
# ---------------------------------------------------------------------------
# Prologue unit: directive extraction
# ---------------------------------------------------------------------------
class TestDirectiveExtraction:
def test_context_still_collected_when_no_block(self, block_env):
make_agent, handler, db, sid, hook_state = block_env
hook_state["results"] = [{"context": "CTX-A"}, {"context": "CTX-B"}]
agent = make_agent()
result = agent.run_conversation("hi", conversation_history=[], task_id="t")
assert result["final_response"] == "MODEL-ANSWER"
sent = _chat_requests(handler)[0]
user_msgs = [m for m in sent["messages"] if m["role"] == "user"]
assert "CTX-A" in user_msgs[0]["content"]
assert "CTX-B" in user_msgs[0]["content"]
# ---------------------------------------------------------------------------
# Shell-hook stdout translation
# ---------------------------------------------------------------------------
class TestShellHookParse:
def test_pre_llm_call_block_hermes_shape(self):
from agent.shell_hooks import _parse_response
out = _parse_response("pre_llm_call", json.dumps({"action": "block", "message": "nope"}))
assert out == {"action": "block", "message": "nope"}
def test_pre_llm_call_block_claude_code_shape(self):
from agent.shell_hooks import _parse_response
out = _parse_response("pre_llm_call", json.dumps({"decision": "block", "reason": "nope"}))
assert out == {"action": "block", "message": "nope"}
def test_pre_llm_call_context_passthrough_unchanged(self):
from agent.shell_hooks import _parse_response
out = _parse_response("pre_llm_call", json.dumps({"context": "Today is Friday"}))
assert out == {"context": "Today is Friday"}

View File

@ -672,7 +672,7 @@ Each hook is documented in full on the **[Event Hooks reference](/user-guide/fea
| `kanban_task_completed` | A kanban task completes (worker process) | `task_id, board, assignee, run_id, profile_name, summary: str \| None` | ignored |
| `kanban_task_blocked` | A kanban task is blocked (worker process) | `task_id, board, assignee, run_id, profile_name, reason: str \| None` | ignored |
Most hooks are fire-and-forget observers — their return values are ignored. The exceptions are `pre_llm_call`, which can inject context into the conversation, and `pre_tool_call`, which can return a block/approve directive.
Most hooks are fire-and-forget observers — their return values are ignored. The exceptions are `pre_llm_call`, which can inject context into the conversation or block the turn before it reaches the model, and `pre_tool_call`, which can return a block/approve directive.
All callbacks should accept `**kwargs` for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally.
@ -680,7 +680,7 @@ The kanban lifecycle hooks fire **after** the board DB change commits, so a call
### `pre_llm_call` context injection
This is the only hook whose return value matters. When a `pre_llm_call` callback returns a dict with a `"context"` key (or a plain string), Hermes injects that text into the **current turn's user message**. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context.
When a `pre_llm_call` callback returns a dict with a `"context"` key (or a plain string), Hermes injects that text into the **current turn's user message**. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context.
#### Return format
@ -691,12 +691,18 @@ return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project
# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"
# Block the turn before it reaches the model (DLP / policy middleware —
# inspired by Claude Cowork's inference hooks)
return {"action": "block", "message": "⛔ Blocked by policy: prompt contains a customer SSN."}
# Return None or don't return → no injection (observer-only)
return None
```
Any non-None, non-empty return with a `"context"` key (or a plain non-empty string) is collected and appended to the user message for the current turn.
A `{"action": "block", "message": "..."}` return vetoes the turn **before any provider request is made**: the message is delivered to the user in place of a model response, zero API calls happen, and the turn result carries `turn_exit_reason: "blocked_by_plugin_pre_llm_call"`. The blocked turn still persists a valid `user → assistant` pair (the block message becomes the assistant response), so message-role alternation and session resume are unaffected. The first valid block across all plugins wins; a block without a message is ignored.
#### Oversized-context spill
Per-hook context is capped at `10,000` characters by default. Anything above the cap is written to `$HERMES_HOME/hook_outputs/<session_id>/<uuid>.txt` and replaced with a head/tail preview plus the saved path. The model can read the full content via `read_file` or `terminal` if it genuinely needs it. This keeps a runaway plugin from inflating every subsequent turn's prompt and blowing out the prompt cache prefix. Tune in `config.yaml`:

View File

@ -383,7 +383,7 @@ def register(ctx):
- Callbacks receive **keyword arguments**. Always accept `**kwargs` for forward compatibility — new parameters may be added in future versions without breaking your plugin.
- If a callback **crashes**, it's logged and skipped. Other hooks and the agent continue normally. A misbehaving plugin can never break the agent.
- Two hooks' return values affect behavior: [`pre_tool_call`](#pre_tool_call) can **block** the tool, and [`pre_llm_call`](#pre_llm_call) can **inject context** into the LLM call. All other hooks are fire-and-forget observers.
- Two hooks' return values affect behavior: [`pre_tool_call`](#pre_tool_call) can **block** the tool, and [`pre_llm_call`](#pre_llm_call) can **inject context** into the LLM call or **block the turn** before it reaches the model. All other hooks are fire-and-forget observers.
- Observer callbacks receive `telemetry_schema_version` automatically. When present, `turn_id`, `api_request_id`, `task_id`, `session_id`, and `api_call_count` are separate correlation fields. Treat `api_request_id` as an opaque identifier; do not parse its string format.
### Quick reference
@ -392,7 +392,7 @@ def register(ctx):
|------|-----------|---------|
| [`pre_tool_call`](#pre_tool_call) | Before any tool executes | `{"action": "block", "message": str}` to veto the call |
| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored |
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message |
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message, or `{"action": "block", "message": str}` to veto the turn |
| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored |
| [`pre_verify`](#pre_verify) | Once per turn when the agent edited code, before it verifies/finishes | `{"action": "continue", "message": str}` to keep going |
| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored |
@ -522,7 +522,7 @@ def register(ctx):
### `pre_llm_call`
Fires **once per turn**, before the tool-calling loop begins. This is the **only hook whose return value is used** — it can inject context into the current turn's user message.
Fires **once per turn**, before the tool-calling loop begins. Its return value can inject context into the current turn's user message, or block the turn entirely before any provider request is made.
**Callback signature:**
@ -542,7 +542,7 @@ def my_callback(session_id: str, user_message: str, conversation_history: list,
**Fires:** In `run_agent.py`, inside `run_conversation()`, after context compression but before the main `while` loop. Fires once per `run_conversation()` call (i.e. once per user turn), not once per API call within the tool loop.
**Return value:** If the callback returns a dict with a `"context"` key, or a plain non-empty string, the text is appended to the current turn's user message. Return `None` for no injection.
**Return value:** If the callback returns a dict with a `"context"` key, or a plain non-empty string, the text is appended to the current turn's user message. Return `None` for no injection. A callback may instead return a **block directive** to veto the turn before any provider request is made (inspired by Claude Cowork's inference hooks): the block message is returned to the user in place of a model response, and no prompt bytes ever reach the provider.
```python
# Inject context
@ -551,10 +551,15 @@ return {"context": "Recalled memories:\n- User likes Python\n- Working on hermes
# Plain string (equivalent)
return "Recalled memories:\n- User likes Python"
# Block the turn before it reaches the model (DLP / policy enforcement)
return {"action": "block", "message": "⛔ Blocked by policy: prompt contains a customer SSN."}
# No injection
return None
```
**Block semantics:** the first valid block directive across all plugins wins; a block without a message is ignored. The blocked turn still persists a valid `user → assistant` pair to the session (the block message becomes the assistant response), so session resume and message-role alternation are unaffected. Zero API calls are made and the turn result carries `turn_exit_reason: "blocked_by_plugin_pre_llm_call"`.
**Where context is injected:** Always the **user message**, never the system prompt. This preserves the prompt cache — the system prompt stays identical across turns, so cached tokens are reused. The system prompt is Hermes's territory (model guidance, tool enforcement, personality, skills). Plugins contribute context alongside the user's input.
All injected context is **ephemeral** — added at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database.