cheapskate 0.1.0__py3-none-any.whl
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.
- cheapskate/__init__.py +22 -0
- cheapskate/callback.py +115 -0
- cheapskate/compressor.py +229 -0
- cheapskate/pruner.py +318 -0
- cheapskate/router.py +429 -0
- cheapskate/token_counter.py +189 -0
- cheapskate-0.1.0.dist-info/METADATA +207 -0
- cheapskate-0.1.0.dist-info/RECORD +11 -0
- cheapskate-0.1.0.dist-info/WHEEL +4 -0
- cheapskate-0.1.0.dist-info/entry_points.txt +3 -0
- cheapskate-0.1.0.dist-info/licenses/LICENSE +21 -0
cheapskate/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from cheapskate.callback import CheapSkateCallbackHandler
|
|
2
|
+
from cheapskate.compressor import PromptCompressor
|
|
3
|
+
from cheapskate.pruner import ToolNamespace, ToolPruner
|
|
4
|
+
from cheapskate.router import CheapSkateRouter
|
|
5
|
+
from cheapskate.token_counter import TokenCounter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CheapSkateRouter",
|
|
9
|
+
"CheapSkateCallbackHandler",
|
|
10
|
+
"TokenCounter",
|
|
11
|
+
"ToolPruner",
|
|
12
|
+
"ToolNamespace",
|
|
13
|
+
"PromptCompressor",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def package_info() -> str:
|
|
20
|
+
info = f"cheapskate {__version__}"
|
|
21
|
+
print(info)
|
|
22
|
+
return info
|
cheapskate/callback.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, Union
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
7
|
+
from langchain_core.messages import BaseMessage
|
|
8
|
+
from langchain_core.outputs import LLMResult
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from cheapskate.token_counter import TokenCounter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CheapSkateCallbackHandler(BaseCallbackHandler):
|
|
15
|
+
def __init__(self, token_counter: Optional[TokenCounter] = None) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.token_counter = token_counter or TokenCounter()
|
|
18
|
+
self.routing_events: List[Dict[str, Any]] = []
|
|
19
|
+
self.prune_events: List[Dict[str, Any]] = []
|
|
20
|
+
self.compression_events: List[Dict[str, Any]] = []
|
|
21
|
+
self.fallback_events: List[Dict[str, Any]] = []
|
|
22
|
+
self._pending_baseline: Dict[UUID, int] = {}
|
|
23
|
+
|
|
24
|
+
def on_chat_model_start(
|
|
25
|
+
self,
|
|
26
|
+
serialized: Dict[str, Any],
|
|
27
|
+
messages: List[List[BaseMessage]],
|
|
28
|
+
*,
|
|
29
|
+
run_id: UUID,
|
|
30
|
+
parent_run_id: Optional[UUID] = None,
|
|
31
|
+
tags: Optional[List[str]] = None,
|
|
32
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> None:
|
|
35
|
+
try:
|
|
36
|
+
flat_messages = [message for batch in messages for message in batch]
|
|
37
|
+
baseline = self.token_counter.record_baseline(flat_messages)
|
|
38
|
+
self._pending_baseline[run_id] = baseline
|
|
39
|
+
logger.debug("CheapSkate tracked chat start run_id={} baseline_tokens={}", run_id, baseline)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
logger.warning("CheapSkateCallbackHandler failed during chat start: {}", exc)
|
|
42
|
+
|
|
43
|
+
def on_llm_end(
|
|
44
|
+
self,
|
|
45
|
+
response: LLMResult,
|
|
46
|
+
*,
|
|
47
|
+
run_id: UUID,
|
|
48
|
+
parent_run_id: Optional[UUID] = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
try:
|
|
52
|
+
self._pending_baseline.pop(run_id, None)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning("CheapSkateCallbackHandler failed during llm end: {}", exc)
|
|
55
|
+
|
|
56
|
+
def on_llm_error(
|
|
57
|
+
self,
|
|
58
|
+
error: BaseException,
|
|
59
|
+
*,
|
|
60
|
+
run_id: UUID,
|
|
61
|
+
parent_run_id: Optional[UUID] = None,
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> None:
|
|
64
|
+
logger.error("CheapSkate observed LLM error for run_id={}: {}", run_id, error)
|
|
65
|
+
self._pending_baseline.pop(run_id, None)
|
|
66
|
+
|
|
67
|
+
def record_routing(self, destination: str, reason: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
68
|
+
event = {"destination": destination, "reason": reason, "metadata": metadata or {}}
|
|
69
|
+
self.routing_events.append(event)
|
|
70
|
+
logger.info("CheapSkate routed to {} because {}", destination, reason)
|
|
71
|
+
|
|
72
|
+
def record_prune(
|
|
73
|
+
self,
|
|
74
|
+
retained: List[str],
|
|
75
|
+
dropped: List[str],
|
|
76
|
+
namespaces: List[str],
|
|
77
|
+
) -> None:
|
|
78
|
+
event = {
|
|
79
|
+
"retained": list(retained),
|
|
80
|
+
"dropped": list(dropped),
|
|
81
|
+
"namespaces": list(namespaces),
|
|
82
|
+
}
|
|
83
|
+
self.prune_events.append(event)
|
|
84
|
+
|
|
85
|
+
def record_compression(self, before_tokens: int, after_tokens: int) -> None:
|
|
86
|
+
event = {
|
|
87
|
+
"before_tokens": before_tokens,
|
|
88
|
+
"after_tokens": after_tokens,
|
|
89
|
+
"tokens_saved": max(0, before_tokens - after_tokens),
|
|
90
|
+
}
|
|
91
|
+
self.compression_events.append(event)
|
|
92
|
+
|
|
93
|
+
def record_fallback(self, error: Union[str, BaseException], recovered: bool = True) -> None:
|
|
94
|
+
event = {"error": str(error), "recovered": recovered}
|
|
95
|
+
self.fallback_events.append(event)
|
|
96
|
+
logger.warning("CheapSkate fallback engaged: {}", error)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def summary(self) -> Dict[str, Any]:
|
|
100
|
+
return {
|
|
101
|
+
"token_stats": self.token_counter.stats,
|
|
102
|
+
"routing_events": len(self.routing_events),
|
|
103
|
+
"prune_events": len(self.prune_events),
|
|
104
|
+
"compression_events": len(self.compression_events),
|
|
105
|
+
"fallback_events": len(self.fallback_events),
|
|
106
|
+
"last_route": self.routing_events[-1] if self.routing_events else None,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def reset(self) -> None:
|
|
110
|
+
self.routing_events.clear()
|
|
111
|
+
self.prune_events.clear()
|
|
112
|
+
self.compression_events.clear()
|
|
113
|
+
self.fallback_events.clear()
|
|
114
|
+
self._pending_baseline.clear()
|
|
115
|
+
self.token_counter.reset()
|
cheapskate/compressor.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from cheapskate.token_counter import TokenCounter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PromptCompressor:
|
|
15
|
+
BOILERPLATE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|
16
|
+
re.compile(r"\bplease\s+(note|be\s+advised|remember|ensure)\b[:\s]*", re.IGNORECASE),
|
|
17
|
+
re.compile(r"\bas\s+(an?\s+)?(ai|assistant|language\s+model)\b[,:\s]*", re.IGNORECASE),
|
|
18
|
+
re.compile(r"\bi\s+(hope\s+this\s+helps|understand\s+your\s+request)\b[!.\s]*", re.IGNORECASE),
|
|
19
|
+
re.compile(r"\b(just\s+to\s+clarify|to\s+be\s+clear|for\s+your\s+information)\b[,:\s]*", re.IGNORECASE),
|
|
20
|
+
re.compile(r"\b(kindly|basically|literally|actually|obviously|certainly)\b\s+", re.IGNORECASE),
|
|
21
|
+
re.compile(r"\b(in\s+order\s+to)\b", re.IGNORECASE),
|
|
22
|
+
re.compile(r"\b(it\s+is\s+important\s+to\s+note\s+that)\b\s*", re.IGNORECASE),
|
|
23
|
+
re.compile(r"\b(at\s+this\s+point\s+in\s+time)\b", re.IGNORECASE),
|
|
24
|
+
re.compile(r"[ \t]{2,}"),
|
|
25
|
+
re.compile(r"\n{3,}"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FILLER_REPLACEMENTS: Tuple[Tuple[re.Pattern[str], str], ...] = (
|
|
29
|
+
(re.compile(r"\bin\s+order\s+to\b", re.IGNORECASE), "to"),
|
|
30
|
+
(re.compile(r"\bdue\s+to\s+the\s+fact\s+that\b", re.IGNORECASE), "because"),
|
|
31
|
+
(re.compile(r"\ba\s+large\s+number\s+of\b", re.IGNORECASE), "many"),
|
|
32
|
+
(re.compile(r"\bat\s+the\s+present\s+time\b", re.IGNORECASE), "now"),
|
|
33
|
+
(re.compile(r"\bin\s+the\s+event\s+that\b", re.IGNORECASE), "if"),
|
|
34
|
+
(re.compile(r"\bwith\s+regard\s+to\b", re.IGNORECASE), "about"),
|
|
35
|
+
(re.compile(r"\bprior\s+to\b", re.IGNORECASE), "before"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
JSON_BLOCK_PATTERN = re.compile(r"(\{[\s\S]*?\}|\[[\s\S]*?\])")
|
|
39
|
+
WHITESPACE_COLLAPSE = re.compile(r"[ \t]+")
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
token_counter: Optional[TokenCounter] = None,
|
|
44
|
+
target_reduction_ratio: float = 0.35,
|
|
45
|
+
min_message_tokens: int = 24,
|
|
46
|
+
preserve_system_messages: bool = True,
|
|
47
|
+
preserve_recent_turns: int = 2,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._token_counter = token_counter or TokenCounter()
|
|
50
|
+
self._target_reduction_ratio = min(0.85, max(0.0, target_reduction_ratio))
|
|
51
|
+
self._min_message_tokens = max(1, min_message_tokens)
|
|
52
|
+
self._preserve_system_messages = preserve_system_messages
|
|
53
|
+
self._preserve_recent_turns = max(0, preserve_recent_turns)
|
|
54
|
+
|
|
55
|
+
def _shallow_copy_messages(self, messages: Sequence[BaseMessage]) -> List[BaseMessage]:
|
|
56
|
+
duplicated: List[BaseMessage] = []
|
|
57
|
+
for message in messages:
|
|
58
|
+
try:
|
|
59
|
+
duplicated.append(message.model_copy(deep=False))
|
|
60
|
+
except Exception:
|
|
61
|
+
duplicated.append(copy.copy(message))
|
|
62
|
+
return duplicated
|
|
63
|
+
|
|
64
|
+
def _protect_json_segments(self, text: str) -> Tuple[str, Dict[str, str]]:
|
|
65
|
+
placeholders: Dict[str, str] = {}
|
|
66
|
+
counter = 0
|
|
67
|
+
|
|
68
|
+
def _replacer(match: re.Match[str]) -> str:
|
|
69
|
+
nonlocal counter
|
|
70
|
+
candidate = match.group(0)
|
|
71
|
+
try:
|
|
72
|
+
json.loads(candidate)
|
|
73
|
+
except (json.JSONDecodeError, ValueError):
|
|
74
|
+
return candidate
|
|
75
|
+
token = f"__CHEAPSKATE_JSON_{counter}__"
|
|
76
|
+
placeholders[token] = candidate
|
|
77
|
+
counter += 1
|
|
78
|
+
return token
|
|
79
|
+
|
|
80
|
+
protected = self.JSON_BLOCK_PATTERN.sub(_replacer, text)
|
|
81
|
+
return protected, placeholders
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _restore_json_segments(text: str, placeholders: Dict[str, str]) -> str:
|
|
85
|
+
restored = text
|
|
86
|
+
for token, original in placeholders.items():
|
|
87
|
+
restored = restored.replace(token, original)
|
|
88
|
+
return restored
|
|
89
|
+
|
|
90
|
+
def _scrub_text(self, text: str) -> str:
|
|
91
|
+
if not text or not text.strip():
|
|
92
|
+
return text
|
|
93
|
+
protected, placeholders = self._protect_json_segments(text)
|
|
94
|
+
scrubbed = protected
|
|
95
|
+
for pattern, replacement in self.FILLER_REPLACEMENTS:
|
|
96
|
+
scrubbed = pattern.sub(replacement, scrubbed)
|
|
97
|
+
for pattern in self.BOILERPLATE_PATTERNS:
|
|
98
|
+
if pattern.pattern.startswith("[") or pattern.pattern.startswith("\\n"):
|
|
99
|
+
scrubbed = pattern.sub("\n\n" if "\\n" in pattern.pattern else " ", scrubbed)
|
|
100
|
+
else:
|
|
101
|
+
scrubbed = pattern.sub("", scrubbed)
|
|
102
|
+
scrubbed = self.WHITESPACE_COLLAPSE.sub(" ", scrubbed)
|
|
103
|
+
scrubbed = re.sub(r"\n{3,}", "\n\n", scrubbed)
|
|
104
|
+
scrubbed = scrubbed.strip()
|
|
105
|
+
return self._restore_json_segments(scrubbed, placeholders)
|
|
106
|
+
|
|
107
|
+
def _token_density(self, text: str) -> float:
|
|
108
|
+
tokens = self._token_counter.count_text(text)
|
|
109
|
+
if tokens <= 0:
|
|
110
|
+
return 0.0
|
|
111
|
+
unique_terms = len({term.lower() for term in re.findall(r"[A-Za-z0-9_]+", text)})
|
|
112
|
+
return unique_terms / float(tokens)
|
|
113
|
+
|
|
114
|
+
def _truncate_by_density(self, text: str, budget_tokens: int) -> str:
|
|
115
|
+
if budget_tokens <= 0:
|
|
116
|
+
return text
|
|
117
|
+
current_tokens = self._token_counter.count_text(text)
|
|
118
|
+
if current_tokens <= budget_tokens:
|
|
119
|
+
return text
|
|
120
|
+
sentences = re.split(r"(?<=[.!?])\s+", text)
|
|
121
|
+
if len(sentences) <= 1:
|
|
122
|
+
words = text.split()
|
|
123
|
+
while words and self._token_counter.count_text(" ".join(words)) > budget_tokens:
|
|
124
|
+
words.pop()
|
|
125
|
+
return " ".join(words).strip()
|
|
126
|
+
ranked = sorted(
|
|
127
|
+
((self._token_density(sentence), index, sentence) for index, sentence in enumerate(sentences)),
|
|
128
|
+
key=lambda item: item[0],
|
|
129
|
+
reverse=True,
|
|
130
|
+
)
|
|
131
|
+
selected_indices = set()
|
|
132
|
+
running = 0
|
|
133
|
+
for _, index, sentence in ranked:
|
|
134
|
+
sentence_tokens = self._token_counter.count_text(sentence)
|
|
135
|
+
if running + sentence_tokens > budget_tokens and selected_indices:
|
|
136
|
+
continue
|
|
137
|
+
selected_indices.add(index)
|
|
138
|
+
running += sentence_tokens
|
|
139
|
+
if running >= budget_tokens:
|
|
140
|
+
break
|
|
141
|
+
ordered = [sentences[index] for index in sorted(selected_indices)]
|
|
142
|
+
return " ".join(ordered).strip()
|
|
143
|
+
|
|
144
|
+
def _should_preserve(self, message: BaseMessage, absolute_index: int, total: int) -> bool:
|
|
145
|
+
if self._preserve_system_messages and isinstance(message, SystemMessage):
|
|
146
|
+
return True
|
|
147
|
+
if absolute_index >= max(0, total - self._preserve_recent_turns):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _set_content(self, message: BaseMessage, content: str) -> BaseMessage:
|
|
152
|
+
try:
|
|
153
|
+
message.content = content
|
|
154
|
+
return message
|
|
155
|
+
except Exception:
|
|
156
|
+
if isinstance(message, SystemMessage):
|
|
157
|
+
return SystemMessage(content=content)
|
|
158
|
+
if isinstance(message, HumanMessage):
|
|
159
|
+
return HumanMessage(content=content)
|
|
160
|
+
if isinstance(message, AIMessage):
|
|
161
|
+
return AIMessage(content=content)
|
|
162
|
+
if isinstance(message, ToolMessage):
|
|
163
|
+
return ToolMessage(
|
|
164
|
+
content=content,
|
|
165
|
+
tool_call_id=getattr(message, "tool_call_id", "unknown"),
|
|
166
|
+
name=getattr(message, "name", None),
|
|
167
|
+
)
|
|
168
|
+
return message
|
|
169
|
+
|
|
170
|
+
def compress(self, messages: Sequence[BaseMessage]) -> List[BaseMessage]:
|
|
171
|
+
if not messages:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
working_copy = self._shallow_copy_messages(messages)
|
|
175
|
+
baseline_tokens = self._token_counter.count_messages(working_copy)
|
|
176
|
+
target_tokens = int(baseline_tokens * (1.0 - self._target_reduction_ratio))
|
|
177
|
+
target_tokens = max(target_tokens, self._min_message_tokens)
|
|
178
|
+
|
|
179
|
+
compressed: List[BaseMessage] = []
|
|
180
|
+
total = len(working_copy)
|
|
181
|
+
for index, message in enumerate(working_copy):
|
|
182
|
+
raw_content = message.content
|
|
183
|
+
if isinstance(raw_content, list):
|
|
184
|
+
text_content = "\n".join(
|
|
185
|
+
str(block.get("text", block) if isinstance(block, dict) else block)
|
|
186
|
+
for block in raw_content
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
text_content = str(raw_content or "")
|
|
190
|
+
|
|
191
|
+
if self._should_preserve(message, index, total):
|
|
192
|
+
scrubbed = self._scrub_text(text_content) if text_content else text_content
|
|
193
|
+
compressed.append(self._set_content(message, scrubbed))
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
scrubbed = self._scrub_text(text_content)
|
|
197
|
+
message_tokens = self._token_counter.count_text(scrubbed)
|
|
198
|
+
if message_tokens > self._min_message_tokens * 2:
|
|
199
|
+
density = self._token_density(scrubbed)
|
|
200
|
+
budget = max(
|
|
201
|
+
self._min_message_tokens,
|
|
202
|
+
int(message_tokens * max(0.45, min(0.95, density + 0.25))),
|
|
203
|
+
)
|
|
204
|
+
scrubbed = self._truncate_by_density(scrubbed, budget)
|
|
205
|
+
compressed.append(self._set_content(message, scrubbed))
|
|
206
|
+
|
|
207
|
+
after_tokens = self._token_counter.count_messages(compressed)
|
|
208
|
+
if after_tokens > target_tokens and len(compressed) > 2:
|
|
209
|
+
overflow = after_tokens - target_tokens
|
|
210
|
+
for index in range(len(compressed) - self._preserve_recent_turns - 1, -1, -1):
|
|
211
|
+
if overflow <= 0:
|
|
212
|
+
break
|
|
213
|
+
message = compressed[index]
|
|
214
|
+
if isinstance(message, SystemMessage):
|
|
215
|
+
continue
|
|
216
|
+
content = str(message.content or "")
|
|
217
|
+
current = self._token_counter.count_text(content)
|
|
218
|
+
reduced_budget = max(self._min_message_tokens, current - overflow)
|
|
219
|
+
truncated = self._truncate_by_density(content, reduced_budget)
|
|
220
|
+
compressed[index] = self._set_content(message, truncated)
|
|
221
|
+
new_count = self._token_counter.count_text(truncated)
|
|
222
|
+
overflow -= max(0, current - new_count)
|
|
223
|
+
|
|
224
|
+
logger.debug(
|
|
225
|
+
"Prompt compression reduced tokens from {} to {}",
|
|
226
|
+
baseline_tokens,
|
|
227
|
+
self._token_counter.count_messages(compressed),
|
|
228
|
+
)
|
|
229
|
+
return compressed
|
cheapskate/pruner.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ToolLike = Any
|
|
15
|
+
NamespaceClassifier = Callable[[str, Sequence[str]], Sequence[str]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ToolNamespace:
|
|
20
|
+
name: str
|
|
21
|
+
tools: Tuple[str, ...]
|
|
22
|
+
description: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PruneResult:
|
|
27
|
+
retained_tools: List[ToolLike]
|
|
28
|
+
retained_names: List[str]
|
|
29
|
+
dropped_names: List[str]
|
|
30
|
+
selected_namespaces: List[str]
|
|
31
|
+
similarity_scores: Dict[str, float] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolPruner:
|
|
35
|
+
DEFAULT_EMBEDDING_DIM = 384
|
|
36
|
+
DEFAULT_TOP_K = 8
|
|
37
|
+
DEFAULT_SIMILARITY_FLOOR = 0.12
|
|
38
|
+
TOKEN_PATTERN = re.compile(r"[a-z0-9_]+", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
namespaces: Optional[Sequence[ToolNamespace]] = None,
|
|
43
|
+
always_keep: Optional[Sequence[str]] = None,
|
|
44
|
+
top_k: int = DEFAULT_TOP_K,
|
|
45
|
+
similarity_floor: float = DEFAULT_SIMILARITY_FLOOR,
|
|
46
|
+
embedding_dim: int = DEFAULT_EMBEDDING_DIM,
|
|
47
|
+
namespace_classifier: Optional[NamespaceClassifier] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._namespaces: List[ToolNamespace] = list(namespaces or [])
|
|
50
|
+
self._always_keep: Set[str] = {name.strip() for name in (always_keep or []) if name}
|
|
51
|
+
self._top_k = max(1, top_k)
|
|
52
|
+
self._similarity_floor = max(0.0, min(1.0, similarity_floor))
|
|
53
|
+
self._embedding_dim = max(64, embedding_dim)
|
|
54
|
+
self._namespace_classifier = namespace_classifier
|
|
55
|
+
self._namespace_lookup: Dict[str, ToolNamespace] = {
|
|
56
|
+
namespace.name: namespace for namespace in self._namespaces
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def register_namespace(self, namespace: ToolNamespace) -> None:
|
|
60
|
+
self._namespaces.append(namespace)
|
|
61
|
+
self._namespace_lookup[namespace.name] = namespace
|
|
62
|
+
|
|
63
|
+
def set_always_keep(self, tool_names: Sequence[str]) -> None:
|
|
64
|
+
self._always_keep = {name.strip() for name in tool_names if name}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def always_keep(self) -> Set[str]:
|
|
68
|
+
return set(self._always_keep)
|
|
69
|
+
|
|
70
|
+
def _tool_name(self, tool: ToolLike) -> str:
|
|
71
|
+
if isinstance(tool, dict):
|
|
72
|
+
if "function" in tool and isinstance(tool["function"], dict):
|
|
73
|
+
return str(tool["function"].get("name", "")).strip()
|
|
74
|
+
return str(tool.get("name", "")).strip()
|
|
75
|
+
name = getattr(tool, "name", None)
|
|
76
|
+
if name:
|
|
77
|
+
return str(name).strip()
|
|
78
|
+
return str(tool).strip()
|
|
79
|
+
|
|
80
|
+
def _tool_description(self, tool: ToolLike) -> str:
|
|
81
|
+
if isinstance(tool, dict):
|
|
82
|
+
if "function" in tool and isinstance(tool["function"], dict):
|
|
83
|
+
function_block = tool["function"]
|
|
84
|
+
return " ".join(
|
|
85
|
+
[
|
|
86
|
+
str(function_block.get("name", "")),
|
|
87
|
+
str(function_block.get("description", "")),
|
|
88
|
+
str(function_block.get("parameters", "")),
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
return " ".join(
|
|
92
|
+
[
|
|
93
|
+
str(tool.get("name", "")),
|
|
94
|
+
str(tool.get("description", "")),
|
|
95
|
+
str(tool.get("parameters", tool.get("args_schema", ""))),
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
pieces = [
|
|
99
|
+
str(getattr(tool, "name", "") or ""),
|
|
100
|
+
str(getattr(tool, "description", "") or ""),
|
|
101
|
+
]
|
|
102
|
+
args_schema = getattr(tool, "args_schema", None)
|
|
103
|
+
if args_schema is not None:
|
|
104
|
+
schema_method = getattr(args_schema, "schema", None)
|
|
105
|
+
if callable(schema_method):
|
|
106
|
+
pieces.append(str(schema_method()))
|
|
107
|
+
else:
|
|
108
|
+
pieces.append(str(args_schema))
|
|
109
|
+
return " ".join(piece for piece in pieces if piece)
|
|
110
|
+
|
|
111
|
+
def _tokenize(self, text: str) -> List[str]:
|
|
112
|
+
return [token.lower() for token in self.TOKEN_PATTERN.findall(text or "") if token]
|
|
113
|
+
|
|
114
|
+
def _embed_text(self, text: str) -> np.ndarray:
|
|
115
|
+
vector = np.zeros(self._embedding_dim, dtype=np.float64)
|
|
116
|
+
tokens = self._tokenize(text)
|
|
117
|
+
if not tokens:
|
|
118
|
+
return vector
|
|
119
|
+
for index, token in enumerate(tokens):
|
|
120
|
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
121
|
+
bucket = int.from_bytes(digest[:4], byteorder="big") % self._embedding_dim
|
|
122
|
+
sign = 1.0 if digest[4] % 2 == 0 else -1.0
|
|
123
|
+
weight = 1.0 + (1.0 / (1.0 + index))
|
|
124
|
+
vector[bucket] += sign * weight
|
|
125
|
+
if len(token) >= 4:
|
|
126
|
+
bigram = token[:4]
|
|
127
|
+
bigram_digest = hashlib.md5(bigram.encode("utf-8")).digest()
|
|
128
|
+
bigram_bucket = int.from_bytes(bigram_digest[:4], byteorder="big") % self._embedding_dim
|
|
129
|
+
vector[bigram_bucket] += 0.35 * sign
|
|
130
|
+
norm = np.linalg.norm(vector)
|
|
131
|
+
if norm > 0:
|
|
132
|
+
vector /= norm
|
|
133
|
+
return vector
|
|
134
|
+
|
|
135
|
+
def _message_corpus(self, messages: Sequence[BaseMessage]) -> str:
|
|
136
|
+
fragments: List[str] = []
|
|
137
|
+
for message in messages:
|
|
138
|
+
content = getattr(message, "content", "")
|
|
139
|
+
if isinstance(content, list):
|
|
140
|
+
content = " ".join(
|
|
141
|
+
str(block.get("text", block) if isinstance(block, dict) else block)
|
|
142
|
+
for block in content
|
|
143
|
+
)
|
|
144
|
+
fragments.append(str(content or ""))
|
|
145
|
+
if isinstance(message, AIMessage) and getattr(message, "tool_calls", None):
|
|
146
|
+
for tool_call in message.tool_calls or []:
|
|
147
|
+
fragments.append(str(tool_call.get("name", "")))
|
|
148
|
+
fragments.append(str(tool_call.get("args", "")))
|
|
149
|
+
if isinstance(message, ToolMessage):
|
|
150
|
+
fragments.append(str(getattr(message, "name", "") or ""))
|
|
151
|
+
fragments.append(str(getattr(message, "tool_call_id", "") or ""))
|
|
152
|
+
if isinstance(message, HumanMessage):
|
|
153
|
+
fragments.append(str(content or ""))
|
|
154
|
+
return "\n".join(fragment for fragment in fragments if fragment)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _cosine_similarity_matrix(query_vector: np.ndarray, matrix: np.ndarray) -> np.ndarray:
|
|
158
|
+
if matrix.size == 0:
|
|
159
|
+
return np.array([], dtype=np.float64)
|
|
160
|
+
query_norm = np.linalg.norm(query_vector)
|
|
161
|
+
if query_norm == 0:
|
|
162
|
+
return np.zeros(matrix.shape[0], dtype=np.float64)
|
|
163
|
+
matrix_norms = np.linalg.norm(matrix, axis=1)
|
|
164
|
+
safe_norms = np.where(matrix_norms == 0, 1.0, matrix_norms)
|
|
165
|
+
scores = (matrix @ query_vector) / (safe_norms * query_norm)
|
|
166
|
+
return np.nan_to_num(scores, nan=0.0, posinf=0.0, neginf=0.0)
|
|
167
|
+
|
|
168
|
+
def _classify_namespaces(self, query_text: str) -> List[str]:
|
|
169
|
+
if not self._namespaces:
|
|
170
|
+
return []
|
|
171
|
+
namespace_names = [namespace.name for namespace in self._namespaces]
|
|
172
|
+
if self._namespace_classifier is not None:
|
|
173
|
+
try:
|
|
174
|
+
classified = list(self._namespace_classifier(query_text, namespace_names))
|
|
175
|
+
valid = [name for name in classified if name in self._namespace_lookup]
|
|
176
|
+
if valid:
|
|
177
|
+
return valid
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("Namespace classifier failed; using embedding fallback: {}", exc)
|
|
180
|
+
namespace_matrix = np.vstack(
|
|
181
|
+
[
|
|
182
|
+
self._embed_text(f"{namespace.name} {namespace.description} {' '.join(namespace.tools)}")
|
|
183
|
+
for namespace in self._namespaces
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
query_vector = self._embed_text(query_text)
|
|
187
|
+
scores = self._cosine_similarity_matrix(query_vector, namespace_matrix)
|
|
188
|
+
ranked_indices = np.argsort(scores)[::-1]
|
|
189
|
+
selected: List[str] = []
|
|
190
|
+
for index in ranked_indices:
|
|
191
|
+
if float(scores[index]) < self._similarity_floor and selected:
|
|
192
|
+
break
|
|
193
|
+
selected.append(self._namespaces[int(index)].name)
|
|
194
|
+
if len(selected) >= max(1, math.ceil(len(self._namespaces) * 0.5)):
|
|
195
|
+
break
|
|
196
|
+
return selected or [self._namespaces[int(ranked_indices[0])].name]
|
|
197
|
+
|
|
198
|
+
def _namespace_tool_allowlist(self, selected_namespaces: Sequence[str]) -> Optional[Set[str]]:
|
|
199
|
+
if not selected_namespaces or not self._namespaces:
|
|
200
|
+
return None
|
|
201
|
+
allowlist: Set[str] = set()
|
|
202
|
+
for namespace_name in selected_namespaces:
|
|
203
|
+
namespace = self._namespace_lookup.get(namespace_name)
|
|
204
|
+
if namespace is None:
|
|
205
|
+
continue
|
|
206
|
+
allowlist.update(namespace.tools)
|
|
207
|
+
return allowlist or None
|
|
208
|
+
|
|
209
|
+
def prune(
|
|
210
|
+
self,
|
|
211
|
+
tools: Sequence[ToolLike],
|
|
212
|
+
messages: Sequence[BaseMessage],
|
|
213
|
+
always_keep: Optional[Sequence[str]] = None,
|
|
214
|
+
top_k: Optional[int] = None,
|
|
215
|
+
) -> PruneResult:
|
|
216
|
+
if not tools:
|
|
217
|
+
return PruneResult(
|
|
218
|
+
retained_tools=[],
|
|
219
|
+
retained_names=[],
|
|
220
|
+
dropped_names=[],
|
|
221
|
+
selected_namespaces=[],
|
|
222
|
+
similarity_scores={},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
mandatory_names = set(self._always_keep)
|
|
226
|
+
if always_keep:
|
|
227
|
+
mandatory_names.update(name.strip() for name in always_keep if name)
|
|
228
|
+
|
|
229
|
+
query_text = self._message_corpus(messages)
|
|
230
|
+
selected_namespaces = self._classify_namespaces(query_text)
|
|
231
|
+
namespace_allowlist = self._namespace_tool_allowlist(selected_namespaces)
|
|
232
|
+
|
|
233
|
+
tool_entries: List[Tuple[ToolLike, str, str]] = []
|
|
234
|
+
for tool in tools:
|
|
235
|
+
name = self._tool_name(tool)
|
|
236
|
+
description = self._tool_description(tool)
|
|
237
|
+
tool_entries.append((tool, name, description))
|
|
238
|
+
|
|
239
|
+
if namespace_allowlist is not None:
|
|
240
|
+
candidate_entries = [
|
|
241
|
+
entry
|
|
242
|
+
for entry in tool_entries
|
|
243
|
+
if entry[1] in mandatory_names or entry[1] in namespace_allowlist
|
|
244
|
+
]
|
|
245
|
+
if not candidate_entries:
|
|
246
|
+
candidate_entries = list(tool_entries)
|
|
247
|
+
else:
|
|
248
|
+
candidate_entries = list(tool_entries)
|
|
249
|
+
|
|
250
|
+
query_vector = self._embed_text(query_text)
|
|
251
|
+
description_matrix = np.vstack(
|
|
252
|
+
[self._embed_text(f"{name} {description}") for _, name, description in candidate_entries]
|
|
253
|
+
)
|
|
254
|
+
similarity_scores = self._cosine_similarity_matrix(query_vector, description_matrix)
|
|
255
|
+
|
|
256
|
+
scored_candidates: List[Tuple[float, ToolLike, str]] = []
|
|
257
|
+
for index, (tool, name, _) in enumerate(candidate_entries):
|
|
258
|
+
score = float(similarity_scores[index])
|
|
259
|
+
if name in mandatory_names:
|
|
260
|
+
score = max(score, 1.0)
|
|
261
|
+
scored_candidates.append((score, tool, name))
|
|
262
|
+
|
|
263
|
+
scored_candidates.sort(key=lambda item: item[0], reverse=True)
|
|
264
|
+
limit = top_k if top_k is not None else self._top_k
|
|
265
|
+
retained: List[ToolLike] = []
|
|
266
|
+
retained_names: List[str] = []
|
|
267
|
+
score_map: Dict[str, float] = {}
|
|
268
|
+
seen_names: Set[str] = set()
|
|
269
|
+
|
|
270
|
+
for score, tool, name in scored_candidates:
|
|
271
|
+
if name in mandatory_names:
|
|
272
|
+
if name not in seen_names:
|
|
273
|
+
retained.append(tool)
|
|
274
|
+
retained_names.append(name)
|
|
275
|
+
score_map[name] = score
|
|
276
|
+
seen_names.add(name)
|
|
277
|
+
continue
|
|
278
|
+
if len([item for item in retained_names if item not in mandatory_names]) >= limit:
|
|
279
|
+
continue
|
|
280
|
+
if score < self._similarity_floor and name not in mandatory_names:
|
|
281
|
+
continue
|
|
282
|
+
if name not in seen_names:
|
|
283
|
+
retained.append(tool)
|
|
284
|
+
retained_names.append(name)
|
|
285
|
+
score_map[name] = score
|
|
286
|
+
seen_names.add(name)
|
|
287
|
+
|
|
288
|
+
for tool, name, _ in tool_entries:
|
|
289
|
+
if name in mandatory_names and name not in seen_names:
|
|
290
|
+
retained.append(tool)
|
|
291
|
+
retained_names.append(name)
|
|
292
|
+
score_map[name] = 1.0
|
|
293
|
+
seen_names.add(name)
|
|
294
|
+
|
|
295
|
+
if not retained:
|
|
296
|
+
fallback_slice = tool_entries[:limit]
|
|
297
|
+
retained = [tool for tool, _, _ in fallback_slice]
|
|
298
|
+
retained_names = [name for _, name, _ in fallback_slice]
|
|
299
|
+
score_map = {name: 0.0 for name in retained_names}
|
|
300
|
+
|
|
301
|
+
dropped_names = [
|
|
302
|
+
name for _, name, _ in tool_entries if name and name not in set(retained_names)
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
logger.info(
|
|
306
|
+
"Tool pruning retained {}/{} tools across namespaces {}",
|
|
307
|
+
len(retained_names),
|
|
308
|
+
len(tool_entries),
|
|
309
|
+
selected_namespaces,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return PruneResult(
|
|
313
|
+
retained_tools=retained,
|
|
314
|
+
retained_names=retained_names,
|
|
315
|
+
dropped_names=dropped_names,
|
|
316
|
+
selected_namespaces=list(selected_namespaces),
|
|
317
|
+
similarity_scores=score_map,
|
|
318
|
+
)
|