Coverage for src / harnessutils / hooks.py: 100%
11 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-03-12 13:03 -0600
« prev ^ index » next coverage.py v7.13.2, created at 2026-03-12 13:03 -0600
1"""Message lifecycle hooks for ConversationManager.
3Hooks let consumers inject behaviour at the two key points in the
4add_message() lifecycle without subclassing ConversationManager.
6Pre-hook (on_before_add_message)
7 Fires before the message is persisted. The return value replaces
8 the message that will be stored. Raising any exception rejects the
9 message — it is never written to storage.
11Post-hook (on_after_add_message)
12 Fires after the message has been successfully persisted. The return
13 value is ignored. Exceptions propagate to the caller but the message
14 has already been stored.
16See docs/message-hooks.md for usage guide and worked examples.
17"""
19from collections.abc import Callable
20from dataclasses import dataclass, field
21from typing import TYPE_CHECKING, Any
23if TYPE_CHECKING:
24 from harnessutils.models.message import Message
27@dataclass
28class MessageHooks:
29 """Callbacks for the add_message() lifecycle.
31 Pass an instance to ConversationManager() to intercept messages
32 before and after they are persisted.
34 Example — reject messages that contain a forbidden string::
36 def guard(conv_id: str, msg: Message) -> Message:
37 for part in msg.parts:
38 if part.type == "text" and "forbidden" in part.text:
39 raise ValueError("Rejected: forbidden content")
40 return msg
42 manager = ConversationManager(
43 message_hooks=MessageHooks(on_before_add_message=guard)
44 )
46 Example — write every stored message to a semantic memory backend::
48 def index(conv_id: str, msg: Message) -> None:
49 import asyncio
50 asyncio.get_event_loop().run_until_complete(
51 manager.semantic_memory.add_artifact(
52 content=str(msg.to_dict()),
53 kind="message",
54 namespace=conv_id,
55 )
56 )
58 manager = ConversationManager(
59 semantic_memory=backend,
60 message_hooks=MessageHooks(on_after_add_message=index),
61 )
62 """
64 on_before_add_message: "Callable[[str, Message], Message] | None" = None
65 """Called before the message is persisted.
67 Signature::
69 def hook(conversation_id: str, message: Message) -> Message
71 - Return the message unchanged to pass it through.
72 - Return a *different* Message to replace what gets stored
73 (e.g. for content redaction or metadata injection).
74 - Raise any exception to reject the message. The message will
75 NOT be stored, and the exception propagates to the caller.
76 on_after_add_message will NOT be called.
77 """
79 on_after_add_message: "Callable[[str, Message], None] | None" = None
80 """Called after the message has been successfully persisted.
82 Signature::
84 def hook(conversation_id: str, message: Message) -> None
86 - The message received is the one actually stored (i.e. the value
87 returned by on_before_add_message, if one was set).
88 - Return value is ignored.
89 - Exceptions propagate to the caller. The message has already been
90 stored at this point — raising here does not undo storage.
91 - Intended for side effects: audit logging, semantic memory writes,
92 metrics, etc.
93 """
95 metadata: dict[str, Any] = field(default_factory=dict)
96 """Arbitrary metadata you can read inside your hook implementations."""