anolisa-tokenless 0.7.13 → 0.7.14

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.
@@ -1,20 +1,16 @@
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, compresses the tool response
5
- via ``tokenless compress-response``, then optionally re-encodes to TOON
6
- format via ``tokenless compress-toon`` for additional token savings.
7
-
8
- Pipeline: Env Attribution -> Layered dispatch -> Compression -> TOON Encoding
9
- 1. If tool_response contains errors, classify as environment vs logic issue
10
- and inject "Skip retry" guidance for LLM
11
- 2. 3-layer tool dispatch:
12
- - Content retrieval (Read/Glob/Grep) -> skip all compression
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`` entry point (protocol v1,
6
+ roadmap §5.4), and translates the CompressionResponse 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 (§5.6). Environment-error
12
+ attribution stays hook-side: it is genuinely additive diagnostics, not a
13
+ compression decision.
18
14
 
19
15
  Hook point: **PostToolUse**
20
16
 
@@ -29,18 +25,17 @@ Output contract per agent:
29
25
  - qoder-cli: the compressed payload replaces the response via the string
30
26
  field ``hookSpecificOutput.updatedToolOutput``. Structured responses are
31
27
  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
28
  - opencode: the adapter translates ``updatedToolOutput`` to OpenCode's
35
- mutable ``tool.execute.after`` output. ``additionalContext`` remains
36
- reserved for additive readiness and environment diagnostics.
29
+ mutable ``tool.execute.after`` output.
37
30
  - cosh-ng: the compressed payload replaces the response via
38
31
  ``hookSpecificOutput.updatedToolResponse``. Extract only ``llmContent``
39
- from wrapped responses; never include ``returnDisplay``. Keep
40
- environment/error attribution in ``additionalContext`` (additive).
41
- Unsupported Cosh-NG versions fail open with compression disabled.
42
- - other agents: the compressed payload is injected via
43
- ``additionalContext`` per each runtime's hook contract.
32
+ from wrapped responses; never include ``returnDisplay``. Unsupported
33
+ Cosh-NG versions fail open with compression disabled.
34
+ - other agents (additionalContext-only hosts): passthrough. Additive
35
+ injection would append the compressed copy beside the still-visible
36
+ original a net token increase — so hosts without true output
37
+ replacement remain passthrough (roadmap §7). Environment attribution is
38
+ still injected: it is additive by design.
44
39
 
45
40
  The agent ID is read from the TOKENLESS_AGENT_ID environment variable
46
41
  (set by the install action script). When running under Cosh-NG, the
@@ -62,30 +57,33 @@ from hook_utils import (
62
57
  _TOKENLESS_LOCAL_LIB,
63
58
  _TOKENLESS_LOCAL_SHARE,
64
59
  SKIP_TOOLS,
60
+ build_compression_request,
65
61
  classify_env_error,
66
62
  detect_cosh_ng_runtime,
67
- get_thresholds,
68
63
  is_skill_file,
69
64
  parse_version,
70
65
  resolve_agent_id,
71
66
  resolve_binary,
72
67
  resolve_tool_call_id,
68
+ run_compress,
73
69
  secure_write_text,
74
70
  skip,
75
71
  try_parse_json,
76
- unwrap_string_json,
77
72
  warn,
78
73
  )
79
74
 
80
75
  # -- constants ---------------------------------------------------------------
81
76
 
77
+ # Spawn-avoidance mirror of the entry point's 200-char gate. The authority
78
+ # lives in Rust; skipping here only saves the subprocess for content the
79
+ # entry would pass through anyway (normalization never grows the char
80
+ # count, so raw < 200 implies normalized < 200).
82
81
  _MIN_RESPONSE_CHARS = 200
83
82
 
84
- # Minimum payload size for the TOON encoding step. TOON on small JSON
85
- # saves only a few characters (observed ~0.3% below ~500 chars) while the
86
- # per-event encode cost stays the same, so payloads under this threshold
87
- # keep the response-compressed form and skip the TOON pass entirely.
88
- _MIN_TOON_CHARS = 500
83
+ # Below the qwen/cosh extension manifests' 10 s host wrapper so a
84
+ # pathological input is killed here (fail-open skip) before the host kills
85
+ # the whole hook.
86
+ _COMPRESS_TIMEOUT = 8
89
87
 
