anolisa-tokenless 0.7.14 → 0.8.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/README.md +226 -79
- package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +1 -1
- package/adapters/tokenless/claude-code/hooks/run-hook.sh +62 -0
- package/adapters/tokenless/claude-code/scripts/detect.sh +50 -7
- package/adapters/tokenless/codex/.codex-plugin/plugin.json +1 -1
- package/adapters/tokenless/common/cosh-extension.json +4 -4
- package/adapters/tokenless/common/hooks/compress_response_hook.py +180 -96
- package/adapters/tokenless/common/hooks/compress_schema_hook.py +19 -31
- package/adapters/tokenless/common/hooks/hook_utils.py +240 -70
- package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
- package/adapters/tokenless/dsh/dist/index.js +353 -264
- package/adapters/tokenless/dsh/package.json +2 -2
- package/adapters/tokenless/hermes/__init__.py +338 -363
- package/adapters/tokenless/hermes/plugin.yaml +2 -2
- package/adapters/tokenless/manifest.json +17 -1
- package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
- package/adapters/tokenless/openclaw/dist/index.js +291 -507
- package/adapters/tokenless/openclaw/index.ts +408 -628
- package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
- package/adapters/tokenless/openclaw/package.json +6 -4
- package/adapters/tokenless/openclaw/scripts/install.sh +51 -12
- package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
- package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
- package/adapters/tokenless/qwenpaw/plugin.json +17 -0
- package/adapters/tokenless/qwenpaw/plugin.py +390 -0
- package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
- package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
- package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
- package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
- package/package.json +5 -5
- package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -174
|
@@ -1,30 +1,11 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
2. **TOON encoding** — ``transform_tool_result`` : pipeline step after
|
|
10
|
-
response compression; re-encodes JSON results to TOON format via
|
|
11
|
-
``tokenless compress-toon`` for additional token savings (15-40%)
|
|
12
|
-
with proper stats recording and size check.
|
|
13
|
-
3. **Tool Ready** — ``pre_tool_call`` : environment readiness pre-check
|
|
14
|
-
with auto-fix and skip-retry feedback for missing dependencies.
|
|
15
|
-
4. **Command rewriting** — ``pre_tool_call`` : blocks shell commands
|
|
16
|
-
and suggests RTK-rewritten equivalents. Hermes's hook cannot modify
|
|
17
|
-
arguments, so the agent must re-execute with the suggested command
|
|
18
|
-
(one extra round-trip). Safe: ``rtk rewrite`` only does text
|
|
19
|
-
substitution, never executes the command.
|
|
20
|
-
5. **Session tracking** — ``on_session_start`` : propagates agent/session
|
|
21
|
-
IDs to tokenless stats recording.
|
|
22
|
-
|
|
23
|
-
Not available in Hermes: schema compression (Hermes hooks do not expose
|
|
24
|
-
tool schemas).
|
|
25
|
-
|
|
26
|
-
Every hook degrades gracefully: if ``tokenless`` is not installed, all
|
|
27
|
-
hooks are silently skipped.
|
|
1
|
+
"""Tokenless lifecycle adapter for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Hermes cannot replace tool arguments on older supported releases, so PreTool
|
|
4
|
+
blocks a shell call and suggests the Core-rewritten command. PostTool sends the
|
|
5
|
+
final model-bound result to Core and applies only the returned disposition.
|
|
6
|
+
Schema compression is not available from the Hermes hook surface. Marker-directed
|
|
7
|
+
recovery uses Hermes's existing shell tool and the trusted local Tokenless CLI.
|
|
8
|
+
Tool Ready remains product-wide hard-disabled.
|
|
28
9
|
|
|
29
10
|
Activation is controlled by the Hermes plugin system — list ``tokenless`` in
|
|
30
11
|
``plugins.enabled`` in ``config.yaml``, or enable via
|
|
@@ -33,10 +14,11 @@ Activation is controlled by the Hermes plugin system — list ``tokenless`` in
|
|
|
33
14
|
|
|
34
15
|
from __future__ import annotations
|
|
35
16
|
|
|
17
|
+
import inspect
|
|
36
18
|
import json
|
|
37
19
|
import logging
|
|
38
20
|
import os
|
|
39
|
-
import
|
|
21
|
+
import shlex
|
|
40
22
|
import sys
|
|
41
23
|
from typing import Any
|
|
42
24
|
|
|
@@ -95,18 +77,153 @@ def _validate_hooks_dir(path: str) -> str | None:
|
|
|
95
77
|
return None
|
|
96
78
|
|
|
97
79
|
|
|
80
|
+
# Symbols that must exist in the shared hook_utils module, paired with the
|
|
81
|
+
# exact call shape this adapter uses at its hook entry points. When a
|
|
82
|
+
# candidate passes the trust check but ships an older hook_utils.py (e.g. a
|
|
83
|
+
# stale install from a previous adapter version), the candidate is rejected and
|
|
84
|
+
# the search continues to later paths. The lifecycle migration made the Hermes
|
|
85
|
+
# adapter a thin Core client, so the required symbols are the Protocol v2
|
|
86
|
+
# request builders, the compress runner, and the Retrieve helpers this module
|
|
87
|
+
# from-imports at load time. The remaining load-time imports (resolve_binary,
|
|
88
|
+
# SHELL_TOOLS, SKIP_TOOLS, and the local-path constants) predate Protocol v2,
|
|
89
|
+
# so every module that ships the v2 API also ships them. A module missing any
|
|
90
|
+
# listed symbol would pass a narrower check and then raise ImportError at the
|
|
91
|
+
# top-level from-import, after the candidate search has already ended — with no
|
|
92
|
+
# fallback to later complete candidates.
|
|
93
|
+
#
|
|
94
|
+
# Symbol names alone are not enough: hook_utils has also changed *call
|
|
95
|
+
# signatures* while keeping names stable. ``build_post_tool_request`` took a
|
|
96
|
+
# keyword-only ``retrieval_available`` flag until c2c7e580e replaced it with
|
|
97
|
+
# the ``recovery`` mapping, so the module shipped by 9f109d559 exports all five
|
|
98
|
+
# required symbols yet rejects the ``recovery=`` this adapter passes — the
|
|
99
|
+
# plugin would import cleanly and every PostTool request would then die with
|
|
100
|
+
# "unexpected keyword argument 'recovery'", again after the candidate search
|
|
101
|
+
# has ended and with no fallback. Each shape is therefore bound with
|
|
102
|
+
# :func:`inspect.signature`; the values below are placeholders mirroring the
|
|
103
|
+
# real call sites (``bind`` only checks arity and parameter names, so nothing
|
|
104
|
+
# is ever called here).
|
|
105
|
+
_HOOK_UTILS_CALL_SHAPES: tuple[tuple[str, tuple[Any, ...], dict[str, Any]], ...] = (
|
|
106
|
+
# on_compress_pre_tool:
|
|
107
|
+
# build_pre_tool_request(args, AGENT_ID, tool_name, "command",
|
|
108
|
+
# session_id, tool_call_id,
|
|
109
|
+
# replace_arguments=False, block_and_suggest=True)
|
|
110
|
+
(
|
|
111
|
+
"build_pre_tool_request",
|
|
112
|
+
({}, "", "", "", "", ""),
|
|
113
|
+
{"replace_arguments": False, "block_and_suggest": True},
|
|
114
|
+
),
|
|
115
|
+
# on_transform_tool_result:
|
|
116
|
+
# build_post_tool_request(content, AGENT_ID, tool_name, protocol_status,
|
|
117
|
+
# content_origin, output_optimization,
|
|
118
|
+
# result_kind=..., recovery=..., session_id=...,
|
|
119
|
+
# tool_use_id=..., replace_output=True,
|
|
120
|
+
# replace_with_text=True)
|
|
121
|
+
(
|
|
122
|
+
"build_post_tool_request",
|
|
123
|
+
("", "", "", "", "", ""),
|
|
124
|
+
{
|
|
125
|
+
"result_kind": "tool",
|
|
126
|
+
"recovery": {"kind": "none"},
|
|
127
|
+
"session_id": "",
|
|
128
|
+
"tool_use_id": "",
|
|
129
|
+
"replace_output": True,
|
|
130
|
+
"replace_with_text": True,
|
|
131
|
+
},
|
|
132
|
+
),
|
|
133
|
+
# Both hooks: run_compress(tokenless_bin, request, timeout, operation)
|
|
134
|
+
("run_compress", ("", {}, 0, ""), {}),
|
|
135
|
+
# on_transform_tool_result: is_tokenless_retrieve_command(tool_name, args)
|
|
136
|
+
("is_tokenless_retrieve_command", ("", {}), {}),
|
|
137
|
+
# on_transform_tool_result: tokenless_retrieve_command_available()
|
|
138
|
+
("tokenless_retrieve_command_available", (), {}),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_HOOK_UTILS_REQUIRED_SYMBOLS = tuple(name for name, _, _ in _HOOK_UTILS_CALL_SHAPES)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _restore_cached_hook_utils(saved: Any) -> None:
|
|
145
|
+
"""Reinstate the module cached before a trial import, or drop the trial."""
|
|
146
|
+
if saved is not None:
|
|
147
|
+
sys.modules["hook_utils"] = saved
|
|
148
|
+
else:
|
|
149
|
+
sys.modules.pop("hook_utils", None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _call_shape_rejection(
|
|
153
|
+
symbol: Any, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
154
|
+
) -> str | None:
|
|
155
|
+
"""Return why *symbol* cannot take the adapter call shape, else ``None``."""
|
|
156
|
+
try:
|
|
157
|
+
signature = inspect.signature(symbol)
|
|
158
|
+
except (TypeError, ValueError) as exc:
|
|
159
|
+
return f"{name} is not an introspectable callable ({exc})"
|
|
160
|
+
try:
|
|
161
|
+
signature.bind(*args, **kwargs)
|
|
162
|
+
except TypeError as exc:
|
|
163
|
+
# Name the shape the adapter needs, not only what the module offers:
|
|
164
|
+
# a renamed keyword (retrieval_available -> recovery) reports as a
|
|
165
|
+
# missing argument on some Python versions, which alone would not tell
|
|
166
|
+
# the reader which side of the contract moved.
|
|
167
|
+
return (
|
|
168
|
+
f"{name}{signature} rejects the adapter call shape "
|
|
169
|
+
f"({len(args)} positional, kwargs: {', '.join(sorted(kwargs)) or 'none'}): "
|
|
170
|
+
f"{exc}"
|
|
171
|
+
)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_api_compat(candidate_dir: str) -> str | None:
|
|
176
|
+
"""Trial-import hook_utils from *candidate_dir* and verify its API.
|
|
177
|
+
|
|
178
|
+
Returns ``None`` when the module loads, exposes every symbol in
|
|
179
|
+
:data:`_HOOK_UTILS_REQUIRED_SYMBOLS`, and every one of them accepts the
|
|
180
|
+
matching call shape in :data:`_HOOK_UTILS_CALL_SHAPES`; otherwise a
|
|
181
|
+
human-readable rejection reason. On success the freshly imported module is
|
|
182
|
+
kept in ``sys.modules`` so the subsequent ``from hook_utils import …``
|
|
183
|
+
reuses it rather than a stale cached copy. On rejection the ``sys.path``
|
|
184
|
+
mutation is cleaned up and the previously cached module (if any) is
|
|
185
|
+
restored so later candidates start from a clean state.
|
|
186
|
+
"""
|
|
187
|
+
sys.path.insert(0, candidate_dir)
|
|
188
|
+
saved = sys.modules.pop("hook_utils", None)
|
|
189
|
+
try:
|
|
190
|
+
import hook_utils as _trial # type: ignore[import-not-found]
|
|
191
|
+
missing = [s for s in _HOOK_UTILS_REQUIRED_SYMBOLS
|
|
192
|
+
if not hasattr(_trial, s)]
|
|
193
|
+
if missing:
|
|
194
|
+
_restore_cached_hook_utils(saved)
|
|
195
|
+
return f"API mismatch: missing {', '.join(missing)}"
|
|
196
|
+
mismatched = []
|
|
197
|
+
for name, args, kwargs in _HOOK_UTILS_CALL_SHAPES:
|
|
198
|
+
reason = _call_shape_rejection(getattr(_trial, name), name, args, kwargs)
|
|
199
|
+
if reason is not None:
|
|
200
|
+
mismatched.append(reason)
|
|
201
|
+
if mismatched:
|
|
202
|
+
_restore_cached_hook_utils(saved)
|
|
203
|
+
return "API mismatch: " + "; ".join(mismatched)
|
|
204
|
+
# Success — keep the freshly imported module in sys.modules.
|
|
205
|
+
return None
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
_restore_cached_hook_utils(saved)
|
|
208
|
+
return f"import failed: {exc}"
|
|
209
|
+
finally:
|
|
210
|
+
sys.path.pop(0)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
98
214
|
def _resolve_hook_utils() -> tuple[str, list[str]]:
|
|
99
215
|
"""Locate a trusted shared hooks directory and make it importable.
|
|
100
216
|
|
|
101
217
|
Returns ``(resolved_path, candidate_list)``. The resolved path is
|
|
102
218
|
inserted at the front of ``sys.path`` so the shared ``hook_utils``
|
|
103
219
|
module can be imported. Raises :exc:`ImportError` when no candidate
|
|
104
|
-
passes the trust policy.
|
|
220
|
+
passes both the trust policy and the API compatibility check.
|
|
105
221
|
"""
|
|
106
222
|
# Resolve real home from passwd DB for user-install fallback path
|
|
107
223
|
# (NOT $HOME — env-controllable).
|
|
108
224
|
try:
|
|
109
225
|
import pwd as _pwd
|
|
226
|
+
|
|
110
227
|
real_home = _pwd.getpwuid(os.getuid()).pw_dir
|
|
111
228
|
except (ImportError, KeyError):
|
|
112
229
|
real_home = ""
|
|
@@ -114,9 +231,10 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
|
|
|
114
231
|
real_home = ""
|
|
115
232
|
|
|
116
233
|
candidates = [
|
|
117
|
-
|
|
118
|
-
"
|
|
119
|
-
"/usr/
|
|
234
|
+
# Source-tree / symlink install.
|
|
235
|
+
os.path.join(_HERE, "..", "common", "hooks"),
|
|
236
|
+
"/usr/share/anolisa/adapters/tokenless/common/hooks", # RPM system
|
|
237
|
+
"/usr/local/share/anolisa/adapters/tokenless/common/hooks", # Manual system
|
|
120
238
|
]
|
|
121
239
|
# XDG user data dir first (anolisa FsLayout::user precedence), then the
|
|
122
240
|
# passwd-home default. XDG_DATA_HOME is env-controllable, but candidates
|
|
@@ -124,20 +242,29 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
|
|
|
124
242
|
xdg_data = os.environ.get("XDG_DATA_HOME", "")
|
|
125
243
|
if xdg_data and os.path.isabs(xdg_data):
|
|
126
244
|
candidates.append(
|
|
127
|
-
os.path.join(xdg_data, "anolisa", "adapters", "tokenless", "common", "hooks")
|
|
245
|
+
os.path.join(xdg_data, "anolisa", "adapters", "tokenless", "common", "hooks")
|
|
246
|
+
)
|
|
128
247
|
if real_home:
|
|
129
248
|
candidates.append(
|
|
130
|
-
os.path.join(
|
|
131
|
-
|
|
249
|
+
os.path.join(
|
|
250
|
+
real_home, ".local", "share", "anolisa", "adapters", "tokenless", "common", "hooks"
|
|
251
|
+
)
|
|
252
|
+
)
|
|
132
253
|
|
|
133
254
|
rejections: list[str] = []
|
|
134
255
|
for candidate in candidates:
|
|
135
256
|
reason = _validate_hooks_dir(candidate)
|
|
136
|
-
if reason is None:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
257
|
+
if reason is not None:
|
|
258
|
+
rejections.append(f" - {candidate}: {reason}")
|
|
259
|
+
continue
|
|
260
|
+
# Trust check passed — verify API compat (version mismatch guard).
|
|
261
|
+
resolved = os.path.realpath(candidate)
|
|
262
|
+
api_reason = _check_api_compat(resolved)
|
|
263
|
+
if api_reason is not None:
|
|
264
|
+
rejections.append(f" - {candidate}: {api_reason}")
|
|
265
|
+
continue
|
|
266
|
+
sys.path.insert(0, resolved)
|
|
267
|
+
return resolved, candidates
|
|
141
268
|
|
|
142
269
|
raise ImportError(
|
|
143
270
|
"tokenless: no trusted shared hook_utils module (common/hooks/) found.\n"
|
|
@@ -153,22 +280,22 @@ def _resolve_hook_utils() -> tuple[str, list[str]]:
|
|
|
153
280
|
_HOOK_UTILS_RESOLVED, _HOOK_UTILS_CANDIDATES = _resolve_hook_utils()
|
|
154
281
|
|
|
155
282
|
from hook_utils import (
|
|
156
|
-
_TOKENLESS_FALLBACK,
|
|
157
|
-
_TOKENLESS_LOCAL_SHARE,
|
|
158
|
-
_TOKENLESS_LOCAL_LIB,
|
|
159
283
|
_RTK_FALLBACK,
|
|
160
|
-
_RTK_LOCAL_SHARE,
|
|
161
284
|
_RTK_LOCAL_LIB,
|
|
285
|
+
_RTK_LOCAL_SHARE,
|
|
286
|
+
_TOKENLESS_FALLBACK,
|
|
287
|
+
_TOKENLESS_LOCAL_LIB,
|
|
288
|
+
_TOKENLESS_LOCAL_SHARE,
|
|
289
|
+
)
|
|
290
|
+
from hook_utils import SHELL_TOOLS as _SHELL_TOOLS_SHARED
|
|
291
|
+
from hook_utils import SKIP_TOOLS as _SKIP_TOOLS_SHARED
|
|
292
|
+
from hook_utils import (
|
|
293
|
+
build_post_tool_request,
|
|
294
|
+
build_pre_tool_request,
|
|
295
|
+
is_tokenless_retrieve_command,
|
|
162
296
|
resolve_binary,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
is_skill_file as _is_skill_file,
|
|
166
|
-
write_context as _write_context,
|
|
167
|
-
run as _run,
|
|
168
|
-
parse_version as _parse_version,
|
|
169
|
-
SKIP_TOOLS as _SKIP_TOOLS_SHARED,
|
|
170
|
-
SHELL_TOOLS as _SHELL_TOOLS_SHARED,
|
|
171
|
-
get_thresholds,
|
|
297
|
+
run_compress,
|
|
298
|
+
tokenless_retrieve_command_available,
|
|
172
299
|
)
|
|
173
300
|
|
|
174
301
|
logger = logging.getLogger(__name__)
|
|
@@ -178,26 +305,17 @@ logger = logging.getLogger(__name__)
|
|
|
178
305
|
# ---------------------------------------------------------------------------
|
|
179
306
|
|
|
180
307
|
AGENT_ID = "hermes-agent"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
# Minimum payload size for the TOON encoding step. TOON on small JSON
|
|
184
|
-
# saves only a few characters (observed ~0.3% below ~500 chars) while
|
|
185
|
-
# the per-event encode cost stays the same, so payloads under this
|
|
186
|
-
# threshold keep the response-compressed form and skip TOON entirely.
|
|
187
|
-
# Mirrors tokenless-runtime's MIN_TOON_CHARS, the default the
|
|
188
|
-
# compress-toon CLI applies; keep the two values in sync.
|
|
189
|
-
_MIN_TOON_CHARS = 500
|
|
308
|
+
_COMPRESS_TIMEOUT_SECONDS = 8
|
|
190
309
|
|
|
191
310
|
_SKIP_TOOLS: set[str] = _SKIP_TOOLS_SHARED | {
|
|
192
|
-
"session_search",
|
|
311
|
+
"session_search",
|
|
312
|
+
"list_sessions",
|
|
193
313
|
}
|
|
194
314
|
|
|
195
315
|
# Use shared SHELL_TOOLS directly - all tools (including "terminal") are now
|
|
196
316
|
# defined in the unified tool_categories.json
|
|
197
317
|
_SHELL_TOOLS: set[str] = _SHELL_TOOLS_SHARED
|
|
198
318
|
|
|
199
|
-
_MIN_RTK_VERSION = (0, 35, 0)
|
|
200
|
-
|
|
201
319
|
# ---------------------------------------------------------------------------
|
|
202
320
|
# Binary resolution (thin wrapper over shared cached resolve_binary)
|
|
203
321
|
# ---------------------------------------------------------------------------
|
|
@@ -210,7 +328,14 @@ def _resolve_binary(name: str, fallback: str) -> str | None:
|
|
|
210
328
|
"""Resolve binary with hermes-specific fallback paths (cached via shared)."""
|
|
211
329
|
local_bin = os.path.join(os.path.expanduser("~"), ".local", "bin", name)
|
|
212
330
|
if name == "rtk":
|
|
213
|
-
return resolve_binary(
|
|
331
|
+
return resolve_binary(
|
|
332
|
+
name,
|
|
333
|
+
fallback,
|
|
334
|
+
_RTK_LIB_FALLBACK,
|
|
335
|
+
local_bin,
|
|
336
|
+
_RTK_LOCAL_LIB,
|
|
337
|
+
_RTK_LOCAL_SHARE,
|
|
338
|
+
)
|
|
214
339
|
return resolve_binary(name, fallback, local_bin, _TOKENLESS_LOCAL_LIB, _TOKENLESS_LOCAL_SHARE)
|
|
215
340
|
|
|
216
341
|
|
|
@@ -218,211 +343,60 @@ def _have(name: str, fallback: str) -> bool:
|
|
|
218
343
|
return _resolve_binary(name, fallback) is not None
|
|
219
344
|
|
|
220
345
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
result: str,
|
|
234
|
-
session_id: str,
|
|
235
|
-
tool_call_id: str,
|
|
236
|
-
) -> str | None:
|
|
237
|
-
tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
|
|
238
|
-
if not tokenless_bin:
|
|
239
|
-
return None
|
|
240
|
-
|
|
241
|
-
parsed = _try_parse_json(result)
|
|
242
|
-
if not isinstance(parsed, (dict, list)):
|
|
243
|
-
return None
|
|
244
|
-
|
|
245
|
-
# 3-layer dispatch: shell tools use moderate truncation, API tools use zero-truncation
|
|
246
|
-
thresholds = get_thresholds(tool_name)
|
|
247
|
-
|
|
248
|
-
cmd = [
|
|
249
|
-
tokenless_bin, "compress-response",
|
|
250
|
-
"--agent-id", AGENT_ID,
|
|
251
|
-
"--truncate-strings-at", str(thresholds[0]),
|
|
252
|
-
"--truncate-arrays-at", str(thresholds[1]),
|
|
253
|
-
"--max-depth", str(thresholds[2]),
|
|
254
|
-
]
|
|
255
|
-
if session_id:
|
|
256
|
-
cmd.extend(["--session-id", session_id])
|
|
257
|
-
if tool_call_id:
|
|
258
|
-
cmd.extend(["--tool-use-id", tool_call_id])
|
|
259
|
-
|
|
260
|
-
proc = _run(cmd, result)
|
|
261
|
-
if not proc or proc.returncode != 0 or not proc.stdout.strip():
|
|
262
|
-
return None
|
|
263
|
-
|
|
264
|
-
compressed = proc.stdout.strip()
|
|
265
|
-
if len(compressed) >= len(result):
|
|
266
|
-
return None
|
|
267
|
-
return compressed
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
# ---------------------------------------------------------------------------
|
|
271
|
-
# 2. TOON Encoding (via tokenless compress-toon)
|
|
272
|
-
# ---------------------------------------------------------------------------
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _encode_toon(data: str, session_id: str = "", tool_call_id: str = "") -> tuple[str, int] | None:
|
|
276
|
-
tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
|
|
277
|
-
if not tokenless_bin:
|
|
278
|
-
return None
|
|
279
|
-
|
|
280
|
-
parsed = _try_parse_json(data)
|
|
281
|
-
if not isinstance(parsed, (dict, list)):
|
|
282
|
-
return None
|
|
283
|
-
|
|
284
|
-
cmd = [tokenless_bin, "compress-toon", "--agent-id", AGENT_ID]
|
|
285
|
-
if session_id:
|
|
286
|
-
cmd.extend(["--session-id", session_id])
|
|
287
|
-
if tool_call_id:
|
|
288
|
-
cmd.extend(["--tool-use-id", tool_call_id])
|
|
289
|
-
|
|
290
|
-
proc = _run(cmd, data, timeout=1)
|
|
291
|
-
if not proc or proc.returncode != 0 or not proc.stdout.strip():
|
|
292
|
-
return None
|
|
293
|
-
|
|
294
|
-
toon_text = proc.stdout.strip()
|
|
295
|
-
if len(toon_text) >= len(data):
|
|
296
|
-
return None
|
|
297
|
-
|
|
298
|
-
savings_pct = 0
|
|
299
|
-
if len(data) > 0:
|
|
300
|
-
savings_pct = (len(data) - len(toon_text)) * 100 // len(data)
|
|
301
|
-
|
|
302
|
-
return toon_text, savings_pct
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
# ---------------------------------------------------------------------------
|
|
306
|
-
# 3. Tool Ready (via tokenless env-check)
|
|
307
|
-
# ---------------------------------------------------------------------------
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _env_check(tool_name: str) -> str | None:
|
|
311
|
-
"""Run tool-ready env-check and return feedback if tool is not ready."""
|
|
312
|
-
tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
|
|
313
|
-
if not tokenless_bin:
|
|
314
|
-
return None
|
|
315
|
-
|
|
316
|
-
proc = _run([tokenless_bin, "env-check", "--tool", tool_name, "--json"], "", timeout=5)
|
|
317
|
-
if not proc or not proc.stdout.strip():
|
|
346
|
+
def _protocol_status(status: Any, result: str) -> str | None:
|
|
347
|
+
"""Map Hermes status, deriving it for hosts that omit the field."""
|
|
348
|
+
if isinstance(status, str) and status:
|
|
349
|
+
return {
|
|
350
|
+
"ok": "success",
|
|
351
|
+
"success": "success",
|
|
352
|
+
"error": "error",
|
|
353
|
+
"blocked": "denied",
|
|
354
|
+
"denied": "denied",
|
|
355
|
+
"interrupted": "interrupted",
|
|
356
|
+
}.get(status.lower())
|
|
357
|
+
if status not in (None, ""):
|
|
318
358
|
return None
|
|
319
|
-
|
|
320
|
-
try:
|
|
321
|
-
parsed = json.loads(proc.stdout)
|
|
322
|
-
except json.JSONDecodeError:
|
|
323
|
-
return None
|
|
324
|
-
|
|
325
|
-
status = parsed.get("status", "UNKNOWN")
|
|
326
|
-
if status in ("UNKNOWN", "READY"):
|
|
327
|
-
return None
|
|
328
|
-
|
|
329
|
-
# Attempt auto-fix
|
|
330
|
-
proc = _run([tokenless_bin, "env-check", "--tool", tool_name, "--fix", "--json"], "", timeout=10)
|
|
331
|
-
if not proc or not proc.stdout.strip():
|
|
332
|
-
return _not_ready_msg(tool_name)
|
|
333
|
-
|
|
334
359
|
try:
|
|
335
|
-
|
|
360
|
+
parsed = json.loads(result)
|
|
336
361
|
except json.JSONDecodeError:
|
|
337
|
-
return
|
|
338
|
-
|
|
339
|
-
if fix_parsed.get("status") == "READY":
|
|
340
|
-
return None
|
|
341
|
-
|
|
342
|
-
diagnostic = fix_parsed.get("diagnostic", "")
|
|
343
|
-
return diagnostic or _not_ready_msg(tool_name)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def _not_ready_msg(tool_name: str) -> str:
|
|
347
|
-
return f"[tokenless:ready] {tool_name}: NOT_READY. Skip retry."
|
|
362
|
+
return "success"
|
|
363
|
+
return "error" if isinstance(parsed, dict) and parsed.get("error") else "success"
|
|
348
364
|
|
|
349
365
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
args: Any,
|
|
357
|
-
session_id: str,
|
|
358
|
-
tool_call_id: str,
|
|
359
|
-
) -> dict[str, str] | None:
|
|
360
|
-
"""Attempt RTK command rewrite for terminal tool calls.
|
|
366
|
+
def _content_origin(tool_name: str) -> str:
|
|
367
|
+
if tool_name in _SKIP_TOOLS:
|
|
368
|
+
return "file_content"
|
|
369
|
+
if tool_name in _SHELL_TOOLS:
|
|
370
|
+
return "command_output"
|
|
371
|
+
return "api_response"
|
|
361
372
|
|
|
362
|
-
Calls ``rtk rewrite <command>`` — a pure text substitution that never
|
|
363
|
-
executes the command. On success, returns a block directive suggesting
|
|
364
|
-
the rewritten command so the agent re-executes with the optimized version.
|
|
365
|
-
"""
|
|
366
|
-
rtk_bin = _resolve_binary("rtk", _RTK_FALLBACK)
|
|
367
|
-
if not rtk_bin:
|
|
368
|
-
return None
|
|
369
373
|
|
|
374
|
+
def _output_optimization(args: Any) -> str:
|
|
375
|
+
"""Recognize the attributed RTK wrapper in the command Hermes executed."""
|
|
370
376
|
if not isinstance(args, dict):
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return None
|
|
376
|
-
|
|
377
|
-
# Version guard — non-fatal
|
|
377
|
+
return "none"
|
|
378
|
+
command = args.get("command")
|
|
379
|
+
if not isinstance(command, str):
|
|
380
|
+
return "none"
|
|
378
381
|
try:
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if tool_call_id:
|
|
398
|
-
env["TOKENLESS_TOOL_USE_ID"] = tool_call_id
|
|
399
|
-
|
|
400
|
-
proc = subprocess.run(
|
|
401
|
-
[rtk_bin, "rewrite", command],
|
|
402
|
-
capture_output=True, text=True, timeout=5, env=env,
|
|
403
|
-
)
|
|
404
|
-
|
|
405
|
-
# Exit code protocol (from rtk rewrite_cmd.rs):
|
|
406
|
-
# 0 = rewrite available, Allow verdict (auto-allow by permission rule)
|
|
407
|
-
# 1 = no RTK equivalent (passthrough)
|
|
408
|
-
# 2 = deny rule matched (let Hermes handle)
|
|
409
|
-
# 3 = Ask/Default verdict (rewrite available but permission model requires
|
|
410
|
-
# user confirmation; in non-interactive hook context, treat as valid
|
|
411
|
-
# rewrite since the intent is token optimization, not permission gating)
|
|
412
|
-
if proc.returncode == 1 or proc.returncode == 2:
|
|
413
|
-
return None
|
|
414
|
-
if proc.returncode != 0 and proc.returncode != 3:
|
|
415
|
-
return None
|
|
416
|
-
|
|
417
|
-
rewritten = proc.stdout.strip()
|
|
418
|
-
if not rewritten or rewritten == command:
|
|
419
|
-
return None
|
|
420
|
-
|
|
421
|
-
logger.info("tokenless: rtk rewrite %s → %s", command, rewritten)
|
|
422
|
-
return {
|
|
423
|
-
"action": "block",
|
|
424
|
-
"message": f"[tokenless:rewrite] Re-execute as: {rewritten}",
|
|
425
|
-
}
|
|
382
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
383
|
+
lexer.whitespace_split = True
|
|
384
|
+
tokens = list(lexer)
|
|
385
|
+
except ValueError:
|
|
386
|
+
return "none"
|
|
387
|
+
for index, token in enumerate(tokens):
|
|
388
|
+
wrapper = tokens[index : index + 6]
|
|
389
|
+
if len(wrapper) < 6 or token != "env":
|
|
390
|
+
continue
|
|
391
|
+
if (
|
|
392
|
+
wrapper[1] == f"TOKENLESS_AGENT_ID={AGENT_ID}"
|
|
393
|
+
and wrapper[2].startswith("TOKENLESS_SESSION_ID=")
|
|
394
|
+
and wrapper[3].startswith("TOKENLESS_TOOL_USE_ID=")
|
|
395
|
+
and wrapper[4].startswith("TOKENLESS_DATA_DIR=")
|
|
396
|
+
and os.path.basename(wrapper[5]) == "rtk"
|
|
397
|
+
):
|
|
398
|
+
return "rtk"
|
|
399
|
+
return "none"
|
|
426
400
|
|
|
427
401
|
|
|
428
402
|
# ---------------------------------------------------------------------------
|
|
@@ -446,28 +420,39 @@ def on_pre_tool_call(
|
|
|
446
420
|
tool_call_id: str = "",
|
|
447
421
|
**kwargs: Any,
|
|
448
422
|
) -> dict[str, str] | None:
|
|
449
|
-
"""
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
423
|
+
"""Ask Core for an RTK rewrite and translate it to a Hermes block."""
|
|
424
|
+
if tool_name not in _SHELL_TOOLS or not isinstance(args, dict):
|
|
425
|
+
return None
|
|
426
|
+
tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
|
|
427
|
+
if not tokenless_bin:
|
|
428
|
+
return None
|
|
429
|
+
request = build_pre_tool_request(
|
|
430
|
+
args,
|
|
431
|
+
AGENT_ID,
|
|
432
|
+
tool_name,
|
|
433
|
+
"command",
|
|
434
|
+
str(session_id),
|
|
435
|
+
str(tool_call_id),
|
|
436
|
+
replace_arguments=False,
|
|
437
|
+
block_and_suggest=True,
|
|
438
|
+
)
|
|
439
|
+
result = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT_SECONDS, "pre_tool")
|
|
440
|
+
if not isinstance(result, dict):
|
|
441
|
+
return None
|
|
442
|
+
rewritten_args = result.get("arguments")
|
|
443
|
+
rewritten = rewritten_args.get("command") if isinstance(rewritten_args, dict) else None
|
|
444
|
+
if (
|
|
445
|
+
result.get("action") != "block_and_suggest"
|
|
446
|
+
or result.get("output_optimization") != "rtk"
|
|
447
|
+
or not isinstance(rewritten, str)
|
|
448
|
+
or rewritten == args.get("command")
|
|
449
|
+
):
|
|
450
|
+
return None
|
|
451
|
+
logger.info("tokenless: Core rewrote %s", tool_name)
|
|
452
|
+
return {
|
|
453
|
+
"action": "block",
|
|
454
|
+
"message": f"[tokenless:rewrite] Re-execute as: {rewritten}",
|
|
455
|
+
}
|
|
471
456
|
|
|
472
457
|
|
|
473
458
|
def on_transform_tool_result(
|
|
@@ -478,83 +463,81 @@ def on_transform_tool_result(
|
|
|
478
463
|
session_id: str = "",
|
|
479
464
|
tool_call_id: str = "",
|
|
480
465
|
duration_ms: int = 0,
|
|
466
|
+
status: str = "",
|
|
481
467
|
**kwargs: Any,
|
|
482
468
|
) -> str | None:
|
|
483
|
-
"""
|
|
484
|
-
|
|
485
|
-
Replaces the tool result string with a compressed/TOON-encoded version.
|
|
486
|
-
Runs after post_tool_call; first valid string return wins.
|
|
487
|
-
|
|
488
|
-
Content retrieval tools (Read/Glob/Grep) are skipped entirely.
|
|
489
|
-
Shell/exec tools (Bash/Shell) use moderate truncation (64K/128/8).
|
|
490
|
-
All other tools use zero-truncation compress-response + TOON.
|
|
491
|
-
"""
|
|
492
|
-
if not _have("tokenless", _TOKENLESS_FALLBACK):
|
|
469
|
+
"""Send one final Hermes result to Core and apply its disposition."""
|
|
470
|
+
if not isinstance(result, str):
|
|
493
471
|
return None
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if tool_name in _SKIP_TOOLS:
|
|
497
|
-
return None
|
|
498
|
-
|
|
499
|
-
if not result or result in ("{}", "[]"):
|
|
472
|
+
protocol_status = _protocol_status(status, result)
|
|
473
|
+
if protocol_status is None:
|
|
500
474
|
return None
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if _is_skill_file(result):
|
|
475
|
+
tokenless_bin = _resolve_binary("tokenless", _TOKENLESS_FALLBACK)
|
|
476
|
+
if not tokenless_bin:
|
|
504
477
|
return None
|
|
478
|
+
output_optimization = _output_optimization(args)
|
|
479
|
+
retrieve_result = protocol_status == "success" and is_tokenless_retrieve_command(
|
|
480
|
+
tool_name, args
|
|
481
|
+
)
|
|
505
482
|
|
|
506
|
-
#
|
|
507
|
-
|
|
483
|
+
# Hermes's terminal tool returns a JSON envelope whose `output` field is
|
|
484
|
+
# the model-visible command output. Compress that field so structured JSON
|
|
485
|
+
# produced by the command remains visible to JsonCompressor, then restore
|
|
486
|
+
# the host envelope below. Other tools already expose their model-bound
|
|
487
|
+
# result directly and must keep the existing path.
|
|
488
|
+
shell_envelope = None
|
|
489
|
+
content = result
|
|
490
|
+
if tool_name in _SHELL_TOOLS:
|
|
491
|
+
try:
|
|
492
|
+
parsed_result = json.loads(result)
|
|
493
|
+
except json.JSONDecodeError:
|
|
494
|
+
parsed_result = None
|
|
495
|
+
if isinstance(parsed_result, dict) and isinstance(parsed_result.get("output"), str):
|
|
496
|
+
shell_envelope = parsed_result
|
|
497
|
+
content = parsed_result["output"]
|
|
498
|
+
|
|
499
|
+
request = build_post_tool_request(
|
|
500
|
+
content,
|
|
501
|
+
AGENT_ID,
|
|
502
|
+
tool_name,
|
|
503
|
+
protocol_status,
|
|
504
|
+
_content_origin(tool_name),
|
|
505
|
+
output_optimization,
|
|
506
|
+
result_kind="retrieve" if retrieve_result else "tool",
|
|
507
|
+
recovery={
|
|
508
|
+
"kind": (
|
|
509
|
+
"shell"
|
|
510
|
+
if (
|
|
511
|
+
protocol_status == "success"
|
|
512
|
+
and output_optimization == "none"
|
|
513
|
+
and not retrieve_result
|
|
514
|
+
and tokenless_retrieve_command_available()
|
|
515
|
+
)
|
|
516
|
+
else "none"
|
|
517
|
+
)
|
|
518
|
+
},
|
|
519
|
+
session_id=str(session_id),
|
|
520
|
+
tool_use_id=str(tool_call_id),
|
|
521
|
+
replace_output=True,
|
|
522
|
+
replace_with_text=True,
|
|
523
|
+
)
|
|
524
|
+
response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT_SECONDS, "post_tool")
|
|
525
|
+
if not isinstance(response, dict):
|
|
508
526
|
return None
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
527
|
+
if response.get("disposition") == "applied":
|
|
528
|
+
output = response.get("output")
|
|
529
|
+
if isinstance(output, str):
|
|
530
|
+
logger.info("tokenless: Core optimized %s", tool_name)
|
|
531
|
+
if shell_envelope is not None:
|
|
532
|
+
shell_envelope["output"] = output
|
|
533
|
+
return json.dumps(shell_envelope, ensure_ascii=False)
|
|
534
|
+
return output
|
|
513
535
|
return None
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
# Step 1: Response compression (per-layer thresholds via get_thresholds)
|
|
520
|
-
compressed = _compress_response(tool_name, result,
|
|
521
|
-
str(session_id), str(tool_call_id))
|
|
522
|
-
current = compressed if compressed else result
|
|
523
|
-
|
|
524
|
-
# Step 2: TOON encoding — only for payloads at or above the minimum
|
|
525
|
-
# threshold; small JSON gains near-zero chars from TOON but would still
|
|
526
|
-
# pay the full encode cost on every tool result.
|
|
527
|
-
toon_result = None
|
|
528
|
-
if len(current) >= _MIN_TOON_CHARS:
|
|
529
|
-
toon_result = _encode_toon(current, str(session_id), str(tool_call_id))
|
|
530
|
-
used_compression = compressed is not None
|
|
531
|
-
used_toon = toon_result is not None
|
|
532
|
-
|
|
533
|
-
if not used_compression and not used_toon:
|
|
534
|
-
return None
|
|
535
|
-
|
|
536
|
-
# Build final output
|
|
537
|
-
if used_toon:
|
|
538
|
-
toon_text, savings_pct = toon_result
|
|
539
|
-
final = toon_text
|
|
540
|
-
final_len = len(toon_text)
|
|
541
|
-
savings_label = (
|
|
542
|
-
"response compressed + TOON encoded"
|
|
543
|
-
if used_compression
|
|
544
|
-
else "TOON encoded"
|
|
545
|
-
)
|
|
546
|
-
else:
|
|
547
|
-
final = current # type: ignore[assignment]
|
|
548
|
-
final_len = len(final)
|
|
549
|
-
savings_pct = (original_len - final_len) * 100 // original_len if original_len else 0
|
|
550
|
-
savings_label = "response compressed"
|
|
551
|
-
|
|
552
|
-
logger.info(
|
|
553
|
-
"tokenless: %s %s: %d -> %d chars (%d%% reduction)",
|
|
554
|
-
savings_label, tool_name, original_len, final_len, savings_pct,
|
|
555
|
-
)
|
|
556
|
-
|
|
557
|
-
return final
|
|
536
|
+
if response.get("disposition") == "tool_error":
|
|
537
|
+
additional_context = response.get("additional_context")
|
|
538
|
+
if isinstance(additional_context, str) and additional_context:
|
|
539
|
+
return f"{result}\n\n{additional_context}"
|
|
540
|
+
return None
|
|
558
541
|
|
|
559
542
|
|
|
560
543
|
# ---------------------------------------------------------------------------
|
|
@@ -569,16 +552,8 @@ def register(ctx: Any) -> None:
|
|
|
569
552
|
ctx.register_hook("pre_tool_call", on_pre_tool_call)
|
|
570
553
|
ctx.register_hook("transform_tool_result", on_transform_tool_result)
|
|
571
554
|
|
|
572
|
-
|
|
573
|
-
features: list[str] = []
|
|
574
|
-
if _have("tokenless", _TOKENLESS_FALLBACK):
|
|
575
|
-
features.append("response-compression")
|
|
576
|
-
features.append("toon-encoding")
|
|
577
|
-
features.append("tool-ready")
|
|
578
|
-
if _have("rtk", _RTK_FALLBACK):
|
|
579
|
-
features.append("rtk-rewrite")
|
|
580
|
-
|
|
555
|
+
features = ["pre-tool", "post-tool"] if _have("tokenless", _TOKENLESS_FALLBACK) else []
|
|
581
556
|
logger.info(
|
|
582
557
|
"tokenless: Hermes plugin registered — active features: %s",
|
|
583
|
-
", ".join(features) if features else "none (install tokenless
|
|
558
|
+
", ".join(features) if features else "none (install tokenless binary)",
|
|
584
559
|
)
|