compresr 2.8.2__tar.gz → 2.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {compresr-2.8.2 → compresr-2.9.0}/PKG-INFO +20 -12
  2. {compresr-2.8.2 → compresr-2.9.0}/compresr/config.py +1 -0
  3. compresr-2.9.0/compresr/integrations/hermes/__init__.py +35 -0
  4. compresr-2.9.0/compresr/integrations/hermes/_config.py +68 -0
  5. compresr-2.9.0/compresr/integrations/hermes/_security.py +142 -0
  6. compresr-2.9.0/compresr/integrations/hermes/cache.py +273 -0
  7. compresr-2.9.0/compresr/integrations/hermes/engine.py +304 -0
  8. compresr-2.9.0/compresr/integrations/hermes/plugin.py +107 -0
  9. compresr-2.9.0/compresr/integrations/hermes/recovery.py +146 -0
  10. compresr-2.9.0/compresr/integrations/hermes/tool_output.py +380 -0
  11. {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/__init__.py +8 -0
  12. compresr-2.9.0/compresr/schemas/agentic.py +54 -0
  13. {compresr-2.8.2 → compresr-2.9.0}/compresr/services/base.py +17 -13
  14. {compresr-2.8.2 → compresr-2.9.0}/compresr/services/compression.py +70 -0
  15. {compresr-2.8.2 → compresr-2.9.0}/compresr/services/proxy.py +23 -3
  16. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/PKG-INFO +20 -12
  17. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/SOURCES.txt +9 -0
  18. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/entry_points.txt +3 -0
  19. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/requires.txt +17 -7
  20. {compresr-2.8.2 → compresr-2.9.0}/pyproject.toml +30 -21
  21. {compresr-2.8.2 → compresr-2.9.0}/LICENSE +0 -0
  22. {compresr-2.8.2 → compresr-2.9.0}/README.md +0 -0
  23. {compresr-2.8.2 → compresr-2.9.0}/compresr/__init__.py +0 -0
  24. {compresr-2.8.2 → compresr-2.9.0}/compresr/__main__.py +0 -0
  25. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/__init__.py +0 -0
  26. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/engine.py +0 -0
  27. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/__init__.py +0 -0
  28. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/anthropic.py +0 -0
  29. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/native.py +0 -0
  30. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/openai.py +0 -0
  31. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/normalized.py +0 -0
  32. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/__init__.py +0 -0
  33. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/agent.py +0 -0
  34. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/facade.py +0 -0
  35. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/parser.py +0 -0
  36. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/prompts.py +0 -0
  37. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/types.py +0 -0
  38. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/__init__.py +0 -0
  39. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/anthropic.py +0 -0
  40. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/openai.py +0 -0
  41. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/__init__.py +0 -0
  42. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/_agentcore.py +0 -0
  43. {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/web_search.py +0 -0
  44. {compresr-2.8.2 → compresr-2.9.0}/compresr/auth.py +0 -0
  45. {compresr-2.8.2 → compresr-2.9.0}/compresr/clients.py +0 -0
  46. {compresr-2.8.2 → compresr-2.9.0}/compresr/credentials.py +0 -0
  47. {compresr-2.8.2 → compresr-2.9.0}/compresr/exceptions/__init__.py +0 -0
  48. {compresr-2.8.2 → compresr-2.9.0}/compresr/exceptions/exceptions.py +0 -0
  49. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/__init__.py +0 -0
  50. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/__init__.py +0 -0
  51. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/client.py +0 -0
  52. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/compress.py +0 -0
  53. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/errors.py +0 -0
  54. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/filters.py +0 -0
  55. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/kernel.py +0 -0
  56. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/policy.py +0 -0
  57. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/query.py +0 -0
  58. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/tokens.py +0 -0
  59. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/__init__.py +0 -0
  60. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/middleware.py +0 -0
  61. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/retriever.py +0 -0
  62. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/wrappers.py +0 -0
  63. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/__init__.py +0 -0
  64. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/checkpoint.py +0 -0
  65. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/handoff.py +0 -0
  66. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/nodes.py +0 -0
  67. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/store.py +0 -0
  68. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/__init__.py +0 -0
  69. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/cli.py +0 -0
  70. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/defaults.py +0 -0
  71. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/guardrail.py +0 -0
  72. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/initializer.py +0 -0
  73. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/shim_installer.py +0 -0
  74. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/types.py +0 -0
  75. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/__init__.py +0 -0
  76. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/memory.py +0 -0
  77. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/postprocessor.py +0 -0
  78. {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/wrappers.py +0 -0
  79. {compresr-2.8.2 → compresr-2.9.0}/compresr/retry.py +0 -0
  80. {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/base.py +0 -0
  81. {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/inference.py +0 -0
  82. {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/usage.py +0 -0
  83. {compresr-2.8.2 → compresr-2.9.0}/compresr/services/__init__.py +0 -0
  84. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/dependency_links.txt +0 -0
  85. {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/top_level.txt +0 -0
  86. {compresr-2.8.2 → compresr-2.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compresr
3
- Version: 2.8.2
3
+ Version: 2.9.0
4
4
  Summary: Python SDK for Compresr - Intelligent prompt compression service
5
5
  Author-email: Compresr Team <founders@compresr.ai>
6
6
  License-Expression: Apache-2.0
@@ -13,24 +13,24 @@ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
20
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
20
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.10
23
22
  Description-Content-Type: text/markdown
24
23
  License-File: LICENSE
25
24
  Requires-Dist: httpx>=0.27.0
26
25
  Requires-Dist: pydantic>=2.10.0
27
- Requires-Dist: langchain>=1.0
28
- Requires-Dist: langchain-core>=0.3
29
- Requires-Dist: langchain-anthropic>=1.0
30
- Requires-Dist: langchain-openai>=0.3
31
- Requires-Dist: langchain-google-genai>=4.0
32
- Requires-Dist: langchain-tavily>=0.1
33
- Requires-Dist: langchain-community>=0.3
26
+ Provides-Extra: agents
27
+ Requires-Dist: langchain>=1.0; extra == "agents"
28
+ Requires-Dist: langchain-core>=0.3; extra == "agents"
29
+ Requires-Dist: langchain-anthropic>=1.0; extra == "agents"
30
+ Requires-Dist: langchain-openai>=0.3; extra == "agents"
31
+ Requires-Dist: langchain-google-genai>=4.0; extra == "agents"
32
+ Requires-Dist: langchain-tavily>=0.1; extra == "agents"
33
+ Requires-Dist: langchain-community>=0.3; extra == "agents"
34
34
  Provides-Extra: dev
35
35
  Requires-Dist: pytest>=7.0.0; extra == "dev"
36
36
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -38,7 +38,7 @@ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
38
38
  Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
39
39
  Requires-Dist: black>=23.0.0; extra == "dev"
40
40
  Requires-Dist: isort>=5.12.0; extra == "dev"
41
- Requires-Dist: ruff>=0.1.0; extra == "dev"
41
+ Requires-Dist: ruff<0.16,>=0.1.0; extra == "dev"
42
42
  Requires-Dist: mypy>=1.0.0; extra == "dev"
43
43
  Requires-Dist: python-dotenv>=1.0.0; extra == "dev"
44
44
  Provides-Extra: langgraph
@@ -50,20 +50,28 @@ Requires-Dist: litellm[proxy]>=1.79; extra == "litellm"
50
50
  Provides-Extra: agentcore
51
51
  Requires-Dist: mcp>=1.0; extra == "agentcore"
52
52
  Requires-Dist: nest-asyncio>=1.6; extra == "agentcore"
53
+ Provides-Extra: hermes
53
54
  Provides-Extra: all
55
+ Requires-Dist: compresr[agents]; extra == "all"
54
56
  Requires-Dist: langgraph>=0.2; extra == "all"
55
57
  Requires-Dist: llama-index-core>=0.11; extra == "all"
56
58
  Requires-Dist: litellm[proxy]>=1.79; extra == "all"
57
59
  Requires-Dist: mcp>=1.0; extra == "all"
58
60
  Requires-Dist: nest-asyncio>=1.6; extra == "all"
59
61
  Provides-Extra: langchain
60
- Provides-Extra: agents
62
+ Requires-Dist: compresr[agents]; extra == "langchain"
61
63
  Provides-Extra: agents-anthropic
64
+ Requires-Dist: compresr[agents]; extra == "agents-anthropic"
62
65
  Provides-Extra: agents-openai
66
+ Requires-Dist: compresr[agents]; extra == "agents-openai"
63
67
  Provides-Extra: agents-gemini
68
+ Requires-Dist: compresr[agents]; extra == "agents-gemini"
64
69
  Provides-Extra: agents-tavily
70
+ Requires-Dist: compresr[agents]; extra == "agents-tavily"
65
71
  Provides-Extra: agents-brave
72
+ Requires-Dist: compresr[agents]; extra == "agents-brave"
66
73
  Provides-Extra: agents-all
74
+ Requires-Dist: compresr[agents]; extra == "agents-all"
67
75
  Dynamic: license-file
68
76
 
69
77
  # Compresr Python SDK
@@ -23,6 +23,7 @@ class Endpoints:
23
23
  COMPRESS: str = "/api/compress/question-specific/"
24
24
  COMPRESS_STREAM: str = "/api/compress/question-specific/stream"
25
25
  COMPRESS_BATCH: str = "/api/compress/question-specific/batch"
26
+ COMPRESS_TOOL_OUTPUT: str = "/api/compress/tool-output/"
26
27
 
27
28
 
28
29
  @dataclass(frozen=True)
@@ -0,0 +1,35 @@
1
+ """Compresr × Hermes agent integration.
2
+
3
+ Two surfaces, both fail-open and opt-in:
4
+
5
+ - :class:`CompresrContextEngine` — drop-in replacement for Hermes's built-in
6
+ ``compressor`` context engine (``context.engine: compresr``).
7
+ - :class:`ToolOutputCompressor` — ``transform_tool_result`` hook that shrinks
8
+ large tool outputs and caches the verbatim original for recovery.
9
+
10
+ :func:`register` is the Hermes plugin entry point wiring both.
11
+
12
+ Requires the Hermes agent runtime (this package is loaded as a Hermes plugin,
13
+ inside Hermes's Python process). Importing :mod:`compresr` itself never pulls
14
+ Hermes in — resolution is deferred until these names are accessed.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ __all__ = ["CompresrContextEngine", "ToolOutputCompressor", "register"]
20
+
21
+
22
+ def __getattr__(name: str) -> Any:
23
+ if name == "CompresrContextEngine":
24
+ from .engine import CompresrContextEngine as _engine_cls
25
+
26
+ return _engine_cls
27
+ if name == "ToolOutputCompressor":
28
+ from .tool_output import ToolOutputCompressor as _hook_cls
29
+
30
+ return _hook_cls
31
+ if name == "register":
32
+ from .plugin import register as _fn
33
+
34
+ return _fn
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,68 @@
1
+ """Config resolution for the Hermes integration: env var > config.yaml > default.
2
+
3
+ All coercions are tolerant — a typo'd value falls back to the default instead
4
+ of raising in a plugin ``__init__`` and silently disabling the feature.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from typing import Any, Dict, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def read_config_block(key: str = "compresr") -> Dict[str, Any]:
17
+ """Best-effort read of a top-level *key* block from Hermes's config.yaml. Never raises."""
18
+ try:
19
+ from hermes_constants import get_hermes_home
20
+
21
+ cfg_path = get_hermes_home() / "config.yaml"
22
+ except Exception:
23
+ return {}
24
+ if not cfg_path.exists():
25
+ return {}
26
+ try:
27
+ import yaml
28
+
29
+ with open(cfg_path, encoding="utf-8-sig") as f:
30
+ data = yaml.safe_load(f) or {}
31
+ block = data.get(key, {})
32
+ return block if isinstance(block, dict) else {}
33
+ except Exception as e:
34
+ logger.debug("compresr: could not read config block %r: %s", key, e)
35
+ return {}
36
+
37
+
38
+ def opt(cfg: Dict[str, Any], env_key: str, cfg_key: str, default: Any) -> Any:
39
+ val = os.environ.get(env_key)
40
+ if val is not None and val != "":
41
+ return val
42
+ if cfg_key in cfg and cfg[cfg_key] not in (None, ""):
43
+ return cfg[cfg_key]
44
+ return default
45
+
46
+
47
+ def as_bool(v: Any) -> bool:
48
+ return str(v).lower() in ("1", "true", "yes", "on")
49
+
50
+
51
+ def as_int(v: Any, default: int) -> int:
52
+ try:
53
+ return int(v)
54
+ except (TypeError, ValueError):
55
+ logger.warning("compresr: invalid numeric value %r, using %s", v, default)
56
+ return default
57
+
58
+
59
+ def as_float(v: Any, default: Optional[float]) -> Optional[float]:
60
+ try:
61
+ return float(v)
62
+ except (TypeError, ValueError):
63
+ if v not in (None, ""):
64
+ logger.warning("compresr: invalid numeric value %r, using %s", v, default)
65
+ return default
66
+
67
+
68
+ __all__ = ["read_config_block", "opt", "as_bool", "as_int", "as_float"]
@@ -0,0 +1,142 @@
1
+ """Base-URL / host validation and secret sanitization for the Hermes integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import logging
7
+ import re
8
+ import socket
9
+ from typing import Any, Optional, Tuple
10
+ from urllib.parse import urlparse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_BASE_URL = "https://api.compresr.ai"
15
+
16
+ _SECRET_CTL_RE = re.compile(r"[\r\n\x00]")
17
+ _NUMERIC_ONLY_HOST_RE = re.compile(r"\A(?:0x[0-9a-fA-F]+|[0-9]+)\Z")
18
+
19
+ _BLOCKED_METADATA_HOST_NAMES = frozenset({"metadata.google.internal", "metadata.goog", "metadata"})
20
+ _BLOCKED_NETWORKS = tuple(
21
+ ipaddress.ip_network(n)
22
+ for n in (
23
+ "169.254.0.0/16",
24
+ "fd00::/8",
25
+ "fe80::/10",
26
+ "100.100.100.200/32",
27
+ "168.63.129.16/32",
28
+ "10.0.0.0/8",
29
+ "172.16.0.0/12",
30
+ "192.168.0.0/16",
31
+ "100.64.0.0/10",
32
+ )
33
+ )
34
+ _LOCALHOST_NAMES = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
35
+
36
+
37
+ def sanitize_secret(raw: str, label: str) -> str:
38
+ """Strip whitespace; reject CR/LF/NUL. Returns "" on rejection, never the value."""
39
+ if not raw:
40
+ return ""
41
+ stripped = raw.strip()
42
+ if _SECRET_CTL_RE.search(stripped):
43
+ logger.error("compresr: %s contained CR/LF/NUL and was rejected", label)
44
+ return ""
45
+ return stripped
46
+
47
+
48
+ def _resolve_host_ips(host: str) -> Tuple:
49
+ if not host:
50
+ return ()
51
+ ips: list = []
52
+ try:
53
+ ip = ipaddress.ip_address(host)
54
+ mapped = getattr(ip, "ipv4_mapped", None)
55
+ ips.append(mapped if mapped is not None else ip)
56
+ return tuple(ips)
57
+ except ValueError:
58
+ pass
59
+ try:
60
+ infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
61
+ except (socket.gaierror, socket.herror, UnicodeError, OSError):
62
+ return ()
63
+ for _, _, _, _, sockaddr in infos:
64
+ try:
65
+ ip = ipaddress.ip_address(str(sockaddr[0]).split("%", 1)[0])
66
+ except ValueError:
67
+ continue
68
+ mapped = getattr(ip, "ipv4_mapped", None)
69
+ ips.append(mapped if mapped is not None else ip)
70
+ return tuple(ips)
71
+
72
+
73
+ def _is_blocked_host(host: str) -> bool:
74
+ """Canonical localhost forms are allow-listed before this check runs; any
75
+ other loopback representation (127.0.0.2, octal/hex shorthand) is blocked."""
76
+ h = (host or "").rstrip(".").lower()
77
+ if h in _BLOCKED_METADATA_HOST_NAMES:
78
+ return True
79
+ return any(
80
+ ip.is_loopback or any(ip in net for net in _BLOCKED_NETWORKS) for ip in _resolve_host_ips(h)
81
+ )
82
+
83
+
84
+ def _safe_url_repr(parsed: Any) -> str:
85
+ if parsed is None:
86
+ return "?"
87
+ try:
88
+ return f"{parsed.scheme or '?'}://{parsed.hostname or '?'}"
89
+ except Exception:
90
+ return "?"
91
+
92
+
93
+ def secure_base_url(url: str, default: str = DEFAULT_BASE_URL) -> str:
94
+ """Reject non-HTTPS (except localhost), numeric-shorthand IPv4 literals,
95
+ cloud-metadata hosts, and hosts resolving to private/link-local nets.
96
+ Logs only scheme://host — the raw URL may carry userinfo credentials."""
97
+ try:
98
+ parsed = urlparse(url)
99
+ except Exception:
100
+ parsed = None
101
+ host = (parsed.hostname or "").lower() if parsed else ""
102
+ safe = _safe_url_repr(parsed)
103
+
104
+ if parsed and host and _NUMERIC_ONLY_HOST_RE.match(host):
105
+ logger.warning("compresr: refusing numeric-shorthand IPv4 host %s; using %s", safe, default)
106
+ return default
107
+ if parsed and parsed.scheme in ("http", "https") and host in _LOCALHOST_NAMES:
108
+ return url
109
+ if parsed and _is_blocked_host(host):
110
+ logger.warning(
111
+ "compresr: refusing base_url %s (metadata/private host); using %s",
112
+ safe,
113
+ default,
114
+ )
115
+ return default
116
+ if parsed and parsed.scheme == "https":
117
+ return url
118
+ logger.warning(
119
+ "compresr: ignoring insecure base_url %s (must be https); using %s",
120
+ safe,
121
+ default,
122
+ )
123
+ return default
124
+
125
+
126
+ def resolve_base_url(raw: Optional[str]) -> Optional[str]:
127
+ """Validate a user-supplied base URL for the SDK client; None when unset.
128
+ A trailing /api is stripped — SDK endpoint paths already carry it."""
129
+ if raw is None or not str(raw).strip():
130
+ return None
131
+ url = secure_base_url(str(raw).strip().rstrip("/"))
132
+ if url.endswith("/api"):
133
+ url = url[: -len("/api")]
134
+ return url
135
+
136
+
137
+ __all__ = [
138
+ "DEFAULT_BASE_URL",
139
+ "sanitize_secret",
140
+ "secure_base_url",
141
+ "resolve_base_url",
142
+ ]
@@ -0,0 +1,273 @@
1
+ """Persist original tool outputs under ``HERMES_HOME/cache/compresr/tool-output``
2
+ so recovery references resolve. Paths are handed out only when the active
3
+ backend can prove it can read them; otherwise callers fail open.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ import shlex
12
+ import threading
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _CACHE_SUBDIR = Path("cache") / "compresr" / "tool-output"
21
+ # Canonical string form of the cache subdir. Exposed so out-of-tree callers
22
+ # (e.g. a host plugin shim) import one source of truth instead of duplicating
23
+ # the literal and risking drift.
24
+ CACHE_SUBPATH = _CACHE_SUBDIR.as_posix()
25
+ _CONTAINER_HERMES_HOME = "/root/.hermes"
26
+ _DIR_MODE = 0o700
27
+ _FILE_MODE = 0o600
28
+ _MB = 1024 * 1024
29
+
30
+ _STORE_LOCK = threading.Lock()
31
+
32
+ # Recently-written entries are pinned against eviction so a footer path just
33
+ # handed to the model survives a parallel prune.
34
+ _PRUNE_PIN_SECONDS = 300.0
35
+
36
+
37
+ def cache_relpath() -> str:
38
+ """Cache dir relative to HERMES_HOME, for host mount registration."""
39
+ return CACHE_SUBPATH
40
+
41
+
42
+ def get_cache_root() -> Path:
43
+ from hermes_constants import get_hermes_home
44
+
45
+ return Path(get_hermes_home()) / _CACHE_SUBDIR
46
+
47
+
48
+ def ensure_cache_root() -> Path:
49
+ root = get_cache_root()
50
+ root.mkdir(parents=True, exist_ok=True, mode=_DIR_MODE)
51
+ try:
52
+ os.chmod(root, _DIR_MODE)
53
+ except OSError:
54
+ pass
55
+ return root
56
+
57
+
58
+ def cache_file_path(cache_id: str) -> Path:
59
+ return get_cache_root() / cache_id
60
+
61
+
62
+ def _get_active_env(task_id: str) -> Any:
63
+ try:
64
+ from tools.terminal_tool import get_active_env as _get
65
+
66
+ return _get(task_id)
67
+ except Exception:
68
+ return None
69
+
70
+
71
+ def _container_file_visible(active_env: Any, container_path: str, host_path: Path) -> bool:
72
+ """Prove the running container can read *container_path*: Docker bind-mounts
73
+ are fixed at container creation, so a reused container may lack the mount
74
+ even when path translation succeeds."""
75
+ execute = getattr(active_env, "execute", None)
76
+ if not callable(execute):
77
+ return False
78
+ try:
79
+ expected = host_path.stat().st_size
80
+ except OSError:
81
+ return False
82
+ try:
83
+ res = execute(f"wc -c < {shlex.quote(str(container_path))}")
84
+ except Exception as e:
85
+ logger.debug("compresr: container visibility probe failed: %s", e)
86
+ return False
87
+ if not isinstance(res, dict) or res.get("returncode", 1) != 0:
88
+ return False
89
+ match = re.search(r"\d+", str(res.get("output", "")))
90
+ return match is not None and int(match.group()) == expected
91
+
92
+
93
+ def _agent_visible_cache_path(cache_path: Path, task_id: str) -> Optional[str]:
94
+ """Translate *cache_path* to a path the active backend can read, or None."""
95
+ active_env = _get_active_env(task_id)
96
+ probe_container = False
97
+
98
+ if active_env is not None:
99
+ env_name = active_env.__class__.__name__
100
+ if env_name == "LocalEnvironment":
101
+ try:
102
+ return str(cache_path.resolve())
103
+ except OSError:
104
+ return str(cache_path)
105
+ if env_name == "SingularityEnvironment" or "singularity" in env_name.lower():
106
+ return None
107
+
108
+ remote_home = getattr(active_env, "_remote_home", None)
109
+ if isinstance(remote_home, str) and remote_home.strip():
110
+ container_base = f"{remote_home.rstrip('/')}/.hermes"
111
+ elif env_name in {"DockerEnvironment", "ModalEnvironment"}:
112
+ container_base = _CONTAINER_HERMES_HOME
113
+ probe_container = env_name == "DockerEnvironment"
114
+ else:
115
+ return None
116
+ else:
117
+ backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() or "local"
118
+ if backend == "local":
119
+ try:
120
+ return str(cache_path.resolve())
121
+ except OSError:
122
+ return str(cache_path)
123
+ if backend in {"docker", "modal"}:
124
+ container_base = _CONTAINER_HERMES_HOME
125
+ else:
126
+ return None
127
+
128
+ try:
129
+ from tools.credential_files import map_cache_path_to_container
130
+
131
+ translated = map_cache_path_to_container(str(cache_path), container_base=container_base)
132
+ translated = str(translated) if translated else None
133
+ except Exception as e:
134
+ logger.debug("compresr: cache path mapping failed: %s", e)
135
+ return None
136
+
137
+ if (
138
+ translated
139
+ and probe_container
140
+ and not _container_file_visible(active_env, translated, cache_path)
141
+ ):
142
+ logger.warning(
143
+ "compresr: container cannot read cache path %s — failing open",
144
+ translated,
145
+ )
146
+ return None
147
+ return translated
148
+
149
+
150
+ def _force_sync_visible_cache(cache_path: Path, task_id: str) -> bool:
151
+ active_env = _get_active_env(task_id)
152
+ if active_env is None:
153
+ return True
154
+
155
+ env_name = active_env.__class__.__name__
156
+ if env_name == "LocalEnvironment":
157
+ return True
158
+ if env_name == "SingularityEnvironment" or "singularity" in env_name.lower():
159
+ return True
160
+
161
+ sync_manager = None
162
+ for attr in ("_sync_manager", "sync_manager", "_file_sync_manager"):
163
+ candidate = getattr(active_env, attr, None)
164
+ if candidate is not None and callable(getattr(candidate, "sync", None)):
165
+ sync_manager = candidate
166
+ break
167
+ if sync_manager is None:
168
+ return True
169
+
170
+ try:
171
+ try:
172
+ sync_manager.sync(force=True, raise_on_error=True)
173
+ except TypeError:
174
+ sync_manager.sync(force=True)
175
+ return True
176
+ except Exception as e:
177
+ logger.warning("compresr: force sync failed for %s: %s", cache_path, e)
178
+ return False
179
+
180
+
181
+ def _prune_cache_dir(cache_dir: str, max_bytes: int, keep_path: str) -> None:
182
+ if max_bytes <= 0:
183
+ return
184
+ root = Path(cache_dir)
185
+ keep = Path(keep_path)
186
+ now = time.time()
187
+ try:
188
+ entries = []
189
+ total = 0
190
+ for path in root.iterdir():
191
+ if not path.is_file():
192
+ continue
193
+ try:
194
+ stat = path.stat()
195
+ except OSError:
196
+ continue
197
+ size = int(stat.st_size)
198
+ total += size
199
+ entries.append((float(stat.st_mtime), path, size))
200
+ if total <= max_bytes:
201
+ return
202
+ for mtime, path, size in sorted(entries):
203
+ try:
204
+ if path.resolve() == keep.resolve():
205
+ continue
206
+ if now - mtime < _PRUNE_PIN_SECONDS:
207
+ continue
208
+ path.unlink()
209
+ total -= size
210
+ except OSError:
211
+ continue
212
+ if total <= max_bytes:
213
+ break
214
+ except Exception as e:
215
+ logger.debug("compresr: cache prune failed for %s: %s", cache_dir, e)
216
+
217
+
218
+ def store_original(
219
+ cache_id: str,
220
+ content: str,
221
+ task_id: str = "default",
222
+ max_cache_mb: int = 256,
223
+ ) -> Optional[str]:
224
+ """Persist ``content`` under ``cache_id``; return an agent-visible path or
225
+ None (caller must fail open)."""
226
+ root = ensure_cache_root()
227
+ cache_path = cache_file_path(cache_id)
228
+ # O_NOFOLLOW + os.replace: symlink-safe, and concurrent readers see either
229
+ # full old or full new bytes. Temp name is unique per call: identical
230
+ # content-addressed ids written from two threads must never share a path.
231
+ tmp_path = cache_path.with_name(
232
+ f".{cache_path.name}.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex}.tmp"
233
+ )
234
+ try:
235
+ fd = os.open(
236
+ str(tmp_path),
237
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
238
+ _FILE_MODE,
239
+ )
240
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
241
+ f.write(content)
242
+ try:
243
+ os.chmod(tmp_path, _FILE_MODE)
244
+ except OSError:
245
+ pass
246
+ os.replace(str(tmp_path), str(cache_path))
247
+ except Exception as e:
248
+ logger.warning("compresr: cache write failed for %s: %s", cache_path, e)
249
+ try:
250
+ tmp_path.unlink(missing_ok=True)
251
+ except OSError:
252
+ pass
253
+ return None
254
+
255
+ sync_ok = _force_sync_visible_cache(cache_path, task_id)
256
+ visible_path = _agent_visible_cache_path(cache_path, task_id) if sync_ok else None
257
+
258
+ # Prune even when the path isn't visible so non-visible backends can't grow
259
+ # the cache without bound. Never unlink on visibility failure: cache ids are
260
+ # content-addressed and a sibling may already hold this path.
261
+ try:
262
+ with _STORE_LOCK:
263
+ _prune_cache_dir(str(root), max(0, int(max_cache_mb)) * _MB, str(cache_path))
264
+ except Exception as e:
265
+ logger.debug("compresr: cache prune failed: %s", e)
266
+
267
+ if visible_path is None:
268
+ logger.warning(
269
+ "compresr: cache path not visible to the active backend: %s",
270
+ cache_path,
271
+ )
272
+ return None
273
+ return visible_path