90
88
  # Claude Code added hookSpecificOutput.updatedToolOutput (normal-path tool
91
89
  # output replacement for all tools) in v2.1.121. Older versions only support
@@ -106,17 +104,6 @@ _CLAUDE_VERSION_CACHE = os.path.join(
106
104
  # -- helpers -------------------------------------------------------------------
107
105
 
108
106
 
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
107
  def _emit(output: dict) -> None:
121
108
  print(json.dumps(output, ensure_ascii=False))
122
109
 
@@ -181,8 +168,8 @@ def _cached_claude_version(claude_bin: str) -> tuple | None:
181
168
  def _claude_supports_replacement() -> bool:
182
169
  """Whether the running Claude Code supports updatedToolOutput (>= 2.1.121).
183
170
 
184
- Returns False when the version cannot be determined; the caller then
185
- fails open by disabling compression, so unknown versions never receive a
171
+ Returns False when the version cannot be determined; the hook then
172
+ declares no replacement capability, so unknown versions never receive a
186
173
  duplicate compressed payload through additionalContext.
187
174
  """
188
175
  claude_bin = resolve_binary("claude")
@@ -192,74 +179,9 @@ def _claude_supports_replacement() -> bool:
192
179
  return ver is not None and ver >= _CLAUDE_MIN_REPLACE_VERSION
193
180
 
194
181
 
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
182
  # -- main --------------------------------------------------------------------
251
183
 
252
184
 
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
185
  def main() -> None:
264
186
  # 1. Detect runtime (Cosh-NG vs copilot-shell)
265
187
  cosh_ng_version = detect_cosh_ng_runtime()
@@ -288,15 +210,12 @@ def main() -> None:
288
210
  warn("failed to read PostToolUse payload. Passing through unchanged.")
289
211
  skip()
290
212
 
291
- # 5. Extract tool_name (skip-tools handled after attribution)
292
213
  tool_name = input_data.get("tool_name", "unknown")
293
-
294
- # 6. Extract tool_response
295
214
  tool_response_raw = input_data.get("tool_response", "")
296
215
  if not tool_response_raw or tool_response_raw == "{}":
297
216
  skip()
298
217
 
299
- # 7. For Cosh-NG, extract only llmContent from the wrapped response.
218
+ # 5. For Cosh-NG, extract only llmContent from the wrapped response.
300
219
  # Never include returnDisplay in the provider-visible replacement.
301
220
  llm_content = None
302
221
  if isinstance(tool_response_raw, dict):
@@ -304,201 +223,141 @@ def main() -> None:
304
223
  if llm_content is None:
305
224
  llm_content = tool_response_raw.get("returnDisplay")
306
225
  elif isinstance(tool_response_raw, str):
307
- # Try to parse as the {llmContent, returnDisplay} wrapper
308
226
  parsed_wrapper = try_parse_json(tool_response_raw)
309
227
  if isinstance(parsed_wrapper, dict) and "llmContent" in parsed_wrapper:
310
228
  llm_content = parsed_wrapper["llmContent"]
311
229
 
312
- # The model-visible content we will compress
230
+ # The model-visible content we will send for compression
313
231
  model_visible_before = llm_content if llm_content is not None else tool_response_raw
314
232
 
315
- # 8. Skip skill files (YAML frontmatter)
233
+ # 6. Skip skill files (YAML frontmatter). Spawn avoidance only: they are
234
+ # never JSON, so the entry point would pass them through anyway.
316
235
  if isinstance(model_visible_before, str) and is_skill_file(model_visible_before):
317
236
  skip()
318
237
 
319
- # 9. Normalize response
238
+ # 7. Copy the model-visible value into the request content (§4.5).
239
+ # ensure_ascii=False matches the entry point's normalization, so size
240
+ # gates measure Unicode characters on both sides.
320
241
  if isinstance(model_visible_before, str):
321
- unwrapped = unwrap_string_json(model_visible_before)
322
- if not unwrapped:
323
- skip() # Plain text, not JSON
324
- tool_response = unwrapped
242
+ content = model_visible_before
325
243
  elif isinstance(model_visible_before, (dict, list)):
326
- # ensure_ascii=False: size gates below must count Unicode
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(
244
+ content = json.dumps(
331
245
  model_visible_before, separators=(",", ":"), ensure_ascii=False
332
246
  )
333
247
  else:
334
248
  skip()
335
249
 
336
- # 10. Validate it's JSON (needed for attribution on skip-tools too)
337
- parsed = try_parse_json(tool_response)
338
- if parsed is None:
339
- skip()
340
-
341
- # 11. Extract caller context
250
+ # 8. Extract caller context
342
251
  session_id = input_data.get("session_id", "")
343
252
  tool_use_id = resolve_tool_call_id(agent_id, input_data)
344
253
 
345
- # 12. Environment attribution analysis
254
+ # 9. Environment attribution analysis — additive diagnostics, computed
255
+ # hook-side. Only structured payloads are classified (with the same
256
+ # string-unwrap the entry point applies): plain text never reached
257
+ # attribution in the two-subprocess hook and still does not.
258
+ if isinstance(model_visible_before, dict):
259
+ attr_subject = model_visible_before
260
+ else:
261
+ parsed = try_parse_json(content)
262
+ if isinstance(parsed, str):
263
+ parsed = try_parse_json(parsed)
264
+ attr_subject = parsed if isinstance(parsed, (dict, list)) else None
346
265
  env_attribution = ""
347
- attr_category, attr_fix_hint = classify_env_error(parsed)
266
+ attr_category, attr_fix_hint = classify_env_error(attr_subject)
348
267
  if attr_category:
349
268
  env_attribution = (
350
269
  f"[tokenless:env] {tool_name} failed: "
351
270
  f"{attr_category} ({attr_fix_hint}). Skip retry."
352
271
  )
353
272
 
354
- # 13. Content retrieval -- skip entirely (preserve integrity)
355
- if tool_name in SKIP_TOOLS:
356
- _emit_attribution_or_skip(env_attribution)
357
-
358
- # 14. All other tools -- skip small responses, but still inject
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():
273
+ # 10. Capability declaration (§4.5): what can this host actually do?
274
+ if cosh_ng_detected:
275
+ can_replace = True
276
+ replace_with_text = True # updatedToolResponse accepts any text
277
+ elif agent_id in {_QODER_AGENT_ID, _OPENCODE_AGENT_ID}:
278
+ can_replace = True
279
+ replace_with_text = not isinstance(tool_response_raw, (dict, list))
280
+ elif agent_id == _CLAUDE_AGENT_ID:
281
+ can_replace = _claude_supports_replacement()
282
+ replace_with_text = not isinstance(tool_response_raw, (dict, list))
283
+ if not can_replace:
442
284
  warn(
443
285
  "Claude Code < 2.1.121 (or version unknown): "
444
286
  "updatedToolOutput unsupported, response compression disabled."
445
287
  )
446
- _emit_attribution_or_skip(env_attribution)
447
-
448
- replace, updated_output = _build_replacement_output(
449
- tool_response_raw,
450
- tool_response,
451
- compressed,
452
- final_output,
453
- used_resp_compression,
454
- )
455
- if not replace:
456
- _emit_attribution_or_skip(env_attribution)
288
+ else:
289
+ # additionalContext-only hosts have no true replacement: passthrough
290
+ # (additive injection would duplicate the original — see module doc).
291
+ can_replace = False
292
+ replace_with_text = True
293
+
294
+ # 11. Spawn-avoidance prefilters. The entry point re-checks all three
295
+ # authoritatively; skipping here just saves the exec. SKIP_TOOLS reads
296
+ # the same tool_categories.json the entry point embeds, so content
297
+ # retrieval — the hottest PostToolUse traffic — never pays a spawn.
298
+ if not can_replace:
299
+ _emit_attribution_or_skip(env_attribution)
300
+ if tool_name in SKIP_TOOLS:
301
+ _emit_attribution_or_skip(env_attribution)
302
+ if len(content) < _MIN_RESPONSE_CHARS:
303
+ _emit_attribution_or_skip(env_attribution)
457
304
 
458
- # Qoder validates updatedToolOutput as a string even when the original
459
- # tool response is structured. Preserve the compact schema as JSON text.
460
- if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
461
- updated_output = json.dumps(
462
- updated_output, separators=(",", ":"), ensure_ascii=False
463
- )
305
+ # 12. The one Tokenless subprocess: the unified entry point decides.
306
+ request = build_compression_request(
307
+ content,
308
+ agent_id,
309
+ "post_tool",
310
+ session_id=session_id,
311
+ tool_use_id=tool_use_id,
312
+ tool_name=tool_name,
313
+ replace_output=True,
314
+ publish_retrieve_tool=True,
315
+ replace_with_text=replace_with_text,
316
+ )
317
+ response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT)
318
+ if response is None or response.get("disposition") != "applied":
319
+ _emit_attribution_or_skip(env_attribution)
464
320
 
465
- hook_output = {
466
- "hookEventName": "PostToolUse",
467
- "updatedToolOutput": updated_output,
468
- }
469
- if env_attribution:
470
- hook_output["additionalContext"] = env_attribution
471
- _emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
472
- return
321
+ output_text = response.get("output")
322
+ if not isinstance(output_text, str) or not output_text:
323
+ warn("tokenless compress returned no output. Passing through unchanged.")
324
+ _emit_attribution_or_skip(env_attribution)
473
325
 
474
- # Cosh-NG: use updatedToolResponse for response replacement.
475
- # Skip compression if it doesn't reduce model-visible size.
326
+ # 13. Envelope construction dispatch by agent runtime.
476
327
  if cosh_ng_detected:
477
- if len(final_output) >= len(tool_response):
478
- _emit_attribution_or_skip(env_attribution)
479
-
480
328
  hook_specific = {
481
329
  "hookEventName": "PostToolUse",
482
- "updatedToolResponse": final_output,
330
+ "updatedToolResponse": output_text,
483
331
  }
484
332
  if env_attribution:
485
333
  hook_specific["additionalContext"] = env_attribution
486
334
  _emit({"suppressOutput": True, "hookSpecificOutput": hook_specific})
487
335
  return
488
336
 
489
- # Other agents: inject via additionalContext per their hook contracts.
490
- context = _build_additional_context(
491
- final_output,
492
- env_attribution=env_attribution,
493
- )
337
+ if replace_with_text:
338
+ updated_output = output_text
339
+ else:
340
+ # Structured slot: the entry point guarantees schema-stable JSON for
341
+ # an applied response. A parse failure means the subprocess boundary
342
+ # was violated — fail open.
343
+ updated_output = try_parse_json(output_text)
344
+ if updated_output is None:
345
+ warn("tokenless compress returned non-JSON for a structured slot.")
346
+ _emit_attribution_or_skip(env_attribution)
494
347
 
495
- _emit({
496
- "suppressOutput": True,
497
- "hookSpecificOutput": {
498
- "hookEventName": "PostToolUse",
499
- "additionalContext": context,
500
- },
501
- })
348
+ # Qoder validates updatedToolOutput as a string even when the original
349
+ # tool response is structured. The entry point's compact serialization
350
+ # is exactly that string.
351
+ if agent_id == _QODER_AGENT_ID and not isinstance(updated_output, str):
352
+ updated_output = output_text
353
+
354
+ hook_output = {
355
+ "hookEventName": "PostToolUse",
356
+ "updatedToolOutput": updated_output,
357
+ }
358
+ if env_attribution:
359
+ hook_output["additionalContext"] = env_attribution
360
+ _emit({"suppressOutput": True, "hookSpecificOutput": hook_output})
502
361
 
503
362
 
504
363
  if __name__ == "__main__":
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  """Tokenless schema compression hook.
3
3
 
4
- Reads a BeforeModel JSON from stdin, extracts the tools array,
5
- invokes ``tokenless compress-schema --batch`` via subprocess, and
6
- writes a HookOutput JSON to stdout.
4
+ Reads a BeforeModel JSON from stdin, extracts the tools array, forwards it
5
+ to the unified ``tokenless compress`` entry point (protocol v1, seam
6
+ ``before_model``, roadmap §5.4), and writes a HookOutput JSON to stdout.
7
+ The entry point returns the original array on no-savings, which this hook
8
+ wraps exactly like a compressed one — the historical behavior of the
9
+ ``compress-schema`` flow it replaces.
7
10
 
8
11
  Hook point: **BeforeModel**
9
12
 
@@ -16,7 +19,6 @@ from __future__ import annotations
16
19
  import contextlib
17
20
  import json
18
21
  import os
19
- import subprocess
20
22
  import sys
21
23
 
22
24
  try: # POSIX hosts (cosh / Cosh-NG) — the platforms these hooks target.
@@ -30,9 +32,11 @@ from hook_utils import (
30
32
  _TOKENLESS_FALLBACK,
31
33
  _TOKENLESS_LOCAL_LIB,
32
34
  _TOKENLESS_LOCAL_SHARE,
35
+ build_compression_request,
33
36
  resolve_agent_id,
34
37
  resolve_binary,
35
38
  resolve_tool_call_id,
39
+ run_compress,
36
40
  secure_write_text,
37
41
  skip,
38
42
  warn,
@@ -42,6 +46,11 @@ from hook_utils import (
42
46
 
43
47
  _AGENT_ID = resolve_agent_id()
44
48
 
49
+ # Below the extension manifests' 10 s host wrapper, so a pathological batch
50
+ # is killed here (fail-open skip) instead of racing the host's kill of the
51
+ # whole hook (the old subprocess timeout was 10 s against a 10 s wrapper).
52
+ _COMPRESS_TIMEOUT = 8
53
+
45
54
  # One marker file holds the session keys that already emitted the "no tool
46
55
  # declarations" warning — one key per line, most recent last — so the warning
47
56
  # repeats at most once per session even though BeforeModel fires on every
@@ -272,36 +281,31 @@ def main() -> None:
272
281
  session_id = input_data.get("session_id", "")
273
282
  tool_use_id = resolve_tool_call_id(_AGENT_ID, input_data)
274
283
 
275
- # 5. Compress schemas via tokenless compress-schema --batch
276
- cmd = [tokenless_bin, "compress-schema", "--batch", "--agent-id", _AGENT_ID]
277
- if session_id:
278
- cmd.extend(["--session-id", session_id])
279
- if tool_use_id:
280
- cmd.extend(["--tool-use-id", tool_use_id])
281
-
282
- try:
283
- proc = subprocess.run(
284
- cmd,
285
- input=tools_json,
286
- capture_output=True,
287
- text=True,
288
- timeout=10,
289
- )
290
- except Exception:
284
+ # 5. Compress schemas via the unified entry point (one subprocess).
285
+ request = build_compression_request(
286
+ tools_json,
287
+ _AGENT_ID,
288
+ "before_model",
289
+ session_id=session_id,
290
+ tool_use_id=tool_use_id,
291
+ replace_output=True,
292
+ publish_retrieve_tool=True,
293
+ )
294
+ response = run_compress(tokenless_bin, request, _COMPRESS_TIMEOUT)
295
+ if response is None:
291
296
  warn("Schema compression subprocess failed. Passing through unchanged.")
292
297
  skip()
293
-
294
- if proc.returncode != 0:
295
- detail = (proc.stderr or "").strip()[:200]
296
- warn(
297
- f"Schema compression failed with exit code {proc.returncode}: {detail}"
298
- if detail
299
- else f"Schema compression failed with exit code {proc.returncode}. Passing through unchanged."
300
- )
298
+ if response.get("disposition") in {"error", "timeout"}:
299
+ warn("Schema compression failed. Passing through unchanged.")
301
300
  skip()
302
-
303
- compressed = proc.stdout.strip()
304
- if not compressed or not _is_json_array(compressed):
301
+ if response.get("disposition") == "reversibility_unavailable":
302
+ # Savings existed but the stash could not record the originals;
303
+ # the entry point returned the uncompressed schemas. Surface the
304
+ # distinction from "nothing to compress" (envelope unchanged).
305
+ warn("Schema stash unavailable; truncated descriptions would be lossy.")
306
+
307
+ compressed = response.get("output")
308
+ if not isinstance(compressed, str) or not _is_json_array(compressed):
305
309
  warn(
306
310
  "Schema compression returned invalid JSON. Passing through unchanged."
307
311
  )
@@ -43,6 +43,9 @@ _AGENT_ID = resolve_agent_id()
43
43
  # Minimum payload size for TOON encoding. TOON on small JSON saves only a
44
44
  # few characters (observed ~0.3% below ~500 chars) while the per-event
45
45
  # encode cost stays the same, so smaller responses pass through untouched.
46
+ # This early check avoids the subprocess spawn; the compress-toon CLI
47
+ # enforces the same threshold by default (tokenless-runtime MIN_TOON_CHARS),
48
+ # so keep the two values in sync.
46
49
  _MIN_TOON_CHARS = 500
47
50
 
48
51