Skip to content

Commit 2a34b7e

Browse files
authored
feat: Support timestamp filtering for archival memories [LET-3469] (#4330)
Finish temporal filtering
1 parent 06e4c7f commit 2a34b7e

File tree

5 files changed

+300
-13
lines changed

5 files changed

+300
-13
lines changed

letta/functions/function_sets/base.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,37 @@ async def archival_memory_insert(self: "Agent", content: str, tags: Optional[lis
7878

7979

8080
async def archival_memory_search(
81-
self: "Agent", query: str, tags: Optional[list[str]] = None, tag_match_mode: Literal["any", "all"] = "any", top_k: Optional[int] = None
81+
self: "Agent",
82+
query: str,
83+
tags: Optional[list[str]] = None,
84+
tag_match_mode: Literal["any", "all"] = "any",
85+
top_k: Optional[int] = None,
86+
start_datetime: Optional[str] = None,
87+
end_datetime: Optional[str] = None,
8288
) -> Optional[str]:
8389
"""
84-
Search archival memory using semantic (embedding-based) search.
90+
Search archival memory using semantic (embedding-based) search with optional temporal filtering.
8591
8692
Args:
8793
query (str): String to search for using semantic similarity.
8894
tags (Optional[list[str]]): Optional list of tags to filter search results. Only passages with these tags will be returned.
8995
tag_match_mode (Literal["any", "all"]): How to match tags - "any" to match passages with any of the tags, "all" to match only passages with all tags. Defaults to "any".
9096
top_k (Optional[int]): Maximum number of results to return. Uses system default if not specified.
97+
start_datetime (Optional[str]): Filter results to passages created after this datetime. ISO 8601 format: "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM". Examples: "2024-01-15", "2024-01-15T14:30".
98+
end_datetime (Optional[str]): Filter results to passages created before this datetime. ISO 8601 format: "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM". Examples: "2024-01-20", "2024-01-20T17:00".
99+
100+
Examples:
101+
# Search all passages
102+
archival_memory_search(query="project updates")
103+
104+
# Search with date range (full days)
105+
archival_memory_search(query="meetings", start_datetime="2024-01-15", end_datetime="2024-01-20")
106+
107+
# Search with specific time range
108+
archival_memory_search(query="error logs", start_datetime="2024-01-15T09:30", end_datetime="2024-01-15T17:30")
109+
110+
# Search from a specific point in time onwards
111+
archival_memory_search(query="customer feedback", start_datetime="2024-01-15T14:00")
91112
92113
Returns:
93114
str: Query result string containing matching passages with timestamps and content.

letta/helpers/tpuf_client.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ async def query_passages(
167167
tag_match_mode: TagMatchMode = TagMatchMode.ANY,
168168
vector_weight: float = 0.5,
169169
fts_weight: float = 0.5,
170+
start_date: Optional[datetime] = None,
171+
end_date: Optional[datetime] = None,
170172
) -> List[Tuple[PydanticPassage, float]]:
171173
"""Query passages from Turbopuffer using vector search, full-text search, or hybrid search.
172174
@@ -180,6 +182,8 @@ async def query_passages(
180182
tag_match_mode: TagMatchMode.ANY (match any tag) or TagMatchMode.ALL (match all tags) - default: TagMatchMode.ANY
181183
vector_weight: Weight for vector search results in hybrid mode (default: 0.5)
182184
fts_weight: Weight for FTS results in hybrid mode (default: 0.5)
185+
start_date: Optional datetime to filter passages created after this date
186+
end_date: Optional datetime to filter passages created before this date
183187
184188
Returns:
185189
List of (passage, score) tuples
@@ -225,15 +229,38 @@ async def query_passages(
225229
# For ANY mode, use ContainsAny to match any of the tags
226230
tag_filter = ("tags", "ContainsAny", tags)
227231

232+
# build date filter conditions
233+
date_filters = []
234+
if start_date:
235+
# Turbopuffer expects datetime objects directly for comparison
236+
date_filters.append(("created_at", "Gte", start_date))
237+
if end_date:
238+
# Turbopuffer expects datetime objects directly for comparison
239+
date_filters.append(("created_at", "Lte", end_date))
240+
241+
# combine all filters
242+
all_filters = []
243+
if tag_filter:
244+
all_filters.append(tag_filter)
245+
if date_filters:
246+
all_filters.extend(date_filters)
247+
248+
# create final filter expression
249+
final_filter = None
250+
if len(all_filters) == 1:
251+
final_filter = all_filters[0]
252+
elif len(all_filters) > 1:
253+
final_filter = ("And", all_filters)
254+
228255
if search_mode == "timestamp":
229256
# Fallback: retrieve most recent passages by timestamp
230257
query_params = {
231258
"rank_by": ("created_at", "desc"), # Order by created_at in descending order
232259
"top_k": top_k,
233260
"include_attributes": ["text", "organization_id", "archive_id", "created_at", "tags"],
234261
}
235-
if tag_filter:
236-
query_params["filters"] = tag_filter
262+
if final_filter:
263+
query_params["filters"] = final_filter
237264

238265
result = await namespace.query(**query_params)
239266
return self._process_single_query_results(result, archive_id, tags)
@@ -245,8 +272,8 @@ async def query_passages(
245272
"top_k": top_k,
246273
"include_attributes": ["text", "organization_id", "archive_id", "created_at", "tags"],
247274
}
248-
if tag_filter:
249-
query_params["filters"] = tag_filter
275+
if final_filter:
276+
query_params["filters"] = final_filter
250277

251278
result = await namespace.query(**query_params)
252279
return self._process_single_query_results(result, archive_id, tags)
@@ -258,8 +285,8 @@ async def query_passages(
258285
"top_k": top_k,
259286
"include_attributes": ["text", "organization_id", "archive_id", "created_at", "tags"],
260287
}
261-
if tag_filter:
262-
query_params["filters"] = tag_filter
288+
if final_filter:
289+
query_params["filters"] = final_filter
263290

264291
result = await namespace.query(**query_params)
265292
return self._process_single_query_results(result, archive_id, tags, is_fts=True)
@@ -274,8 +301,8 @@ async def query_passages(
274301
"top_k": top_k,
275302
"include_attributes": ["text", "organization_id", "archive_id", "created_at", "tags"],
276303
}
277-
if tag_filter:
278-
vector_query["filters"] = tag_filter
304+
if final_filter:
305+
vector_query["filters"] = final_filter
279306
queries.append(vector_query)
280307

281308
# full-text search query
@@ -284,8 +311,8 @@ async def query_passages(
284311
"top_k": top_k,
285312
"include_attributes": ["text", "organization_id", "archive_id", "created_at", "tags"],
286313
}
287-
if tag_filter:
288-
fts_query["filters"] = tag_filter
314+
if final_filter:
315+
fts_query["filters"] = final_filter
289316
queries.append(fts_query)
290317

291318
# execute multi-query

letta/services/agent_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2690,6 +2690,8 @@ async def query_agent_passages_async(
26902690
top_k=limit,
26912691
tags=tags,
26922692
tag_match_mode=tag_match_mode or TagMatchMode.ANY,
2693+
start_date=start_date,
2694+
end_date=end_date,
26932695
)
26942696

26952697
# Return just the passages (without scores)

letta/services/tool_executor/core_tool_executor.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,70 @@ async def archival_memory_search(
126126
tags: Optional[list[str]] = None,
127127
tag_match_mode: Literal["any", "all"] = "any",
128128
top_k: Optional[int] = None,
129+
start_datetime: Optional[str] = None,
130+
end_datetime: Optional[str] = None,
129131
) -> Optional[str]:
130132
"""
131-
Search archival memory using semantic (embedding-based) search.
133+
Search archival memory using semantic (embedding-based) search with optional temporal filtering.
132134
133135
Args:
134136
query (str): String to search for using semantic similarity.
135137
tags (Optional[list[str]]): Optional list of tags to filter search results. Only passages with these tags will be returned.
136138
tag_match_mode (Literal["any", "all"]): How to match tags - "any" to match passages with any of the tags, "all" to match only passages with all tags. Defaults to "any".
137139
top_k (Optional[int]): Maximum number of results to return. Uses system default if not specified.
140+
start_datetime (Optional[str]): Filter results to passages created after this datetime. ISO 8601 format.
141+
end_datetime (Optional[str]): Filter results to passages created before this datetime. ISO 8601 format.
138142
139143
Returns:
140144
str: Query result string containing matching passages with timestamps, content, and tags.
141145
"""
142146
try:
147+
# Parse datetime parameters if provided
148+
from datetime import datetime
149+
150+
start_date = None
151+
end_date = None
152+
153+
if start_datetime:
154+
try:
155+
# Try parsing as full datetime first (with time)
156+
start_date = datetime.fromisoformat(start_datetime)
157+
except ValueError:
158+
try:
159+
# Fall back to date-only format
160+
start_date = datetime.strptime(start_datetime, "%Y-%m-%d")
161+
# Set to beginning of day
162+
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
163+
except ValueError:
164+
raise ValueError(
165+
f"Invalid start_datetime format: {start_datetime}. Use ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM)"
166+
)
167+
168+
# Apply agent's timezone if datetime is naive
169+
if start_date.tzinfo is None and agent_state.timezone:
170+
tz = ZoneInfo(agent_state.timezone)
171+
start_date = start_date.replace(tzinfo=tz)
172+
173+
if end_datetime:
174+
try:
175+
# Try parsing as full datetime first (with time)
176+
end_date = datetime.fromisoformat(end_datetime)
177+
except ValueError:
178+
try:
179+
# Fall back to date-only format
180+
end_date = datetime.strptime(end_datetime, "%Y-%m-%d")
181+
# Set to end of day for end dates
182+
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
183+
except ValueError:
184+
raise ValueError(
185+
f"Invalid end_datetime format: {end_datetime}. Use ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM)"
186+
)
187+
188+
# Apply agent's timezone if datetime is naive
189+
if end_date.tzinfo is None and agent_state.timezone:
190+
tz = ZoneInfo(agent_state.timezone)
191+
end_date = end_date.replace(tzinfo=tz)
192+
143193
# Convert string to TagMatchMode enum
144194
tag_mode = TagMatchMode.ANY if tag_match_mode == "any" else TagMatchMode.ALL
145195

@@ -154,6 +204,8 @@ async def archival_memory_search(
154204
embed_query=True,
155205
tags=tags,
156206
tag_match_mode=tag_mode,
207+
start_date=start_date,
208+
end_date=end_date,
157209
)
158210

159211
# Format results to include tags with friendly timestamps

0 commit comments

Comments
 (0)