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.
- {compresr-2.8.2 → compresr-2.9.0}/PKG-INFO +20 -12
- {compresr-2.8.2 → compresr-2.9.0}/compresr/config.py +1 -0
- compresr-2.9.0/compresr/integrations/hermes/__init__.py +35 -0
- compresr-2.9.0/compresr/integrations/hermes/_config.py +68 -0
- compresr-2.9.0/compresr/integrations/hermes/_security.py +142 -0
- compresr-2.9.0/compresr/integrations/hermes/cache.py +273 -0
- compresr-2.9.0/compresr/integrations/hermes/engine.py +304 -0
- compresr-2.9.0/compresr/integrations/hermes/plugin.py +107 -0
- compresr-2.9.0/compresr/integrations/hermes/recovery.py +146 -0
- compresr-2.9.0/compresr/integrations/hermes/tool_output.py +380 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/__init__.py +8 -0
- compresr-2.9.0/compresr/schemas/agentic.py +54 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/services/base.py +17 -13
- {compresr-2.8.2 → compresr-2.9.0}/compresr/services/compression.py +70 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/services/proxy.py +23 -3
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/PKG-INFO +20 -12
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/SOURCES.txt +9 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/entry_points.txt +3 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/requires.txt +17 -7
- {compresr-2.8.2 → compresr-2.9.0}/pyproject.toml +30 -21
- {compresr-2.8.2 → compresr-2.9.0}/LICENSE +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/README.md +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/__main__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/engine.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/anthropic.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/native.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/facades/openai.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/normalized.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/agent.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/facade.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/parser.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/prompts.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/research/types.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/anthropic.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/schemas/openai.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/_agentcore.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/agents/tools/web_search.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/auth.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/clients.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/credentials.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/exceptions/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/exceptions/exceptions.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/client.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/compress.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/errors.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/filters.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/kernel.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/policy.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/query.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/_shared/tokens.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/middleware.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/retriever.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langchain/wrappers.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/checkpoint.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/handoff.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/nodes.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/langgraph/store.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/cli.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/defaults.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/guardrail.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/initializer.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/shim_installer.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/litellm/types.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/memory.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/postprocessor.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/integrations/llamaindex/wrappers.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/retry.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/base.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/inference.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/schemas/usage.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr/services/__init__.py +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/dependency_links.txt +0 -0
- {compresr-2.8.2 → compresr-2.9.0}/compresr.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
28
|
-
Requires-Dist: langchain
|
|
29
|
-
Requires-Dist: langchain-
|
|
30
|
-
Requires-Dist: langchain-
|
|
31
|
-
Requires-Dist: langchain-
|
|
32
|
-
Requires-Dist: langchain-
|
|
33
|
-
Requires-Dist: langchain-
|
|
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
|
|
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
|
-
|
|
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
|