letta-nightly 0.11.0.dev20250807104511__py3-none-any.whl → 0.11.0.dev20250808104456__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.
- letta/agent.py +2 -1
- letta/agents/letta_agent.py +215 -143
- letta/constants.py +4 -1
- letta/embeddings.py +6 -5
- letta/functions/function_sets/base.py +2 -2
- letta/functions/function_sets/files.py +22 -9
- letta/interfaces/anthropic_streaming_interface.py +291 -265
- letta/interfaces/openai_streaming_interface.py +270 -250
- letta/llm_api/anthropic.py +3 -10
- letta/llm_api/openai_client.py +6 -1
- letta/orm/__init__.py +1 -0
- letta/orm/step.py +14 -0
- letta/orm/step_metrics.py +71 -0
- letta/schemas/enums.py +9 -0
- letta/schemas/llm_config.py +8 -6
- letta/schemas/providers/lmstudio.py +2 -2
- letta/schemas/providers/ollama.py +42 -54
- letta/schemas/providers/openai.py +1 -1
- letta/schemas/step.py +6 -0
- letta/schemas/step_metrics.py +23 -0
- letta/schemas/tool_rule.py +10 -29
- letta/services/step_manager.py +179 -1
- letta/services/tool_executor/builtin_tool_executor.py +4 -1
- letta/services/tool_executor/core_tool_executor.py +2 -10
- letta/services/tool_executor/files_tool_executor.py +89 -40
- {letta_nightly-0.11.0.dev20250807104511.dist-info → letta_nightly-0.11.0.dev20250808104456.dist-info}/METADATA +1 -1
- {letta_nightly-0.11.0.dev20250807104511.dist-info → letta_nightly-0.11.0.dev20250808104456.dist-info}/RECORD +30 -28
- {letta_nightly-0.11.0.dev20250807104511.dist-info → letta_nightly-0.11.0.dev20250808104456.dist-info}/LICENSE +0 -0
- {letta_nightly-0.11.0.dev20250807104511.dist-info → letta_nightly-0.11.0.dev20250808104456.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.0.dev20250807104511.dist-info → letta_nightly-0.11.0.dev20250808104456.dist-info}/entry_points.txt +0 -0
letta/services/step_manager.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
from datetime import datetime
|
2
2
|
from enum import Enum
|
3
|
-
from typing import List, Literal, Optional
|
3
|
+
from typing import Dict, List, Literal, Optional
|
4
4
|
|
5
5
|
from sqlalchemy import select
|
6
6
|
from sqlalchemy.ext.asyncio import AsyncSession
|
@@ -12,6 +12,7 @@ from letta.orm.job import Job as JobModel
|
|
12
12
|
from letta.orm.sqlalchemy_base import AccessType
|
13
13
|
from letta.orm.step import Step as StepModel
|
14
14
|
from letta.otel.tracing import get_trace_id, trace_method
|
15
|
+
from letta.schemas.enums import StepStatus
|
15
16
|
from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
|
16
17
|
from letta.schemas.openai.chat_completion_response import UsageStatistics
|
17
18
|
from letta.schemas.step import Step as PydanticStep
|
@@ -87,6 +88,10 @@ class StepManager:
|
|
87
88
|
job_id: Optional[str] = None,
|
88
89
|
step_id: Optional[str] = None,
|
89
90
|
project_id: Optional[str] = None,
|
91
|
+
stop_reason: Optional[LettaStopReason] = None,
|
92
|
+
status: Optional[StepStatus] = None,
|
93
|
+
error_type: Optional[str] = None,
|
94
|
+
error_data: Optional[Dict] = None,
|
90
95
|
) -> PydanticStep:
|
91
96
|
step_data = {
|
92
97
|
"origin": None,
|
@@ -106,9 +111,14 @@ class StepManager:
|
|
106
111
|
"tid": None,
|
107
112
|
"trace_id": get_trace_id(), # Get the current trace ID
|
108
113
|
"project_id": project_id,
|
114
|
+
"status": status if status else StepStatus.PENDING,
|
115
|
+
"error_type": error_type,
|
116
|
+
"error_data": error_data,
|
109
117
|
}
|
110
118
|
if step_id:
|
111
119
|
step_data["id"] = step_id
|
120
|
+
if stop_reason:
|
121
|
+
step_data["stop_reason"] = stop_reason.stop_reason
|
112
122
|
with db_registry.session() as session:
|
113
123
|
if job_id:
|
114
124
|
self._verify_job_access(session, job_id, actor, access=["write"])
|
@@ -133,6 +143,9 @@ class StepManager:
|
|
133
143
|
step_id: Optional[str] = None,
|
134
144
|
project_id: Optional[str] = None,
|
135
145
|
stop_reason: Optional[LettaStopReason] = None,
|
146
|
+
status: Optional[StepStatus] = None,
|
147
|
+
error_type: Optional[str] = None,
|
148
|
+
error_data: Optional[Dict] = None,
|
136
149
|
) -> PydanticStep:
|
137
150
|
step_data = {
|
138
151
|
"origin": None,
|
@@ -152,6 +165,9 @@ class StepManager:
|
|
152
165
|
"tid": None,
|
153
166
|
"trace_id": get_trace_id(), # Get the current trace ID
|
154
167
|
"project_id": project_id,
|
168
|
+
"status": status if status else StepStatus.PENDING,
|
169
|
+
"error_type": error_type,
|
170
|
+
"error_data": error_data,
|
155
171
|
}
|
156
172
|
if step_id:
|
157
173
|
step_data["id"] = step_id
|
@@ -236,6 +252,126 @@ class StepManager:
|
|
236
252
|
await session.commit()
|
237
253
|
return step
|
238
254
|
|
255
|
+
@enforce_types
|
256
|
+
@trace_method
|
257
|
+
async def update_step_error_async(
|
258
|
+
self,
|
259
|
+
actor: PydanticUser,
|
260
|
+
step_id: str,
|
261
|
+
error_type: str,
|
262
|
+
error_message: str,
|
263
|
+
error_traceback: str,
|
264
|
+
error_details: Optional[Dict] = None,
|
265
|
+
stop_reason: Optional[LettaStopReason] = None,
|
266
|
+
) -> PydanticStep:
|
267
|
+
"""Update a step with error information.
|
268
|
+
|
269
|
+
Args:
|
270
|
+
actor: The user making the request
|
271
|
+
step_id: The ID of the step to update
|
272
|
+
error_type: The type/class of the error
|
273
|
+
error_message: The error message
|
274
|
+
error_traceback: Full error traceback
|
275
|
+
error_details: Additional error context
|
276
|
+
stop_reason: The stop reason to set
|
277
|
+
|
278
|
+
Returns:
|
279
|
+
The updated step
|
280
|
+
|
281
|
+
Raises:
|
282
|
+
NoResultFound: If the step does not exist
|
283
|
+
"""
|
284
|
+
async with db_registry.async_session() as session:
|
285
|
+
step = await session.get(StepModel, step_id)
|
286
|
+
if not step:
|
287
|
+
raise NoResultFound(f"Step with id {step_id} does not exist")
|
288
|
+
if step.organization_id != actor.organization_id:
|
289
|
+
raise Exception("Unauthorized")
|
290
|
+
|
291
|
+
step.status = StepStatus.FAILED
|
292
|
+
step.error_type = error_type
|
293
|
+
step.error_data = {"message": error_message, "traceback": error_traceback, "details": error_details}
|
294
|
+
if stop_reason:
|
295
|
+
step.stop_reason = stop_reason.stop_reason
|
296
|
+
|
297
|
+
await session.commit()
|
298
|
+
return step.to_pydantic()
|
299
|
+
|
300
|
+
@enforce_types
|
301
|
+
@trace_method
|
302
|
+
async def update_step_success_async(
|
303
|
+
self,
|
304
|
+
actor: PydanticUser,
|
305
|
+
step_id: str,
|
306
|
+
usage: UsageStatistics,
|
307
|
+
stop_reason: Optional[LettaStopReason] = None,
|
308
|
+
) -> PydanticStep:
|
309
|
+
"""Update a step with success status and final usage statistics.
|
310
|
+
|
311
|
+
Args:
|
312
|
+
actor: The user making the request
|
313
|
+
step_id: The ID of the step to update
|
314
|
+
usage: Final usage statistics
|
315
|
+
stop_reason: The stop reason to set
|
316
|
+
|
317
|
+
Returns:
|
318
|
+
The updated step
|
319
|
+
|
320
|
+
Raises:
|
321
|
+
NoResultFound: If the step does not exist
|
322
|
+
"""
|
323
|
+
async with db_registry.async_session() as session:
|
324
|
+
step = await session.get(StepModel, step_id)
|
325
|
+
if not step:
|
326
|
+
raise NoResultFound(f"Step with id {step_id} does not exist")
|
327
|
+
if step.organization_id != actor.organization_id:
|
328
|
+
raise Exception("Unauthorized")
|
329
|
+
|
330
|
+
step.status = StepStatus.SUCCESS
|
331
|
+
step.completion_tokens = usage.completion_tokens
|
332
|
+
step.prompt_tokens = usage.prompt_tokens
|
333
|
+
step.total_tokens = usage.total_tokens
|
334
|
+
if stop_reason:
|
335
|
+
step.stop_reason = stop_reason.stop_reason
|
336
|
+
|
337
|
+
await session.commit()
|
338
|
+
return step.to_pydantic()
|
339
|
+
|
340
|
+
@enforce_types
|
341
|
+
@trace_method
|
342
|
+
async def update_step_cancelled_async(
|
343
|
+
self,
|
344
|
+
actor: PydanticUser,
|
345
|
+
step_id: str,
|
346
|
+
stop_reason: Optional[LettaStopReason] = None,
|
347
|
+
) -> PydanticStep:
|
348
|
+
"""Update a step with cancelled status.
|
349
|
+
|
350
|
+
Args:
|
351
|
+
actor: The user making the request
|
352
|
+
step_id: The ID of the step to update
|
353
|
+
stop_reason: The stop reason to set
|
354
|
+
|
355
|
+
Returns:
|
356
|
+
The updated step
|
357
|
+
|
358
|
+
Raises:
|
359
|
+
NoResultFound: If the step does not exist
|
360
|
+
"""
|
361
|
+
async with db_registry.async_session() as session:
|
362
|
+
step = await session.get(StepModel, step_id)
|
363
|
+
if not step:
|
364
|
+
raise NoResultFound(f"Step with id {step_id} does not exist")
|
365
|
+
if step.organization_id != actor.organization_id:
|
366
|
+
raise Exception("Unauthorized")
|
367
|
+
|
368
|
+
step.status = StepStatus.CANCELLED
|
369
|
+
if stop_reason:
|
370
|
+
step.stop_reason = stop_reason.stop_reason
|
371
|
+
|
372
|
+
await session.commit()
|
373
|
+
return step.to_pydantic()
|
374
|
+
|
239
375
|
def _verify_job_access(
|
240
376
|
self,
|
241
377
|
session: Session,
|
@@ -319,6 +455,10 @@ class NoopStepManager(StepManager):
|
|
319
455
|
job_id: Optional[str] = None,
|
320
456
|
step_id: Optional[str] = None,
|
321
457
|
project_id: Optional[str] = None,
|
458
|
+
stop_reason: Optional[LettaStopReason] = None,
|
459
|
+
status: Optional[StepStatus] = None,
|
460
|
+
error_type: Optional[str] = None,
|
461
|
+
error_data: Optional[Dict] = None,
|
322
462
|
) -> PydanticStep:
|
323
463
|
return
|
324
464
|
|
@@ -339,5 +479,43 @@ class NoopStepManager(StepManager):
|
|
339
479
|
step_id: Optional[str] = None,
|
340
480
|
project_id: Optional[str] = None,
|
341
481
|
stop_reason: Optional[LettaStopReason] = None,
|
482
|
+
status: Optional[StepStatus] = None,
|
483
|
+
error_type: Optional[str] = None,
|
484
|
+
error_data: Optional[Dict] = None,
|
485
|
+
) -> PydanticStep:
|
486
|
+
return
|
487
|
+
|
488
|
+
@enforce_types
|
489
|
+
@trace_method
|
490
|
+
async def update_step_error_async(
|
491
|
+
self,
|
492
|
+
actor: PydanticUser,
|
493
|
+
step_id: str,
|
494
|
+
error_type: str,
|
495
|
+
error_message: str,
|
496
|
+
error_traceback: str,
|
497
|
+
error_details: Optional[Dict] = None,
|
498
|
+
stop_reason: Optional[LettaStopReason] = None,
|
499
|
+
) -> PydanticStep:
|
500
|
+
return
|
501
|
+
|
502
|
+
@enforce_types
|
503
|
+
@trace_method
|
504
|
+
async def update_step_success_async(
|
505
|
+
self,
|
506
|
+
actor: PydanticUser,
|
507
|
+
step_id: str,
|
508
|
+
usage: UsageStatistics,
|
509
|
+
stop_reason: Optional[LettaStopReason] = None,
|
510
|
+
) -> PydanticStep:
|
511
|
+
return
|
512
|
+
|
513
|
+
@enforce_types
|
514
|
+
@trace_method
|
515
|
+
async def update_step_cancelled_async(
|
516
|
+
self,
|
517
|
+
actor: PydanticUser,
|
518
|
+
step_id: str,
|
519
|
+
stop_reason: Optional[LettaStopReason] = None,
|
342
520
|
) -> PydanticStep:
|
343
521
|
return
|
@@ -210,7 +210,10 @@ class LettaBuiltinToolExecutor(ToolExecutor):
|
|
210
210
|
logger.info(f"[DEBUG] Starting Firecrawl search for query: '{task.query}' with limit={limit}")
|
211
211
|
|
212
212
|
# Perform the search for this task
|
213
|
-
|
213
|
+
scrape_options = ScrapeOptions(
|
214
|
+
formats=["markdown"], excludeTags=["#ad", "#footer"], onlyMainContent=True, parsePDF=True, removeBase64Images=True
|
215
|
+
)
|
216
|
+
search_result = await app.search(task.query, limit=limit, scrape_options=scrape_options)
|
214
217
|
|
215
218
|
logger.info(
|
216
219
|
f"[DEBUG] Firecrawl search completed for '{task.query}': {len(search_result.get('data', [])) if search_result else 0} results"
|
@@ -230,14 +230,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
230
230
|
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
231
231
|
return None
|
232
232
|
|
233
|
-
async def memory_replace(
|
234
|
-
self,
|
235
|
-
agent_state: AgentState,
|
236
|
-
actor: User,
|
237
|
-
label: str,
|
238
|
-
old_str: str,
|
239
|
-
new_str: Optional[str] = None,
|
240
|
-
) -> str:
|
233
|
+
async def memory_replace(self, agent_state: AgentState, actor: User, label: str, old_str: str, new_str: str) -> str:
|
241
234
|
"""
|
242
235
|
The memory_replace command allows you to replace a specific string in a memory
|
243
236
|
block with a new string. This is used for making precise edits.
|
@@ -246,8 +239,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
246
239
|
label (str): Section of the memory to be edited, identified by its label.
|
247
240
|
old_str (str): The text to replace (must match exactly, including whitespace
|
248
241
|
and indentation). Do not include line number prefixes.
|
249
|
-
new_str (
|
250
|
-
Omit this argument to delete the old_str. Do not include line number prefixes.
|
242
|
+
new_str (str): The new text to insert in place of the old text. Do not include line number prefixes.
|
251
243
|
|
252
244
|
Returns:
|
253
245
|
str: The success message
|
@@ -32,10 +32,12 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
32
32
|
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB limit per file
|
33
33
|
MAX_TOTAL_CONTENT_SIZE = 200 * 1024 * 1024 # 200MB total across all files
|
34
34
|
MAX_REGEX_COMPLEXITY = 1000 # Prevent catastrophic backtracking
|
35
|
-
MAX_MATCHES_PER_FILE = 20 # Limit matches per file
|
36
|
-
MAX_TOTAL_MATCHES = 50 #
|
35
|
+
MAX_MATCHES_PER_FILE = 20 # Limit matches per file (legacy, not used with new pagination)
|
36
|
+
MAX_TOTAL_MATCHES = 50 # Keep original value for semantic search
|
37
|
+
GREP_PAGE_SIZE = 20 # Number of grep matches to show per page
|
37
38
|
GREP_TIMEOUT_SECONDS = 30 # Max time for grep_files operation
|
38
39
|
MAX_CONTEXT_LINES = 1 # Lines of context around matches
|
40
|
+
MAX_TOTAL_COLLECTED = 1000 # Reasonable upper limit to prevent memory issues
|
39
41
|
|
40
42
|
def __init__(
|
41
43
|
self,
|
@@ -298,7 +300,12 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
298
300
|
|
299
301
|
@trace_method
|
300
302
|
async def grep_files(
|
301
|
-
self,
|
303
|
+
self,
|
304
|
+
agent_state: AgentState,
|
305
|
+
pattern: str,
|
306
|
+
include: Optional[str] = None,
|
307
|
+
context_lines: Optional[int] = 1,
|
308
|
+
offset: Optional[int] = None,
|
302
309
|
) -> str:
|
303
310
|
"""
|
304
311
|
Search for pattern in all attached files and return matches with context.
|
@@ -308,7 +315,9 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
308
315
|
pattern: Regular expression pattern to search for
|
309
316
|
include: Optional pattern to filter filenames to include in the search
|
310
317
|
context_lines (Optional[int]): Number of lines of context to show before and after each match.
|
311
|
-
Equivalent to `-C` in grep_files. Defaults to
|
318
|
+
Equivalent to `-C` in grep_files. Defaults to 1.
|
319
|
+
offset (Optional[int]): Number of matches to skip before showing results. Used for pagination.
|
320
|
+
Defaults to 0 (show from first match).
|
312
321
|
|
313
322
|
Returns:
|
314
323
|
Formatted string with search results, file names, line numbers, and context
|
@@ -350,14 +359,18 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
350
359
|
if not file_agents:
|
351
360
|
return f"No files match the filename pattern '{include}' (filtered {original_count} files)"
|
352
361
|
|
362
|
+
# Validate offset parameter
|
363
|
+
if offset is not None and offset < 0:
|
364
|
+
offset = 0 # Treat negative offsets as 0
|
365
|
+
|
353
366
|
# Compile regex pattern with appropriate flags
|
354
367
|
regex_flags = re.MULTILINE
|
355
368
|
regex_flags |= re.IGNORECASE
|
356
369
|
|
357
370
|
pattern_regex = re.compile(pattern, regex_flags)
|
358
371
|
|
359
|
-
|
360
|
-
|
372
|
+
# Collect all matches first (up to a reasonable limit)
|
373
|
+
all_matches = [] # List of tuples: (file_name, line_num, context_lines)
|
361
374
|
total_content_size = 0
|
362
375
|
files_processed = 0
|
363
376
|
files_skipped = 0
|
@@ -365,7 +378,7 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
365
378
|
|
366
379
|
# Use asyncio timeout to prevent hanging
|
367
380
|
async def _search_files():
|
368
|
-
nonlocal
|
381
|
+
nonlocal all_matches, total_content_size, files_processed, files_skipped, files_with_matches
|
369
382
|
|
370
383
|
for file_agent in file_agents:
|
371
384
|
# Load file content
|
@@ -383,7 +396,6 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
383
396
|
self.logger.warning(
|
384
397
|
f"Grep: Skipping file {file.file_name} - too large ({content_size:,} bytes > {self.MAX_FILE_SIZE_BYTES:,} limit)"
|
385
398
|
)
|
386
|
-
results.append(f"[SKIPPED] {file.file_name}: File too large ({content_size:,} bytes)")
|
387
399
|
continue
|
388
400
|
|
389
401
|
# Check total content size across all files
|
@@ -393,11 +405,9 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
393
405
|
self.logger.warning(
|
394
406
|
f"Grep: Skipping file {file.file_name} - total content size limit exceeded ({total_content_size:,} bytes > {self.MAX_TOTAL_CONTENT_SIZE:,} limit)"
|
395
407
|
)
|
396
|
-
results.append(f"[SKIPPED] {file.file_name}: Total content size limit exceeded")
|
397
408
|
break
|
398
409
|
|
399
410
|
files_processed += 1
|
400
|
-
file_matches = 0
|
401
411
|
|
402
412
|
# Use LineChunker to get all lines with proper formatting
|
403
413
|
chunker = LineChunker()
|
@@ -407,16 +417,10 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
407
417
|
if formatted_lines and formatted_lines[0].startswith("[Viewing"):
|
408
418
|
formatted_lines = formatted_lines[1:]
|
409
419
|
|
410
|
-
# LineChunker now returns 1-indexed line numbers, so no conversion needed
|
411
|
-
|
412
420
|
# Search for matches in formatted lines
|
413
421
|
for formatted_line in formatted_lines:
|
414
|
-
if
|
415
|
-
|
416
|
-
return
|
417
|
-
|
418
|
-
if file_matches >= self.MAX_MATCHES_PER_FILE:
|
419
|
-
results.append(f"[TRUNCATED] {file.file_name}: Maximum matches per file ({self.MAX_MATCHES_PER_FILE}) reached")
|
422
|
+
if len(all_matches) >= self.MAX_TOTAL_COLLECTED:
|
423
|
+
# Stop collecting if we hit the upper limit
|
420
424
|
break
|
421
425
|
|
422
426
|
# Extract line number and content from formatted line
|
@@ -433,16 +437,11 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
433
437
|
files_with_matches.add(file.file_name)
|
434
438
|
context = self._get_context_lines(formatted_lines, match_line_num=line_num, context_lines=context_lines or 0)
|
435
439
|
|
436
|
-
#
|
437
|
-
|
438
|
-
match_content = "\n".join(context)
|
439
|
-
results.append(f"{match_header}\n{match_content}")
|
440
|
-
|
441
|
-
file_matches += 1
|
442
|
-
total_matches += 1
|
440
|
+
# Store match data for later pagination
|
441
|
+
all_matches.append((file.file_name, line_num, context))
|
443
442
|
|
444
|
-
# Break if
|
445
|
-
if
|
443
|
+
# Break if we've collected enough matches
|
444
|
+
if len(all_matches) >= self.MAX_TOTAL_COLLECTED:
|
446
445
|
break
|
447
446
|
|
448
447
|
# Execute with timeout
|
@@ -452,8 +451,9 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
452
451
|
if files_with_matches:
|
453
452
|
await self.files_agents_manager.mark_access_bulk(agent_id=agent_state.id, file_names=list(files_with_matches), actor=self.actor)
|
454
453
|
|
455
|
-
#
|
456
|
-
|
454
|
+
# Handle no matches case
|
455
|
+
total_matches = len(all_matches)
|
456
|
+
if total_matches == 0:
|
457
457
|
summary = f"No matches found for pattern: '{pattern}'"
|
458
458
|
if include:
|
459
459
|
summary += f" in files matching '{include}'"
|
@@ -461,21 +461,70 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
461
461
|
summary += f" (searched {files_processed} files, skipped {files_skipped})"
|
462
462
|
return summary
|
463
463
|
|
464
|
-
#
|
465
|
-
|
466
|
-
|
467
|
-
|
464
|
+
# Apply pagination
|
465
|
+
start_idx = offset if offset else 0
|
466
|
+
end_idx = start_idx + self.GREP_PAGE_SIZE
|
467
|
+
paginated_matches = all_matches[start_idx:end_idx]
|
468
|
+
|
469
|
+
# Check if we hit the collection limit
|
470
|
+
hit_collection_limit = len(all_matches) >= self.MAX_TOTAL_COLLECTED
|
471
|
+
|
472
|
+
# Format the paginated results
|
473
|
+
results = []
|
474
|
+
|
475
|
+
# Build summary showing the range of matches displayed
|
476
|
+
if hit_collection_limit:
|
477
|
+
# We collected MAX_TOTAL_COLLECTED but there might be more
|
478
|
+
summary = f"Found {self.MAX_TOTAL_COLLECTED}+ total matches across {len(files_with_matches)} files (showing matches {start_idx + 1}-{min(end_idx, total_matches)} of {self.MAX_TOTAL_COLLECTED}+)"
|
479
|
+
else:
|
480
|
+
# We found all matches
|
481
|
+
summary = f"Found {total_matches} total matches across {len(files_with_matches)} files (showing matches {start_idx + 1}-{min(end_idx, total_matches)} of {total_matches})"
|
482
|
+
|
468
483
|
if files_skipped > 0:
|
469
|
-
|
484
|
+
summary += f"\nNote: Skipped {files_skipped} files due to size limits"
|
470
485
|
|
471
|
-
|
472
|
-
|
473
|
-
summary += f" in files matching '{include}'"
|
486
|
+
results.append(summary)
|
487
|
+
results.append("=" * 80)
|
474
488
|
|
475
|
-
#
|
476
|
-
|
489
|
+
# Add file summary - count matches per file
|
490
|
+
file_match_counts = {}
|
491
|
+
for file_name, _, _ in all_matches:
|
492
|
+
file_match_counts[file_name] = file_match_counts.get(file_name, 0) + 1
|
477
493
|
|
478
|
-
|
494
|
+
# Sort files by match count (descending) for better overview
|
495
|
+
sorted_files = sorted(file_match_counts.items(), key=lambda x: x[1], reverse=True)
|
496
|
+
|
497
|
+
results.append("\nFiles with matches:")
|
498
|
+
for file_name, count in sorted_files:
|
499
|
+
if hit_collection_limit and count >= self.MAX_TOTAL_COLLECTED:
|
500
|
+
results.append(f" - {file_name}: {count}+ matches")
|
501
|
+
else:
|
502
|
+
results.append(f" - {file_name}: {count} matches")
|
503
|
+
results.append("") # blank line before matches
|
504
|
+
|
505
|
+
# Format each match in the current page
|
506
|
+
for file_name, line_num, context_lines in paginated_matches:
|
507
|
+
match_header = f"\n=== {file_name}:{line_num} ==="
|
508
|
+
match_content = "\n".join(context_lines)
|
509
|
+
results.append(f"{match_header}\n{match_content}")
|
510
|
+
|
511
|
+
# Add navigation hint
|
512
|
+
results.append("") # blank line
|
513
|
+
if end_idx < total_matches:
|
514
|
+
if hit_collection_limit:
|
515
|
+
results.append(f'To see more matches, call: grep_files(pattern="{pattern}", offset={end_idx})')
|
516
|
+
results.append(
|
517
|
+
f"Note: Only the first {self.MAX_TOTAL_COLLECTED} matches were collected. There may be more matches beyond this limit."
|
518
|
+
)
|
519
|
+
else:
|
520
|
+
results.append(f'To see more matches, call: grep_files(pattern="{pattern}", offset={end_idx})')
|
521
|
+
else:
|
522
|
+
if hit_collection_limit:
|
523
|
+
results.append("Showing last page of collected matches. There may be more matches beyond the collection limit.")
|
524
|
+
else:
|
525
|
+
results.append("No more matches to show.")
|
526
|
+
|
527
|
+
return "\n".join(results)
|
479
528
|
|
480
529
|
@trace_method
|
481
530
|
async def semantic_search_files(self, agent_state: AgentState, query: str, limit: int = 5) -> str:
|