loom-agent 0.0.3__py3-none-any.whl → 0.0.5__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.

Potentially problematic release.


This version of loom-agent might be problematic. Click here for more details.

@@ -0,0 +1,403 @@
1
+ """
2
+ FAISS Vector Store
3
+
4
+ Lightweight, in-memory vector storage using FAISS.
5
+ Ideal for development and small to medium scale deployments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from loom.interfaces.vector_store import BaseVectorStore
14
+ from loom.interfaces.retriever import Document
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class FAISSVectorStore(BaseVectorStore):
20
+ """
21
+ FAISS-based vector storage
22
+
23
+ Lightweight, in-memory vector database using Facebook's FAISS library.
24
+ Ideal for:
25
+ - Development and testing
26
+ - Small to medium scale deployments (< 1M documents)
27
+ - Applications without persistence requirements
28
+
29
+ Features:
30
+ - Fast similarity search
31
+ - Multiple index types (Flat, IVF, HNSW)
32
+ - In-memory storage
33
+ - Optional persistence
34
+
35
+ Example:
36
+ # Basic usage
37
+ store = FAISSVectorStore(dimension=1536)
38
+ await store.initialize()
39
+
40
+ # Add documents
41
+ await store.add_documents(
42
+ documents=[doc1, doc2],
43
+ embeddings=[[0.1, ...], [0.2, ...]]
44
+ )
45
+
46
+ # Search
47
+ results = await store.search(
48
+ query_embedding=[0.15, ...],
49
+ top_k=5
50
+ )
51
+
52
+ # Advanced: Use IVF index for larger datasets
53
+ store = FAISSVectorStore(
54
+ dimension=1536,
55
+ index_type="IVF",
56
+ nlist=100 # Number of clusters
57
+ )
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ dimension: int,
63
+ index_type: str = "Flat",
64
+ metric: str = "L2",
65
+ nlist: int = 100,
66
+ nprobe: int = 10
67
+ ):
68
+ """
69
+ Args:
70
+ dimension: Embedding dimension
71
+ index_type: Index type ("Flat", "IVF", "HNSW")
72
+ metric: Distance metric ("L2" or "IP" for inner product)
73
+ nlist: Number of clusters for IVF index
74
+ nprobe: Number of clusters to search in IVF
75
+ """
76
+ self.dimension = dimension
77
+ self.index_type = index_type
78
+ self.metric = metric
79
+ self.nlist = nlist
80
+ self.nprobe = nprobe
81
+
82
+ # FAISS index
83
+ self.index = None
84
+
85
+ # Document storage
86
+ self.documents: Dict[str, Document] = {}
87
+ self.id_to_index: Dict[str, int] = {}
88
+ self.index_to_id: Dict[int, str] = {}
89
+
90
+ self._initialized = False
91
+
92
+ async def initialize(self) -> None:
93
+ """Initialize FAISS index"""
94
+ if self._initialized:
95
+ return
96
+
97
+ try:
98
+ import faiss
99
+ except ImportError:
100
+ raise ImportError(
101
+ "FAISS is required for FAISSVectorStore. "
102
+ "Install it with: pip install faiss-cpu or pip install faiss-gpu"
103
+ )
104
+
105
+ # Create index based on type
106
+ if self.index_type == "Flat":
107
+ if self.metric == "L2":
108
+ self.index = faiss.IndexFlatL2(self.dimension)
109
+ else: # IP (Inner Product)
110
+ self.index = faiss.IndexFlatIP(self.dimension)
111
+
112
+ elif self.index_type == "IVF":
113
+ if self.metric == "L2":
114
+ quantizer = faiss.IndexFlatL2(self.dimension)
115
+ self.index = faiss.IndexIVFFlat(
116
+ quantizer,
117
+ self.dimension,
118
+ self.nlist
119
+ )
120
+ else:
121
+ quantizer = faiss.IndexFlatIP(self.dimension)
122
+ self.index = faiss.IndexIVFFlat(
123
+ quantizer,
124
+ self.dimension,
125
+ self.nlist
126
+ )
127
+ # IVF needs training (will be done when first batch is added)
128
+ self.index.nprobe = self.nprobe
129
+
130
+ elif self.index_type == "HNSW":
131
+ self.index = faiss.IndexHNSWFlat(self.dimension, 32)
132
+
133
+ else:
134
+ raise ValueError(f"Unknown index type: {self.index_type}")
135
+
136
+ self._initialized = True
137
+ logger.info(f"FAISS index initialized: type={self.index_type}, dimension={self.dimension}")
138
+
139
+ async def add_documents(
140
+ self,
141
+ documents: List[Document],
142
+ embeddings: List[List[float]]
143
+ ) -> None:
144
+ """
145
+ Add documents with their embeddings
146
+
147
+ Args:
148
+ documents: List of documents
149
+ embeddings: List of embedding vectors
150
+ """
151
+ if not self._initialized:
152
+ await self.initialize()
153
+
154
+ if len(documents) != len(embeddings):
155
+ raise ValueError("Number of documents must match number of embeddings")
156
+
157
+ import numpy as np
158
+
159
+ # Convert embeddings to numpy array
160
+ embeddings_array = np.array(embeddings, dtype=np.float32)
161
+
162
+ # Train IVF index if needed
163
+ if self.index_type == "IVF" and not self.index.is_trained:
164
+ logger.info(f"Training IVF index with {len(embeddings)} vectors")
165
+ self.index.train(embeddings_array)
166
+
167
+ # Get current index size
168
+ start_index = len(self.id_to_index)
169
+
170
+ # Add to FAISS
171
+ self.index.add(embeddings_array)
172
+
173
+ # Store documents and mappings
174
+ for i, doc in enumerate(documents):
175
+ index = start_index + i
176
+ self.documents[doc.doc_id] = doc
177
+ self.id_to_index[doc.doc_id] = index
178
+ self.index_to_id[index] = doc.doc_id
179
+
180
+ logger.debug(f"Added {len(documents)} documents. Total: {len(self.documents)}")
181
+
182
+ async def search(
183
+ self,
184
+ query_embedding: List[float],
185
+ top_k: int = 5,
186
+ filters: Optional[Dict[str, Any]] = None
187
+ ) -> List[Document]:
188
+ """
189
+ Search for similar documents
190
+
191
+ Args:
192
+ query_embedding: Query embedding vector
193
+ top_k: Number of results to return
194
+ filters: Metadata filters (applied post-search)
195
+
196
+ Returns:
197
+ List of documents with similarity scores
198
+ """
199
+ if not self._initialized:
200
+ await self.initialize()
201
+
202
+ if self.index.ntotal == 0:
203
+ logger.warning("No documents in index")
204
+ return []
205
+
206
+ import numpy as np
207
+
208
+ # Convert query to numpy array
209
+ query_array = np.array([query_embedding], dtype=np.float32)
210
+
211
+ # Search
212
+ # Get more results if we need to filter
213
+ search_k = top_k * 3 if filters else top_k
214
+ distances, indices = self.index.search(query_array, search_k)
215
+
216
+ # Convert results to documents
217
+ results = []
218
+ for i, idx in enumerate(indices[0]):
219
+ if idx == -1: # FAISS returns -1 for missing results
220
+ break
221
+
222
+ # Get document
223
+ doc_id = self.index_to_id[idx]
224
+ doc = self.documents[doc_id]
225
+
226
+ # Apply filters
227
+ if filters and not self._match_filters(doc, filters):
228
+ continue
229
+
230
+ # Calculate similarity score
231
+ distance = distances[0][i]
232
+ score = self._distance_to_score(distance)
233
+
234
+ # Create result document with score
235
+ result_doc = Document(
236
+ doc_id=doc.doc_id,
237
+ content=doc.content,
238
+ score=score,
239
+ metadata=doc.metadata
240
+ )
241
+
242
+ results.append(result_doc)
243
+
244
+ if len(results) >= top_k:
245
+ break
246
+
247
+ return results
248
+
249
+ async def get_document(self, doc_id: str) -> Optional[Document]:
250
+ """
251
+ Get document by ID
252
+
253
+ Args:
254
+ doc_id: Document identifier
255
+
256
+ Returns:
257
+ Document if found, None otherwise
258
+ """
259
+ return self.documents.get(doc_id)
260
+
261
+ async def delete(self, doc_ids: List[str]) -> None:
262
+ """
263
+ Delete documents
264
+
265
+ Note: FAISS doesn't support efficient deletion.
266
+ This implementation removes from metadata but not from index.
267
+ For true deletion, rebuild the index.
268
+
269
+ Args:
270
+ doc_ids: List of document IDs to delete
271
+ """
272
+ for doc_id in doc_ids:
273
+ if doc_id in self.documents:
274
+ del self.documents[doc_id]
275
+ if doc_id in self.id_to_index:
276
+ index = self.id_to_index[doc_id]
277
+ del self.id_to_index[doc_id]
278
+ del self.index_to_id[index]
279
+
280
+ logger.warning(
281
+ f"Deleted {len(doc_ids)} documents from metadata. "
282
+ "Note: FAISS index still contains vectors. Rebuild index for full deletion."
283
+ )
284
+
285
+ def _match_filters(self, doc: Document, filters: Dict[str, Any]) -> bool:
286
+ """Check if document matches metadata filters"""
287
+ if not doc.metadata:
288
+ return False
289
+
290
+ for key, value in filters.items():
291
+ if doc.metadata.get(key) != value:
292
+ return False
293
+
294
+ return True
295
+
296
+ def _distance_to_score(self, distance: float) -> float:
297
+ """
298
+ Convert distance to similarity score
299
+
300
+ Args:
301
+ distance: Distance from FAISS (L2 or IP)
302
+
303
+ Returns:
304
+ Similarity score (0-1, higher is better)
305
+ """
306
+ if self.metric == "L2":
307
+ # L2 distance: lower is better
308
+ # Convert to similarity: 1 / (1 + distance)
309
+ return 1.0 / (1.0 + distance)
310
+ else:
311
+ # Inner product: higher is better
312
+ # Assuming normalized vectors, IP is in [-1, 1]
313
+ # Convert to [0, 1]
314
+ return (distance + 1.0) / 2.0
315
+
316
+ async def persist(self, path: str) -> None:
317
+ """
318
+ Save index to disk
319
+
320
+ Args:
321
+ path: File path to save index
322
+ """
323
+ if not self._initialized:
324
+ raise RuntimeError("Index not initialized")
325
+
326
+ import faiss
327
+ import pickle
328
+
329
+ # Save FAISS index
330
+ faiss.write_index(self.index, f"{path}.index")
331
+
332
+ # Save metadata
333
+ metadata = {
334
+ "documents": self.documents,
335
+ "id_to_index": self.id_to_index,
336
+ "index_to_id": self.index_to_id,
337
+ "dimension": self.dimension,
338
+ "index_type": self.index_type,
339
+ "metric": self.metric,
340
+ "nlist": self.nlist,
341
+ "nprobe": self.nprobe
342
+ }
343
+
344
+ with open(f"{path}.metadata", "wb") as f:
345
+ pickle.dump(metadata, f)
346
+
347
+ logger.info(f"Index persisted to {path}")
348
+
349
+ @classmethod
350
+ async def load(cls, path: str) -> "FAISSVectorStore":
351
+ """
352
+ Load index from disk
353
+
354
+ Args:
355
+ path: File path to load index from
356
+
357
+ Returns:
358
+ FAISSVectorStore instance
359
+ """
360
+ import faiss
361
+ import pickle
362
+
363
+ # Load metadata
364
+ with open(f"{path}.metadata", "rb") as f:
365
+ metadata = pickle.load(f)
366
+
367
+ # Create instance
368
+ instance = cls(
369
+ dimension=metadata["dimension"],
370
+ index_type=metadata["index_type"],
371
+ metric=metadata["metric"],
372
+ nlist=metadata["nlist"],
373
+ nprobe=metadata["nprobe"]
374
+ )
375
+
376
+ # Load FAISS index
377
+ instance.index = faiss.read_index(f"{path}.index")
378
+ instance._initialized = True
379
+
380
+ # Load metadata
381
+ instance.documents = metadata["documents"]
382
+ instance.id_to_index = metadata["id_to_index"]
383
+ instance.index_to_id = metadata["index_to_id"]
384
+
385
+ logger.info(f"Index loaded from {path}")
386
+
387
+ return instance
388
+
389
+ def get_stats(self) -> Dict[str, Any]:
390
+ """
391
+ Get statistics
392
+
393
+ Returns:
394
+ Statistics dictionary
395
+ """
396
+ return {
397
+ "initialized": self._initialized,
398
+ "total_documents": len(self.documents),
399
+ "index_size": self.index.ntotal if self.index else 0,
400
+ "dimension": self.dimension,
401
+ "index_type": self.index_type,
402
+ "metric": self.metric
403
+ }
@@ -20,6 +20,7 @@ from loom.core.context_assembly import ComponentPriority, ContextAssembler
20
20
  from loom.core.events import AgentEvent, AgentEventType, ToolResult
