letta-nightly 0.11.7.dev20250913103940__py3-none-any.whl → 0.11.7.dev20250915104130__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.
@@ -24,7 +24,11 @@ from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
24
24
  from letta.schemas.message import Message
25
25
  from letta.schemas.openai.chat_completion_response import FunctionCall, ToolCall
26
26
  from letta.server.rest_api.json_parser import OptimisticJSONParser
27
- from letta.streaming_utils import FunctionArgumentsStreamHandler, JSONInnerThoughtsExtractor
27
+ from letta.streaming_utils import (
28
+ FunctionArgumentsStreamHandler,
29
+ JSONInnerThoughtsExtractor,
30
+ sanitize_streamed_message_content,
31
+ )
28
32
  from letta.utils import count_tokens
29
33
 
30
34
  logger = get_logger(__name__)
@@ -278,8 +282,6 @@ class OpenAIStreamingInterface:
278
282
  self.prev_assistant_message_id = self.function_id_buffer
279
283
  # Reset message reader at the start of a new send_message stream
280
284
  self.assistant_message_json_reader.reset()
281
- self.assistant_message_json_reader.in_message = True
282
- self.assistant_message_json_reader.message_started = True
283
285
 
284
286
  else:
285
287
  if prev_message_type and prev_message_type != "tool_call_message":
@@ -334,8 +336,15 @@ class OpenAIStreamingInterface:
334
336
  self.last_flushed_function_name is not None
335
337
  and self.last_flushed_function_name == self.assistant_message_tool_name
336
338
  ):
337
- # Minimal, robust extraction: only emit the value of "message"
338
- extracted = self.assistant_message_json_reader.process_json_chunk(tool_call.function.arguments)
339
+ # Minimal, robust extraction: only emit the value of "message".
340
+ # If we buffered a prefix while name was streaming, feed it first.
341
+ if self.function_args_buffer:
342
+ payload = self.function_args_buffer + tool_call.function.arguments
343
+ self.function_args_buffer = None
344
+ else:
345
+ payload = tool_call.function.arguments
346
+ extracted = self.assistant_message_json_reader.process_json_chunk(payload)
347
+ extracted = sanitize_streamed_message_content(extracted or "")
339
348
  if extracted:
340
349
  if prev_message_type and prev_message_type != "assistant_message":
341
350
  message_index += 1
@@ -808,86 +808,33 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
808
808
  # If there was nothing in the name buffer, we can proceed to
809
809
  # output the arguments chunk as a ToolCallMessage
810
810
  else:
811
- # use_assisitant_message means that we should also not release main_json raw, and instead should only release the contents of "message": "..."
811
+ # use_assistant_message means we should emit only the value of "message"
812
812
  if self.use_assistant_message and (
813
813
  self.last_flushed_function_name is not None
814
814
  and self.last_flushed_function_name == self.assistant_message_tool_name
815
815
  ):
816
- # do an additional parse on the updates_main_json
817
- if self.function_args_buffer:
818
- updates_main_json = self.function_args_buffer + updates_main_json
819
- self.function_args_buffer = None
820
-
821
- # Pretty gross hardcoding that assumes that if we're toggling into the keywords, we have the full prefix
822
- match_str = '{"' + self.assistant_message_tool_kwarg + '":"'
823
- if updates_main_json == match_str:
824
- updates_main_json = None
825
-
826
- else:
827
- # Some hardcoding to strip off the trailing "}"
828
- if updates_main_json in ["}", '"}']:
829
- updates_main_json = None
830
- if updates_main_json and len(updates_main_json) > 0 and updates_main_json[-1:] == '"':
831
- updates_main_json = updates_main_json[:-1]
832
-
833
- if not updates_main_json:
834
- # early exit to turn into content mode
816
+ # Feed any buffered prefix first to avoid missing the start of the value
817
+ payload = (self.function_args_buffer or "") + (updates_main_json or "")
818
+ self.function_args_buffer = None
819
+ cleaned = self.streaming_chat_completion_json_reader.process_json_chunk(payload)
820
+ from letta.streaming_utils import sanitize_streamed_message_content
821
+
822
+ cleaned = sanitize_streamed_message_content(cleaned or "")
823
+ if not cleaned:
835
824
  return None
836
-
837
- # There may be a buffer from a previous chunk, for example
838
- # if the previous chunk had arguments but we needed to flush name
839
- if self.function_args_buffer:
840
- # In this case, we should release the buffer + new data at once
841
- combined_chunk = self.function_args_buffer + updates_main_json
842
-
843
- if prev_message_type and prev_message_type != "assistant_message":
844
- message_index += 1
845
- processed_chunk = AssistantMessage(
846
- id=message_id,
847
- date=message_date,
848
- content=combined_chunk,
849
- name=name,
850
- otid=Message.generate_otid_from_id(message_id, message_index),
851
- )
852
- # Store the ID of the tool call so allow skipping the corresponding response
853
- if self.function_id_buffer:
854
- self.prev_assistant_message_id = self.function_id_buffer
855
- # clear buffer
856
- self.function_args_buffer = None
857
- self.function_id_buffer = None
858
-
859
- else:
860
- # If there's no buffer to clear, just output a new chunk with new data
861
- # TODO: THIS IS HORRIBLE
862
- # TODO: WE USE THE OLD JSON PARSER EARLIER (WHICH DOES NOTHING) AND NOW THE NEW JSON PARSER
863
- # TODO: THIS IS TOTALLY WRONG AND BAD, BUT SAVING FOR A LARGER REWRITE IN THE NEAR FUTURE
864
- parsed_args = self.optimistic_json_parser.parse(self.current_function_arguments)
865
-
866
- if parsed_args.get(self.assistant_message_tool_kwarg) and parsed_args.get(
867
- self.assistant_message_tool_kwarg
868
- ) != self.current_json_parse_result.get(self.assistant_message_tool_kwarg):
869
- new_content = parsed_args.get(self.assistant_message_tool_kwarg)
870
- prev_content = self.current_json_parse_result.get(self.assistant_message_tool_kwarg, "")
871
- # TODO: Assumes consistent state and that prev_content is subset of new_content
872
- diff = new_content.replace(prev_content, "", 1)
873
- self.current_json_parse_result = parsed_args
874
- if prev_message_type and prev_message_type != "assistant_message":
875
- message_index += 1
876
- processed_chunk = AssistantMessage(
877
- id=message_id,
878
- date=message_date,
879
- content=diff,
880
- name=name,
881
- otid=Message.generate_otid_from_id(message_id, message_index),
882
- )
883
- else:
884
- return None
885
-
886
- # Store the ID of the tool call so allow skipping the corresponding response
887
- if self.function_id_buffer:
888
- self.prev_assistant_message_id = self.function_id_buffer
889
- # clear buffers
890
- self.function_id_buffer = None
825
+ if prev_message_type and prev_message_type != "assistant_message":
826
+ message_index += 1
827
+ processed_chunk = AssistantMessage(
828
+ id=message_id,
829
+ date=message_date,
830
+ content=cleaned,
831
+ name=name,
832
+ otid=Message.generate_otid_from_id(message_id, message_index),
833
+ )
834
+ # Store the ID of the tool call so allow skipping the corresponding response
835
+ if self.function_id_buffer:
836
+ self.prev_assistant_message_id = self.function_id_buffer
837
+ # Do not clear function_id_buffer here — we may still need it
891
838
  else:
