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