21
21
  from loom.core.execution_context import ExecutionContext
22
22
  from loom.core.permissions import PermissionManager
23
+ from loom.core.recursion_control import RecursionMonitor, RecursionState
23
24
  from loom.core.steering_control import SteeringControl
24
25
  from loom.core.tool_orchestrator import ToolOrchestrator
25
26
  from loom.core.tool_pipeline import ToolExecutionPipeline
@@ -127,6 +128,9 @@ class AgentExecutor:
127
128
  task_handlers: Optional[List[TaskHandler]] = None,
128
129
  unified_context: Optional["UnifiedExecutionContext"] = None,
129
130
  enable_unified_coordination: bool = True,
131
+ # Phase 2: Recursion Control
132
+ enable_recursion_control: bool = True,
133
+ recursion_monitor: Optional[RecursionMonitor] = None,
130
134
  ) -> None:
131
135
  self.llm = llm
132
136
  self.tools = tools or {}
@@ -144,11 +148,17 @@ class AgentExecutor:
144
148
  self.callbacks = callbacks or []
145
149
  self.enable_steering = enable_steering
146
150
  self.task_handlers = task_handlers or []
147
-
151
+
148
152
  # Unified coordination
149
153
  self.unified_context = unified_context
150
154
  self.enable_unified_coordination = enable_unified_coordination
