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 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()
@@ -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
+ )