-
Notifications
You must be signed in to change notification settings - Fork 0
feat: memory enhancements phase 1 #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
45695ca
b72085f
538c61b
439ca4e
8782761
996f9c9
87acc13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from shared.constants import DB_NAME | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hashlib | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Any, Optional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -24,11 +25,80 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| StatsResult, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from mcp_server.registry import _get_ctx, register_tool | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from mcp_server.utils.privacy import strip_secrets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from shared.metrics import metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class _DedupCache: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _doc_ = "SHA-256 dedup with TTL and periodic cleanup." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def __init__(self, ttl=300, max_size=10000): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cache = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._ttl = ttl | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._max_size = max_size | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._last_cleanup = time.time() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _cleanup(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| now = time.time() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if now - self._last_cleanup < 60: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._last_cleanup = now | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expired = [k for k, v in self._cache.items() if now - v > self._ttl] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for k in expired: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| del self._cache[k] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(self._cache) > self._max_size: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| oldest = sorted(self._cache.keys(), key=lambda k: self._cache[k])[: len(self._cache) // 4] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for k in oldest: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| del self._cache[k] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def is_duplicate(self, session_id, tool, input_text): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key = hashlib.sha256(f"{session_id}:{tool}:{input_text[:500]}".encode()).hexdigest() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| now = time.time() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if key in self._cache and now - self._cache[key] < self._ttl: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._cache[key] = now | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _dedup_cache = _DedupCache(ttl=300, max_size=10000) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Token budget configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_TOKEN_BUDGET = 2000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CHARS_PER_TOKEN = 4 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _estimate_tokens(text: str) -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not text: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cjk_count = len(re.findall(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]", text)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| remaining_chars = len(text) - cjk_count | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| non_cjk_tokens = remaining_chars // CHARS_PER_TOKEN | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cjk_count + non_cjk_tokens | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _truncate_to_budget(text: str, max_tokens: int) -> tuple: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| estimated = _estimate_tokens(text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if estimated <= max_tokens: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return text, False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| char_limit = max_tokens * CHARS_PER_TOKEN | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lines = text.split("\\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result_lines = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| current_len = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for line in lines: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| line_len = len(line) + 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if current_len + line_len > char_limit: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result_lines.append(line) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| current_len += line_len | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| truncated = "\\n".join(result_lines) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| truncated += "\\n[...truncated to token budget]" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return truncated, True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Make truncation enforce the token budget. The current path converts 2000 tokens to 8000 chars, then appends a suffix. CJK-heavy context can still exceed 2000 estimated tokens, and exact-fit ASCII can exceed after the suffix is added. 🐛 Suggested budget-aware truncation def _estimate_tokens(text: str) -> int:
@@
- non_cjk_tokens = remaining_chars // CHARS_PER_TOKEN
+ non_cjk_tokens = (remaining_chars + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN
return cjk_count + non_cjk_tokens
def _truncate_to_budget(text: str, max_tokens: int) -> tuple:
estimated = _estimate_tokens(text)
if estimated <= max_tokens:
return text, False
- char_limit = max_tokens * CHARS_PER_TOKEN
- lines = text.split('\\n')
- result_lines = []
- current_len = 0
- for line in lines:
- line_len = len(line) + 1
- if current_len + line_len > char_limit:
- break
- result_lines.append(line)
- current_len += line_len
- truncated = '\\n'.join(result_lines)
- truncated += '\\n[...truncated to token budget]'
+ suffix = "\n[...truncated to token budget]"
+ content_budget = max(0, max_tokens - _estimate_tokens(suffix))
+ lo, hi = 0, len(text)
+ while lo < hi:
+ mid = (lo + hi + 1) // 2
+ if _estimate_tokens(text[:mid]) <= content_budget:
+ lo = mid
+ else:
+ hi = mid - 1
+ truncated = text[:lo].rstrip() + suffix
return truncated, True📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: CI / 0_lint.txt[error] 76-76: F821 Undefined name 🪛 GitHub Actions: CI / 7_typecheck.txt[error] 76-76: mypy: Name "re" is not defined [name-defined] 🪛 GitHub Actions: CI / lint[error] 76-76: ruff F821: Undefined name 🪛 GitHub Actions: CI / typecheck[error] 76-76: mypy: Name "re" is not defined [name-defined] 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _get_memory(app, layer: str, user_id: str): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if layer == "agent": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return app.mm.agent_memory(user_id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -135,6 +205,7 @@ async def memory_remember( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: str = "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value: str = "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importance: float = 0.5, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| session_id: str = "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx: Optional[Context] = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> dict: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Save a fact to long-term memory (L4 CoreMemory). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -145,7 +216,14 @@ async def memory_remember( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: Fact key (e.g. "name", "language", "principle") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value: Fact value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importance: Importance score 0.0-1.0 (default 0.5) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| session_id: Session ID for dedup (optional) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # SHA-256 dedup: skip identical calls within 5min window | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value = strip_secrets(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if session_id and _dedup_cache.is_duplicate(session_id, key, value): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("Dedup: skipping identical remember key=%s user=%s", key, user_id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return RememberResult(status="skipped", reason="duplicate_within_ttl").dict() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app = _get_ctx(ctx) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| layer = _validate_layer(layer) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metrics.inc("tool_calls") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
216
to
229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: mcp_server/tools_layer.py
Line: 215-228
Comment:
**Privacy filter only covers `memory_remember.value`**
`strip_secrets` is applied to `value` here, but other write tools (e.g., `add_observation`, `record_episode`) receive free-form text that is never sanitized. A credential in an observation or episode body would be stored in plain text. The `key` parameter of `memory_remember` is also not filtered.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -802,17 +880,24 @@ async def memory_context_inject( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if facts_text: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context_parts.append("REMEMBER: " + facts_text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Apply token budget | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context_text = "\n".join(context_parts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context_text, was_truncated = _truncate_to_budget(context_text, DEFAULT_TOKEN_BUDGET) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "context": "\n".join(context_parts), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "context": context_text, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "l4_facts_count": len(l4_facts), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "l3_episodes_count": len(l3_episodes), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "l1_recent_count": len(l1_recent), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "wiki_count": len(wiki_entries), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "estimated_tokens": _estimate_tokens(context_text), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "was_truncated": was_truncated, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "token_budget": DEFAULT_TOKEN_BUDGET, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _set_cached(cache_key, result) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Trigger dream_buffer hook for context staging | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await _fire_hook("dream_buffer", layer, {"text": "\n".join(context_parts), "user_id": user_id}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await _fire_hook("dream_buffer", layer, {"text": context_text, "user_id": user_id}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return result | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from mcp_server.utils.circuit_breaker import CircuitBreaker | ||
|
|
||
| __all__ = ["CircuitBreaker"] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,170 @@ | ||||||||||||||||||
| """ | ||||||||||||||||||
| Circuit Breaker pattern for LLM/embedding calls. | ||||||||||||||||||
|
|
||||||||||||||||||
| States: | ||||||||||||||||||
| - closed: normal operation, requests pass through | ||||||||||||||||||
| - open: failures exceeded threshold, requests blocked | ||||||||||||||||||
| - half-open: recovery probe, one request allowed through | ||||||||||||||||||
|
|
||||||||||||||||||
| Usage: | ||||||||||||||||||
| breaker = CircuitBreaker(threshold=3, recovery_timeout=30) | ||||||||||||||||||
|
|
||||||||||||||||||
| if not breaker.allow_request(): | ||||||||||||||||||
| return cached_result or fallback | ||||||||||||||||||
|
|
||||||||||||||||||
| try: | ||||||||||||||||||
| result = await llm_call() | ||||||||||||||||||
| breaker.record_success() | ||||||||||||||||||
| return result | ||||||||||||||||||
| except Exception as e: | ||||||||||||||||||
| breaker.record_failure() | ||||||||||||||||||
| raise | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| import logging | ||||||||||||||||||
| import time | ||||||||||||||||||
| from enum import Enum | ||||||||||||||||||
| from typing import Callable, Optional | ||||||||||||||||||
|
|
||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CircuitState(Enum): | ||||||||||||||||||
| CLOSED = "closed" | ||||||||||||||||||
| OPEN = "open" | ||||||||||||||||||
| HALF_OPEN = "half_open" | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CircuitBreaker: | ||||||||||||||||||
| """Circuit breaker with configurable threshold and recovery timeout.""" | ||||||||||||||||||
|
|
||||||||||||||||||
| def __init__( | ||||||||||||||||||
| self, | ||||||||||||||||||
| threshold: int = 3, | ||||||||||||||||||
| recovery_timeout: float = 30.0, | ||||||||||||||||||
| name: str = "default", | ||||||||||||||||||
| on_state_change: Optional[Callable] = None, | ||||||||||||||||||
| ): | ||||||||||||||||||
|
Comment on lines
+41
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add return annotations to public methods. Several public methods omit return annotations ( Also applies to: 84-142, 145-167 🤖 Prompt for AI AgentsSource: Path instructions |
||||||||||||||||||
| self.threshold = threshold | ||||||||||||||||||
| self.recovery_timeout = recovery_timeout | ||||||||||||||||||
| self.name = name | ||||||||||||||||||
|
Comment on lines
+38
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: mcp_server/utils/circuit_breaker.py
Line: 38-50
Comment:
**Circuit breaker defined but never wired to any call path**
`CircuitBreaker` / `breaker_registry` are exported from `utils/__init__.py` but are not imported or used anywhere in `tools_layer.py` or any other production module in the diff. Every LLM/embedding call path remains unprotected. The PR description states this feature "prevents cascading LLM/embedding failures," but that protection is not active — the class exists purely as dead infrastructure.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||
|
|
||||||||||||||||||
| self._failures = 0 | ||||||||||||||||||
| self._state = CircuitState.CLOSED | ||||||||||||||||||
| self._opened_at = 0.0 | ||||||||||||||||||
| self._last_failure_at = 0.0 | ||||||||||||||||||
|
|
||||||||||||||||||
| self._on_state_change = on_state_change | ||||||||||||||||||
|
|
||||||||||||||||||
| # Metrics | ||||||||||||||||||
| self._total_requests = 0 | ||||||||||||||||||
| self._total_failures = 0 | ||||||||||||||||||
| self._total_rejections = 0 | ||||||||||||||||||
| self._state_changes = 0 | ||||||||||||||||||
|
|
||||||||||||||||||
| @property | ||||||||||||||||||
| def state(self) -> CircuitState: | ||||||||||||||||||
| if self._state == CircuitState.OPEN: | ||||||||||||||||||
| if time.time() - self._opened_at > self.recovery_timeout: | ||||||||||||||||||
| self._transition_to(CircuitState.HALF_OPEN) | ||||||||||||||||||
| return self._state | ||||||||||||||||||
|
|
||||||||||||||||||
| @property | ||||||||||||||||||
| def failures(self) -> int: | ||||||||||||||||||
| return self._failures | ||||||||||||||||||
|
|
||||||||||||||||||
| def _transition_to(self, new_state: CircuitState): | ||||||||||||||||||
| old_state = self._state | ||||||||||||||||||
| self._state = new_state | ||||||||||||||||||
| self._state_changes += 1 | ||||||||||||||||||
| logger.info("CircuitBreaker[%s]: %s -> %s", self.name, old_state.value, new_state.value) | ||||||||||||||||||
| if self._on_state_change: | ||||||||||||||||||
| self._on_state_change(self.name, old_state, new_state) | ||||||||||||||||||
|
|
||||||||||||||||||
| def record_success(self): | ||||||||||||||||||
| self._total_requests += 1 | ||||||||||||||||||
| self._failures = 0 | ||||||||||||||||||
| if self._state == CircuitState.HALF_OPEN: | ||||||||||||||||||
| self._transition_to(CircuitState.CLOSED) | ||||||||||||||||||
|
|
||||||||||||||||||
| def record_failure(self): | ||||||||||||||||||
| self._total_requests += 1 | ||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||
| self._total_failures += 1 | ||||||||||||||||||
| self._failures += 1 | ||||||||||||||||||
| self._last_failure_at = time.time() | ||||||||||||||||||
|
|
||||||||||||||||||
| if self._state == CircuitState.HALF_OPEN: | ||||||||||||||||||
| self._transition_to(CircuitState.OPEN) | ||||||||||||||||||
| self._opened_at = time.time() | ||||||||||||||||||
| elif self._failures >= self.threshold: | ||||||||||||||||||
| self._transition_to(CircuitState.OPEN) | ||||||||||||||||||
| self._opened_at = time.time() | ||||||||||||||||||
|
|
||||||||||||||||||
| def allow_request(self) -> bool: | ||||||||||||||||||
| self._total_requests += 1 | ||||||||||||||||||
| current_state = self.state | ||||||||||||||||||
|
|
||||||||||||||||||
| if current_state == CircuitState.CLOSED: | ||||||||||||||||||
| return True | ||||||||||||||||||
| if current_state == CircuitState.HALF_OPEN: | ||||||||||||||||||
| return True | ||||||||||||||||||
|
Comment on lines
+109
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Limit half-open to a single recovery probe. The docstring says half-open allows one request, but this returns 🛡️ Suggested probe guard self._state_changes = 0
+ self._half_open_probe_in_flight = False
@@
if current_state == CircuitState.HALF_OPEN:
+ if self._half_open_probe_in_flight:
+ self._total_rejections += 1
+ return False
+ self._half_open_probe_in_flight = True
return TrueAlso clear 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| self._total_rejections += 1 | ||||||||||||||||||
| return False | ||||||||||||||||||
|
Comment on lines
+103
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: mcp_server/utils/circuit_breaker.py
Line: 103-112
Comment:
**`allow_request()` double-counts `_total_requests` when paired with the context manager**
`allow_request()` increments `_total_requests` at line 104, then `record_success()` / `record_failure()` (called from `__exit__`) each increment it again. Any caller using `with breaker as allowed:` will count every request twice, making `total_requests` in `get_metrics()` unreliable as a failure-rate denominator.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||
|
|
||||||||||||||||||
| def reset(self): | ||||||||||||||||||
| self._failures = 0 | ||||||||||||||||||
| self._state = CircuitState.CLOSED | ||||||||||||||||||
| self._opened_at = 0.0 | ||||||||||||||||||
|
|
||||||||||||||||||
| def get_metrics(self) -> dict: | ||||||||||||||||||
| return { | ||||||||||||||||||
| "name": self.name, | ||||||||||||||||||
| "state": self.state.value, | ||||||||||||||||||
| "failures": self._failures, | ||||||||||||||||||
| "threshold": self.threshold, | ||||||||||||||||||
| "recovery_timeout": self.recovery_timeout, | ||||||||||||||||||
| "total_requests": self._total_requests, | ||||||||||||||||||
| "total_failures": self._total_failures, | ||||||||||||||||||
| "total_rejections": self._total_rejections, | ||||||||||||||||||
| "state_changes": self._state_changes, | ||||||||||||||||||
| "last_failure_at": self._last_failure_at, | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| def __enter__(self): | ||||||||||||||||||
| self._context_allowed = self.allow_request() | ||||||||||||||||||
| return self._context_allowed | ||||||||||||||||||
|
|
||||||||||||||||||
| def __exit__(self, exc_type, exc_val, exc_tb): | ||||||||||||||||||
| if exc_type is None: | ||||||||||||||||||
| self.record_success() | ||||||||||||||||||
| else: | ||||||||||||||||||
| self.record_failure() | ||||||||||||||||||
| return False | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CircuitBreakerRegistry: | ||||||||||||||||||
| def __init__(self): | ||||||||||||||||||
| self._breakers: dict[str, CircuitBreaker] = {} | ||||||||||||||||||
|
|
||||||||||||||||||
| def get(self, name: str, threshold: int = 3, recovery_timeout: float = 30.0, on_state_change: Optional[Callable] = None) -> CircuitBreaker: | ||||||||||||||||||
| if name not in self._breakers: | ||||||||||||||||||
| self._breakers[name] = CircuitBreaker( | ||||||||||||||||||
| threshold=threshold, | ||||||||||||||||||
| recovery_timeout=recovery_timeout, | ||||||||||||||||||
| name=name, | ||||||||||||||||||
| on_state_change=on_state_change, | ||||||||||||||||||
| ) | ||||||||||||||||||
| return self._breakers[name] | ||||||||||||||||||
|
|
||||||||||||||||||
| def get_all(self) -> dict[str, CircuitBreaker]: | ||||||||||||||||||
| return dict(self._breakers) | ||||||||||||||||||
|
|
||||||||||||||||||
| def get_all_metrics(self) -> dict: | ||||||||||||||||||
| return {name: breaker.get_metrics() for name, breaker in self._breakers.items()} | ||||||||||||||||||
|
|
||||||||||||||||||
| def reset_all(self): | ||||||||||||||||||
| for breaker in self._breakers.values(): | ||||||||||||||||||
| breaker.reset() | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| breaker_registry = CircuitBreakerRegistry() | ||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.