anolisa-tokenless 0.7.13 → 0.8.0
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 +219 -87
- 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/codex/.codex-plugin/plugin.json +5 -4
- package/adapters/tokenless/codex/README.md +22 -32
- package/adapters/tokenless/codex/hooks/hooks.json +3 -3
- package/adapters/tokenless/codex/scripts/response-diagnostics +170 -0
- package/adapters/tokenless/common/cosh-extension.json +4 -4
- package/adapters/tokenless/common/hooks/compress_response_hook.py +250 -307
- package/adapters/tokenless/common/hooks/compress_schema_hook.py +34 -42
- package/adapters/tokenless/common/hooks/hook_utils.py +281 -44
- 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 +191 -355
- package/adapters/tokenless/hermes/plugin.yaml +2 -2
- package/adapters/tokenless/manifest.json +18 -3
- 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/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/codex/scripts/compress-response +0 -445
- package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -171
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Tokenless response compression hook for Cosh-NG, Claude Code, Qoder, and OpenCode.
|
|
3
3
|
|
|
4
|
-
Reads a PostToolUse JSON from stdin,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- Shell/exec (Bash/Shell) -> moderate truncation (64K strings)
|
|
14
|
-
- Other tools -> zero-truncation compress-response + TOON
|
|
15
|
-
3. Strip debug fields, nulls, empty values (no truncation risk)
|
|
16
|
-
4. If the compressed result is still valid JSON, encode to TOON format
|
|
17
|
-
5. Stats are recorded automatically by tokenless CLI commands.
|
|
4
|
+
Reads a PostToolUse JSON from stdin, forwards the model-visible tool
|
|
5
|
+
response to the unified ``tokenless compress`` Protocol v2 PostTool operation
|
|
6
|
+
and translates the result into the host's
|
|
7
|
+
envelope. JSON detection, tool threshold selection, TOON selection, and
|
|
8
|
+
final acceptance all live behind the entry point; this hook only parses the
|
|
9
|
+
host object, declares capabilities, and builds envelopes (§4.5).
|
|
10
|
+
|
|
11
|
+
One Tokenless subprocess per invocation. Environment-error attribution is
|
|
12
|
+
owned by the Rust PostTool service.
|
|
18
13
|
|
|
19
14
|
Hook point: **PostToolUse**
|
|
20
15
|
|
|
@@ -29,23 +24,22 @@ Output contract per agent:
|
|
|
29
24
|
- qoder-cli: the compressed payload replaces the response via the string
|
|
30
25
|
field ``hookSpecificOutput.updatedToolOutput``. Structured responses are
|
|
31
26
|
serialized as compact JSON because Qoder rejects object and array values.
|
|
32
|
-
Qoder supports replacement for every tool, so compressed data is never
|
|
33
|
-
appended beside the original.
|
|
34
27
|
- opencode: the adapter translates ``updatedToolOutput`` to OpenCode's
|
|
35
|
-
mutable ``tool.execute.after`` output.
|
|
36
|
-
reserved for additive readiness and environment diagnostics.
|
|
28
|
+
mutable ``tool.execute.after`` output.
|
|
37
29
|
- cosh-ng: the compressed payload replaces the response via
|
|
38
30
|
``hookSpecificOutput.updatedToolResponse``. Extract only ``llmContent``
|
|
39
|
-
from wrapped responses; never include ``returnDisplay``.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
agent ID is
|
|
48
|
-
|
|
31
|
+
from wrapped responses; never include ``returnDisplay``. Unsupported
|
|
32
|
+
Cosh-NG versions fail open with compression disabled.
|
|
33
|
+
- other agents (additionalContext-only hosts): passthrough. Additive
|
|
34
|
+
injection would append the compressed copy beside the still-visible
|
|
35
|
+
original — a net token increase — so hosts without true output
|
|
36
|
+
replacement remain passthrough (roadmap §7). Environment attribution is
|
|
37
|
+
still injected: it is additive by design.
|
|
38
|
+
|
|
39
|
+
The agent ID is resolved from the host runtime, ``--agent-id`` argument, or
|
|
40
|
+
TOKENLESS_AGENT_ID environment variable. When running under Cosh-NG, runtime
|
|
41
|
+
detection overrides the declared ID for correct stats attribution. Fallback
|
|
42
|
+
paths follow the ANOLISA FHS spec: /usr/bin/tokenless.
|
|
49
43
|
"""
|
|
50
44
|
|
|
51
45
|
from __future__ import annotations
|
|
@@ -61,31 +55,38 @@ from hook_utils import (
|
|
|
61
55
|
_TOKENLESS_FALLBACK,
|
|
62
56
|
_TOKENLESS_LOCAL_LIB,
|
|
63
57
|
_TOKENLESS_LOCAL_SHARE,
|
|
58
|
+
SHELL_TOOLS,
|
|
64
59
|
SKIP_TOOLS,
|
|
65
|
-
|
|
60
|
+
build_post_tool_request,
|
|
61
|
+
consume_output_optimization,
|
|
66
62
|
detect_cosh_ng_runtime,
|
|
67
|
-
get_thresholds,
|
|
68
63
|
is_skill_file,
|
|
64
|
+
is_tokenless_retrieve_command,
|
|
69
65
|
parse_version,
|
|
70
66
|
resolve_agent_id,
|
|
71
67
|
resolve_binary,
|
|
72
68
|
resolve_tool_call_id,
|
|
69
|
+
run_compress,
|
|
73
70
|
secure_write_text,
|
|
74
71
|
skip,
|
|
72
|
+
tokenless_retrieve_command_available,
|
|
75
73
|
try_parse_json,
|
|
76
|
-
unwrap_string_json,
|
|
77
74
|
warn,
|
|
78
75
|
)
|
|
79
76
|
|
|
80
77
|
# -- constants ---------------------------------------------------------------
|
|
81
78
|
|
|
82
|
-
|
|
79
|
+
# Shell tool envelopes carry the log in one dominant text field. Unwrapping
|
|
80
|
+
# is worth a rebuilt envelope only when that field is large enough for the
|
|
81
|
+
# build/log engine to bite (its own gates start at 30 lines / 200 chars;
|
|
82
|
+
# 2000 chars keeps the rewrap machinery out of trivial outputs).
|
|
83
|
+
_SHELL_TEXT_FIELDS = ("stdout", "stderr")
|
|
84
|
+
_SHELL_UNWRAP_MIN_CHARS = 2_000
|
|
83
85
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
_MIN_TOON_CHARS = 500
|
|
86
|
+
# Below the qwen/cosh extension manifests' 10 s host wrapper so a
|
|
87
|
+
# pathological input is killed here (fail-open skip) before the host kills
|
|
88
|
+
# the whole hook.
|
|
89
|
+
_COMPRESS_TIMEOUT = 8
|
|
89
90
|
|
|
90
91
|
# Claude Code added hookSpecificOutput.updatedToolOutput (normal-path tool
|
|
91
92
|
# output replacement for all tools) in v2.1.121. Older versions only support
|
|
@@ -98,25 +99,12 @@ _OPENCODE_AGENT_ID = "opencode"
|
|
|
98
99
|
# Cache for `claude --version`, keyed on binary path+mtime+size so upgrades
|
|
99
100
|
# invalidate it. Hooks run as a fresh process per tool call and spawning the
|
|
100
101
|
# node CLI every time would add noticeable latency.
|
|
101
|
-
_CLAUDE_VERSION_CACHE = os.path.join(
|
|
102
|
-
os.path.expanduser("~"), ".tokenless", ".claude-version"
|
|
103
|
-
)
|
|
102
|
+
_CLAUDE_VERSION_CACHE = os.path.join(os.path.expanduser("~"), ".tokenless", ".claude-version")
|
|
104
103
|
|
|
105
104
|
|
|
106
105
|
# -- helpers -------------------------------------------------------------------
|
|
107
106
|
|
|
108
107
|
|
|
109
|
-
def _build_additional_context(
|
|
110
|
-
content: str,
|
|
111
|
-
env_attribution: str = "",
|
|
112
|
-
) -> str:
|
|
113
|
-
parts = []
|
|
114
|
-
if env_attribution:
|
|
115
|
-
parts.append(env_attribution)
|
|
116
|
-
parts.append(content)
|
|
117
|
-
return "\n".join(parts)
|
|
118
|
-
|
|
119
|
-
|
|
120
108
|
def _emit(output: dict) -> None:
|
|
121
109
|
print(json.dumps(output, ensure_ascii=False))
|
|
122
110
|
|
|
@@ -128,17 +116,44 @@ def _emit_attribution_or_skip(env_attribution: str) -> None:
|
|
|
128
116
|
additive and safe on every agent), otherwise a plain skip. Never returns.
|
|
129
117
|
"""
|
|
130
118
|
if env_attribution:
|
|
131
|
-
_emit(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
119
|
+
_emit(
|
|
120
|
+
{
|
|
121
|
+
"suppressOutput": True,
|
|
122
|
+
"hookSpecificOutput": {
|
|
123
|
+
"hookEventName": "PostToolUse",
|
|
124
|
+
"additionalContext": env_attribution,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
)
|
|
138
128
|
sys.exit(0)
|
|
139
129
|
skip()
|
|
140
130
|
|
|
141
131
|
|
|
132
|
+
def _shell_text_field(tool_name: str, envelope) -> tuple | None:
|
|
133
|
+
"""The dominant text field of a shell tool's envelope, or ``None``.
|
|
134
|
+
|
|
135
|
+
Shell envelopes (``{"stdout": …, "stderr": …}``) are JSON to the entry
|
|
136
|
+
point, which would compress them log-blind. Unwrapping the largest text
|
|
137
|
+
field sends the log itself through the text slot; step 13 re-injects the
|
|
138
|
+
compressed text into a same-shaped envelope, so the host's tool protocol
|
|
139
|
+
is untouched (adapters own envelope knowledge, §4.5). Only the single
|
|
140
|
+
largest field is compressed — one Tokenless subprocess per invocation
|
|
141
|
+
(§5.6) — the other field stays byte-identical.
|
|
142
|
+
"""
|
|
143
|
+
if tool_name not in SHELL_TOOLS or not isinstance(envelope, dict):
|
|
144
|
+
return None
|
|
145
|
+
best = None
|
|
146
|
+
for name in _SHELL_TEXT_FIELDS:
|
|
147
|
+
value = envelope.get(name)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(value, str)
|
|
150
|
+
and len(value) >= _SHELL_UNWRAP_MIN_CHARS
|
|
151
|
+
and (best is None or len(value) > len(best[1]))
|
|
152
|
+
):
|
|
153
|
+
best = (name, value)
|
|
154
|
+
return best
|
|
155
|
+
|
|
156
|
+
|
|
142
157
|
def _cached_claude_version(claude_bin: str) -> tuple | None:
|
|
143
158
|
"""Return the Claude Code version tuple, caching `claude --version`."""
|
|
144
159
|
try:
|
|
@@ -158,7 +173,9 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
|
|
|
158
173
|
try:
|
|
159
174
|
proc = subprocess.run(
|
|
160
175
|
[claude_bin, "--version"],
|
|
161
|
-
capture_output=True,
|
|
176
|
+
capture_output=True,
|
|
177
|
+
text=True,
|
|
178
|
+
timeout=5,
|
|
162
179
|
)
|
|
163
180
|
except Exception as e:
|
|
164
181
|
warn(f"claude --version failed: {e}")
|
|
@@ -170,9 +187,7 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
|
|
|
170
187
|
try:
|
|
171
188
|
# Same hardened write as other ~/.tokenless state files (0o600,
|
|
172
189
|
# symlink-safe) so the cache stays private on shared HOMEs.
|
|
173
|
-
secure_write_text(
|
|
174
|
-
_CLAUDE_VERSION_CACHE, f"{cache_key}\n{proc.stdout.strip()}"
|
|
175
|
-
)
|
|
190
|
+
secure_write_text(_CLAUDE_VERSION_CACHE, f"{cache_key}\n{proc.stdout.strip()}")
|
|
176
191
|
except OSError:
|
|
177
192
|
pass
|
|
178
193
|
return ver
|
|
@@ -181,8 +196,8 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
|
|
|
181
196
|
def _claude_supports_replacement() -> bool:
|
|
182
197
|
"""Whether the running Claude Code supports updatedToolOutput (>= 2.1.121).
|
|
183
198
|
|
|
184
|
-
Returns False when the version cannot be determined; the
|
|
185
|
-
|
|
199
|
+
Returns False when the version cannot be determined; the hook then
|
|
200
|
+
declares no replacement capability, so unknown versions never receive a
|
|
186
201
|
duplicate compressed payload through additionalContext.
|
|
187
202
|
"""
|
|
188
203
|
claude_bin = resolve_binary("claude")
|
|
@@ -192,88 +207,37 @@ def _claude_supports_replacement() -> bool:
|
|
|
192
207
|
return ver is not None and ver >= _CLAUDE_MIN_REPLACE_VERSION
|
|
193
208
|
|
|
194
209
|
|
|
195
|
-
def _restore_dropped_schema_fields(original: dict, compressed: dict) -> dict:
|
|
196
|
-
"""Restore top-level keys dropped by compression when originally empty.
|
|
197
|
-
|
|
198
|
-
compress-response drops nulls, empty values ("" / {} / []) and configured
|
|
199
|
-
debug fields. Built-in Claude Code tools expect a stable output schema
|
|
200
|
-
(e.g. Bash: stdout/stderr/interrupted/isImage), so cheap empty fields are
|
|
201
|
-
restored for updatedToolOutput; intentionally dropped non-empty debug
|
|
202
|
-
payloads stay dropped.
|
|
203
|
-
"""
|
|
204
|
-
restored = dict(compressed)
|
|
205
|
-
for key, value in original.items():
|
|
206
|
-
if key in restored:
|
|
207
|
-
continue
|
|
208
|
-
if value is None or value == "" or value == {} or value == []:
|
|
209
|
-
restored[key] = value
|
|
210
|
-
return restored
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def _build_replacement_output(
|
|
214
|
-
tool_response_raw: object,
|
|
215
|
-
tool_response: str,
|
|
216
|
-
compressed: str,
|
|
217
|
-
final_output: str,
|
|
218
|
-
used_resp_compression: bool,
|
|
219
|
-
) -> tuple[bool, object]:
|
|
220
|
-
"""Build a schema-safe replacement for runtimes that support one."""
|
|
221
|
-
if not isinstance(tool_response_raw, (dict, list)):
|
|
222
|
-
return True, final_output
|
|
223
|
-
|
|
224
|
-
# TOON text cannot replace a structured response without changing the
|
|
225
|
-
# host tool schema, so this path requires a real JSON compression win.
|
|
226
|
-
if not used_resp_compression:
|
|
227
|
-
return False, None
|
|
228
|
-
|
|
229
|
-
compressed_parsed = try_parse_json(compressed)
|
|
230
|
-
if isinstance(tool_response_raw, dict) and isinstance(compressed_parsed, dict):
|
|
231
|
-
updated_output = _restore_dropped_schema_fields(
|
|
232
|
-
tool_response_raw, compressed_parsed
|
|
233
|
-
)
|
|
234
|
-
elif compressed_parsed is not None:
|
|
235
|
-
updated_output = compressed_parsed
|
|
236
|
-
else:
|
|
237
|
-
return False, None
|
|
238
|
-
|
|
239
|
-
# Restoring empty schema fields can cancel out a marginal win.
|
|
240
|
-
# ensure_ascii=False keeps the size comparison in Unicode characters,
|
|
241
|
-
# consistent with the non-escaped normalization below.
|
|
242
|
-
serialized = json.dumps(
|
|
243
|
-
updated_output, separators=(",", ":"), ensure_ascii=False
|
|
244
|
-
)
|
|
245
|
-
if len(serialized) >= len(tool_response):
|
|
246
|
-
return False, None
|
|
247
|
-
return True, updated_output
|
|
248
|
-
|
|
249
|
-
|
|
250
210
|
# -- main --------------------------------------------------------------------
|
|
251
211
|
|
|
252
212
|
|
|
253
|
-
def _warn_subprocess(label: str, proc: subprocess.CompletedProcess) -> None:
|
|
254
|
-
"""Log a non-zero subprocess exit with truncated stderr."""
|
|
255
|
-
detail = (proc.stderr or "").strip()[:200]
|
|
256
|
-
warn(
|
|
257
|
-
f"{label} exited {proc.returncode}: {detail}"
|
|
258
|
-
if detail
|
|
259
|
-
else f"{label} exited {proc.returncode} with empty stderr"
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
213
|
def main() -> None:
|
|
264
214
|
# 1. Detect runtime (Cosh-NG vs copilot-shell)
|
|
265
215
|
cosh_ng_version = detect_cosh_ng_runtime()
|
|
266
216
|
cosh_ng_detected = cosh_ng_version is not None
|
|
267
217
|
|
|
268
|
-
#
|
|
218
|
+
# 2. Resolve agent ID based on runtime
|
|
219
|
+
agent_id = resolve_agent_id()
|
|
220
|
+
|
|
221
|
+
# 3. Read stdin JSON and consume any matching PreTool state.
|
|
222
|
+
try:
|
|
223
|
+
input_data = json.load(sys.stdin)
|
|
224
|
+
except (json.JSONDecodeError, EOFError, ValueError):
|
|
225
|
+
warn("failed to read PostToolUse payload. Passing through unchanged.")
|
|
226
|
+
skip()
|
|
227
|
+
|
|
228
|
+
session_id = input_data.get("session_id", "")
|
|
229
|
+
tool_use_id = resolve_tool_call_id(agent_id, input_data)
|
|
230
|
+
try:
|
|
231
|
+
output_optimization = consume_output_optimization(agent_id, session_id, tool_use_id)
|
|
232
|
+
except OSError as error:
|
|
233
|
+
warn(f"failed to consume PreTool optimization state: {error}")
|
|
234
|
+
output_optimization = "none"
|
|
235
|
+
|
|
269
236
|
if cosh_ng_detected and cosh_ng_version == (0, 0, 0):
|
|
270
237
|
warn("Unsupported Cosh-NG version. Response compression disabled (fail open).")
|
|
271
238
|
skip()
|
|
272
239
|
|
|
273
|
-
#
|
|
274
|
-
agent_id = resolve_agent_id()
|
|
275
|
-
|
|
276
|
-
# 3. Resolve binaries
|
|
240
|
+
# 4. Resolve the single Core entry point after consuming per-call state.
|
|
277
241
|
tokenless_bin = resolve_binary(
|
|
278
242
|
"tokenless", _TOKENLESS_FALLBACK, _TOKENLESS_LOCAL_SHARE, _TOKENLESS_LOCAL_LIB
|
|
279
243
|
)
|
|
@@ -281,22 +245,12 @@ def main() -> None:
|
|
|
281
245
|
warn("tokenless is not installed. Response compression hook disabled.")
|
|
282
246
|
skip()
|
|
283
247
|
|
|
284
|
-
# 4. Read stdin JSON
|
|
285
|
-
try:
|
|
286
|
-
input_data = json.load(sys.stdin)
|
|
287
|
-
except (json.JSONDecodeError, EOFError, ValueError):
|
|
288
|
-
warn("failed to read PostToolUse payload. Passing through unchanged.")
|
|
289
|
-
skip()
|
|
290
|
-
|
|
291
|
-
# 5. Extract tool_name (skip-tools handled after attribution)
|
|
292
248
|
tool_name = input_data.get("tool_name", "unknown")
|
|
293
|
-
|
|
294
|
-
# 6. Extract tool_response
|
|
295
249
|
tool_response_raw = input_data.get("tool_response", "")
|
|
296
250
|
if not tool_response_raw or tool_response_raw == "{}":
|
|
297
251
|
skip()
|
|
298
252
|
|
|
299
|
-
#
|
|
253
|
+
# 5. For Cosh-NG, extract only llmContent from the wrapped response.
|
|
300
254
|
# Never include returnDisplay in the provider-visible replacement.
|
|
301
255
|
llm_content = None
|
|
302
256
|
if isinstance(tool_response_raw, dict):
|
|
@@ -304,201 +258,190 @@ def main() -> None:
|
|
|
304
258
|
if llm_content is None:
|
|
305
259
|
llm_content = tool_response_raw.get("returnDisplay")
|
|
306
260
|
elif isinstance(tool_response_raw, str):
|
|
307
|
-
# Try to parse as the {llmContent, returnDisplay} wrapper
|
|
308
261
|
parsed_wrapper = try_parse_json(tool_response_raw)
|
|
309
262
|
if isinstance(parsed_wrapper, dict) and "llmContent" in parsed_wrapper:
|
|
310
263
|
llm_content = parsed_wrapper["llmContent"]
|
|
311
264
|
|
|
312
|
-
# The model-visible content we will
|
|
265
|
+
# The model-visible content we will send for compression
|
|
313
266
|
model_visible_before = llm_content if llm_content is not None else tool_response_raw
|
|
314
267
|
|
|
315
|
-
#
|
|
268
|
+
# 6. Skip skill files (YAML frontmatter). Spawn avoidance only: they are
|
|
269
|
+
# never JSON, so the entry point would pass them through anyway.
|
|
316
270
|
if isinstance(model_visible_before, str) and is_skill_file(model_visible_before):
|
|
317
271
|
skip()
|
|
318
272
|
|
|
319
|
-
#
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
273
|
+
# 7. Copy the model-visible value into the request content (§4.5). A
|
|
274
|
+
# shell envelope's dominant text field goes through the text slot
|
|
275
|
+
# instead of log-blind JSON; ensure_ascii=False matches the entry
|
|
276
|
+
# point's normalization, so size gates measure Unicode characters on
|
|
277
|
+
# both sides.
|
|
278
|
+
shell_field = _shell_text_field(tool_name, model_visible_before)
|
|
279
|
+
if shell_field is not None:
|
|
280
|
+
content = shell_field[1]
|
|
281
|
+
elif isinstance(model_visible_before, str):
|
|
282
|
+
content = model_visible_before
|
|
325
283
|
elif isinstance(model_visible_before, (dict, list)):
|
|
326
|
-
|
|
327
|
-
# characters (code points), not \uXXXX escape sequences, so
|
|
328
|
-
# structured payloads are measured the same way as JSON string
|
|
329
|
-
# inputs and the OpenClaw adapter.
|
|
330
|
-
tool_response = json.dumps(
|
|
331
|
-
model_visible_before, separators=(",", ":"), ensure_ascii=False
|
|
332
|
-
)
|
|
284
|
+
content = json.dumps(model_visible_before, separators=(",", ":"), ensure_ascii=False)
|
|
333
285
|
else:
|
|
334
286
|
skip()
|
|
335
287
|
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# 12. Environment attribution analysis
|
|
346
|
-
env_attribution = ""
|
|
347
|
-
attr_category, attr_fix_hint = classify_env_error(parsed)
|
|
348
|
-
if attr_category:
|
|
349
|
-
env_attribution = (
|
|
350
|
-
f"[tokenless:env] {tool_name} failed: "
|
|
351
|
-
f"{attr_category} ({attr_fix_hint}). Skip retry."
|
|
288
|
+
# 8. Capability declaration: what can this host actually do?
|
|
289
|
+
if cosh_ng_detected:
|
|
290
|
+
can_replace = True
|
|
291
|
+
replace_with_text = True # updatedToolResponse accepts any text
|
|
292
|
+
elif agent_id in {_QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
|
|
293
|
+
can_replace = True
|
|
294
|
+
# An unwrapped shell field is plain text regardless of its envelope.
|
|
295
|
+
replace_with_text = shell_field is not None or not isinstance(
|
|
296
|
+
tool_response_raw, (dict, list)
|
|
352
297
|
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
# env attribution for error cases (small size doesn't mean the
|
|
360
|
-
# error classification is unimportant to the agent).
|
|
361
|
-
if len(tool_response) < _MIN_RESPONSE_CHARS:
|
|
362
|
-
_emit_attribution_or_skip(env_attribution)
|
|
363
|
-
|
|
364
|
-
# 15. Step 1: Response compression with 3-layer thresholds
|
|
365
|
-
compressed = tool_response
|
|
366
|
-
used_resp_compression = False
|
|
367
|
-
|
|
368
|
-
if isinstance(parsed, (dict, list)):
|
|
369
|
-
thresholds = get_thresholds(tool_name)
|
|
370
|
-
cmd = [
|
|
371
|
-
tokenless_bin, "compress-response",
|
|
372
|
-
"--agent-id", agent_id,
|
|
373
|
-
"--truncate-strings-at", str(thresholds[0]),
|
|
374
|
-
"--truncate-arrays-at", str(thresholds[1]),
|
|
375
|
-
"--max-depth", str(thresholds[2]),
|
|
376
|
-
]
|
|
377
|
-
if session_id:
|
|
378
|
-
cmd.extend(["--session-id", session_id])
|
|
379
|
-
if tool_use_id:
|
|
380
|
-
cmd.extend(["--tool-use-id", tool_use_id])
|
|
381
|
-
|
|
382
|
-
try:
|
|
383
|
-
proc = subprocess.run(
|
|
384
|
-
cmd,
|
|
385
|
-
input=tool_response,
|
|
386
|
-
capture_output=True, text=True, timeout=3,
|
|
387
|
-
)
|
|
388
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
389
|
-
candidate = proc.stdout.strip()
|
|
390
|
-
# Compare against actual model-visible before size
|
|
391
|
-
if len(candidate) < len(tool_response):
|
|
392
|
-
compressed = candidate
|
|
393
|
-
used_resp_compression = True
|
|
394
|
-
elif proc.returncode != 0:
|
|
395
|
-
_warn_subprocess("compress-response", proc)
|
|
396
|
-
except Exception as e:
|
|
397
|
-
warn(f"Response compression error: {e}")
|
|
398
|
-
|
|
399
|
-
# 16. Step 2: TOON encoding — only for payloads at or above the
|
|
400
|
-
# minimum threshold; small JSON gains near-zero chars from TOON but
|
|
401
|
-
# would still pay the full encode cost on every PostToolUse event.
|
|
402
|
-
toon_output = ""
|
|
403
|
-
|
|
404
|
-
if tokenless_bin and len(compressed) >= _MIN_TOON_CHARS:
|
|
405
|
-
toon_parsed = try_parse_json(compressed)
|
|
406
|
-
if toon_parsed is not None:
|
|
407
|
-
toon_cmd = [tokenless_bin, "compress-toon", "--agent-id", agent_id]
|
|
408
|
-
if session_id:
|
|
409
|
-
toon_cmd.extend(["--session-id", session_id])
|
|
410
|
-
if tool_use_id:
|
|
411
|
-
toon_cmd.extend(["--tool-use-id", tool_use_id])
|
|
412
|
-
try:
|
|
413
|
-
proc = subprocess.run(
|
|
414
|
-
toon_cmd,
|
|
415
|
-
input=compressed,
|
|
416
|
-
capture_output=True, text=True, timeout=1,
|
|
417
|
-
)
|
|
418
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
419
|
-
candidate = proc.stdout.strip()
|
|
420
|
-
if len(candidate) < len(compressed):
|
|
421
|
-
toon_output = candidate
|
|
422
|
-
elif proc.returncode != 0:
|
|
423
|
-
_warn_subprocess("compress-toon", proc)
|
|
424
|
-
except Exception as e:
|
|
425
|
-
warn(f"TOON encoding error: {e}")
|
|
426
|
-
|
|
427
|
-
# Determine final output
|
|
428
|
-
final_output = toon_output if toon_output else compressed
|
|
429
|
-
|
|
430
|
-
# Nothing shrank — pass the original through untouched instead of
|
|
431
|
-
# emitting a same-size duplicate of the response (applies to all agents).
|
|
432
|
-
if not used_resp_compression and not toon_output:
|
|
433
|
-
_emit_attribution_or_skip(env_attribution)
|
|
434
|
-
|
|
435
|
-
# 17. Build response — dispatch by agent runtime.
|
|
436
|
-
#
|
|
437
|
-
# Claude Code, Qoder, and OpenCode support real tool-output replacement. Keep
|
|
438
|
-
# additionalContext for additive diagnostics only; using it for compressed
|
|
439
|
-
# data would leave the original result in context and increase token use.
|
|
440
|
-
if agent_id in {_CLAUDE_AGENT_ID, _QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
|
|
441
|
-
if agent_id == _CLAUDE_AGENT_ID and not _claude_supports_replacement():
|
|
298
|
+
elif agent_id == _CLAUDE_AGENT_ID:
|
|
299
|
+
can_replace = _claude_supports_replacement()
|
|
300
|
+
replace_with_text = shell_field is not None or not isinstance(
|
|
301
|
+
tool_response_raw, (dict, list)
|
|
302
|
+
)
|
|
303
|
+
if not can_replace:
|
|
442
304
|
warn(
|
|
443
305
|
"Claude Code < 2.1.121 (or version unknown): "
|
|
444
306
|
"updatedToolOutput unsupported, response compression disabled."
|
|
445
307
|
)
|
|
446
|
-
|
|
308
|
+
else:
|
|
309
|
+
# additionalContext-only hosts have no true replacement: passthrough
|
|
310
|
+
# (additive injection would duplicate the original — see module doc).
|
|
311
|
+
can_replace = False
|
|
312
|
+
replace_with_text = True
|
|
447
313
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
314
|
+
# 9. Map host facts into the required lifecycle fields.
|
|
315
|
+
if tool_name in SKIP_TOOLS:
|
|
316
|
+
content_origin = "file_content"
|
|
317
|
+
elif tool_name in SHELL_TOOLS:
|
|
318
|
+
content_origin = "command_output"
|
|
319
|
+
else:
|
|
320
|
+
content_origin = "api_response"
|
|
321
|
+
raw_status = str(input_data.get("status", "")).lower()
|
|
322
|
+
shell_process_result = model_visible_before if isinstance(model_visible_before, dict) else None
|
|
323
|
+
shell_process_error = (
|
|
324
|
+
tool_name in SHELL_TOOLS
|
|
325
|
+
and shell_process_result is not None
|
|
326
|
+
and (
|
|
327
|
+
shell_process_result.get("error") is not None
|
|
328
|
+
or (
|
|
329
|
+
shell_process_result.get("exit_code") is not None
|
|
330
|
+
and shell_process_result.get("exit_code") != 0
|
|
331
|
+
)
|
|
332
|
+
or (
|
|
333
|
+
shell_process_result.get("exitCode") is not None
|
|
334
|
+
and shell_process_result.get("exitCode") != 0
|
|
335
|
+
)
|
|
454
336
|
)
|
|
455
|
-
|
|
456
|
-
|
|
337
|
+
)
|
|
338
|
+
if raw_status in {"interrupted", "denied"}:
|
|
339
|
+
status = raw_status
|
|
340
|
+
elif input_data.get("is_error") is True or (
|
|
341
|
+
isinstance(tool_response_raw, dict) and tool_response_raw.get("isError") is True
|
|
342
|
+
):
|
|
343
|
+
status = "error"
|
|
344
|
+
elif shell_process_error:
|
|
345
|
+
status = "error"
|
|
346
|
+
else:
|
|
347
|
+
status = "success"
|
|
348
|
+
|
|
349
|
+
# Shell envelopes often carry a large stdout alongside the actual failure
|
|
350
|
+
# in a short stderr. Error results are never replaced, so send the error
|
|
351
|
+
# stream to Core for diagnosis while the host keeps the original envelope.
|
|
352
|
+
if status == "error" and tool_name in SHELL_TOOLS and isinstance(model_visible_before, dict):
|
|
353
|
+
error_parts = []
|
|
354
|
+
for field in ("stderr", "error"):
|
|
355
|
+
value = model_visible_before.get(field)
|
|
356
|
+
if isinstance(value, str) and value.strip():
|
|
357
|
+
error_parts.append(value)
|
|
358
|
+
if error_parts:
|
|
359
|
+
content = "\n".join(error_parts)
|
|
360
|
+
|
|
361
|
+
retrieve_result = status == "success" and is_tokenless_retrieve_command(
|
|
362
|
+
tool_name, input_data.get("tool_input")
|
|
363
|
+
)
|
|
364
|
+
retrieval_available = (
|
|
365
|
+
can_replace
|
|
366
|
+
and status == "success"
|
|
367
|
+
and output_optimization == "none"
|
|
368
|
+
and not retrieve_result
|
|
369
|
+
and tokenless_retrieve_command_available()
|
|
370
|
+
)
|
|
457
371
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
372
|
+
# 10. The one Tokenless subprocess: Core owns all PostTool policy.
|
|
373
|
+
request = build_post_tool_request(
|
|
374
|
+
content,
|
|
375
|
+
agent_id,
|
|
376
|
+
tool_name,
|
|
377
|
+
status,
|
|
378
|
+
content_origin,
|
|
379
|
+
output_optimization,
|
|
380
|
+
result_kind="retrieve" if retrieve_result else "tool",
|
|
381
|
+
recovery={"kind": "shell" if retrieval_available else "none"},
|
|
382
|
+
session_id=session_id,
|
|
383
|
+
tool_use_id=tool_use_id,
|
|
384
|
+
replace_output=can_replace,
|
|
385
|
+
replace_with_text=replace_with_text,
|
|
386
|
+
)
|
|
387
|
+
response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT, "post_tool")
|
|
388
|
+
env_attribution = response.get("additional_context", "") if response is not None else ""
|
|
389
|
+
if response is None or response.get("disposition") != "applied":
|
|
390
|
+
_emit_attribution_or_skip(env_attribution)
|
|
464
391
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if env_attribution:
|
|
470
|
-
hook_output["additionalContext"] = env_attribution
|
|
471
|
-
_emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
|
|
472
|
-
return
|
|
392
|
+
output_text = response.get("output")
|
|
393
|
+
if not isinstance(output_text, str) or not output_text:
|
|
394
|
+
warn("tokenless compress returned no output. Passing through unchanged.")
|
|
395
|
+
_emit_attribution_or_skip(env_attribution)
|
|
473
396
|
|
|
474
|
-
#
|
|
475
|
-
#
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
397
|
+
# 11. Envelope construction — dispatch by agent runtime. An unwrapped
|
|
398
|
+
# shell field is re-injected into a same-shaped envelope: the compressed
|
|
399
|
+
# text replaces exactly the field that was sent, every other field stays
|
|
400
|
+
# byte-identical.
|
|
401
|
+
rewrapped = None
|
|
402
|
+
if shell_field is not None:
|
|
403
|
+
rewrapped = dict(model_visible_before)
|
|
404
|
+
rewrapped[shell_field[0]] = output_text
|
|
479
405
|
|
|
406
|
+
if cosh_ng_detected:
|
|
480
407
|
hook_specific = {
|
|
481
408
|
"hookEventName": "PostToolUse",
|
|
482
|
-
"updatedToolResponse":
|
|
409
|
+
"updatedToolResponse": rewrapped if rewrapped is not None else output_text,
|
|
483
410
|
}
|
|
484
411
|
if env_attribution:
|
|
485
412
|
hook_specific["additionalContext"] = env_attribution
|
|
486
413
|
_emit({"suppressOutput": True, "hookSpecificOutput": hook_specific})
|
|
487
414
|
return
|
|
488
415
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
416
|
+
if rewrapped is not None:
|
|
417
|
+
updated_output = rewrapped
|
|
418
|
+
elif replace_with_text:
|
|
419
|
+
updated_output = output_text
|
|
420
|
+
else:
|
|
421
|
+
# Structured slot: the entry point guarantees schema-stable JSON for
|
|
422
|
+
# an applied response. A parse failure means the subprocess boundary
|
|
423
|
+
# was violated — fail open.
|
|
424
|
+
updated_output = try_parse_json(output_text)
|
|
425
|
+
if updated_output is None:
|
|
426
|
+
warn("tokenless compress returned non-JSON for a structured slot.")
|
|
427
|
+
_emit_attribution_or_skip(env_attribution)
|
|
494
428
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
429
|
+
# Qoder validates updatedToolOutput as a string even when the original
|
|
430
|
+
# tool response is structured. The entry point's compact serialization
|
|
431
|
+
# is exactly that string; a rewrapped shell envelope serializes here.
|
|
432
|
+
if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
|
|
433
|
+
if rewrapped is not None:
|
|
434
|
+
updated_output = json.dumps(rewrapped, separators=(",", ":"), ensure_ascii=False)
|
|
435
|
+
else:
|
|
436
|
+
updated_output = output_text
|
|
437
|
+
|
|
438
|
+
hook_output = {
|
|
439
|
+
"hookEventName": "PostToolUse",
|
|
440
|
+
"updatedToolOutput": updated_output,
|
|
441
|
+
}
|
|
442
|
+
if env_attribution:
|
|
443
|
+
hook_output["additionalContext"] = env_attribution
|
|
444
|
+
_emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
|
|
502
445
|
|
|
503
446
|
|
|
504
447
|
if __name__ == "__main__":
|