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:
parent
b3aa561faf
commit
dbaf473ae3
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue