letta-nightly 0.13.0.dev20251030104218__py3-none-any.whl → 0.13.1.dev20251031234110__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 letta-nightly might be problematic. Click here for more details.

Files changed (101) hide show
  1. letta/__init__.py +1 -1
  2. letta/adapters/simple_llm_stream_adapter.py +1 -0
  3. letta/agents/letta_agent_v2.py +8 -0
  4. letta/agents/letta_agent_v3.py +120 -27
  5. letta/agents/temporal/activities/__init__.py +25 -0
  6. letta/agents/temporal/activities/create_messages.py +26 -0
  7. letta/agents/temporal/activities/create_step.py +57 -0
  8. letta/agents/temporal/activities/example_activity.py +9 -0
  9. letta/agents/temporal/activities/execute_tool.py +130 -0
  10. letta/agents/temporal/activities/llm_request.py +114 -0
  11. letta/agents/temporal/activities/prepare_messages.py +27 -0
  12. letta/agents/temporal/activities/refresh_context.py +160 -0
  13. letta/agents/temporal/activities/summarize_conversation_history.py +77 -0
  14. letta/agents/temporal/activities/update_message_ids.py +25 -0
  15. letta/agents/temporal/activities/update_run.py +43 -0
  16. letta/agents/temporal/constants.py +59 -0
  17. letta/agents/temporal/temporal_agent_workflow.py +704 -0
  18. letta/agents/temporal/types.py +275 -0
  19. letta/constants.py +8 -0
  20. letta/errors.py +4 -0
  21. letta/functions/function_sets/base.py +0 -11
  22. letta/groups/helpers.py +7 -1
  23. letta/groups/sleeptime_multi_agent_v4.py +4 -3
  24. letta/interfaces/anthropic_streaming_interface.py +0 -1
  25. letta/interfaces/openai_streaming_interface.py +103 -100
  26. letta/llm_api/anthropic_client.py +57 -12
  27. letta/llm_api/bedrock_client.py +1 -0
  28. letta/llm_api/deepseek_client.py +3 -2
  29. letta/llm_api/google_vertex_client.py +1 -0
  30. letta/llm_api/groq_client.py +1 -0
  31. letta/llm_api/llm_client_base.py +15 -1
  32. letta/llm_api/openai.py +2 -2
  33. letta/llm_api/openai_client.py +17 -3
  34. letta/llm_api/xai_client.py +1 -0
  35. letta/orm/organization.py +4 -0
  36. letta/orm/sqlalchemy_base.py +7 -0
  37. letta/otel/tracing.py +131 -4
  38. letta/schemas/agent_file.py +10 -10
  39. letta/schemas/block.py +22 -3
  40. letta/schemas/enums.py +21 -0
  41. letta/schemas/environment_variables.py +3 -2
  42. letta/schemas/group.py +3 -3
  43. letta/schemas/letta_response.py +36 -4
  44. letta/schemas/llm_batch_job.py +3 -3
  45. letta/schemas/llm_config.py +27 -3
  46. letta/schemas/mcp.py +3 -2
  47. letta/schemas/mcp_server.py +3 -2
  48. letta/schemas/message.py +167 -49
  49. letta/schemas/organization.py +2 -1
  50. letta/schemas/passage.py +2 -1
  51. letta/schemas/provider_trace.py +2 -1
  52. letta/schemas/providers/openrouter.py +1 -2
  53. letta/schemas/run_metrics.py +2 -1
  54. letta/schemas/sandbox_config.py +3 -1
  55. letta/schemas/step_metrics.py +2 -1
  56. letta/schemas/tool_rule.py +2 -2
  57. letta/schemas/user.py +2 -1
  58. letta/server/rest_api/app.py +5 -1
  59. letta/server/rest_api/routers/v1/__init__.py +4 -0
  60. letta/server/rest_api/routers/v1/agents.py +71 -9
  61. letta/server/rest_api/routers/v1/blocks.py +7 -7
  62. letta/server/rest_api/routers/v1/groups.py +40 -0
  63. letta/server/rest_api/routers/v1/identities.py +2 -2
  64. letta/server/rest_api/routers/v1/internal_agents.py +31 -0
  65. letta/server/rest_api/routers/v1/internal_blocks.py +177 -0
  66. letta/server/rest_api/routers/v1/internal_runs.py +25 -1
  67. letta/server/rest_api/routers/v1/runs.py +2 -22
  68. letta/server/rest_api/routers/v1/tools.py +10 -0
  69. letta/server/server.py +5 -2
  70. letta/services/agent_manager.py +4 -4
  71. letta/services/archive_manager.py +16 -0
  72. letta/services/group_manager.py +44 -0
  73. letta/services/helpers/run_manager_helper.py +2 -2
  74. letta/services/lettuce/lettuce_client.py +148 -0
  75. letta/services/mcp/base_client.py +9 -3
  76. letta/services/run_manager.py +148 -37
  77. letta/services/source_manager.py +91 -3
  78. letta/services/step_manager.py +2 -3
  79. letta/services/streaming_service.py +52 -13
  80. letta/services/summarizer/summarizer.py +28 -2
  81. letta/services/tool_executor/builtin_tool_executor.py +1 -1
  82. letta/services/tool_executor/core_tool_executor.py +2 -117
  83. letta/services/tool_schema_generator.py +2 -2
  84. letta/validators.py +21 -0
  85. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/METADATA +1 -1
  86. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/RECORD +89 -84
  87. letta/agent.py +0 -1758
  88. letta/cli/cli_load.py +0 -16
  89. letta/client/__init__.py +0 -0
  90. letta/client/streaming.py +0 -95
  91. letta/client/utils.py +0 -78
  92. letta/functions/async_composio_toolset.py +0 -109
  93. letta/functions/composio_helpers.py +0 -96
  94. letta/helpers/composio_helpers.py +0 -38
  95. letta/orm/job_messages.py +0 -33
  96. letta/schemas/providers.py +0 -1617
  97. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +0 -132
  98. letta/services/tool_executor/composio_tool_executor.py +0 -57
  99. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/WHEEL +0 -0
  100. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/entry_points.txt +0 -0
  101. {letta_nightly-0.13.0.dev20251030104218.dist-info → letta_nightly-0.13.1.dev20251031234110.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,704 @@
1
+ import json
2
+ import uuid
3
+
4
+ from temporalio import workflow
5
+ from temporalio.exceptions import ActivityError, ApplicationError
6
+
7
+ from letta.agents.helpers import _load_last_function_response, _maybe_get_approval_messages, generate_step_id
8
+ from letta.agents.temporal.activities.execute_tool import deserialize_func_return, is_serialized_exception
9
+ from letta.agents.temporal.constants import (
10
+ CREATE_MESSAGES_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
11
+ CREATE_MESSAGES_ACTIVITY_START_TO_CLOSE_TIMEOUT,
12
+ CREATE_STEP_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
13
+ CREATE_STEP_ACTIVITY_START_TO_CLOSE_TIMEOUT,
14
+ LLM_ACTIVITY_RETRY_POLICY,
15
+ LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
16
+ LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT,
17
+ PREPARE_MESSAGES_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
18
+ PREPARE_MESSAGES_ACTIVITY_START_TO_CLOSE_TIMEOUT,
19
+ REFRESH_CONTEXT_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
20
+ REFRESH_CONTEXT_ACTIVITY_START_TO_CLOSE_TIMEOUT,
21
+ SUMMARIZE_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
22
+ SUMMARIZE_ACTIVITY_START_TO_CLOSE_TIMEOUT,
23
+ TOOL_EXECUTION_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
24
+ TOOL_EXECUTION_ACTIVITY_START_TO_CLOSE_TIMEOUT,
25
+ UPDATE_RUN_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
26
+ UPDATE_RUN_ACTIVITY_START_TO_CLOSE_TIMEOUT,
27
+ )
28
+ from letta.constants import REQUEST_HEARTBEAT_PARAM
29
+ from letta.helpers import ToolRulesSolver
30
+ from letta.helpers.tool_execution_helper import enable_strict_mode
31
+ from letta.schemas.agent import AgentState
32
+ from letta.schemas.enums import RunStatus
33
+ from letta.schemas.letta_message import MessageType
34
+ from letta.schemas.letta_message_content import (
35
+ OmittedReasoningContent,
36
+ ReasoningContent,
37
+ RedactedReasoningContent,
38
+ TextContent,
39
+ )
40
+ from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
41
+ from letta.schemas.message import Message
42
+ from letta.schemas.openai.chat_completion_response import FunctionCall, ToolCall, UsageStatistics
43
+ from letta.schemas.tool_execution_result import ToolExecutionResult
44
+ from letta.schemas.usage import LettaUsageStatistics
45
+ from letta.schemas.user import User
46
+ from letta.server.rest_api.utils import create_letta_messages_from_llm_response
47
+ from letta.services.helpers.tool_parser_helper import runtime_override_tool_json_schema
48
+
49
+ # Import activity, passing it through the sandbox without reloading the module
50
+ with workflow.unsafe.imports_passed_through():
51
+ from letta.agents.helpers import _build_rule_violation_result, _load_last_function_response, _pop_heartbeat, _safe_load_tool_call_str
52
+ from letta.agents.temporal.activities import (
53
+ create_messages,
54
+ create_step,
55
+ execute_tool,
56
+ llm_request,
57
+ prepare_messages,
58
+ refresh_context_and_system_message,
59
+ summarize_conversation_history,
60
+ update_message_ids,
61
+ update_run,
62
+ )
63
+ from letta.agents.temporal.types import (
64
+ CreateMessagesParams,
65
+ CreateStepParams,
66
+ ExecuteToolParams,
67
+ ExecuteToolResult,
68
+ FinalResult,
69
+ InnerStepResult,
70
+ LLMCallResult,
71
+ LLMRequestParams,
72
+ PreparedMessages,
73
+ RefreshContextParams,
74
+ RefreshContextResult,
75
+ SummarizeParams,
76
+ UpdateMessageIdsParams,
77
+ UpdateMessageIdsResult,
78
+ UpdateRunParams,
79
+ WorkflowInputParams,
80
+ )
81
+ from letta.constants import NON_USER_MSG_PREFIX
82
+ from letta.local_llm.constants import INNER_THOUGHTS_KWARG
83
+ from letta.log import get_logger
84
+ from letta.server.rest_api.utils import create_approval_request_message_from_llm_response
85
+ from letta.settings import summarizer_settings
86
+ from letta.system import package_function_response
87
+ from letta.utils import validate_function_response
88
+
89
+ logger = get_logger(__name__)
90
+
91
+
92
+ def get_workflow_time_ns() -> int:
93
+ """Get current workflow time in nanoseconds for deterministic timing."""
94
+ return int(workflow.now().timestamp() * 1e9)
95
+
96
+
97
+ @workflow.defn
98
+ class TemporalAgentWorkflow:
99
+ @workflow.run
100
+ async def run(self, params: WorkflowInputParams) -> FinalResult:
101
+ # Capture workflow start time for duration tracking
102
+ workflow_start_ns = get_workflow_time_ns()
103
+
104
+ # Initialize workflow state
105
+ agent_state = params.agent_state # track mutable agent state throughout workflow
106
+ tool_rules_solver = ToolRulesSolver(tool_rules=agent_state.tool_rules)
107
+ # Initialize tracking variables
108
+ usage = LettaUsageStatistics()
109
+ stop_reason = StopReasonType.end_turn
110
+ response_messages = []
111
+
112
+ try:
113
+ # Prepare messages (context + new input), no persistence
114
+ prepared: PreparedMessages = await workflow.execute_activity(
115
+ prepare_messages,
116
+ params,
117
+ start_to_close_timeout=PREPARE_MESSAGES_ACTIVITY_START_TO_CLOSE_TIMEOUT,
118
+ schedule_to_close_timeout=PREPARE_MESSAGES_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
119
+ )
120
+ combined_messages = prepared.in_context_messages + prepared.input_messages_to_persist
121
+ input_messages = prepared.input_messages_to_persist
122
+
123
+ # Main agent loop - execute steps until max_steps or stop condition
124
+ for step_index in range(params.max_steps):
125
+ remaining_turns = params.max_steps - step_index - 1
126
+
127
+ # Execute single step
128
+ step_result = await self.inner_step(
129
+ agent_state=agent_state,
130
+ tool_rules_solver=tool_rules_solver,
131
+ messages=combined_messages,
132
+ input_messages_to_persist=input_messages,
133
+ use_assistant_message=params.use_assistant_message,
134
+ include_return_message_types=params.include_return_message_types,
135
+ actor=params.actor,
136
+ remaining_turns=remaining_turns,
137
+ run_id=params.run_id,
138
+ )
139
+
140
+ # Update agent state from the step result
141
+ agent_state = step_result.agent_state
142
+
143
+ # Update aggregate usage
144
+ usage.step_count += step_result.usage.step_count
145
+ usage.completion_tokens += step_result.usage.completion_tokens
146
+ usage.prompt_tokens += step_result.usage.prompt_tokens
147
+ usage.total_tokens += step_result.usage.total_tokens
148
+
149
+ # Update stop reason from step result
150
+ if step_result.stop_reason is not None:
151
+ stop_reason = step_result.stop_reason
152
+ response_messages.extend(step_result.response_messages)
153
+ combined_messages.extend(step_result.response_messages)
154
+
155
+ # Check if we should continue
156
+ if not step_result.should_continue:
157
+ break
158
+
159
+ input_messages = [] # Only need to persist the input messages for the first step
160
+
161
+ # convert to letta messages from Message objs
162
+ letta_messages = Message.to_letta_messages_from_list(
163
+ response_messages,
164
+ use_assistant_message=params.use_assistant_message,
165
+ reverse=False,
166
+ )
167
+ # Finalize run with all messages to avoid partial metadata overwrites
168
+ # Determine final stop reason and run status
169
+ try:
170
+ if isinstance(stop_reason, StopReasonType):
171
+ final_stop_reason_type = stop_reason
172
+ elif isinstance(stop_reason, LettaStopReason):
173
+ final_stop_reason_type = stop_reason.stop_reason
174
+ elif isinstance(stop_reason, str):
175
+ final_stop_reason_type = StopReasonType(stop_reason)
176
+ else:
177
+ final_stop_reason_type = StopReasonType.end_turn
178
+ except Exception:
179
+ final_stop_reason_type = StopReasonType.end_turn
180
+
181
+ # Calculate total duration
182
+ workflow_end_ns = get_workflow_time_ns()
183
+ total_duration_ns = workflow_end_ns - workflow_start_ns
184
+
185
+ await workflow.execute_activity(
186
+ update_run,
187
+ UpdateRunParams(
188
+ run_id=params.run_id,
189
+ actor=params.actor,
190
+ run_status=final_stop_reason_type.run_status,
191
+ stop_reason=LettaStopReason(stop_reason=final_stop_reason_type),
192
+ # Pass all messages accumulated across the workflow
193
+ persisted_messages=response_messages,
194
+ usage=usage,
195
+ total_duration_ns=total_duration_ns,
196
+ ),
197
+ start_to_close_timeout=UPDATE_RUN_ACTIVITY_START_TO_CLOSE_TIMEOUT,
198
+ schedule_to_close_timeout=UPDATE_RUN_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
199
+ )
200
+ return FinalResult(
201
+ stop_reason=stop_reason.value if isinstance(stop_reason, StopReasonType) else str(stop_reason),
202
+ usage=usage,
203
+ messages=letta_messages,
204
+ )
205
+ except Exception as e:
206
+ final_stop_reason_type = self._map_exception_to_stop_reason(e)
207
+ try:
208
+ # Calculate total duration on exception path
209
+ workflow_end_ns = get_workflow_time_ns()
210
+ total_duration_ns = workflow_end_ns - workflow_start_ns
211
+
212
+ await workflow.execute_activity(
213
+ update_run,
214
+ UpdateRunParams(
215
+ run_id=params.run_id,
216
+ actor=params.actor,
217
+ run_status=final_stop_reason_type.run_status,
218
+ stop_reason=LettaStopReason(stop_reason=final_stop_reason_type),
219
+ persisted_messages=response_messages,
220
+ usage=usage,
221
+ total_duration_ns=total_duration_ns,
222
+ ),
223
+ start_to_close_timeout=UPDATE_RUN_ACTIVITY_START_TO_CLOSE_TIMEOUT,
224
+ schedule_to_close_timeout=UPDATE_RUN_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
225
+ )
226
+ except Exception as update_err:
227
+ logger.error(f"Failed to update run {params.run_id} after workflow error: {update_err}")
228
+ raise
229
+
230
+ async def inner_step(
231
+ self,
232
+ agent_state: AgentState,
233
+ tool_rules_solver: ToolRulesSolver,
234
+ messages: list[Message],
235
+ actor: User,
236
+ input_messages_to_persist: list[Message] | None = None,
237
+ use_assistant_message: bool = True,
238
+ include_return_message_types: list[MessageType] | None = None,
239
+ request_start_timestamp_ns: int | None = None,
240
+ remaining_turns: int = -1,
241
+ run_id: str | None = None,
242
+ ) -> InnerStepResult:
243
+ # Initialize step state
244
+ usage = LettaUsageStatistics()
245
+ stop_reason = StopReasonType.end_turn
246
+ tool_call = None
247
+ reasoning_content = None
248
+ step_id = None
249
+
250
+ # Track step start time using workflow.now() for deterministic time
251
+ step_start_time_ns = get_workflow_time_ns()
252
+
253
+ last_function_response = _load_last_function_response(messages)
254
+ allowed_tools = await self._get_valid_tools(
255
+ agent_state=agent_state, tool_rules_solver=tool_rules_solver, last_function_response=last_function_response
256
+ )
257
+
258
+ approval_request, approval_response = _maybe_get_approval_messages(messages)
259
+
260
+ # TODO: Need to check approval functionality
261
+ if approval_request and approval_response:
262
+ tool_call = approval_request.tool_calls[0]
263
+ reasoning_content = approval_request.content
264
+ step_id = approval_request.step_id
265
+ else:
266
+ # TODO: check for run cancellation if run_id provided
267
+
268
+ # Generate new step ID
269
+ step_id = generate_step_id(workflow.uuid4())
270
+
271
+ # TODO: step checkpoint start (logging/telemetry)
272
+
273
+ refresh_result: RefreshContextResult = await workflow.execute_activity(
274
+ refresh_context_and_system_message,
275
+ RefreshContextParams(
276
+ agent_state=agent_state,
277
+ in_context_messages=messages,
278
+ tool_rules_solver=tool_rules_solver,
279
+ actor=actor,
280
+ ),
281
+ start_to_close_timeout=REFRESH_CONTEXT_ACTIVITY_START_TO_CLOSE_TIMEOUT,
282
+ schedule_to_close_timeout=REFRESH_CONTEXT_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
283
+ )
284
+ refreshed_messages = refresh_result.messages
285
+ agent_state = refresh_result.agent_state
286
+
287
+ force_tool_call = allowed_tools[0]["name"] if len(allowed_tools) == 1 else None
288
+ requires_approval_tools = (
289
+ tool_rules_solver.get_requires_approval_tools(set([t["name"] for t in allowed_tools])) if allowed_tools else None
290
+ )
291
+
292
+ # LLM request with Temporal native retries; on context window overflow,
293
+ # perform workflow-level summarization before retrying with updated input.
294
+ call_result: LLMCallResult | None = None
295
+ for summarize_attempt in range(summarizer_settings.max_summarizer_retries + 1):
296
+ try:
297
+ # TODO: step checkpoint for LLM request start
298
+
299
+ call_result = await workflow.execute_activity(
300
+ llm_request,
301
+ LLMRequestParams(
302
+ agent_state=agent_state,
303
+ messages=refreshed_messages,
304
+ allowed_tools=allowed_tools,
305
+ force_tool_call=force_tool_call,
306
+ requires_approval_tools=requires_approval_tools,
307
+ actor=actor,
308
+ step_id=step_id,
309
+ use_assistant_message=use_assistant_message,
310
+ ),
311
+ start_to_close_timeout=LLM_ACTIVITY_START_TO_CLOSE_TIMEOUT,
312
+ schedule_to_close_timeout=LLM_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
313
+ retry_policy=LLM_ACTIVITY_RETRY_POLICY,
314
+ )
315
+
316
+ # Capture LLM timing from the result
317
+ llm_request_ns = call_result.llm_request_ns
318
+
319
+ # If successful, break out of summarization retry loop
320
+ break
321
+
322
+ except (ApplicationError, ActivityError) as e:
323
+ app_err = e.cause if isinstance(e, ActivityError) else e
324
+ error_type = getattr(app_err, "type", None)
325
+
326
+ # If context window exceeded, summarize then retry (up to max)
327
+ if (
328
+ error_type
329
+ and "ContextWindowExceededError" in error_type
330
+ and summarize_attempt < summarizer_settings.max_summarizer_retries
331
+ ):
332
+ refreshed_messages = await workflow.execute_activity(
333
+ summarize_conversation_history,
334
+ SummarizeParams(
335
+ agent_state=agent_state,
336
+ in_context_messages=refreshed_messages,
337
+ new_letta_messages=[],
338
+ actor=actor,
339
+ force=True,
340
+ ),
341
+ start_to_close_timeout=SUMMARIZE_ACTIVITY_START_TO_CLOSE_TIMEOUT,
342
+ schedule_to_close_timeout=SUMMARIZE_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
343
+ )
344
+ continue
345
+
346
+ # Map error to stop reasons similar to non‑Temporal implementation
347
+ if error_type in ("ValueError", "LLMJSONParsingError"):
348
+ stop_reason = StopReasonType.invalid_llm_response
349
+ else:
350
+ stop_reason = StopReasonType.llm_api_error
351
+ # Exit summarization loop and finish step with stop_reason
352
+ break
353
+
354
+ # If LLM call ultimately failed, finish step early with mapped stop_reason
355
+ if call_result is None:
356
+ response_messages = []
357
+ should_continue = False
358
+ return InnerStepResult(
359
+ stop_reason=stop_reason,
360
+ usage=usage,
361
+ should_continue=should_continue,
362
+ response_messages=response_messages,
363
+ agent_state=agent_state,
364
+ )
365
+
366
+ # TODO: step checkpoint for LLM request finish
367
+
368
+ # Update usage stats (pure)
369
+ usage.step_count += 1
370
+ usage.completion_tokens += call_result.usage.completion_tokens
371
+ usage.prompt_tokens += call_result.usage.prompt_tokens
372
+ usage.total_tokens += call_result.usage.total_tokens
373
+
374
+ # Validate tool call exists
375
+ tool_call = call_result.tool_call
376
+ if tool_call is None:
377
+ stop_reason = StopReasonType.no_tool_call.value
378
+ should_continue = False
379
+ response_messages = []
380
+ return InnerStepResult(
381
+ stop_reason=stop_reason,
382
+ usage=usage,
383
+ should_continue=should_continue,
384
+ response_messages=response_messages,
385
+ agent_state=agent_state,
386
+ )
387
+
388
+ # Handle the AI response (execute tool, create messages, determine continuation)
389
+ persisted_messages, should_continue, stop_reason, recent_last_function_response = await self._handle_ai_response(
390
+ tool_call=tool_call,
391
+ valid_tool_names=[t["name"] for t in allowed_tools],
392
+ agent_state=agent_state,
393
+ tool_rules_solver=tool_rules_solver,
394
+ actor=actor,
395
+ step_id=step_id,
396
+ reasoning_content=call_result.reasoning_content,
397
+ pre_computed_assistant_message_id=call_result.assistant_message_id,
398
+ initial_messages=input_messages_to_persist,
399
+ is_approval=approval_response.approve if approval_response is not None else False,
400
+ is_denial=(approval_response.approve == False) if approval_response is not None else False,
401
+ denial_reason=approval_response.denial_reason if approval_response is not None else None,
402
+ is_final_step=(remaining_turns == 0),
403
+ usage=usage,
404
+ run_id=run_id,
405
+ step_start_time_ns=step_start_time_ns,
406
+ llm_request_ns=llm_request_ns,
407
+ )
408
+
409
+ if recent_last_function_response:
410
+ # TODO: This doesn't get used, so we can skip parsing this in the above function
411
+ last_function_response = recent_last_function_response
412
+
413
+ # persist approval responses immediately to prevent agent from getting into a bad state
414
+ if (
415
+ len(input_messages_to_persist) == 1
416
+ and input_messages_to_persist[0].role == "approval"
417
+ and persisted_messages[0].role == "approval"
418
+ and persisted_messages[1].role == "tool"
419
+ ):
420
+ # update message ids immediately for approval persistence
421
+ message_ids = agent_state.message_ids + [m.id for m in persisted_messages[:2]]
422
+
423
+ # call activity to persist the updated message ids
424
+ update_result: UpdateMessageIdsResult = await workflow.execute_activity(
425
+ update_message_ids,
426
+ UpdateMessageIdsParams(
427
+ agent_id=agent_state.id,
428
+ message_ids=message_ids,
429
+ actor=actor,
430
+ ),
431
+ start_to_close_timeout=CREATE_MESSAGES_ACTIVITY_START_TO_CLOSE_TIMEOUT,
432
+ schedule_to_close_timeout=CREATE_MESSAGES_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
433
+ )
434
+
435
+ # update agent state from the activity result
436
+ agent_state = update_result.agent_state
437
+
438
+ # TODO: process response messages for streaming/non-streaming
439
+ # - yield appropriate messages based on include_return_message_types
440
+
441
+ # TODO: step checkpoint finish
442
+
443
+ # Update response messages with the persisted messages
444
+ response_messages = persisted_messages
445
+
446
+ return InnerStepResult(
447
+ stop_reason=stop_reason,
448
+ usage=usage,
449
+ should_continue=should_continue,
450
+ response_messages=response_messages,
451
+ agent_state=agent_state,
452
+ )
453
+
454
+ async def _handle_ai_response(
455
+ self,
456
+ tool_call: ToolCall,
457
+ valid_tool_names: list[str],
458
+ agent_state: AgentState,
459
+ tool_rules_solver: ToolRulesSolver,
460
+ actor: User,
461
+ step_id: str | None = None,
462
+ reasoning_content: list[TextContent | ReasoningContent | RedactedReasoningContent | OmittedReasoningContent] | None = None,
463
+ pre_computed_assistant_message_id: str | None = None,
464
+ initial_messages: list[Message] | None = None,
465
+ is_approval: bool = False,
466
+ is_denial: bool = False,
467
+ denial_reason: str | None = None,
468
+ is_final_step: bool = False,
469
+ usage: UsageStatistics | None = None,
470
+ run_id: str | None = None,
471
+ step_start_time_ns: int | None = None,
472
+ llm_request_ns: int | None = None,
473
+ ) -> tuple[list[Message], bool, LettaStopReason | None, str | None]:
474
+ """
475
+ Handle the AI response by executing the tool call, creating messages,
476
+ and determining whether to continue stepping.
477
+
478
+ Returns:
479
+ tuple[list[Message], bool, LettaStopReason | None]: (persisted_messages, should_continue, stop_reason)
480
+ """
481
+ # Initialize default
482
+ initial_messages = initial_messages or []
483
+
484
+ # Parse and validate the tool-call envelope
485
+ tool_call_id = tool_call.id or f"call_{uuid.uuid4().hex[:8]}"
486
+ tool_call_name = tool_call.function.name
487
+ tool_args = _safe_load_tool_call_str(tool_call.function.arguments)
488
+ request_heartbeat = _pop_heartbeat(tool_args)
489
+ tool_args.pop(INNER_THOUGHTS_KWARG, None)
490
+
491
+ # Handle denial flow
492
+ if is_denial:
493
+ continue_stepping = True
494
+ stop_reason = None
495
+ tool_call_messages = create_letta_messages_from_llm_response(
496
+ agent_id=agent_state.id,
497
+ model=agent_state.llm_config.model,
498
+ function_name=tool_call.function.name,
499
+ function_arguments={},
500
+ tool_execution_result=ToolExecutionResult(status="error"),
501
+ tool_call_id=tool_call_id,
502
+ function_response=f"Error: request to call tool denied. User reason: {denial_reason}",
503
+ timezone=agent_state.timezone,
504
+ continue_stepping=continue_stepping,
505
+ heartbeat_reason=f"{NON_USER_MSG_PREFIX}Continuing: user denied request to call tool.",
506
+ reasoning_content=reasoning_content,
507
+ pre_computed_assistant_message_id=pre_computed_assistant_message_id,
508
+ is_approval_response=True,
509
+ step_id=step_id,
510
+ run_id=run_id,
511
+ )
512
+ messages_to_persist = initial_messages + tool_call_messages
513
+ return messages_to_persist, continue_stepping, stop_reason, None
514
+
515
+ # Handle approval request flow
516
+ if not is_approval and tool_rules_solver.is_requires_approval_tool(tool_call_name):
517
+ tool_args[REQUEST_HEARTBEAT_PARAM] = request_heartbeat
518
+ approval_messages = create_approval_request_message_from_llm_response(
519
+ agent_id=agent_state.id,
520
+ model=agent_state.llm_config.model,
521
+ requested_tool_calls=[
522
+ ToolCall(id=tool_call_id, function=FunctionCall(name=tool_call_name, arguments=json.dumps(tool_args)))
523
+ ],
524
+ reasoning_content=reasoning_content,
525
+ pre_computed_assistant_message_id=pre_computed_assistant_message_id,
526
+ step_id=step_id,
527
+ run_id=run_id,
528
+ )
529
+ messages_to_persist = initial_messages + approval_messages
530
+ continue_stepping = False
531
+ stop_reason = LettaStopReason(stop_reason=StopReasonType.requires_approval.value)
532
+ return messages_to_persist, continue_stepping, stop_reason, None
533
+
534
+ # Execute tool if tool rules allow
535
+ tool_execution_ns = None
536
+ tool_rule_violated = tool_call_name not in valid_tool_names and not is_approval
537
+ if tool_rule_violated:
538
+ tool_result = _build_rule_violation_result(tool_call_name, valid_tool_names, tool_rules_solver)
539
+ else:
540
+ execution: ExecuteToolResult = await workflow.execute_activity(
541
+ execute_tool,
542
+ ExecuteToolParams(
543
+ tool_name=tool_call_name,
544
+ tool_args=tool_args,
545
+ agent_state=agent_state,
546
+ actor=actor,
547
+ step_id=step_id,
548
+ ),
549
+ start_to_close_timeout=TOOL_EXECUTION_ACTIVITY_START_TO_CLOSE_TIMEOUT,
550
+ schedule_to_close_timeout=TOOL_EXECUTION_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
551
+ )
552
+
553
+ # Capture tool execution timing
554
+ tool_execution_ns = execution.execution_time_ns
555
+
556
+ # Deserialize any serialized exceptions for post processing
557
+ if is_serialized_exception(execution.tool_execution_result.func_return):
558
+ execution.tool_execution_result.func_return = deserialize_func_return(execution.tool_execution_result.func_return)
559
+ tool_result = execution.tool_execution_result
560
+
561
+ # Prepare the function-response payload
562
+ truncate = tool_call_name not in {"conversation_search", "conversation_search_date", "archival_memory_search"}
563
+ return_char_limit = next(
564
+ (t.return_char_limit for t in agent_state.tools if t.name == tool_call_name),
565
+ None,
566
+ )
567
+ function_response_string = validate_function_response(
568
+ tool_result.func_return,
569
+ return_char_limit=return_char_limit,
570
+ truncate=truncate,
571
+ )
572
+
573
+ # Package the function response (for last_function_response tracking)
574
+ last_function_response = package_function_response(
575
+ was_success=tool_result.success_flag,
576
+ response_string=function_response_string,
577
+ timezone=agent_state.timezone,
578
+ )
579
+
580
+ # Decide whether to continue stepping
581
+ continue_stepping = request_heartbeat
582
+ heartbeat_reason = None
583
+ stop_reason = None
584
+
585
+ if tool_rule_violated:
586
+ continue_stepping = True
587
+ heartbeat_reason = f"{NON_USER_MSG_PREFIX}Continuing: tool rule violation."
588
+ else:
589
+ tool_rules_solver.register_tool_call(tool_call_name)
590
+
591
+ if tool_rules_solver.is_terminal_tool(tool_call_name):
592
+ if continue_stepping:
593
+ stop_reason = LettaStopReason(stop_reason=StopReasonType.tool_rule.value)
594
+ continue_stepping = False
595
+ elif tool_rules_solver.has_children_tools(tool_call_name):
596
+ continue_stepping = True
597
+ heartbeat_reason = f"{NON_USER_MSG_PREFIX}Continuing: child tool rule."
598
+ elif tool_rules_solver.is_continue_tool(tool_call_name):
599
+ continue_stepping = True
600
+ heartbeat_reason = f"{NON_USER_MSG_PREFIX}Continuing: continue tool rule."
601
+
602
+ # Check if we're at max steps
603
+ if is_final_step and continue_stepping:
604
+ continue_stepping = False
605
+ stop_reason = LettaStopReason(stop_reason=StopReasonType.max_steps.value)
606
+ else:
607
+ uncalled = tool_rules_solver.get_uncalled_required_tools(available_tools=set([t.name for t in agent_state.tools]))
608
+ if not continue_stepping and uncalled:
609
+ continue_stepping = True
610
+ heartbeat_reason = f"{NON_USER_MSG_PREFIX}Continuing, user expects these tools: [{', '.join(uncalled)}] to be called still."
611
+ stop_reason = None
612
+
613
+ # Create Letta messages from the tool response
614
+ tool_call_messages = create_letta_messages_from_llm_response(
615
+ agent_id=agent_state.id,
616
+ model=agent_state.llm_config.model,
617
+ function_name=tool_call_name,
618
+ function_arguments=tool_args,
619
+ tool_execution_result=tool_result,
620
+ tool_call_id=tool_call_id,
621
+ function_response=function_response_string,
622
+ timezone=agent_state.timezone,
623
+ continue_stepping=continue_stepping,
624
+ heartbeat_reason=heartbeat_reason,
625
+ reasoning_content=reasoning_content,
626
+ pre_computed_assistant_message_id=pre_computed_assistant_message_id,
627
+ is_approval_response=is_approval or is_denial,
628
+ step_id=step_id,
629
+ run_id=run_id,
630
+ )
631
+
632
+ # Log step
633
+ step_ns = get_workflow_time_ns() - step_start_time_ns
634
+ await workflow.execute_activity(
635
+ create_step,
636
+ CreateStepParams(
637
+ agent_state=agent_state,
638
+ messages=tool_call_messages,
639
+ actor=actor,
640
+ run_id=run_id,
641
+ step_id=step_id,
642
+ usage=usage,
643
+ step_ns=step_ns,
644
+ llm_request_ns=llm_request_ns,
645
+ tool_execution_ns=tool_execution_ns,
646
+ stop_reason=stop_reason.stop_reason.value if stop_reason else None,
647
+ ),
648
+ start_to_close_timeout=CREATE_STEP_ACTIVITY_START_TO_CLOSE_TIMEOUT,
649
+ schedule_to_close_timeout=CREATE_STEP_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
650
+ )
651
+
652
+ messages_to_persist = initial_messages + tool_call_messages
653
+
654
+ # Persist messages to database
655
+ persisted_messages_result = await workflow.execute_activity(
656
+ create_messages,
657
+ CreateMessagesParams(
658
+ messages=messages_to_persist,
659
+ actor=actor,
660
+ project_id=agent_state.project_id,
661
+ template_id=agent_state.template_id,
662
+ ),
663
+ start_to_close_timeout=CREATE_MESSAGES_ACTIVITY_START_TO_CLOSE_TIMEOUT,
664
+ schedule_to_close_timeout=CREATE_MESSAGES_ACTIVITY_SCHEDULE_TO_CLOSE_TIMEOUT,
665
+ )
666
+
667
+ return persisted_messages_result.messages, continue_stepping, stop_reason, last_function_response
668
+
669
+ async def _get_valid_tools(self, agent_state: AgentState, tool_rules_solver: ToolRulesSolver, last_function_response: str):
670
+ tools = agent_state.tools
671
+ valid_tool_names = tool_rules_solver.get_allowed_tool_names(
672
+ available_tools=set([t.name for t in tools]),
673
+ last_function_response=last_function_response,
674
+ error_on_empty=False, # Return empty list instead of raising error
675
+ ) or list(set(t.name for t in tools))
676
+ allowed_tools = [enable_strict_mode(t.json_schema) for t in tools if t.name in set(valid_tool_names)]
677
+ terminal_tool_names = {rule.tool_name for rule in tool_rules_solver.terminal_tool_rules}
678
+ allowed_tools = runtime_override_tool_json_schema(
679
+ tool_list=allowed_tools,
680
+ response_format=agent_state.response_format,
681
+ request_heartbeat=True,
682
+ terminal_tools=terminal_tool_names,
683
+ )
684
+ return allowed_tools
685
+
686
+ def _map_exception_to_stop_reason(self, exc: Exception) -> StopReasonType:
687
+ """Map activity/workflow exceptions to a StopReasonType for run updates."""
688
+ try:
689
+ if isinstance(exc, ActivityError) and getattr(exc, "cause", None) is not None:
690
+ return self._map_exception_to_stop_reason(exc.cause) # type: ignore[arg-type]
691
+
692
+ if isinstance(exc, ApplicationError):
693
+ err_type = (exc.type or "").strip()
694
+ if err_type in ("ValueError", "LLMJSONParsingError"):
695
+ return StopReasonType.invalid_llm_response
696
+ if err_type == "ContextWindowExceededError":
697
+ return StopReasonType.invalid_llm_response
698
+ if err_type.startswith("LLM") or err_type.endswith("Error"):
699
+ return StopReasonType.llm_api_error
700
+ return StopReasonType.error
701
+ except Exception:
702
+ return StopReasonType.error
703
+
704
+ return StopReasonType.error