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.
- loom/__init__.py +51 -0
- loom/api/__init__.py +19 -0
- loom/api/v0_0_3.py +300 -0
- loom/builtin/retriever/faiss_store.py +403 -0
- loom/core/agent_executor.py +212 -26
- loom/core/events.py +3 -0
- loom/core/recursion_control.py +298 -0
- loom/core/turn_state.py +58 -6
- loom/retrieval/__init__.py +61 -0
- loom/retrieval/domain_adapter.py +195 -0
- loom/retrieval/embedding_retriever.py +393 -0
- loom_agent-0.0.5.dist-info/METADATA +561 -0
- {loom_agent-0.0.3.dist-info → loom_agent-0.0.5.dist-info}/RECORD +15 -8
- loom_agent-0.0.3.dist-info/METADATA +0 -292
- {loom_agent-0.0.3.dist-info → loom_agent-0.0.5.dist-info}/WHEEL +0 -0
- {loom_agent-0.0.3.dist-info → loom_agent-0.0.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
}
|
loom/core/agent_executor.py
CHANGED
|
@@ -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
|
-
#
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
"""分析工具结果类型和质量"""
|