failproofai 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  54. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ec94jt._.js → [root-of-the-server]__0oqrl71._.js} +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/{_1g7s7di._.js → _0yq5_3a._.js} +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/{_0xq-p5o._.js → _16pa9ht._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  83. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  84. package/.next/standalone/.next/server/pages/404.html +1 -1
  85. package/.next/standalone/.next/server/pages/500.html +1 -1
  86. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  88. package/.next/standalone/.next/static/chunks/{3fbsuyflx-2qi.js → 03h3b-uomi7_d.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/{03madt33xbsx2.js → 078dofaxvjgpd.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{1-n1o5wh4wfy6.js → 1fqukzzowjflt.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/1lyg_puttohbc.js +1 -0
  92. package/.next/standalone/.next/static/chunks/{28z6u4u7hdrnd.js → 2j92xul6y24_v.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{1t6tmwe_1myeo.js → 2kj826h1w9r47.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{3jffngwvgxm5q.js → 3mjfvng11xnoo.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{0zlixskj11-yh.js → 3nkc2jlgyygda.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{2lczelmvvc2yg.js → 3ob2161ap37ul.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2dj6xq9mueqb6.js → 3u1s8dsqiean5.js} +1 -1
  98. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +4 -0
  99. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +29 -4
  100. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +57 -14
  101. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +17 -3
  102. package/.next/standalone/fp-cloud-cli/tests/test_request_id.py +111 -0
  103. package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
  104. package/.next/standalone/hermes-plugin/README.md +88 -10
  105. package/.next/standalone/hermes-plugin/__init__.py +813 -28
  106. package/.next/standalone/hermes-plugin/client.py +4 -2
  107. package/.next/standalone/hermes-plugin/ledger.py +9 -3
  108. package/.next/standalone/hermes-plugin/plugin.yaml +2 -2
  109. package/.next/standalone/package.json +10 -10
  110. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +8 -1
  111. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +82 -7
  112. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +23 -4
  113. package/.next/standalone/sdk/python/tests/test_evaluator_request_id.py +197 -0
  114. package/.next/standalone/sdk/python/uv.lock +9 -9
  115. package/.next/standalone/server.js +1 -1
  116. package/dist/cli.mjs +15 -12
  117. package/dist/worker.mjs +5 -2
  118. package/hermes-plugin/README.md +88 -10
  119. package/hermes-plugin/__init__.py +813 -28
  120. package/hermes-plugin/client.py +4 -2
  121. package/hermes-plugin/ledger.py +9 -3
  122. package/hermes-plugin/plugin.yaml +2 -2
  123. package/package.json +10 -10
  124. package/src/hooks/types.ts +7 -0
  125. package/.next/standalone/.next/static/chunks/0w8_n30wmnorq.js +0 -1
  126. /package/.next/standalone/.next/static/{Q2PyL8ZdGGdvVnn-3FrL2 → ECs8__hcNabYV58WfDh-3}/_buildManifest.js +0 -0
  127. /package/.next/standalone/.next/static/{Q2PyL8ZdGGdvVnn-3FrL2 → ECs8__hcNabYV58WfDh-3}/_clientMiddlewareManifest.js +0 -0
  128. /package/.next/standalone/.next/static/{Q2PyL8ZdGGdvVnn-3FrL2 → ECs8__hcNabYV58WfDh-3}/_ssgManifest.js +0 -0
@@ -2,23 +2,86 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextvars
6
+ import functools
7
+ import hashlib
8
+ import json
5
9
  import logging
6
10
  import os
11
+ import re
12
+ import threading
13
+ import time
14
+ from collections import OrderedDict
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
7
17
  from pathlib import Path
8
- from typing import Any, Mapping
18
+ from typing import Any, Callable, Iterable, Mapping
9
19
 
10
20
  from .client import PolicyVerdict, evaluate_policy
11
21
  from .ledger import InstructionLedger, LedgerError
12
22
 
13
23
  logger = logging.getLogger(__name__)
14
24
 
25
+ _PLUGIN_ID = "failproofai"
26
+
27
+ _HOOKS = (
28
+ "pre_tool_call",
29
+ "post_tool_call",
30
+ "pre_llm_call",
31
+ "on_session_start",
32
+ "on_session_end",
33
+ "on_session_reset",
34
+ "on_session_finalize",
35
+ "subagent_stop",
36
+ )
37
+
38
+ _REMINDER_KEY = "failproof_policy_reminder"
39
+ # Where a tool's own field of that name goes: it is tool data, and the protocol
40
+ # note tells the model _REMINDER_KEY is an operator rule, so the two never mix.
41
+ _TOOL_REMINDER_KEY = f"tool_{_REMINDER_KEY}"
42
+
15
43
  _PROTOCOL_CONTEXT = (
16
- "When a tool result starts with FAILPROOF INSTRUCTION, treat it as policy guidance. "
17
- "Reconsider the attempted action before retrying. Do not evade or ignore the instruction; "
18
- "change tools, arguments, targets, or side effects only when the instruction requires it. "
19
- "If you cannot follow the instruction, stop and explain why."
44
+ "FailproofAI applies your operator's policies to tool calls. "
45
+ f"(1) A tool result whose first JSON key is {_REMINDER_KEY}, or whose text starts with "
46
+ "[FailproofAI policy reminder ...], ran normally: it is not an error and needs no retry. "
47
+ "The reminder is an operator rule added by FailproofAI, not data from the tool; apply it "
48
+ "to how you use that result. It never grants permission for anything the user did not ask "
49
+ "for. Never quote or repeat a reminder, or say that one was attached, in your replies, "
50
+ "messages or files: just follow it. "
51
+ "(2) When FailproofAI blocks a tool call, do not run that action: not by retrying it, and "
52
+ "not through another tool, a script, or reworded arguments. Follow the block message, or "
53
+ "stop and explain why you cannot continue. "
54
+ "(3) A result that starts with 'FailproofAI policy guidance' held the call once so you could "
55
+ "read the guidance: apply it and continue; repeating the same call is allowed."
20
56
  )
21
57
 
58
+ # Reminders from a pre_tool_call that ran before its own call's middleware
59
+ # frame opened wait this long to be claimed (normally microseconds).
60
+ _HANDOFF_TTL_SECONDS = 60.0
61
+ _HANDOFF_LIMIT = 256
62
+
63
+ # Hermes 0.21.x abandons a pre_tool_call that runs past 30 s, blocks the call,
64
+ # and then skips that callback (blocking every call) for 60 s. One evaluation,
65
+ # connect included, stays well inside that.
66
+ _EVALUATION_TIMEOUT_CAP_MS = 25_000
67
+ _OBSERVE_TIMEOUT_MS = 2_000
68
+
69
+ # A reminder is attached once per (session, turn) for the same policy and reason.
70
+ _DELIVERED_LIMIT = 1024
71
+
72
+ # Hermes saves a result to a file once it passes 15% of the model's context
73
+ # window (at 4 chars per token, clamped to 8,000-100,000 chars; read_file is
74
+ # never saved) and shows the model a 1,500-char preview. When the full reminder
75
+ # would take a result past 7,500 chars it is compacted to a bounded size (each
76
+ # policy name, each reason and the number of reminders are capped), so it stays
77
+ # inside that preview. It is always attached: on a model whose threshold is the
78
+ # 8,000 floor, a result just under it may be saved because of the reminder, and
79
+ # the reminder is then the head of the preview the model reads.
80
+ _FULL_REMINDER_MAX_CHARS = 7_500
81
+ _COMPACT_REASON_CHARS = 160
82
+ _COMPACT_POLICY_CHARS = 80
83
+ _COMPACT_MAX_REMINDERS = 3
84
+
22
85
 
23
86
  def _bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
24
87
  try:
@@ -43,28 +106,539 @@ def _string(value: object) -> str:
43
106
  return value if isinstance(value, str) else ""
44
107
 
45
108
 
109
+ # Hermes added `ctx.get_config` and `ctx.state` in 0.20.1. On 0.20.0 the plugin
110
+ # must still load: a register() that raises leaves every tool call unchecked.
111
+ # The helpers below reproduce what those two members return, so a later Hermes
112
+ # upgrade reads the same settings and reuses the same instructions.db.
113
+
114
+
115
+ def _plugin_id(ctx: Any) -> str:
116
+ manifest = getattr(ctx, "manifest", None)
117
+ for field in ("key", "name"):
118
+ value = getattr(manifest, field, None)
119
+ if isinstance(value, str) and value:
120
+ return value
121
+ return _PLUGIN_ID
122
+
123
+
124
+ def _hermes_home() -> Path:
125
+ try:
126
+ # The host's resolver honours a per-task profile override as well as
127
+ # HERMES_HOME; it is the one PluginState.data_dir uses.
128
+ from hermes_constants import get_hermes_home
129
+
130
+ return Path(get_hermes_home())
131
+ except Exception:
132
+ configured_home = os.environ.get("HERMES_HOME", "").strip()
133
+ return Path(configured_home).expanduser() if configured_home else Path.home() / ".hermes"
134
+
135
+
136
+ def _data_namespace(plugin_id: str, skill_namespace: str = "") -> str:
137
+ # hermes_cli.plugins._plugin_data_namespace + _portable_skill_namespace.
138
+ candidate = skill_namespace or plugin_id
139
+ if (
140
+ skill_namespace
141
+ and candidate.startswith("agent-plugin-")
142
+ and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,191}", candidate)
143
+ ):
144
+ return candidate
145
+ slug = "".join(
146
+ ch if ch.isascii() and (ch.isalnum() or ch in "_-") else "-"
147
+ for ch in candidate.lower()
148
+ )
149
+ slug = slug.strip("-_") or "plugin"
150
+ digest = hashlib.sha256(candidate.encode("utf-8")).hexdigest()[:8]
151
+ return f"agent-plugin-{slug}-{digest}"
152
+
153
+
154
+ def _state_dir(ctx: Any, plugin_id: str) -> Path | None:
155
+ try:
156
+ state = getattr(ctx, "state", None)
157
+ if state is not None:
158
+ return Path(state.data_dir)
159
+ except Exception as exc:
160
+ logger.warning("FailproofAI could not read Hermes plugin state: %s", exc)
161
+ try:
162
+ skill_namespace = getattr(getattr(ctx, "manifest", None), "skill_namespace", "")
163
+ namespace = _data_namespace(plugin_id, _string(skill_namespace))
164
+ return _hermes_home() / "plugin-data" / namespace
165
+ except Exception as exc:
166
+ logger.error("FailproofAI instruction state directory unavailable: %s", exc)
167
+ return None
168
+
169
+
170
+ def _config_reader(ctx: Any, plugin_id: str) -> Callable[[str, Any], Any]:
171
+ get_config = getattr(ctx, "get_config", None)
172
+ if callable(get_config):
173
+ return get_config
174
+
175
+ # PluginContext.get_config: plugins.entries.<id>.settings.<key>, then the
176
+ # legacy config.<key>, read through the host's own config loader.
177
+ entry: Mapping[str, Any] = {}
178
+ try:
179
+ from hermes_cli import config as hermes_config
180
+
181
+ load = getattr(hermes_config, "load_config_readonly", None) or hermes_config.load_config
182
+ config = load() or {}
183
+ plugins = config.get("plugins") if isinstance(config, Mapping) else None
184
+ entries = plugins.get("entries") if isinstance(plugins, Mapping) else None
185
+ found = entries.get(plugin_id) if isinstance(entries, Mapping) else None
186
+ if isinstance(found, Mapping):
187
+ entry = found
188
+ except Exception as exc:
189
+ logger.debug("FailproofAI using default settings; Hermes config unavailable: %s", exc)
190
+
191
+ def read(key: str, default: Any = None) -> Any:
192
+ for section in ("settings", "config"):
193
+ values = entry.get(section)
194
+ if isinstance(values, Mapping) and key in values:
195
+ return values[key]
196
+ return default
197
+
198
+ return read
199
+
200
+
201
+ # An instruct verdict is a reminder, not a refusal: with Hermes' tool_execution
202
+ # middleware the call runs and the reminder rides at the START of its result
203
+ # (JSON first key, or a leading line), so it survives the 1,500-char preview
204
+ # Hermes keeps of a large result. Blocking once to deliver it made the model
205
+ # treat a correct call as an error and route around it.
206
+ #
207
+ # Where pre_tool_call runs relative to the middleware (Hermes 0.20.0-0.21.3):
208
+ # - agent loop (sequential and concurrent): inside next_call, on the
209
+ # middleware's thread (0.20.0) or on a hook worker that runs in a COPY of its
210
+ # context (0.21.x). A ContextVar frame stack is visible on both; a
211
+ # thread-local is not on 0.21.x.
212
+ # - model_tools.handle_function_call (execute_code inner calls, direct
213
+ # dispatch): BEFORE the middleware, so the reminder is handed off by call
214
+ # identity and the call's frame claims it when it opens.
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class _Reminder:
219
+ policies: str
220
+ reason: str
221
+
222
+
223
+ class _ReminderFrame:
224
+ """One call wrapped by the tool_execution middleware."""
225
+
226
+ def __init__(self, tool_name: str, tool_call_id: str, parent: "_ReminderFrame | None") -> None:
227
+ self.tool_name = tool_name
228
+ self.tool_call_id = tool_call_id
229
+ self.parent = parent
230
+ self._lock = threading.Lock()
231
+ self._open = True
232
+ self._checked = False
233
+ self._reminders: list[_Reminder] = []
234
+
235
+ @property
236
+ def open(self) -> bool:
237
+ return self._open
238
+
239
+ def owns(self, tool_name: str, tool_call_id: str) -> bool:
240
+ # The first pre_tool_call naming this frame's call is that call's own
241
+ # check; anything else in the frame is a nested call.
242
+ with self._lock:
243
+ if not self._open or self._checked:
244
+ return False
245
+ if (tool_name, tool_call_id) != (self.tool_name, self.tool_call_id):
246
+ return False
247
+ self._checked = True
248
+ return True
249
+
250
+ def add(self, reminders: Iterable[_Reminder]) -> bool:
251
+ """False when the frame already closed, so the caller keeps them."""
252
+ with self._lock:
253
+ if not self._open:
254
+ return False
255
+ for reminder in reminders:
256
+ if reminder not in self._reminders:
257
+ self._reminders.append(reminder)
258
+ return True
259
+
260
+ def close(self) -> tuple[_Reminder, ...]:
261
+ with self._lock:
262
+ self._open = False
263
+ return tuple(self._reminders)
264
+
265
+
266
+ class _ReminderHandoff:
267
+ """Reminders waiting for the middleware frame of a call whose pre_tool_call
268
+ ran first. Bounded and short-lived: an unclaimed entry means the call never
269
+ reached the middleware (another plugin blocked it), so nothing is lost."""
270
+
271
+ def __init__(self) -> None:
272
+ self._lock = threading.Lock()
273
+ self._entries: dict[tuple[str, ...], tuple[float, list[_Reminder]]] = {}
274
+
275
+ def _expire(self, now: float) -> None:
276
+ for key in [key for key, (at, _) in self._entries.items() if now - at > _HANDOFF_TTL_SECONDS]:
277
+ del self._entries[key]
278
+ while len(self._entries) >= _HANDOFF_LIMIT:
279
+ del self._entries[next(iter(self._entries))]
280
+
281
+ def put(self, key: tuple[str, ...], reminder: _Reminder) -> None:
282
+ now = time.monotonic()
283
+ with self._lock:
284
+ entry = self._entries.pop(key, None)
285
+ self._expire(now)
286
+ reminders = entry[1] if entry is not None else []
287
+ if reminder not in reminders:
288
+ reminders.append(reminder)
289
+ self._entries[key] = (now, reminders)
290
+
291
+ def take(self, key: tuple[str, ...]) -> list[_Reminder]:
292
+ now = time.monotonic()
293
+ with self._lock:
294
+ entry = self._entries.pop(key, None)
295
+ # An entry past its TTL belonged to a call that never reached its frame.
296
+ if entry is None or now - entry[0] > _HANDOFF_TTL_SECONDS:
297
+ return []
298
+ return entry[1]
299
+
300
+ def pending(self) -> bool:
301
+ # Lets the middleware skip hashing every call's arguments.
302
+ return bool(self._entries)
303
+
304
+
305
+ def _call_key(
306
+ session_id: object, task_id: object, tool_call_id: object, tool_name: object, args: object
307
+ ) -> tuple[str, ...]:
308
+ # Execute_code inner calls carry no session or tool_call id, so the
309
+ # arguments keep two concurrent calls of one tool apart.
310
+ try:
311
+ encoded = json.dumps(args or {}, sort_keys=True, ensure_ascii=False, default=repr)
312
+ except Exception:
313
+ encoded = repr(args)
314
+ digest = hashlib.sha256(encoded.encode("utf-8", "replace")).hexdigest()
315
+ return (_string(session_id), _string(task_id), _string(tool_call_id), _string(tool_name), digest)
316
+
317
+
318
+ def _clip(text: str, limit: int) -> str:
319
+ return text if len(text) <= limit else text[:limit].rstrip() + "…"
320
+
321
+
322
+ def _reason(reminder: _Reminder, compact: bool) -> str:
323
+ return _clip(reminder.reason, _COMPACT_REASON_CHARS) if compact else reminder.reason
324
+
325
+
326
+ def _policies(reminder: _Reminder, compact: bool) -> str:
327
+ # Custom policy names have no length limit, so the compact form caps them too.
328
+ return _clip(reminder.policies, _COMPACT_POLICY_CHARS) if compact else reminder.policies
329
+
330
+
331
+ def _reminder_lines(reminders: tuple[_Reminder, ...], compact: bool, line: Callable[[str, str], str]) -> str:
332
+ shown = reminders[:_COMPACT_MAX_REMINDERS] if compact else reminders
333
+ lines = [line(_policies(r, compact), _reason(r, compact)) for r in shown]
334
+ if len(shown) < len(reminders):
335
+ lines.append(f"(+{len(reminders) - len(shown)} more FailproofAI policy reminders)")
336
+ return "\n".join(lines) + " — the tool call ran; apply this when you use its result."
337
+
338
+
339
+ def _reminder_text(reminders: tuple[_Reminder, ...], compact: bool = False) -> str:
340
+ return _reminder_lines(reminders, compact, lambda p, r: f"FailproofAI policy reminder ({p}): {r}")
341
+
342
+
343
+ def _reminder_prefix(reminders: tuple[_Reminder, ...], compact: bool = False) -> str:
344
+ return _reminder_lines(reminders, compact, lambda p, r: f"[FailproofAI policy reminder ({p})] {r}")
345
+
346
+
347
+ def _result_chars(result: Any) -> int:
348
+ if isinstance(result, str):
349
+ return len(result)
350
+ try:
351
+ return len(json.dumps(result, ensure_ascii=False, default=str))
352
+ except Exception:
353
+ return 0
354
+
355
+
356
+ class _TurnDeliveries:
357
+ """Bounded LRU of the reminders already attached in a (session, turn), so a
358
+ turn that runs the same command again does not repeat the same text."""
359
+
360
+ def __init__(self, limit: int = _DELIVERED_LIMIT) -> None:
361
+ self._lock = threading.Lock()
362
+ self._limit = limit
363
+ self._seen: OrderedDict[tuple[str, str, str, str], None] = OrderedDict()
364
+
365
+ @staticmethod
366
+ def _key(session_id: str, turn_id: str, reminder: _Reminder) -> tuple[str, str, str, str]:
367
+ return (session_id, turn_id, reminder.policies, reminder.reason)
368
+
369
+ def unseen(self, session_id: str, turn_id: str, reminders: tuple[_Reminder, ...]) -> tuple[_Reminder, ...]:
370
+ # A call without both ids (execute_code inner calls) cannot be scoped
371
+ # to a turn, so it always carries its reminders.
372
+ if not session_id or not turn_id:
373
+ return reminders
374
+ with self._lock:
375
+ return tuple(r for r in reminders if self._key(session_id, turn_id, r) not in self._seen)
376
+
377
+ def mark(self, session_id: str, turn_id: str, reminders: tuple[_Reminder, ...]) -> None:
378
+ if not session_id or not turn_id:
379
+ return
380
+ with self._lock:
381
+ self._mark_locked(session_id, turn_id, reminders)
382
+
383
+ def _mark_locked(self, session_id: str, turn_id: str, reminders: tuple[_Reminder, ...]) -> None:
384
+ for reminder in reminders:
385
+ key = self._key(session_id, turn_id, reminder)
386
+ self._seen[key] = None
387
+ self._seen.move_to_end(key)
388
+ while len(self._seen) > self._limit:
389
+ self._seen.popitem(last=False)
390
+
391
+ def claim(self, session_id: str, turn_id: str, reminders: tuple[_Reminder, ...]) -> tuple[_Reminder, ...]:
392
+ """The reminders not yet delivered in this turn, marked delivered in the
393
+ same step, so two calls running at once cannot both take one."""
394
+ if not session_id or not turn_id:
395
+ return reminders
396
+ with self._lock:
397
+ fresh = tuple(r for r in reminders if self._key(session_id, turn_id, r) not in self._seen)
398
+ self._mark_locked(session_id, turn_id, fresh)
399
+ return fresh
400
+
401
+ def release(self, session_id: str, turn_id: str, reminders: tuple[_Reminder, ...]) -> None:
402
+ """Undo a claim whose reminders found no carrier, so a later call delivers them."""
403
+ if not session_id or not turn_id:
404
+ return
405
+ with self._lock:
406
+ for reminder in reminders:
407
+ self._seen.pop(self._key(session_id, turn_id, reminder), None)
408
+
409
+
410
+ def _unexecuted(result: Any) -> bool:
411
+ # Hermes reports a blocked or rejected call as {"error": ...} (tool_error
412
+ # may add "success"). A reminder there would claim a call ran that did not.
413
+ if isinstance(result, str):
414
+ if not result.lstrip().startswith("{"):
415
+ return False
416
+ try:
417
+ result = json.loads(result)
418
+ except Exception:
419
+ return False
420
+ return (
421
+ isinstance(result, dict)
422
+ and bool(result.get("error"))
423
+ and set(result) <= {"error", "success"}
424
+ )
425
+
426
+
427
+ def _with_first_key(value: Mapping[str, Any], text: str) -> dict[str, Any]:
428
+ rest = {k: v for k, v in value.items() if k != _REMINDER_KEY}
429
+ if _REMINDER_KEY in value and value[_REMINDER_KEY] != text:
430
+ # Never merged into ours: that would present tool output as operator text.
431
+ rest = {_TOOL_REMINDER_KEY: value[_REMINDER_KEY], **rest}
432
+ return {_REMINDER_KEY: text, **rest}
433
+
434
+
435
+ def _json_layout(original: str) -> dict[str, Any]:
436
+ # Keep the tool's own JSON shape: Hermes tools emit json.dumps defaults,
437
+ # a few emit compact or indented JSON.
438
+ if original.lstrip().startswith("{\n"):
439
+ return {"indent": 2}
440
+ if re.search(r'"\s*:\s', original[:4096]):
441
+ return {}
442
+ return {"separators": (",", ":")}
443
+
444
+
445
+ def _attach_reminders(result: Any, reminders: tuple[_Reminder, ...]) -> Any:
446
+ """Put the reminder at the start of the result, compact when the full one
447
+ would take it past _FULL_REMINDER_MAX_CHARS; on any failure return the
448
+ result untouched. Never raises."""
449
+ try:
450
+ full = _render(result, reminders, compact=False)
451
+ if full is result or _result_chars(full) <= _FULL_REMINDER_MAX_CHARS:
452
+ return full
453
+ return _render(result, reminders, compact=True)
454
+ except Exception as exc:
455
+ logger.warning("FailproofAI could not attach a policy reminder: %s", exc)
456
+ return result
457
+
458
+
459
+ def _render(result: Any, reminders: tuple[_Reminder, ...], *, compact: bool) -> Any:
460
+ """The result with the reminder at its start; the result itself for a
461
+ type that cannot carry one."""
462
+ text = _reminder_text(reminders, compact)
463
+ prefix = _reminder_prefix(reminders, compact)
464
+ if isinstance(result, str):
465
+ if result.lstrip().startswith("{"):
466
+ try:
467
+ parsed = json.loads(result)
468
+ except ValueError:
469
+ parsed = None
470
+ if isinstance(parsed, dict):
471
+ return json.dumps(
472
+ _with_first_key(parsed, text),
473
+ ensure_ascii=False,
474
+ **_json_layout(result),
475
+ )
476
+ if result.startswith("Error"):
477
+ # Hermes' failure detection keys on this prefix; a short error
478
+ # keeps the reminder visible at its end.
479
+ return f"{result}\n\n{prefix}"
480
+ return f"{prefix}\n\n{result}"
481
+ if isinstance(result, list):
482
+ return [{"type": "text", "text": prefix}, *result]
483
+ if isinstance(result, dict):
484
+ content = result.get("content")
485
+ if result.get("_multimodal") is True and isinstance(content, list):
486
+ # Multimodal envelope: the model reads `content`; image parts stay intact.
487
+ annotated = dict(result)
488
+ annotated["content"] = [{"type": "text", "text": prefix}, *content]
489
+ if isinstance(result.get("text_summary"), str):
490
+ annotated["text_summary"] = f"{prefix}\n\n{result['text_summary']}"
491
+ return annotated
492
+ return _with_first_key(result, text)
493
+ return result
494
+
495
+
496
+ def _guarded(on_error: Callable[[str, Exception], Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
497
+ """Hermes 0.20.0 and 0.21.x treat a pre_tool_call that raises as ALLOW and
498
+ log the rest, so no hook may raise: each returns a defined value instead.
499
+ functools.wraps keeps the **kwargs signature Hermes inspects."""
500
+
501
+ def decorate(method: Callable[..., Any]) -> Callable[..., Any]:
502
+ @functools.wraps(method)
503
+ def guarded(self: Any, *args: Any, **kwargs: Any) -> Any:
504
+ try:
505
+ return method(self, *args, **kwargs)
506
+ except Exception as exc:
507
+ logger.exception("FailproofAI %s failed", method.__name__)
508
+ return on_error(method.__name__, exc)
509
+
510
+ return guarded
511
+
512
+ return decorate
513
+
514
+
515
+ def _block_on_internal_error(hook: str, error: Exception) -> dict[str, str]:
516
+ # Fail closed: the call is not run when FailproofAI could not check it.
517
+ return {
518
+ "action": "block",
519
+ "message": (
520
+ f"FailproofAI could not check this tool call (internal error: {type(error).__name__}), "
521
+ "so it was not run. Report this to your operator before retrying."
522
+ ),
523
+ }
524
+
525
+
526
+ def _nothing(hook: str, error: Exception) -> None:
527
+ return None
528
+
529
+
530
+ def _protocol_context(hook: str, error: Exception) -> dict[str, str]:
531
+ return {"context": _PROTOCOL_CONTEXT}
532
+
533
+
534
+ def _plugin_version() -> str:
535
+ try:
536
+ manifest = Path(__file__).with_name("plugin.yaml").read_text(encoding="utf-8")
537
+ found = re.search(r"^version:\s*['\"]?([^'\"\s#]+)", manifest, re.MULTILINE)
538
+ return found.group(1) if found else ""
539
+ except Exception:
540
+ return ""
541
+
542
+
543
+ def _hermes_version() -> str:
544
+ try:
545
+ from hermes_cli import __version__
546
+
547
+ return str(__version__)
548
+ except Exception:
549
+ return ""
550
+
551
+
552
+ def _heartbeat_base(ctx: Any) -> dict[str, Any]:
553
+ """Who loaded the plugin, where. Every field is best-effort."""
554
+ record: dict[str, Any] = {"pid": os.getpid()}
555
+ for field, read in (
556
+ ("hermes_version", _hermes_version),
557
+ ("hermes_home", lambda: str(_hermes_home())),
558
+ ("profile", _profile_name),
559
+ ("plugin_path", lambda: str(Path(__file__).resolve().parent)),
560
+ ("plugin_version", _plugin_version),
561
+ ):
562
+ try:
563
+ record[field] = read()
564
+ except Exception as exc:
565
+ record[field] = f"unavailable: {type(exc).__name__}"
566
+ return record
567
+
568
+
569
+ def _write_heartbeat(state_dir: Path | None, record: Mapping[str, Any]) -> None:
570
+ """heartbeat.json beside instructions.db: proof of which plugin a Hermes
571
+ process actually loaded (`hermes plugins list` reads config only). Never raises."""
572
+ if state_dir is None:
573
+ return
574
+ temporary: Path | None = None
575
+ try:
576
+ now = time.time()
577
+ body = {
578
+ **record,
579
+ "timestamp": datetime.fromtimestamp(now, timezone.utc).isoformat(timespec="seconds"),
580
+ "timestamp_ms": int(now * 1000),
581
+ }
582
+ state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
583
+ target = state_dir / "heartbeat.json"
584
+ temporary = state_dir / f".heartbeat.{os.getpid()}.{threading.get_ident()}.tmp"
585
+ temporary.write_text(json.dumps(body, indent=2, sort_keys=True, default=str), encoding="utf-8")
586
+ os.replace(temporary, target)
587
+ except Exception as exc:
588
+ logger.warning("FailproofAI could not write its heartbeat in %s: %s", state_dir, exc)
589
+ if temporary is not None:
590
+ try:
591
+ temporary.unlink(missing_ok=True)
592
+ except Exception:
593
+ pass
594
+
595
+
46
596
  class FailproofAIPlugin:
47
597
  def __init__(self, ctx: Any) -> None:
48
598
  self.ctx = ctx
49
599
  self.profile = _profile_name()
50
- self.failure_mode = str(ctx.get_config("failure_mode", "deny")).strip().lower()
600
+ plugin_id = _plugin_id(ctx)
601
+ read = _config_reader(ctx, plugin_id)
602
+
603
+ def setting(key: str, default: Any) -> Any:
604
+ try:
605
+ return read(key, default)
606
+ except Exception as exc:
607
+ logger.warning("FailproofAI setting %s unreadable; using %r: %s", key, default, exc)
608
+ return default
609
+
610
+ self.failure_mode = str(setting("failure_mode", "deny")).strip().lower()
51
611
  self.connect_timeout_ms = _bounded_int(
52
- ctx.get_config("connect_timeout_ms", 250), 250, 25, 5_000
612
+ setting("connect_timeout_ms", 250), 250, 25, 5_000
53
613
  )
614
+ # The client spends one deadline on connect + send + read, so this is
615
+ # the whole time a tool call can wait on FailproofAI.
54
616
  self.evaluation_timeout_ms = _bounded_int(
55
- ctx.get_config("evaluation_timeout_ms", 12_000), 12_000, 100, 29_000
617
+ setting("evaluation_timeout_ms", 12_000), 12_000, 100, _EVALUATION_TIMEOUT_CAP_MS
56
618
  )
57
619
  ttl_seconds = _bounded_int(
58
- ctx.get_config("instruction_ttl_seconds", 3_600), 3_600, 60, 86_400
620
+ setting("instruction_ttl_seconds", 3_600), 3_600, 60, 86_400
59
621
  )
60
622
  max_rounds = _bounded_int(
61
- ctx.get_config("max_instruction_rounds", 2), 2, 0, 10
623
+ setting("max_instruction_rounds", 2), 2, 0, 10
62
624
  )
625
+ # Without a state directory only instruct() degrades (see LedgerError
626
+ # below); allow and deny never touch the ledger.
627
+ state_dir = _state_dir(ctx, plugin_id)
628
+ self.state_dir = state_dir
63
629
  self.ledger = InstructionLedger(
64
- Path(ctx.state.data_dir) / "instructions.db",
630
+ state_dir / "instructions.db" if state_dir is not None else None,
65
631
  ttl_seconds=ttl_seconds,
66
632
  max_rounds=max_rounds,
67
633
  )
634
+ # Set by register() once Hermes accepts the tool_execution middleware.
635
+ # Until then an instruct verdict blocks once through the ledger above.
636
+ self.wraps_execution = False
637
+ self._frames: contextvars.ContextVar[tuple[_ReminderFrame, ...]] = contextvars.ContextVar(
638
+ f"failproofai_reminder_frames_{id(self)}", default=()
639
+ )
640
+ self._handoff = _ReminderHandoff()
641
+ self._delivered = _TurnDeliveries()
68
642
 
69
643
  def _fallback(self, error: Exception) -> dict[str, str] | None:
70
644
  logger.error("FailproofAI evaluator unavailable: %s", error)
@@ -78,16 +652,19 @@ class FailproofAIPlugin:
78
652
  ),
79
653
  }
80
654
 
81
- def _evaluate(self, event: str, payload: Mapping[str, Any]) -> PolicyVerdict:
655
+ def _evaluate(
656
+ self, event: str, payload: Mapping[str, Any], timeout_ms: int | None = None
657
+ ) -> PolicyVerdict:
82
658
  cwd = _string(payload.get("cwd")) or os.getcwd()
83
659
  return evaluate_policy(
84
660
  event=event,
85
661
  payload=payload,
86
662
  cwd=cwd,
87
663
  connect_timeout_ms=self.connect_timeout_ms,
88
- evaluation_timeout_ms=self.evaluation_timeout_ms,
664
+ evaluation_timeout_ms=timeout_ms or self.evaluation_timeout_ms,
89
665
  )
90
666
 
667
+ @_guarded(_block_on_internal_error)
91
668
  def pre_tool_call(
92
669
  self,
93
670
  tool_name: str = "",
@@ -118,6 +695,11 @@ class FailproofAIPlugin:
118
695
  except Exception as exc:
119
696
  return self._fallback(exc)
120
697
 
698
+ if verdict.decision in ("allow", "deny"):
699
+ # An identical earlier call that never reached its frame (another
700
+ # plugin blocked it) left a reminder under this call's key; this
701
+ # call's own verdict replaces it.
702
+ self._drop_handoff(session_id, task_id, tool_call_id, tool_name, payload["tool_input"])
121
703
  if verdict.decision == "allow":
122
704
  return None
123
705
  if verdict.decision == "deny":
@@ -126,7 +708,20 @@ class FailproofAIPlugin:
126
708
  "message": verdict.reason or "Blocked by FailproofAI policy.",
127
709
  }
128
710
 
129
- reason = verdict.reason or "Reconsider this tool call before retrying."
711
+ if self._remind(
712
+ verdict,
713
+ tool_name=tool_name,
714
+ tool_input=payload["tool_input"],
715
+ session_id=session_id,
716
+ task_id=task_id,
717
+ tool_call_id=tool_call_id,
718
+ ):
719
+ return None
720
+
721
+ # Host without the tool_execution middleware: hold the call once so the
722
+ # guidance is seen. The scope is the policy (+ reason) in this turn, not
723
+ # the tool, so moving the same work to another tool is not held again.
724
+ reason = verdict.reason or "Apply this policy to the call."
130
725
  try:
131
726
  action = self.ledger.decide(
132
727
  profile=self.profile,
@@ -136,7 +731,7 @@ class FailproofAIPlugin:
136
731
  api_request_id=api_request_id,
137
732
  policy_names=verdict.policy_names,
138
733
  reason=reason,
139
- scope_key=verdict.tool_name or tool_name or "unknown-tool",
734
+ scope_key="policy",
140
735
  )
141
736
  except LedgerError as exc:
142
737
  # An advisory instruction must never become an indefinite denial
@@ -153,18 +748,151 @@ class FailproofAIPlugin:
153
748
  return {
154
749
  "action": "block",
155
750
  "message": (
156
- f"FAILPROOF INSTRUCTION ({policies})\n\n{reason}\n\n"
157
- "Reconsider the attempted action, then make a corrected tool call. "
158
- "Do not route around this instruction with an equivalent ungoverned action."
751
+ f"FailproofAI policy guidance ({policies})\n\n{reason}\n\n"
752
+ "This call was held once so you could read this guidance. Apply it, then "
753
+ "continue; repeating the same call is allowed."
159
754
  ),
160
755
  }
161
756
 
757
+ def _drop_handoff(self, session_id: str, task_id: str, tool_call_id: str, tool_name: str,
758
+ tool_input: Mapping[str, Any]) -> None:
759
+ try:
760
+ if self._handoff.pending():
761
+ self._handoff.take(_call_key(session_id, task_id, tool_call_id, tool_name, tool_input))
762
+ except Exception as exc:
763
+ logger.warning("FailproofAI could not clear a stale reminder handoff: %s", exc)
764
+
765
+ def _innermost_frame(self) -> _ReminderFrame | None:
766
+ frames = self._frames.get()
767
+ if frames and frames[-1].open:
768
+ return frames[-1]
769
+ return None
770
+
771
+ def _remind(
772
+ self,
773
+ verdict: PolicyVerdict,
774
+ *,
775
+ tool_name: str,
776
+ tool_input: Mapping[str, Any],
777
+ session_id: str,
778
+ task_id: str,
779
+ tool_call_id: str,
780
+ ) -> bool:
781
+ """Queue an instruct verdict as a reminder on the call's own result.
782
+ False when nothing will carry it; the caller then blocks once."""
783
+ reminder = _Reminder(
784
+ ", ".join(verdict.policy_names) or "unknown policy",
785
+ verdict.reason or "Apply this policy when you use the result.",
786
+ )
787
+ frame = self._innermost_frame()
788
+ if frame is not None and frame.owns(tool_name, tool_call_id):
789
+ frame.add((reminder,))
790
+ return True
791
+ if not self.wraps_execution:
792
+ return False
793
+ # Not inside this call's own frame: model_tools ran pre_tool_call
794
+ # before the middleware, which claims this when the frame opens.
795
+ self._handoff.put(
796
+ _call_key(session_id, task_id, tool_call_id, tool_name, tool_input), reminder
797
+ )
798
+ if frame is not None and not tool_call_id:
799
+ # A programmatic call (execute_code RPC) inside a model-issued one:
800
+ # no model reads the inner result, so the outer result carries it
801
+ # too, even if the inner call never reaches its own frame.
802
+ frame.add((reminder,))
803
+ return True
804
+
805
+ def tool_execution(
806
+ self,
807
+ tool_name: str = "",
808
+ args: Any = None,
809
+ next_call: Callable[[Any], Any] | None = None,
810
+ session_id: str = "",
811
+ task_id: str = "",
812
+ tool_call_id: str = "",
813
+ turn_id: str = "",
814
+ **kwargs: Any,
815
+ ) -> Any:
816
+ """Hermes tool_execution middleware: run the call once, then attach any
817
+ reminders its pre_tool_call (or nested programmatic calls) collected.
818
+ Only the tool's own exception leaves this method; FailproofAI's own
819
+ failures fall back to the plain result."""
820
+ if not callable(next_call):
821
+ # Nothing to run. Raising before next_call makes Hermes skip this
822
+ # middleware and run the rest of the chain, so the tool still runs.
823
+ raise TypeError("tool_execution middleware called without next_call")
824
+ try:
825
+ stack = self._frames.get()
826
+ parent = stack[-1] if stack and stack[-1].open else None
827
+ frame = _ReminderFrame(_string(tool_name), _string(tool_call_id), parent)
828
+
829
+ def claim() -> None:
830
+ if self._handoff.pending():
831
+ frame.add(self._handoff.take(
832
+ _call_key(session_id, task_id, tool_call_id, tool_name, args)
833
+ ))
834
+
835
+ claim()
836
+ token = self._frames.set((*stack, frame))
837
+ except Exception as exc:
838
+ logger.error("FailproofAI reminder frame unavailable; running the call plainly: %s", exc)
839
+ return next_call(args)
840
+
841
+ reminders: tuple[_Reminder, ...] = ()
842
+ try:
843
+ result = next_call(args)
844
+ finally:
845
+ try:
846
+ self._frames.reset(token)
847
+ # A host that runs pre_tool_call without our context lands here.
848
+ claim()
849
+ reminders = frame.close()
850
+ except Exception as exc:
851
+ logger.error("FailproofAI could not close a reminder frame: %s", exc)
852
+ try:
853
+ return self._annotate(result, reminders, parent, session_id, turn_id, tool_call_id)
854
+ except Exception as exc:
855
+ logger.error("FailproofAI could not annotate a tool result: %s", exc)
856
+ return result
857
+
858
+ def _annotate(
859
+ self,
860
+ result: Any,
861
+ reminders: tuple[_Reminder, ...],
862
+ parent: _ReminderFrame | None,
863
+ session_id: object,
864
+ turn_id: object,
865
+ tool_call_id: object,
866
+ ) -> Any:
867
+ if not reminders or _unexecuted(result):
868
+ return result
869
+ if parent is not None and not tool_call_id and parent.add(reminders):
870
+ # A call an execute_code script made: the model reads the outer
871
+ # result, which now carries the reminder. The script gets this one
872
+ # untouched, so nothing it prints, writes or sends can carry it.
873
+ return result
874
+ session, turn = _string(session_id), _string(turn_id)
875
+ fresh = self._delivered.claim(session, turn, reminders)
876
+ if not fresh:
877
+ return result
878
+ annotated = _attach_reminders(result, fresh)
879
+ if annotated is result:
880
+ self._delivered.release(session, turn, fresh)
881
+ return annotated
882
+
162
883
  def _observe(self, event: str, payload: Mapping[str, Any]) -> None:
884
+ # Observers gate nothing, so they never wait a full evaluation deadline:
885
+ # against a hung daemon every blocked call would otherwise pay it twice
886
+ # (pre_tool_call, then post_tool_call). The request is sent first, so a
887
+ # slow but live daemon still records it.
163
888
  try:
164
- self._evaluate(event, payload)
889
+ self._evaluate(
890
+ event, payload, min(self.evaluation_timeout_ms, _OBSERVE_TIMEOUT_MS)
891
+ )
165
892
  except Exception as exc:
166
893
  logger.warning("FailproofAI observation failed for %s: %s", event, exc)
167
894
 
895
+ @_guarded(_nothing)
168
896
  def post_tool_call(
169
897
  self,
170
898
  tool_name: str = "",
@@ -186,6 +914,7 @@ class FailproofAIPlugin:
186
914
  },
187
915
  )
188
916
 
917
+ @_guarded(_nothing)
189
918
  def on_session_start(self, session_id: str = "", **kwargs: Any) -> None:
190
919
  self._observe(
191
920
  "on_session_start",
@@ -197,6 +926,7 @@ class FailproofAIPlugin:
197
926
  },
198
927
  )
199
928
 
929
+ @_guarded(_nothing)
200
930
  def on_session_end(self, session_id: str = "", **kwargs: Any) -> None:
201
931
  self._observe(
202
932
  "on_session_end",
@@ -209,6 +939,7 @@ class FailproofAIPlugin:
209
939
  )
210
940
  self._clear_session(session_id)
211
941
 
942
+ @_guarded(_nothing)
212
943
  def subagent_stop(self, session_id: str = "", **kwargs: Any) -> None:
213
944
  self._observe(
214
945
  "subagent_stop",
@@ -220,12 +951,15 @@ class FailproofAIPlugin:
220
951
  },
221
952
  )
222
953
 
954
+ @_guarded(_protocol_context)
223
955
  def pre_llm_call(self, **kwargs: Any) -> dict[str, str]:
224
956
  return {"context": _PROTOCOL_CONTEXT}
225
957
 
958
+ @_guarded(_nothing)
226
959
  def on_session_reset(self, session_id: str = "", **kwargs: Any) -> None:
227
960
  self._clear_session(session_id)
228
961
 
962
+ @_guarded(_nothing)
229
963
  def on_session_finalize(self, session_id: str = "", **kwargs: Any) -> None:
230
964
  self._clear_session(session_id)
231
965
 
@@ -237,12 +971,63 @@ class FailproofAIPlugin:
237
971
 
238
972
 
239
973
  def register(ctx: Any) -> None:
240
- plugin = FailproofAIPlugin(ctx)
241
- ctx.register_hook("pre_tool_call", plugin.pre_tool_call)
242
- ctx.register_hook("post_tool_call", plugin.post_tool_call)
243
- ctx.register_hook("pre_llm_call", plugin.pre_llm_call)
244
- ctx.register_hook("on_session_start", plugin.on_session_start)
245
- ctx.register_hook("on_session_end", plugin.on_session_end)
246
- ctx.register_hook("on_session_reset", plugin.on_session_reset)
247
- ctx.register_hook("on_session_finalize", plugin.on_session_finalize)
248
- ctx.register_hook("subagent_stop", plugin.subagent_stop)
974
+ # Hermes logs a raising register() as "Failed to load plugin" and runs
975
+ # every tool call unchecked, so nothing here may raise, and one rejected
976
+ # hook must not cost the rest. heartbeat.json records the attempt before
977
+ # anything else and its outcome last.
978
+ try:
979
+ state_dir = _state_dir(ctx, _plugin_id(ctx))
980
+ except Exception:
981
+ state_dir = None
982
+ beat = {
983
+ **_heartbeat_base(ctx),
984
+ "stage": "start",
985
+ "register_ok": False,
986
+ "hooks_registered": [],
987
+ "middleware_registered": False,
988
+ }
989
+ _write_heartbeat(state_dir, beat)
990
+
991
+ hooks: list[str] = []
992
+ error = ""
993
+ try:
994
+ plugin = FailproofAIPlugin(ctx)
995
+ for hook in _HOOKS:
996
+ try:
997
+ ctx.register_hook(hook, getattr(plugin, hook))
998
+ hooks.append(hook)
999
+ except Exception as exc:
1000
+ logger.error("FailproofAI could not register Hermes hook %s: %s", hook, exc)
1001
+ # Without the middleware an instruct verdict still reaches the model, by
1002
+ # holding the call once.
1003
+ if hasattr(ctx, "register_middleware"):
1004
+ try:
1005
+ ctx.register_middleware("tool_execution", plugin.tool_execution)
1006
+ plugin.wraps_execution = True
1007
+ except Exception as exc:
1008
+ logger.error("FailproofAI could not register Hermes tool_execution middleware: %s", exc)
1009
+ middleware = plugin.wraps_execution
1010
+ except Exception as exc:
1011
+ logger.exception("FailproofAI plugin failed to register")
1012
+ error = f"{type(exc).__name__}: {exc}"
1013
+ middleware = False
1014
+
1015
+ beat.update(
1016
+ stage="end",
1017
+ register_ok=not error and hooks == list(_HOOKS),
1018
+ hooks_registered=hooks,
1019
+ middleware_registered=middleware,
1020
+ )
1021
+ if error:
1022
+ beat["error"] = error
1023
+ _write_heartbeat(state_dir, beat)
1024
+ logger.info(
1025
+ "FailproofAI Hermes plugin %s loaded: pid=%s profile=%s hooks=%d/%d middleware=%s register_ok=%s",
1026
+ beat.get("plugin_version") or "?",
1027
+ beat.get("pid"),
1028
+ beat.get("profile"),
1029
+ len(hooks),
1030
+ len(_HOOKS),
1031
+ "yes" if middleware else "no",
1032
+ beat["register_ok"],
1033
+ )