151
-
155
+
156
+ # Phase 2: Recursion control
157
+ self.enable_recursion_control = enable_recursion_control
158
+ self.recursion_monitor = recursion_monitor or RecursionMonitor(
159
+ max_iterations=max_iterations
160
+ )
161
+
152
162
  # Initialize unified coordination if enabled
153
163
  if self.enable_unified_coordination and UnifiedExecutionContext and IntelligentCoordinator:
154
164
  self._setup_unified_coordination()
@@ -301,6 +311,56 @@ class AgentExecutor:
301
311
  metadata={"parent_turn_id": turn_state.parent_turn_id},
302
312
  )
303
313
 
314
+ # Phase 2: Advanced recursion control (optional)
315
+ if self.enable_recursion_control:
316
+ # Build recursion state from turn state
317
+ recursion_state = RecursionState(
318
+ iteration=turn_state.turn_counter,
319
+ tool_call_history=turn_state.tool_call_history,
320
+ error_count=turn_state.error_count,
321
+ last_outputs=turn_state.last_outputs
322
+ )
323
+
324
+ # Check for termination conditions
325
+ termination_reason = self.recursion_monitor.check_termination(
326
+ recursion_state
327
+ )
328
+
329
+ if termination_reason:
330
+ # Emit termination event
331
+ yield AgentEvent(
332
+ type=AgentEventType.RECURSION_TERMINATED,
333
+ metadata={
334
+ "reason": termination_reason.value,
335
+ "iteration": turn_state.turn_counter,
336
+ "tool_call_history": turn_state.tool_call_history[-5:],
337
+ "error_count": turn_state.error_count
338
+ }
339
+ )
340
+
341
+ # Add termination message to prompt LLM to finish
342
+ termination_msg = self.recursion_monitor.build_termination_message(
343
+ termination_reason
344
+ )
345
+
346
+ # Add termination guidance as system message
347
+ messages = messages + [
348
+ Message(role="system", content=termination_msg)
349
+ ]
350
+
351
+ # Note: We continue execution but with termination guidance
352
+ # The LLM will receive the termination message and should wrap up
353
+
354
+ # Check for early warnings (not terminating yet, just warning)
355
+ elif warning_msg := self.recursion_monitor.should_add_warning(
356
+ recursion_state,
357
+ warning_threshold=0.8
358
+ ):
359
+ # Add warning as system message
360
+ messages = messages + [
361
+ Message(role="system", content=warning_msg)
362
+ ]
363
+
304
364
  # Base case 1: Maximum recursion depth reached