892
839
  # There may be a buffer from a previous chunk, for example
893
840
  # if the previous chunk had arguments but we needed to flush name
@@ -162,8 +162,8 @@ class IndentedORJSONResponse(Response):
162
162
  return orjson.dumps(content, option=orjson.OPT_INDENT_2)
163
163
 
164
164
 
165
- @router.get("/{agent_id}/export", response_class=IndentedORJSONResponse, operation_id="export_agent_serialized")
166
- async def export_agent_serialized(
165
+ @router.get("/{agent_id}/export", response_class=IndentedORJSONResponse, operation_id="export_agent")
166
+ async def export_agent(
167
167
  agent_id: str,
168
168
  max_steps: int = 100,
169
169
  server: "SyncServer" = Depends(get_letta_server),
@@ -256,7 +256,7 @@ def import_agent_legacy(
256
256
  raise HTTPException(status_code=500, detail=f"An unexpected error occurred while uploading the agent: {e!s}")
257
257
 
258
258
 
259
- async def import_agent(
259
+ async def _import_agent(
260
260
  agent_file_json: dict,
261
261
  server: "SyncServer",
262
262
  actor: User,
@@ -313,8 +313,8 @@ async def import_agent(
313
313
  raise HTTPException(status_code=500, detail=f"An unexpected error occurred while importing agents: {e!s}")
314
314
 
315
315
 
316
- @router.post("/import", response_model=ImportedAgentsResponse, operation_id="import_agent_serialized")
317
- async def import_agent_serialized(
316
+ @router.post("/import", response_model=ImportedAgentsResponse, operation_id="import_agent")
317
+ async def import_agent(
318
318
  file: UploadFile = File(...),
319
319
  server: "SyncServer" = Depends(get_letta_server),
320
320
  actor_id: str | None = Header(None, alias="user_id"),
@@ -367,7 +367,7 @@ async def import_agent_serialized(
367
367
  # TODO: This is kind of hacky, but should work as long as dont' change the schema
368
368
  if "agents" in agent_json and isinstance(agent_json.get("agents"), list):
369
369
  # This is an AgentFileSchema
370
- agent_ids = await import_agent(
370
+ agent_ids = await _import_agent(
371
371
  agent_file_json=agent_json,
372
372
  server=server,
373
373
  actor=actor,
@@ -12,8 +12,8 @@ router = APIRouter(prefix="/health", tags=["health"])
12
12
 
13
13
 
14
14
  # Health check
15
- @router.get("/", response_model=Health, operation_id="health_check")
16
- def health_check():
15
+ @router.get("/", response_model=Health, operation_id="check_health")
16
+ def check_health():
17
17
  return Health(
18
18
  version=__version__,
19
19
  status="ok",
@@ -19,23 +19,22 @@ router = APIRouter(prefix="/messages", tags=["messages"])
19
19
  logger = get_logger(__name__)
20
20
 
21
21
 
22
- # Batch APIs
23
-
24
-
25
22
  @router.post(
26
23
  "/batches",
27
24
  response_model=BatchJob,
28
- operation_id="create_batch_run",
25
+ operation_id="create_batch",
29
26
  )
30
- async def create_batch_run(
27
+ async def create_batch(
31
28
  request: Request,
32
29
  payload: CreateBatch = Body(..., description="Messages and config for all agents"),
33
30
  server: SyncServer = Depends(get_letta_server),
34
31
  actor_id: Optional[str] = Header(None, alias="user_id"),
35
32
  ):
36
33
  """
37
- Submit a batch of agent messages for asynchronous processing.
34
+ Submit a batch of agent runs for asynchronous processing.
35
+
38
36
  Creates a job that will fan out messages to all listed agents and process them in parallel.
37
+ The request will be rejected if it exceeds 256MB.
39
38
  """
40
39
  # Reject requests greater than 256Mbs
41
40
  max_bytes = 256 * 1024 * 1024
@@ -76,10 +75,7 @@ async def create_batch_run(
76
75
 
77
76
  # TODO: update run metadata
78
77
  except Exception as e:
79
- import traceback
80
-
81
- print("Error creating batch job", e)
82
- traceback.print_exc()
78
+ logger.error(f"Error creating batch job: {e}")
83
79
 
84
80
  # mark job as failed
85
81
  await server.job_manager.update_job_by_id_async(job_id=batch_job.id, job_update=JobUpdate(status=JobStatus.failed), actor=actor)
@@ -87,14 +83,14 @@ async def create_batch_run(
87
83
  return batch_job
88
84
 
89
85
 
90
- @router.get("/batches/{batch_id}", response_model=BatchJob, operation_id="retrieve_batch_run")
91
- async def retrieve_batch_run(
86
+ @router.get("/batches/{batch_id}", response_model=BatchJob, operation_id="retrieve_batch")
87
+ async def retrieve_batch(
92
88
  batch_id: str,
93
89
  actor_id: Optional[str] = Header(None, alias="user_id"),
94
90
  server: "SyncServer" = Depends(get_letta_server),
95
91
  ):
96
92
  """
97
- Get the status of a batch run.
93
+ Retrieve the status and details of a batch run.
98
94
  """
99
95
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
100
96
 
@@ -105,18 +101,36 @@ async def retrieve_batch_run(
105
101
  raise HTTPException(status_code=404, detail="Batch not found")
106
102
 
107
103
 
108
- @router.get("/batches", response_model=List[BatchJob], operation_id="list_batch_runs")
109
- async def list_batch_runs(
104
+ @router.get("/batches", response_model=List[BatchJob], operation_id="list_batches")
105
+ async def list_batches(
106
+ before: Optional[str] = Query(
107
+ None, description="Job ID cursor for pagination. Returns jobs that come before this job ID in the specified sort order"
108
+ ),
109
+ after: Optional[str] = Query(
110
+ None, description="Job ID cursor for pagination. Returns jobs that come after this job ID in the specified sort order"
111
+ ),
112
+ limit: Optional[int] = Query(100, description="Maximum number of jobs to return"),
113
+ order: Literal["asc", "desc"] = Query(
114
+ "desc", description="Sort order for jobs by creation time. 'asc' for oldest first, 'desc' for newest first"
115
+ ),
116
+ order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"),
110
117
  actor_id: Optional[str] = Header(None, alias="user_id"),
111
118
  server: "SyncServer" = Depends(get_letta_server),
112
119
  ):
113
120
  """
114
121
  List all batch runs.
115
122
  """
116
- # TODO: filter
117
123
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
118
124
 
119
- jobs = server.job_manager.list_jobs(actor=actor, statuses=[JobStatus.created, JobStatus.running], job_type=JobType.BATCH)
125
+ jobs = server.job_manager.list_jobs(
126
+ actor=actor,
127
+ statuses=[JobStatus.created, JobStatus.running],
128
+ job_type=JobType.BATCH,
129
+ before=before,
130
+ after=after,
131
+ limit=limit,
132
+ ascending=(order == "asc"),
133
+ )
120
134
  return [BatchJob.from_job(job) for job in jobs]
121
135
 
122
136
 
@@ -137,14 +151,17 @@ async def list_batch_messages(
137
151
  order: Literal["asc", "desc"] = Query(
138
152
  "desc", description="Sort order for messages by creation time. 'asc' for oldest first, 'desc' for newest first"
139
153
  ),
154
+ order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"),
140
155
  agent_id: Optional[str] = Query(None, description="Filter messages by agent ID"),
141
156
  actor_id: Optional[str] = Header(None, alias="user_id"),
142
157
  server: SyncServer = Depends(get_letta_server),
143
158
  ):
144
- """Get response messages for a specific batch job."""
159
+ """
160
+ Get response messages for a specific batch job.
161
+ """
145
162
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
146
163
 
147
- # First, verify the batch job exists and the user has access to it
164
+ # Verify the batch job exists and the user has access to it
148
165
  try:
149
166
  job = await server.job_manager.get_job_by_id_async(job_id=batch_id, actor=actor)
150
167
  BatchJob.from_job(job)
@@ -159,8 +176,8 @@ async def list_batch_messages(
159
176
  return LettaBatchMessages(messages=messages)
160
177
 
161
178
 
162
- @router.patch("/batches/{batch_id}/cancel", operation_id="cancel_batch_run")
163
- async def cancel_batch_run(
179
+ @router.patch("/batches/{batch_id}/cancel", operation_id="cancel_batch")
180
+ async def cancel_batch(
164
181
  batch_id: str,
165
182
  server: "SyncServer" = Depends(get_letta_server),
166
183
  actor_id: Optional[str] = Header(None, alias="user_id"),
@@ -25,7 +25,7 @@ async def list_providers(
25
25
  server: "SyncServer" = Depends(get_letta_server),
26
26
  ):
27
27
  """
28
- Get a list of all custom providers in the database
28
+ Get a list of all custom providers.
29
29
  """
30
30
  try:
31
31
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
@@ -46,7 +46,7 @@ async def create_provider(
46
46
  server: "SyncServer" = Depends(get_letta_server),
47
47
  ):
48
48
  """
49
- Create a new custom provider
49
+ Create a new custom provider.
50
50
  """
51
51
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
52
52
  for field_name in request.model_fields:
@@ -68,7 +68,7 @@ async def modify_provider(
68
68
  server: "SyncServer" = Depends(get_letta_server),
69
69
  ):
70
70
  """
71
- Update an existing custom provider
71
+ Update an existing custom provider.
72
72
  """
73
73
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
74
74
  return await server.provider_manager.update_provider_async(provider_id=provider_id, provider_update=request, actor=actor)
@@ -79,6 +79,9 @@ async def check_provider(
79
79
  request: ProviderCheck = Body(...),
80
80
  server: "SyncServer" = Depends(get_letta_server),
81
81
  ):
82
+ """
83
+ Verify the API key and additional parameters for a provider.
84
+ """
82
85
  try:
83
86
  if request.base_url and len(request.base_url) == 0:
84
87
  # set to null if empty string
@@ -100,7 +103,7 @@ async def delete_provider(
100
103
  server: "SyncServer" = Depends(get_letta_server),
101
104
  ):
102
105
  """
103
- Delete an existing custom provider
106
+ Delete an existing custom provider.
104
107
  """
105
108
  try:
106
109
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
@@ -1,14 +1,17 @@
1
1
  from datetime import datetime
2
2
  from typing import List, Literal, Optional
3
3
 
4
- from fastapi import APIRouter, Depends, Header, HTTPException, Query
4
+ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
5
+ from pydantic import BaseModel, Field
5
6
 
6
7
  from letta.orm.errors import NoResultFound
8
+ from letta.schemas.provider_trace import ProviderTrace
7
9
  from letta.schemas.step import Step
8
10
  from letta.schemas.step_metrics import StepMetrics
9
11
  from letta.server.rest_api.utils import get_letta_server
10
12
  from letta.server.server import SyncServer
11
13
  from letta.services.step_manager import FeedbackType
14
+ from letta.settings import settings
12
15
 
13
16
  router = APIRouter(prefix="/steps", tags=["steps"])
14
17
 
@@ -93,10 +96,33 @@ async def retrieve_step_metrics(
93
96
  raise HTTPException(status_code=404, detail="Step metrics not found")
94
97
 
95
98
 
99
+ @router.get("/{step_id}/trace", response_model=Optional[ProviderTrace], operation_id="retrieve_step_trace")
100
+ async def retrieve_step_trace(
101
+ step_id: str,
102
+ server: SyncServer = Depends(get_letta_server),
103
+ actor_id: str | None = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
104
+ ):
105
+ provider_trace = None
106
+ if settings.track_provider_trace:
107
+ try:
108
+ provider_trace = await server.telemetry_manager.get_provider_trace_by_step_id_async(
109
+ step_id=step_id, actor=await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
110
+ )
111
+ except:
112
+ pass
113
+
114
+ return provider_trace
115
+
116
+
117
+ class AddFeedbackRequest(BaseModel):
118
+ feedback: FeedbackType | None = Field(None, description="Whether this feedback is positive or negative")
119
+ tags: list[str] | None = Field(None, description="Feedback tags to add to the step")
120
+
121
+
96
122
  @router.patch("/{step_id}/feedback", response_model=Step, operation_id="add_feedback")
97
123
  async def add_feedback(
98
124
  step_id: str,
99
- feedback: Optional[FeedbackType],
125
+ request: AddFeedbackRequest = Body(...),
100
126
  actor_id: Optional[str] = Header(None, alias="user_id"),
101
127
  server: SyncServer = Depends(get_letta_server),
102
128
  ):
@@ -105,7 +131,7 @@ async def add_feedback(
105
131
  """
106
132
  try:
107
133
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
108
- return await server.step_manager.add_feedback_async(step_id=step_id, feedback=feedback, actor=actor)
134
+ return await server.step_manager.add_feedback_async(step_id=step_id, feedback=request.feedback, tags=request.tags, actor=actor)
109
135
  except NoResultFound:
110
136
  raise HTTPException(status_code=404, detail="Step not found")
111
137
 
@@ -1,4 +1,4 @@
1
- from typing import TYPE_CHECKING, List, Optional
1
+ from typing import TYPE_CHECKING, List, Literal, Optional
2
2
 
3
3
  from fastapi import APIRouter, Depends, Header, Query
4
4
 
@@ -13,15 +13,26 @@ router = APIRouter(prefix="/tags", tags=["tag", "admin"])
13
13
 
14
14
  @router.get("/", tags=["admin"], response_model=List[str], operation_id="list_tags")
15
15
  async def list_tags(
16
- after: Optional[str] = Query(None),
17
- limit: Optional[int] = Query(50),
16
+ before: Optional[str] = Query(
17
+ None, description="Tag cursor for pagination. Returns tags that come before this tag in the specified sort order"
18
+ ),
19
+ after: Optional[str] = Query(
20
+ None, description="Tag cursor for pagination. Returns tags that come after this tag in the specified sort order"
21
+ ),
22
+ limit: Optional[int] = Query(50, description="Maximum number of tags to return"),
23
+ order: Literal["asc", "desc"] = Query(
24
+ "asc", description="Sort order for tags. 'asc' for alphabetical order, 'desc' for reverse alphabetical order"
25
+ ),
26
+ order_by: Literal["name"] = Query("name", description="Field to sort by"),
27
+ query_text: Optional[str] = Query(None, description="Filter tags by text search"),
18
28
  server: "SyncServer" = Depends(get_letta_server),
19
- query_text: Optional[str] = Query(None),
20
29
  actor_id: Optional[str] = Header(None, alias="user_id"),
21
30
  ):
22
31
  """
23
- Get a list of all tags in the database
32
+ Get a list of all agent tags in the database.
24
33
  """
25
34
  actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
26
- tags = await server.agent_manager.list_tags_async(actor=actor, after=after, limit=limit, query_text=query_text)
35
+ tags = await server.agent_manager.list_tags_async(
36
+ actor=actor, before=before, after=after, limit=limit, query_text=query_text, ascending=(order == "asc")
37
+ )
27
38
  return tags
@@ -3542,19 +3542,27 @@ class AgentManager:
3542
3542
  @enforce_types
3543
3543
  @trace_method
3544
3544
  async def list_tags_async(
3545
- self, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50, query_text: Optional[str] = None
3545
+ self,
3546
+ actor: PydanticUser,
3547
+ before: Optional[str] = None,
3548
+ after: Optional[str] = None,
3549
+ limit: Optional[int] = 50,
3550
+ query_text: Optional[str] = None,
3551
+ ascending: bool = True,
3546
3552
  ) -> List[str]:
3547
3553
  """
3548
3554
  Get all tags a user has created, ordered alphabetically.
3549
3555
 
3550
3556
  Args:
3551
3557
  actor: User performing the action.
3552
- after: Cursor for forward pagination.
3553
- limit: Maximum number of tags to return.
3554
- query text to filter tags by.
3558
+ before: Cursor for backward pagination (tags before this tag).
3559
+ after: Cursor for forward pagination (tags after this tag).
3560
+ limit: Maximum number of tags to return (default: 50).
3561
+ query_text: Filter tags by text search.
3562
+ ascending: Sort order - True for alphabetical, False for reverse (default: True).
3555
3563
 
3556
3564
  Returns:
3557
- List[str]: List of all tags.
3565
+ List[str]: List of all tags matching the criteria.
3558
3566
  """
3559
3567
  async with db_registry.async_session() as session:
3560
3568
  # Build the query using select() for async SQLAlchemy
@@ -3573,10 +3581,26 @@ class AgentManager:
3573
3581
  # SQLite: Use LIKE with LOWER for case-insensitive search
3574
3582
  query = query.where(func.lower(AgentsTags.tag).like(func.lower(f"%{query_text}%")))
3575
3583
 
3584
+ # Handle pagination cursors
3576
3585
  if after:
3577
- query = query.where(AgentsTags.tag > after)
3586
+ if ascending:
3587
+ query = query.where(AgentsTags.tag > after)
3588
+ else:
3589
+ query = query.where(AgentsTags.tag < after)
3578
3590
 
3579
- query = query.order_by(AgentsTags.tag).limit(limit)
3591
+ if before:
3592
+ if ascending:
3593
+ query = query.where(AgentsTags.tag < before)
3594
+ else:
3595
+ query = query.where(AgentsTags.tag > before)
3596
+
3597
+ # Apply ordering based on ascending parameter
3598
+ if ascending:
3599
+ query = query.order_by(AgentsTags.tag.asc())
3600
+ else:
3601
+ query = query.order_by(AgentsTags.tag.desc())
3602
+
3603
+ query = query.limit(limit)
3580
3604
 
3581
3605
  # Execute the query asynchronously
3582
3606
  result = await session.execute(query)
@@ -197,12 +197,16 @@ class StepManager:
197
197
 
198
198
  @enforce_types
199
199
  @trace_method
200
- async def add_feedback_async(self, step_id: str, feedback: Optional[FeedbackType], actor: PydanticUser) -> PydanticStep:
200
+ async def add_feedback_async(
201
+ self, step_id: str, feedback: FeedbackType | None, actor: PydanticUser, tags: list[str] | None = None
202
+ ) -> PydanticStep:
201
203
  async with db_registry.async_session() as session:
202
204
  step = await StepModel.read_async(db_session=session, identifier=step_id, actor=actor)
203
205
  if not step:
204
206
  raise NoResultFound(f"Step with id {step_id} does not exist")
205
207
  step.feedback = feedback
208
+ if tags:
209
+ step.tags = tags
206
210
  step = await step.update_async(session)
207
211
  return step.to_pydantic()
208
212
 
letta/streaming_utils.py CHANGED
@@ -264,39 +264,100 @@ class FunctionArgumentsStreamHandler:
264
264
 
265
265
  def process_json_chunk(self, chunk: str) -> Optional[str]:
266
266
  """Process a chunk from the function arguments and return the plaintext version"""
267
- # Use strip to handle only leading and trailing whitespace in control structures
268
- if self.accumulating:
269
- clean_chunk = chunk.strip()
270
- if self.json_key in self.key_buffer:
271
- if ":" in clean_chunk:
272
- self.in_message = True
273
- self.accumulating = False
274
- return None
267
+ clean_chunk = chunk.strip()
268
+ # Not in message yet: accumulate until we see '<json_key>': (robust to split fragments)
269
+ if not self.in_message:
270
+ if clean_chunk == "{":
271
+ self.key_buffer = ""
272
+ self.accumulating = True
273
+ return None
275
274
  self.key_buffer += clean_chunk
275
+ if self.json_key in self.key_buffer and ":" in clean_chunk:
276
+ # Enter value mode; attempt to extract inline content if it exists in this same chunk
277
+ self.in_message = True
278
+ self.accumulating = False
279
+ # Try to find the first quote after the colon within the original (unstripped) chunk
280
+ s = chunk
281
+ colon_idx = s.find(":")
282
+ if colon_idx != -1:
283
+ q_idx = s.find('"', colon_idx + 1)
284
+ if q_idx != -1:
285
+ self.message_started = True
286
+ rem = s[q_idx + 1 :]
287
+ # Check if this same chunk also contains the terminating quote (and optional delimiter)
288
+ j = len(rem) - 1
289
+ while j >= 0 and rem[j] in " \t\r\n":
290
+ j -= 1
291
+ if j >= 1 and rem[j - 1] == '"' and rem[j] in ",}]":
292
+ out = rem[: j - 1]
293
+ self.in_message = False
294
+ self.message_started = False
295
+ return out
296
+ if j >= 0 and rem[j] == '"':
297
+ out = rem[:j]
298
+ self.in_message = False
299
+ self.message_started = False
300
+ return out
301
+ # No terminator yet; emit remainder as content
302
+ return rem
303
+ return None
304
+ if clean_chunk == "}":
305
+ self.in_message = False
306
+ self.message_started = False
307
+ self.key_buffer = ""
276
308
  return None
277
309
 
310
+ # Inside message value
278
311
  if self.in_message:
279
- if chunk.strip() == '"' and self.message_started:
312
+ # Bare opening/closing quote tokens
313
+ if clean_chunk == '"' and self.message_started:
280
314
  self.in_message = False
281
315
  self.message_started = False
282
316
  return None
283
- if not self.message_started and chunk.strip() == '"':
317
+ if not self.message_started and clean_chunk == '"':
284
318
  self.message_started = True
285
319
  return None
286
320
  if self.message_started:
287
- if chunk.strip().endswith('"'):
321
+ # Detect closing patterns: '"', '",', '"}' (with optional whitespace)
322
+ i = len(chunk) - 1
323
+ while i >= 0 and chunk[i] in " \t\r\n":
324
+ i -= 1
325
+ if i >= 1 and chunk[i - 1] == '"' and chunk[i] in ",}]":
326
+ out = chunk[: i - 1]
288
327
  self.in_message = False
289
- return chunk.rstrip('"\n')
328
+ self.message_started = False
329
+ return out
330
+ if i >= 0 and chunk[i] == '"':
331
+ out = chunk[:i]
332
+ self.in_message = False
333
+ self.message_started = False
334
+ return out
335
+ # Otherwise, still mid-string
290
336
  return chunk
291
337
 
292
- if chunk.strip() == "{":
293
- self.key_buffer = ""
294
- self.accumulating = True
295
- return None
296
-
297
- if chunk.strip() == "}":
338
+ if clean_chunk == "}":
298
339
  self.in_message = False
299
340
  self.message_started = False
341
+ self.key_buffer = ""
300
342
  return None
301
343
 
302
344
  return None
345
+
346
+
347
+ def sanitize_streamed_message_content(text: str) -> str:
348
+ """Remove trailing JSON delimiters that can leak into assistant text.
349
+
350
+ Specifically handles cases where a message string is immediately followed
351
+ by a JSON delimiter in the stream (e.g., '"', '",', '"}', '" ]').
352
+ Internal commas inside the message are preserved.
353
+ """
354
+ if not text:
355
+ return text
356
+ t = text.rstrip()
357
+ # strip trailing quote + delimiter
358
+ if len(t) >= 2 and t[-2] == '"' and t[-1] in ",}]":
359
+ return t[:-2]
360
+ # strip lone trailing quote
361
+ if t.endswith('"'):
362
+ return t[:-1]
363
+ return t
letta/utils.py CHANGED
@@ -536,6 +536,8 @@ def enforce_types(func):
536
536
 
537
537
  if origin is Union: # Handle Union types (including Optional)
538
538
  return any(matches_type(value, arg) for arg in args)
539
+ elif hasattr(hint, "__class__") and hint.__class__.__name__ == "UnionType": # Handle Python 3.10+ X | Y syntax
540
+ return any(matches_type(value, arg) for arg in args)
539
541
  elif origin is list and isinstance(value, list): # Handle List[T]
540
542
  element_type = args[0] if args else None
541
543
  return all(isinstance(v, element_type) for v in value) if element_type else True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: letta-nightly
3
- Version: 0.11.7.dev20250913103940
3
+ Version: 0.11.7.dev20250915104130
4
4
  Summary: Create LLM agents with long-term memory and custom tools
5
5
  Author-email: Letta Team <contact@letta.com>
6
6
  License: Apache License
@@ -11,9 +11,9 @@ letta/memory.py,sha256=l5iNhLAR_xzgTb0GBlQx4SVgH8kuZh8siJdC_CFPKEs,4278
11
11
  letta/pytest.ini,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  letta/settings.py,sha256=QEjNUwRXGBgsQpQAs2kksQmGN5CbxKlxPPydrklx_Ms,15011
13
13
  letta/streaming_interface.py,sha256=rPMfwUcjqITWk2tVqFQm1hmP99tU2IOHg9gU2dgPSo8,16400
14
- letta/streaming_utils.py,sha256=_UhLa0EtUkd6WL_oBYIU65tDcJ9jf3uWEHuzfQ4HCa8,13769
14
+ letta/streaming_utils.py,sha256=ZRFGFpQqn9ujCEbgZdLM7yTjiuNNvqQ47sNhV8ix-yQ,16553
15
15
  letta/system.py,sha256=kHF7n3Viq7gV5UIUEXixod2gWa2jroUgztpEzMC1Sew,8925
16
- letta/utils.py,sha256=bSq3St7MUw9gN1g0ICdOhNNaUFYBC3EfJLG6qsRLSFA,43290
16
+ letta/utils.py,sha256=TwSAZKw3uCWAzmmEA156W4CYRDaEOiZmAO-zvzFdK6Q,43483
17
17
  letta/adapters/letta_llm_adapter.py,sha256=11wkOkEQfPXUuJoJxbK22wCa-8gnWiDAb3UOXOxLt5U,3427
18
18
  letta/adapters/letta_llm_request_adapter.py,sha256=wJhK5M_qOhRPAhgMmYI7EJcM8Op19tClnXe0kJ29a3Q,4831
19
19
  letta/adapters/letta_llm_stream_adapter.py,sha256=G8IqtXor0LUuW-dKtGJWsUt6DfJreVCn5h6W2lHEPBg,7658
@@ -85,7 +85,7 @@ letta/humans/examples/cs_phd.txt,sha256=9C9ZAV_VuG7GB31ksy3-_NAyk8rjE6YtVOkhp08k
85
85
  letta/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
86
86
  letta/interfaces/anthropic_streaming_interface.py,sha256=0VyK8kTRgCLNDLQN6vX1gJ0dfJhqguL_NL1GYgFr6fU,25614
87
87
  letta/interfaces/openai_chat_completions_streaming_interface.py,sha256=3xHXh8cW79EkiMUTYfvcH_s92nkLjxXfvtVOVC3bfLo,5050
88
- letta/interfaces/openai_streaming_interface.py,sha256=abmtQhWWbXSZGTPBPbMGuAJCyMo9euwttPsjI6joiVU,23768
88
+ letta/interfaces/openai_streaming_interface.py,sha256=YLArar2ypOEaVt7suJxpg1QZr0ErwEmPSEVhzaP6JWc,24166
89
89
  letta/interfaces/utils.py,sha256=c6jvO0dBYHh8DQnlN-B0qeNC64d3CSunhfqlFA4pJTY,278
90
90
  letta/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
91
91
  letta/jobs/helpers.py,sha256=kO4aj954xsQ1RAmkjY6LQQ7JEIGuhaxB1e9pzrYKHAY,914
@@ -340,7 +340,7 @@ letta/server/rest_api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
340
340
  letta/server/rest_api/app.py,sha256=T3LLveXRJmfWqR0uEzoaLY8LXwYrwCQGb80XMbSCDUo,21172
341
341
  letta/server/rest_api/auth_token.py,sha256=725EFEIiNj4dh70hrSd94UysmFD8vcJLrTRfNHkzxDo,774
342
342
  letta/server/rest_api/chat_completions_interface.py,sha256=-7wO7pNBWXMqblVkJpuZ8JPJ-LjudLTtT6BJu-q_XAM,11138
343
- letta/server/rest_api/interface.py,sha256=X5NZ8oerDcipG9y1AfD92zJ_2TgVMO4eJ42RP82GFF8,70952
343
+ letta/server/rest_api/interface.py,sha256=_GQfKYUp9w4Wo2HSE_8Ff7QU16t1blspLaqmukpER9s,67099
344
344
  letta/server/rest_api/json_parser.py,sha256=yoakaCkSMdf0Y_pyILoFKZlvzXeqF-E1KNeHzatLMDc,9157
345
345
  letta/server/rest_api/redis_stream_manager.py,sha256=hz85CigFWdLkK1FWUmF-i6ObgoKkuoEgkiwshZ6QPKI,10764
346
346
  letta/server/rest_api/static_files.py,sha256=NG8sN4Z5EJ8JVQdj19tkFa9iQ1kBPTab9f_CUxd_u4Q,3143
@@ -355,24 +355,24 @@ letta/server/rest_api/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
355
355
  letta/server/rest_api/routers/openai/chat_completions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
356
356
  letta/server/rest_api/routers/openai/chat_completions/chat_completions.py,sha256=ohM1i8BsNxTiw8duuRT5X_0tSUzBwctQM4fJ5DXURic,5157
357
357
  letta/server/rest_api/routers/v1/__init__.py,sha256=9MnEA7CgtIxyU_dDNG0jm-Ziqu1somBml-e5gKjgd9I,1997
358
- letta/server/rest_api/routers/v1/agents.py,sha256=2lGLtfgB2ZtAa0EgaiaDlNV0GJhAi_kQQy7XqXB2DG0,77771
358
+ letta/server/rest_api/routers/v1/agents.py,sha256=rYCTQqlWHp0YgahElG1XOznD3OKT7zRr4R7mBo_ln8o,77729
359
359
  letta/server/rest_api/routers/v1/blocks.py,sha256=ykI77xnmIxPLqdAy5kzGyGw0w0ZRyVXn-O5Xcdj6-70,7690
360
360
  letta/server/rest_api/routers/v1/embeddings.py,sha256=PRaQlrmEXPiIdWsTbadrFsv3Afyv5oEFUdhgHA8FTi8,989
361
361
  letta/server/rest_api/routers/v1/folders.py,sha256=8Yb-bw2JdXBxMfrJNIZQk9_FKN2fet9Ccp8T83_c2sc,23539
362
362
  letta/server/rest_api/routers/v1/groups.py,sha256=PlCKfG1ZUubg-bNVRBmqJNBMvvZtHDvT50LUKKd0w9I,11466
363
- letta/server/rest_api/routers/v1/health.py,sha256=MoOjkydhGcJXTiuJrKIB0etVXiRMdTa51S8RQ8-50DQ,399
363
+ letta/server/rest_api/routers/v1/health.py,sha256=j43UoGJ7Yh5WzdwvqbKTEdWzlcKJBF6ZI5I1kslWim0,399
364
364
  letta/server/rest_api/routers/v1/identities.py,sha256=KUfw6avQIVHNw2lWz4pXOyTOPVy1g19CJGG-zayORl8,7858
365
365
  letta/server/rest_api/routers/v1/internal_templates.py,sha256=wY7tUmF7kZEVnjBVsw3_Tez4U2c8SABDJ2vplsKxhzM,11211
366
366
  letta/server/rest_api/routers/v1/jobs.py,sha256=ZcP_cqxgixCEYNtKVMqN1FwErNY-945h7XZhQV4vcEE,4933
367
367
  letta/server/rest_api/routers/v1/llms.py,sha256=0VJuuGW9_ta0cBnSDtXd3Ngw7GjsqEN2NBf5U3b6M3I,1920
368
- letta/server/rest_api/routers/v1/messages.py,sha256=J4Sbn8oMX24_OoBpHFLpcJi7vgJxeihmu36EgcPmfjk,7662
368
+ letta/server/rest_api/routers/v1/messages.py,sha256=iXw59JTqpXs_I6JTxE5bNCh72EUExBOo2dewv68Lb94,8528
369
369
  letta/server/rest_api/routers/v1/organizations.py,sha256=OnG2vMDZEmN4eEvj24CPwiV76ImHQuHi2ojrgwJnw7I,2925
370
- letta/server/rest_api/routers/v1/providers.py,sha256=rypNWQ1VMjmZJYd48uvNGJsE2N22jDTxn89qvbDaOMs,4765
370
+ letta/server/rest_api/routers/v1/providers.py,sha256=T3xvtiJO89p_a0wJ1fFmqPOc--kGbmOXI9eWdDW260c,4834
371
371
  letta/server/rest_api/routers/v1/runs.py,sha256=WnYwoFNjHNZicTnCkvoXCxl0XiyVAEvF70TTaMCBhPw,12982
372
372
  letta/server/rest_api/routers/v1/sandbox_configs.py,sha256=f0xEOwR3PXqCS2HOjEv7UKfMWTwEaTHx105HW_X-LI4,8852
373
373
  letta/server/rest_api/routers/v1/sources.py,sha256=nXZxtHi40281VltWmx1RwGBbau_00UpzDS6teTLvt2w,22679
374
- letta/server/rest_api/routers/v1/steps.py,sha256=bTzfz1GR3VEZdJRYUGiSr6ZLd12i5faPsf3oAqu1eMk,5570
375
- letta/server/rest_api/routers/v1/tags.py,sha256=ef94QitUSJ3NQVffWF1ZqANUZ2b2jRyGHp_I3UUjhno,912
374
+ letta/server/rest_api/routers/v1/steps.py,sha256=t_RnOQR_dwthpPeE8Bko6hSXbW3GtMvJj-9wQYvDh6A,6670
375
+ letta/server/rest_api/routers/v1/tags.py,sha256=9VCZUc0YBZD07PvLPJl7iOaj2-foLaBJ5s5rZ8xzNHA,1608
376
376
  letta/server/rest_api/routers/v1/telemetry.py,sha256=eSTg7mWbuwPb2OTHQxwRM0EUEl49wHzNB6i1xJtH8BQ,1036
377
377
  letta/server/rest_api/routers/v1/tools.py,sha256=UMtJj3bX8fVe0VuuU5JS0TeaFimEzZ4YRyphSO2tQMU,51085
378
378
  letta/server/rest_api/routers/v1/users.py,sha256=J1vaTbS1UrBMgnPya7GdZ2wr3L9XHmkm6qdGY6pWaOI,2366
@@ -389,7 +389,7 @@ letta/server/ws_api/protocol.py,sha256=5mDgpfNZn_kNwHnpt5Dsuw8gdNH298sgxTGed3etz
389
389
  letta/server/ws_api/server.py,sha256=_16TQafm509rqRztZYqo0HKKZoe8ccBrNftd_kbIJTE,5833
390
390
  letta/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
391
391
  letta/services/agent_file_manager.py,sha256=bgYTyQA90Iqo3W-LprPtyyOKf2itoqivcRhh4EOUXss,30847
392
- letta/services/agent_manager.py,sha256=KaJRTWwvA1SpfKQFPPVCeYxIOKwpKmLMioeyHuGw63Y,168884
392
+ letta/services/agent_manager.py,sha256=C-k9S8_TwLRDSdCqu0YZXbwgSRn4_hPOpP2O62NkaMM,169791
393
393
  letta/services/agent_serialization_manager.py,sha256=lWXTzYItqVxJMyy9ZYlcCDQwC3ZKk9XPCHvBkoVuszA,46388
394
394
  letta/services/archive_manager.py,sha256=P10BjZ2PxLoIkCwJ8rx7qLzchNVBsqNG3_KzxTanCLQ,14060
395
395
  letta/services/block_manager.py,sha256=mohj12QqHenSBbBx0Xmry1Rw25Gy5DSljOITzAwqMtw,33683
@@ -407,7 +407,7 @@ letta/services/per_agent_lock_manager.py,sha256=cMaW8r-qhucQbiK27jVqz8wzhlr2yuRN
407
407
  letta/services/provider_manager.py,sha256=vysp_SgJDezn6YymOfTiNVKOF93EK_dLhsM7upzSjrM,10727
408
408
  letta/services/sandbox_config_manager.py,sha256=BwN3bebiFvcliTJpRkbOwGxmV5dUJ8B64kFfXAgAqDw,25989
409
409
  letta/services/source_manager.py,sha256=mH9l2KJ9R7yG1vdqhltOIVsAajQP4KbueKcB7ZgN0QA,18624
410
- letta/services/step_manager.py,sha256=RLDdxyPI0bxlp2CWr1zzGuNbyR6CAttw4wThpOQAQyE,20891
410
+ letta/services/step_manager.py,sha256=vfXhE-cuE40dv2Uv6pICrpleJeXMjYeEOrkvGcY_sqI,20987
411
411
  letta/services/telemetry_manager.py,sha256=zDdSsRrBYunmlemtUUL1Qh3bcKu5-nhL2n7AlAmVrgs,3297
412
412
  letta/services/tool_manager.py,sha256=zh52n6StaFF5-v6nu0kdNSzJq4du5ACv5iGw5_Y9EDM,43192
413
413
  letta/services/user_manager.py,sha256=XuG9eFrvax69sONx7t_D5kgpt5zNwyER-MhqLSDs8L4,9949
@@ -470,8 +470,8 @@ letta/templates/sandbox_code_file_async.py.j2,sha256=lb7nh_P2W9VZHzU_9TxSCEMUod7
470
470
  letta/templates/summary_request_text.j2,sha256=ZttQwXonW2lk4pJLYzLK0pmo4EO4EtUUIXjgXKiizuc,842
471
471
  letta/templates/template_helper.py,sha256=HkG3zwRc5NVGmSTQu5PUTpz7LevK43bzXVaQuN8urf0,1634
472
472
  letta/types/__init__.py,sha256=hokKjCVFGEfR7SLMrtZsRsBfsC7yTIbgKPLdGg4K1eY,147
473
- letta_nightly-0.11.7.dev20250913103940.dist-info/METADATA,sha256=bBw5qZ0Uuj_L5Hs-2tji7ZiKtmp3V0LWaPeCiOkGV9A,24424
474
- letta_nightly-0.11.7.dev20250913103940.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
475
- letta_nightly-0.11.7.dev20250913103940.dist-info/entry_points.txt,sha256=m-94Paj-kxiR6Ktu0us0_2qfhn29DzF2oVzqBE6cu8w,41
476
- letta_nightly-0.11.7.dev20250913103940.dist-info/licenses/LICENSE,sha256=mExtuZ_GYJgDEI38GWdiEYZizZS4KkVt2SF1g_GPNhI,10759
477
- letta_nightly-0.11.7.dev20250913103940.dist-info/RECORD,,
473
+ letta_nightly-0.11.7.dev20250915104130.dist-info/METADATA,sha256=mpHuQ8T7Dn58hWBd4OBq_IJhl-GIqXOmD1T4jt4SwSY,24424
474
+ letta_nightly-0.11.7.dev20250915104130.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
475
+ letta_nightly-0.11.7.dev20250915104130.dist-info/entry_points.txt,sha256=m-94Paj-kxiR6Ktu0us0_2qfhn29DzF2oVzqBE6cu8w,41
476
+ letta_nightly-0.11.7.dev20250915104130.dist-info/licenses/LICENSE,sha256=mExtuZ_GYJgDEI38GWdiEYZizZS4KkVt2SF1g_GPNhI,10759
477
+ letta_nightly-0.11.7.dev20250915104130.dist-info/RECORD,,