3131_repeat_tracker : dict = {} # agent_id -> [{"embedding": ndarray, "time": float, "key": str}, ...]
3232_repeat_tracker_lock = threading .Lock ()
3333
34+ # Lightweight write tracker (no embeddings needed) — always active for loop detection
35+ _write_tracker : dict = {} # agent_id -> [{"time": float, "key": str}, ...]
36+ _write_tracker_lock = threading .Lock ()
37+
3438
3539@dataclass
3640class MemoryResult :
@@ -73,6 +77,7 @@ class SearchResult:
7377 items : list
7478 count : int
7579 latency_us : float
80+ note : Optional [str ] = None
7681
7782 def __iter__ (self ):
7883 return iter (self .items )
@@ -265,6 +270,17 @@ def remember(self, key: str, value: Any, tags: list = None) -> MemoryResult:
265270 except Exception :
266271 pass
267272
273+ # Always track writes for loop detection (no AI deps required)
274+ tracker_key = f"{ self .tenant_id } :{ self .agent_id } "
275+ now = time .time ()
276+ with _write_tracker_lock :
277+ wt_entries = _write_tracker .get (tracker_key , [])
278+ wt_entries = [e for e in wt_entries if e ["time" ] >= (now - 300 )]
279+ wt_entries .append ({"time" : now , "key" : key })
280+ if len (wt_entries ) > 50 :
281+ wt_entries = wt_entries [- 50 :]
282+ _write_tracker [tracker_key ] = wt_entries
283+
268284 # Write to DB with embedding (searchable immediately)
269285 start = time .perf_counter_ns ()
270286 node_id = self .backend .write (
@@ -290,13 +306,13 @@ def _enrich_background(backend, agent_id, nid, nkey, text, llm_config=None):
290306 from synrix .fact_extractor import FactExtractor
291307 from synrix .embeddings import EmbeddingModel
292308 safe_config = {k : ("***" if "key" in k .lower () or "secret" in k .lower () else v ) for k , v in (llm_config or {}).items ()}
293- logger .info ("Fact extraction starting for node %s (config=%s)" , nid , safe_config )
309+ logger .debug ("Fact extraction starting for node %s (config=%s)" , nid , safe_config )
294310 fact_extractor = FactExtractor .get (config = llm_config )
295311 emb_model = EmbeddingModel .get ()
296312 if fact_extractor is None :
297- logger .warning ("FactExtractor.get() returned None — no LLM available " )
313+ logger .debug ("FactExtractor.get() returned None — no LLM configured " )
298314 if emb_model is None :
299- logger .warning ("EmbeddingModel.get() returned None" )
315+ logger .debug ("EmbeddingModel.get() returned None" )
300316 if fact_extractor and emb_model :
301317 fact_result = fact_extractor .extract_facts (text )
302318 logger .info ("Fact extraction result: %d facts, used_llm=%s, provider=%s, time=%.0fms" ,
@@ -497,6 +513,18 @@ def recall_similar(self, query: str, limit: int = 10) -> SearchResult:
497513 Requires sentence-transformers to be installed.
498514 Returns empty results if embeddings are not available.
499515 """
516+ # Check if embeddings are available before searching
517+ try :
518+ from synrix .embeddings import EmbeddingModel
519+ if not EmbeddingModel .get ():
520+ logger .warning ("Semantic search requires embeddings: pip install octopoda[ai]" )
521+ return SearchResult (items = [], count = 0 , latency_us = 0 ,
522+ note = "Semantic search requires embeddings. Install with: pip install octopoda[ai]" )
523+ except ImportError :
524+ logger .warning ("Semantic search requires embeddings: pip install octopoda[ai]" )
525+ return SearchResult (items = [], count = 0 , latency_us = 0 ,
526+ note = "Semantic search requires embeddings. Install with: pip install octopoda[ai]" )
527+
500528 start = time .perf_counter_ns ()
501529 # Search scoped to this agent's data only via SQL prefix filter
502530 agent_prefix = f"agents:{ self .agent_id } :"
@@ -978,11 +1006,19 @@ def get_loop_status(self) -> dict:
9781006 "action" : "Monitor — may resolve naturally or escalate." ,
9791007 })
9801008 except ImportError :
981- pass
1009+ signals .append ({
1010+ "type" : "write_similarity" ,
1011+ "severity" : "info" ,
1012+ "detail" : "Semantic similarity detection requires: pip install octopoda[ai]" ,
1013+ "suggestion" : "Install AI extras for full loop detection (embedding-based write similarity)." ,
1014+ "action" : "pip install octopoda[ai]" ,
1015+ })
9821016
983- # --- Signal 2: Key overwrite frequency ---
984- with _repeat_tracker_lock :
985- recent_keys = [e .get ("key" , "" ) for e in recent_entries ]
1017+ # --- Signal 2: Key overwrite frequency (works WITHOUT AI deps) ---
1018+ with _write_tracker_lock :
1019+ wt_entries = _write_tracker .get (tracker_key , [])
1020+ wt_recent = [e for e in wt_entries if e ["time" ] >= (now - 300 )]
1021+ recent_keys = [e .get ("key" , "" ) for e in wt_recent ]
9861022 key_counts = {}
9871023 for k in recent_keys :
9881024 key_counts [k ] = key_counts .get (k , 0 ) + 1
@@ -1000,10 +1036,9 @@ def get_loop_status(self) -> dict:
10001036 "action" : f"Check the agent's logic around key '{ worst_key } '. Consider using remember_with_ttl() for temporary values." ,
10011037 })
10021038
1003- # --- Signal 3: Write velocity (burst detection) ---
1004- with _repeat_tracker_lock :
1005- last_60s = [e for e in entries if e ["time" ] >= (now - 60 )]
1006- last_300s = [e for e in entries if e ["time" ] >= (now - 300 )]
1039+ # --- Signal 3: Write velocity (burst detection, works WITHOUT AI deps) ---
1040+ last_60s = [e for e in wt_recent if e ["time" ] >= (now - 60 )]
1041+ last_300s = wt_recent
10071042 writes_per_minute = len (last_60s )
10081043 writes_per_5min = len (last_300s )
10091044 if writes_per_minute >= 10 :
@@ -1288,23 +1323,10 @@ def snapshot(self, label: str = None) -> SnapshotResult:
12881323 "created_at" : time .time (),
12891324 }
12901325 size_bytes = len (json .dumps (snapshot_payload ).encode ())
1291- try :
1292- raw_client = self .backend .client
1293- raw_client .add_node (
1294- name = f"agents:{ self .agent_id } :snapshots:{ label } " ,
1295- data = json .dumps (snapshot_payload ),
1296- collection = self .backend .collection ,
1297- node_type = "snapshot" ,
1298- metadata = {"type" : "snapshot" , "agent_id" : self .agent_id },
1299- _background = True ,
1300- )
1301- except Exception as e :
1302- logger .warning ("Snapshot write with _background failed (%s), retrying normal" , e )
1303- self .backend .write (
1304- f"agents:{ self .agent_id } :snapshots:{ label } " ,
1305- snapshot_payload ,
1306- metadata = {"type" : "snapshot" , "agent_id" : self .agent_id }
1307- )
1326+ self .backend .write (
1327+ f"agents:{ self .agent_id } :snapshots:{ label } " ,
1328+ snapshot_payload ,
1329+ )
13081330 latency_us = (time .perf_counter_ns () - start ) / 1000
13091331
13101332 if self ._metrics :
@@ -1362,23 +1384,10 @@ def restore(self, label: str = None) -> RestoreResult:
13621384 except Exception :
13631385 pass
13641386
1365- # Restore each key — use raw client with _background=True to avoid
1366- # blocking on _write_lock during heavy enrichment periods
1387+ # Restore each key
13671388 restored = 0
1368- raw_client = self .backend .client
13691389 for key , value in keys_data .items ():
1370- try :
1371- raw_client .add_node (
1372- name = key ,
1373- data = json .dumps (value ) if isinstance (value , dict ) else json .dumps ({"value" : value }),
1374- collection = self .backend .collection ,
1375- node_type = "memory" ,
1376- metadata = {"type" : "restored" , "agent_id" : self .agent_id },
1377- _background = True ,
1378- )
1379- except Exception :
1380- # Fallback to normal write if _background fails
1381- self .backend .write (key , value , metadata = {"type" : "restored" , "agent_id" : self .agent_id })
1390+ self .backend .write (key , value )
13821391 restored += 1
13831392
13841393 recovery_us = (time .perf_counter_ns () - start ) / 1000
@@ -1758,9 +1767,9 @@ def consolidate(self, similarity_threshold: float = 0.90, dry_run: bool = True)
17581767 from synrix .embeddings import EmbeddingModel
17591768 emb_model = EmbeddingModel .get ()
17601769 if not emb_model :
1761- return {"error" : "Embedding model not available" , "consolidated" : 0 }
1770+ return {"error" : "Embedding model not available" , "consolidated" : 0 , "dry_run" : dry_run }
17621771 except ImportError :
1763- return {"error" : "numpy or embeddings not installed" , "consolidated" : 0 }
1772+ return {"error" : "numpy or embeddings not installed — pip install octopoda[ai] " , "consolidated" : 0 , "dry_run" : dry_run }
17641773
17651774 prefix = f"agents:{ self .agent_id } :"
17661775 all_items = self .backend .query_prefix (prefix , limit = 10000 )
0 commit comments