305
365
  if turn_state.is_final:
306
366
  yield AgentEvent(
@@ -581,22 +641,37 @@ class AgentExecutor:
581
641
  # Phase 5: Recursive Call (Tail Recursion)
582
642
  # ==========================================
583
643
 
584
- # Prepare next turn state
585
- next_state = turn_state.next_turn(compacted=compacted_this_turn)
644
+ # Phase 2: Track tool calls and errors for recursion control
645
+ tool_names_called = [tc.name for tc in tc_models]
646
+ had_tool_errors = any(r.is_error for r in tool_results)
647
+
648
+ # Extract output for loop detection (use first tool result or content)
649
+ output_sample = None
650
+ if tool_results:
651
+ output_sample = tool_results[0].content[:200] # First 200 chars
652
+ elif content:
653
+ output_sample = content[:200]
654
+
655
+ # Prepare next turn state with recursion tracking
656
+ next_state = turn_state.next_turn(
657
+ compacted=compacted_this_turn,
658
+ tool_calls=tool_names_called,
659
+ had_error=had_tool_errors,
660
+ output=output_sample
661
+ )
586
662
 
587
- # Prepare next turn messages with intelligent context guidance
588
- next_messages = self._prepare_recursive_messages(
663
+ # Phase 3: Prepare next turn messages with intelligent context guidance
664
+ # This now includes tool results, compression, and recursion hints
665
+ next_messages = await self._prepare_recursive_messages(
589
666
  messages, tool_results, turn_state, context
590
667
  )
591
-
592
- # Add tool results
593
- for r in tool_results:
594
- next_messages.append(
595
- Message(
596
- role="tool",
597
- content=r.content,
598
- tool_call_id=r.tool_call_id,
599
- )
668
+
669
+ # Check if compression was applied and emit event
670
+ if "last_compression" in context.metadata:
671
+ comp_info = context.metadata.pop("last_compression")
672
+ yield AgentEvent(
673
+ type=AgentEventType.COMPRESSION_APPLIED,
674
+ metadata=comp_info
600
675
  )
601
676
 
602
677
  # Emit recursion event
@@ -606,6 +681,8 @@ class AgentExecutor:
606
681
  "from_turn": turn_state.turn_id,
607
682
  "to_turn": next_state.turn_id,
608
683
  "depth": next_state.turn_counter,
684
+ "tools_called": tool_names_called,
685
+ "message_count": len(next_messages),
609
686
  },
610
687
  )
611
688
 
@@ -617,7 +694,7 @@ class AgentExecutor:
617
694
  # Intelligent Recursion Methods
618
695
  # ==========================================
619
696
 
620
- def _prepare_recursive_messages(
697
+ async def _prepare_recursive_messages(
621
698
  self,
622
699
  messages: List[Message],
623
700
  tool_results: List[ToolResult],
@@ -625,22 +702,131 @@ class AgentExecutor:
625
702
  context: ExecutionContext,
626
703
  ) -> List[Message]:
627
704
  """
628
- 智能准备递归调用的消息
629
-
630
- 基于工具结果类型、任务上下文和递归深度,生成合适的用户指导消息
705
+ Phase 3: 智能准备递归调用的消息
706
+
707
+ 确保工具结果正确传递到下一轮,并进行必要的上下文优化
708
+
709
+ Args:
710
+ messages: 当前轮次的消息
711
+ tool_results: 工具执行结果
712
+ turn_state: 当前轮次状态
713
+ context: 执行上下文
714
+
715
+ Returns:
716
+ 准备好的下一轮消息列表
631
717
  """
632
- # 分析工具结果
718
+ # 1. 分析工具结果(用于生成智能指导)
633
719
  result_analysis = self._analyze_tool_results(tool_results)
634
-
635
- # 获取原始任务
636
720
  original_task = self._extract_original_task(messages)
637
-
638
- # 生成智能指导消息
721
+
722
+ # 2. 生成智能指导消息
639
723
  guidance_message = self._generate_recursion_guidance(
640
724
  original_task, result_analysis, turn_state.turn_counter
641
725
  )
642
-
643
- return [Message(role="user", content=guidance_message)]
726
+
727
+ # 3. 构建下一轮消息:用户指导
728
+ next_messages = [Message(role="user", content=guidance_message)]
729
+
730
+ # 4. 添加工具结果消息(关键:确保工具结果被传递)
731
+ for result in tool_results:
732
+ next_messages.append(Message(
733
+ role="tool",
734
+ content=result.content,
735
+ tool_call_id=result.tool_call_id,
736
+ metadata=result.metadata or {}
737
+ ))
738
+
739
+ # 5. Phase 3: 检查上下文长度
740
+ estimated_tokens = self._estimate_tokens(next_messages)
741
+ compression_applied = False
742
+
743
+ if estimated_tokens > self.max_context_tokens:
744
+ # 触发压缩(如果有 compressor)
745
+ if self.compressor:
746
+ tokens_before = estimated_tokens
747
+ next_messages = await self._compress_messages(next_messages)
748
+ tokens_after = self._estimate_tokens(next_messages)
749
+ compression_applied = True
750
+
751
+ # Store compression info for later emission
752
+ context.metadata["last_compression"] = {
753
+ "tokens_before": tokens_before,
754
+ "tokens_after": tokens_after,
755
+ "trigger": "recursive_message_preparation"
756
+ }
757
+
758
+ # 6. Phase 3: 添加递归深度提示(深度递归时)
759
+ if turn_state.turn_counter > 3:
760
+ hint_content = self._build_recursion_hint(
761
+ turn_state.turn_counter,
762
+ turn_state.max_iterations
763
+ )
764
+
765
+ hint = Message(
766
+ role="system",
767
+ content=hint_content
768
+ )
769
+ next_messages.append(hint)
770
+
771
+ return next_messages
772
+
773
+ def _estimate_tokens(self, messages: List[Message]) -> int:
774
+ """
775
+ 估算消息列表的 token 数量
776
+
777
+ 使用简单的启发式方法:字符数 / 4
778
+ 生产环境中应使用具体模型的 tokenizer
779
+ """
780
+ return count_messages_tokens(messages)
781
+
782
+ async def _compress_messages(
783
+ self,
784
+ messages: List[Message]
785
+ ) -> List[Message]:
786
+ """
787
+ 压缩消息列表(如果有 compressor)
788
+
789
+ 这个方法会调用配置的 compressor 来减少上下文长度
790
+ """
791
+ if not self.compressor:
792
+ return messages
793
+
794
+ try:
795
+ compressed, metadata = await self.compressor.compress(messages)
796
+
797
+ # Update compression metrics
798
+ self.metrics.metrics.compressions = (
799
+ getattr(self.metrics.metrics, "compressions", 0) + 1
800
+ )
801
+
802
+ return compressed
803
+ except Exception as e:
804
+ # If compression fails, return original messages
805
+ self.metrics.metrics.total_errors += 1
806
+ await self._emit(
807
+ "error",
808
+ {"stage": "message_compression", "message": str(e)}
809
+ )
810
+ return messages
811
+
812
+ def _build_recursion_hint(self, current_depth: int, max_depth: int) -> str:
813
+ """
814
+ 构建递归深度提示消息
815
+
816
+ 在深度递归时提醒 LLM 注意进度和避免重复
817
+ """
818
+ remaining = max_depth - current_depth
819
+ progress = (current_depth / max_depth) * 100
820
+
821
+ hint = f"""🔄 Recursion Status:
822
+ - Depth: {current_depth}/{max_depth} ({progress:.0f}% of maximum)
823
+ - Remaining iterations: {remaining}
824
+
825
+ Please review the tool results above and make meaningful progress towards completing the task.
826
+ Avoid calling the same tool repeatedly with the same arguments unless necessary.
827
+ If you have enough information, please provide your final answer."""
828
+
829
+ return hint
644
830
 
645
831
  def _analyze_tool_results(self, tool_results: List[ToolResult]) -> Dict[str, Any]:
646
832
  """分析工具结果类型和质量"""