quantalogic 0.35.0__py3-none-any.whl → 0.40.0__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.
Files changed (107) hide show
  1. quantalogic/__init__.py +0 -4
  2. quantalogic/agent.py +603 -363
  3. quantalogic/agent_config.py +233 -46
  4. quantalogic/agent_factory.py +34 -22
  5. quantalogic/coding_agent.py +16 -14
  6. quantalogic/config.py +2 -1
  7. quantalogic/console_print_events.py +4 -8
  8. quantalogic/console_print_token.py +2 -2
  9. quantalogic/docs_cli.py +15 -10
  10. quantalogic/event_emitter.py +258 -83
  11. quantalogic/flow/__init__.py +23 -0
  12. quantalogic/flow/flow.py +595 -0
  13. quantalogic/flow/flow_extractor.py +672 -0
  14. quantalogic/flow/flow_generator.py +89 -0
  15. quantalogic/flow/flow_manager.py +407 -0
  16. quantalogic/flow/flow_manager_schema.py +169 -0
  17. quantalogic/flow/flow_yaml.md +419 -0
  18. quantalogic/generative_model.py +109 -77
  19. quantalogic/get_model_info.py +5 -5
  20. quantalogic/interactive_text_editor.py +100 -73
  21. quantalogic/main.py +17 -21
  22. quantalogic/model_info_list.py +3 -3
  23. quantalogic/model_info_litellm.py +14 -14
  24. quantalogic/prompts.py +2 -1
  25. quantalogic/{llm.py → quantlitellm.py} +29 -39
  26. quantalogic/search_agent.py +4 -4
  27. quantalogic/server/models.py +4 -1
  28. quantalogic/task_file_reader.py +5 -5
  29. quantalogic/task_runner.py +20 -20
  30. quantalogic/tool_manager.py +10 -21
  31. quantalogic/tools/__init__.py +98 -68
  32. quantalogic/tools/composio/composio.py +416 -0
  33. quantalogic/tools/{generate_database_report_tool.py → database/generate_database_report_tool.py} +4 -9
  34. quantalogic/tools/database/sql_query_tool_advanced.py +261 -0
  35. quantalogic/tools/document_tools/markdown_to_docx_tool.py +620 -0
  36. quantalogic/tools/document_tools/markdown_to_epub_tool.py +438 -0
  37. quantalogic/tools/document_tools/markdown_to_html_tool.py +362 -0
  38. quantalogic/tools/document_tools/markdown_to_ipynb_tool.py +319 -0
  39. quantalogic/tools/document_tools/markdown_to_latex_tool.py +420 -0
  40. quantalogic/tools/document_tools/markdown_to_pdf_tool.py +623 -0
  41. quantalogic/tools/document_tools/markdown_to_pptx_tool.py +319 -0
  42. quantalogic/tools/duckduckgo_search_tool.py +2 -4
  43. quantalogic/tools/finance/alpha_vantage_tool.py +440 -0
  44. quantalogic/tools/finance/ccxt_tool.py +373 -0
  45. quantalogic/tools/finance/finance_llm_tool.py +387 -0
  46. quantalogic/tools/finance/google_finance.py +192 -0
  47. quantalogic/tools/finance/market_intelligence_tool.py +520 -0
  48. quantalogic/tools/finance/technical_analysis_tool.py +491 -0
  49. quantalogic/tools/finance/tradingview_tool.py +336 -0
  50. quantalogic/tools/finance/yahoo_finance.py +236 -0
  51. quantalogic/tools/git/bitbucket_clone_repo_tool.py +181 -0
  52. quantalogic/tools/git/bitbucket_operations_tool.py +326 -0
  53. quantalogic/tools/git/clone_repo_tool.py +189 -0
  54. quantalogic/tools/git/git_operations_tool.py +532 -0
  55. quantalogic/tools/google_packages/google_news_tool.py +480 -0
  56. quantalogic/tools/grep_app_tool.py +123 -186
  57. quantalogic/tools/{dalle_e.py → image_generation/dalle_e.py} +37 -27
  58. quantalogic/tools/jinja_tool.py +6 -10
  59. quantalogic/tools/language_handlers/__init__.py +22 -9
  60. quantalogic/tools/list_directory_tool.py +131 -42
  61. quantalogic/tools/llm_tool.py +45 -15
  62. quantalogic/tools/llm_vision_tool.py +59 -7
  63. quantalogic/tools/markitdown_tool.py +17 -5
  64. quantalogic/tools/nasa_packages/models.py +47 -0
  65. quantalogic/tools/nasa_packages/nasa_apod_tool.py +232 -0
  66. quantalogic/tools/nasa_packages/nasa_neows_tool.py +147 -0
  67. quantalogic/tools/nasa_packages/services.py +82 -0
  68. quantalogic/tools/presentation_tools/presentation_llm_tool.py +396 -0
  69. quantalogic/tools/product_hunt/product_hunt_tool.py +258 -0
  70. quantalogic/tools/product_hunt/services.py +63 -0
  71. quantalogic/tools/rag_tool/__init__.py +48 -0
  72. quantalogic/tools/rag_tool/document_metadata.py +15 -0
  73. quantalogic/tools/rag_tool/query_response.py +20 -0
  74. quantalogic/tools/rag_tool/rag_tool.py +566 -0
  75. quantalogic/tools/rag_tool/rag_tool_beta.py +264 -0
  76. quantalogic/tools/read_html_tool.py +24 -38
  77. quantalogic/tools/replace_in_file_tool.py +10 -10
  78. quantalogic/tools/safe_python_interpreter_tool.py +10 -24
  79. quantalogic/tools/search_definition_names.py +2 -2
  80. quantalogic/tools/sequence_tool.py +14 -23
  81. quantalogic/tools/sql_query_tool.py +17 -19
  82. quantalogic/tools/tool.py +39 -15
  83. quantalogic/tools/unified_diff_tool.py +1 -1
  84. quantalogic/tools/utilities/csv_processor_tool.py +234 -0
  85. quantalogic/tools/utilities/download_file_tool.py +179 -0
  86. quantalogic/tools/utilities/mermaid_validator_tool.py +661 -0
  87. quantalogic/tools/utils/__init__.py +1 -4
  88. quantalogic/tools/utils/create_sample_database.py +24 -38
  89. quantalogic/tools/utils/generate_database_report.py +74 -82
  90. quantalogic/tools/wikipedia_search_tool.py +17 -21
  91. quantalogic/utils/ask_user_validation.py +1 -1
  92. quantalogic/utils/async_utils.py +35 -0
  93. quantalogic/utils/check_version.py +3 -5
  94. quantalogic/utils/get_all_models.py +2 -1
  95. quantalogic/utils/git_ls.py +21 -7
  96. quantalogic/utils/lm_studio_model_info.py +9 -7
  97. quantalogic/utils/python_interpreter.py +113 -43
  98. quantalogic/utils/xml_utility.py +178 -0
  99. quantalogic/version_check.py +1 -1
  100. quantalogic/welcome_message.py +7 -7
  101. quantalogic/xml_parser.py +0 -1
  102. {quantalogic-0.35.0.dist-info → quantalogic-0.40.0.dist-info}/METADATA +41 -1
  103. quantalogic-0.40.0.dist-info/RECORD +148 -0
  104. quantalogic-0.35.0.dist-info/RECORD +0 -102
  105. {quantalogic-0.35.0.dist-info → quantalogic-0.40.0.dist-info}/LICENSE +0 -0
  106. {quantalogic-0.35.0.dist-info → quantalogic-0.40.0.dist-info}/WHEEL +0 -0
  107. {quantalogic-0.35.0.dist-info → quantalogic-0.40.0.dist-info}/entry_points.txt +0 -0
quantalogic/agent.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Enhanced QuantaLogic agent implementing the ReAct framework."""
2
2
 
3
+ import asyncio
3
4
  from collections.abc import Callable
4
5
  from datetime import datetime
5
6
  from typing import Any
@@ -28,6 +29,9 @@ MAX_RESPONSE_LENGTH = 1024 * 32
28
29
  DEFAULT_MAX_INPUT_TOKENS = 128 * 1024
29
30
  DEFAULT_MAX_OUTPUT_TOKENS = 4096
30
31
 
32
+ # Maximum recursion depth for variable interpolation
33
+ MAX_INTERPOLATION_DEPTH = 10
34
+
31
35
 
32
36
  class AgentConfig(BaseModel):
33
37
  """Configuration settings for the Agent."""
@@ -50,7 +54,12 @@ class ObserveResponseResult(BaseModel):
50
54
 
51
55
 
52
56
  class Agent(BaseModel):
53
- """Enhanced QuantaLogic agent implementing ReAct framework."""
57
+ """Enhanced QuantaLogic agent implementing ReAct framework.
58
+
59
+ Supports both synchronous and asynchronous operations for task solving.
60
+ Use `solve_task` for synchronous contexts (e.g., CLI tools) and `async_solve_task`
61
+ for asynchronous contexts (e.g., web servers).
62
+ """
54
63
 
55
64
  model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid")
56
65
 
@@ -87,13 +96,29 @@ class Agent(BaseModel):
87
96
  get_environment: Callable[[], str] = get_environment,
88
97
  compact_every_n_iterations: int | None = None,
89
98
  max_tokens_working_memory: int | None = None,
99
+ event_emitter: EventEmitter | None = None,
90
100
  ):
91
- """Initialize the agent with model, memory, tools, and configurations."""
101
+ """Initialize the agent with model, memory, tools, and configurations.
102
+
103
+ Args:
104
+ model_name: Name of the model to use
105
+ memory: AgentMemory instance for storing conversation history
106
+ variable_store: VariableMemory instance for storing variables
107
+ tools: List of Tool instances
108
+ ask_for_user_validation: Function to ask for user validation
109
+ task_to_solve: Initial task to solve
110
+ specific_expertise: Description of the agent's expertise
111
+ get_environment: Function to get environment details
112
+ compact_every_n_iterations: How often to compact memory
113
+ max_tokens_working_memory: Maximum token count for working memory
114
+ event_emitter: EventEmitter instance for event handling
115
+ """
92
116
  try:
93
117
  logger.debug("Initializing agent...")
94
118
 
95
- # Create event emitter
96
- event_emitter = EventEmitter()
119
+ # Create or use provided event emitter
120
+ if event_emitter is None:
121
+ event_emitter = EventEmitter()
97
122
 
98
123
  # Add TaskCompleteTool to the tools list if not already present
99
124
  if not any(isinstance(t, TaskCompleteTool) for t in tools):
@@ -155,173 +180,345 @@ class Agent(BaseModel):
155
180
 
156
181
  @model_name.setter
157
182
  def model_name(self, value: str) -> None:
158
- """Set the model name."""
183
+ """Set the model name and update the model instance."""
159
184
  self._model_name = value
160
185
  # Update the model instance with the new name
161
186
  self.model = GenerativeModel(model=value, event_emitter=self.event_emitter)
162
187
 
163
- def clear_memory(self):
188
+ def clear_memory(self) -> None:
164
189
  """Clear the memory and reset the session."""
165
190
  self._reset_session(clear_memory=True)
166
191
 
167
192
  def solve_task(
168
193
  self, task: str, max_iterations: int = 30, streaming: bool = False, clear_memory: bool = True
169
194
  ) -> str:
170
- """Solve the given task using the ReAct framework.
195
+ """Solve the given task using the ReAct framework (synchronous version).
196
+
197
+ Ideal for synchronous applications. For asynchronous contexts, use `async_solve_task`.
171
198
 
172
199
  Args:
173
- task (str): The task description.
174
- max_iterations (int, optional): Maximum number of iterations to attempt solving the task.
175
- Defaults to 30 to prevent infinite loops and ensure timely task completion.
176
- streaming (bool, optional): Whether to use streaming mode for generating responses.
177
- clear_memory (bool, optional): Whether to clear the memory before solving the task.
200
+ task: The task description
201
+ max_iterations: Maximum number of iterations
202
+ streaming: Whether to use streaming mode
203
+ clear_memory: Whether to clear memory before solving
178
204
 
179
205
  Returns:
180
- str: The final response after task completion.
206
+ The final response after task completion
181
207
  """
182
208
  logger.debug(f"Solving task... {task}")
183
- self._reset_session(task_to_solve=task, max_iterations=max_iterations, clear_memory=clear_memory)
209
+ try:
210
+ loop = asyncio.get_event_loop()
211
+ except RuntimeError:
212
+ # Create a new event loop if one doesn't exist
213
+ loop = asyncio.new_event_loop()
214
+ asyncio.set_event_loop(loop)
184
215
 
185
- # Generate task summary
186
- self.task_to_solve_summary = self._generate_task_summary(task)
216
+ return loop.run_until_complete(self.async_solve_task(task, max_iterations, streaming, clear_memory))
217
+
218
+ async def async_solve_task(
219
+ self, task: str, max_iterations: int = 30, streaming: bool = False, clear_memory: bool = True
220
+ ) -> str:
221
+ """Solve the given task using the ReAct framework (asynchronous version).
222
+
223
+ Ideal for asynchronous applications. For synchronous contexts, use `solve_task`.
224
+
225
+ Args:
226
+ task: The task description
227
+ max_iterations: Maximum number of iterations
228
+ streaming: Whether to use streaming mode
229
+ clear_memory: Whether to clear memory before solving
230
+
231
+ Returns:
232
+ The final response after task completion
233
+ """
234
+ logger.debug(f"Solving task asynchronously... {task}")
235
+ self._reset_session(task_to_solve=task, max_iterations=max_iterations, clear_memory=clear_memory)
236
+ self.task_to_solve_summary = await self._async_generate_task_summary(task)
187
237
 
188
- # Add system prompt to memory
189
- # Check if system prompt is already in memory
190
- # if not add it
191
- # The system message is always the first message in memory
192
238
  if not self.memory.memory or self.memory.memory[0].role != "system":
193
239
  self.memory.add(Message(role="system", content=self.config.system_prompt))
194
240
 
195
- self._emit_event(
196
- "session_start",
197
- {"system_prompt": self.config.system_prompt, "content": task},
198
- )
241
+ self._emit_event("session_start", {"system_prompt": self.config.system_prompt, "content": task})
199
242
 
200
243
  self.max_output_tokens = self.model.get_model_max_output_tokens() or DEFAULT_MAX_OUTPUT_TOKENS
201
244
  self.max_input_tokens = self.model.get_model_max_input_tokens() or DEFAULT_MAX_INPUT_TOKENS
202
245
 
203
246
  done = False
204
247
  current_prompt = self._prepare_prompt_task(task)
205
-
206
248
  self.current_iteration = 1
207
-
208
- # Emit event: Task Solve Start
209
- self._emit_event(
210
- "task_solve_start",
211
- {"initial_prompt": current_prompt, "task": task},
212
- )
213
-
214
- answer: str = ""
249
+ answer = ""
215
250
 
216
251
  while not done:
217
252
  try:
218
- self._update_total_tokens(message_history=self.memory.memory, prompt=current_prompt)
219
-
220
- # Emit event: Task Think Start after updating total tokens
253
+ self._update_total_tokens(self.memory.memory, current_prompt)
221
254
  self._emit_event("task_think_start", {"prompt": current_prompt})
222
-
223
- self._compact_memory_if_needed(current_prompt)
255
+ await self._async_compact_memory_if_needed(current_prompt)
224
256
 
225
257
  if streaming:
226
- # For streaming, collect the response chunks
227
258
  content = ""
228
- for chunk in self.model.generate_with_history(
259
+ async_stream = await self.model.async_generate_with_history(
229
260
  messages_history=self.memory.memory,
230
261
  prompt=current_prompt,
231
262
  streaming=True,
232
- ):
263
+ )
264
+ async for chunk in async_stream:
233
265
  content += chunk
234
-
235
- # Create a response object similar to non-streaming mode
236
266
  result = ResponseStats(
237
267
  response=content,
238
- usage=TokenUsage(
239
- prompt_tokens=0, # We don't have token counts in streaming mode
240
- completion_tokens=0,
241
- total_tokens=0,
242
- ),
268
+ usage=TokenUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
243
269
  model=self.model.model,
244
270
  finish_reason="stop",
245
271
  )
246
272
  else:
247
- result = self.model.generate_with_history(
248
- messages_history=self.memory.memory, prompt=current_prompt, streaming=False,
249
- stop_words=["thinking"]
273
+ result = await self.model.async_generate_with_history(
274
+ messages_history=self.memory.memory,
275
+ prompt=current_prompt,
276
+ streaming=False,
277
+ stop_words=["thinking"],
250
278
  )
251
279
 
252
280
  content = result.response
253
- if not streaming: # Only update tokens for non-streaming mode
281
+ if not streaming:
254
282
  token_usage = result.usage
255
283
  self.total_tokens = token_usage.total_tokens
256
284
 
257
- # Emit event: Task Think End
258
- self._emit_event(
259
- "task_think_end",
260
- {
261
- "response": content,
262
- },
263
- )
264
-
265
- # Process the assistant's response
266
- result = self._observe_response(content, iteration=self.current_iteration)
267
-
285
+ self._emit_event("task_think_end", {"response": content})
286
+ result = await self._async_observe_response(content, iteration=self.current_iteration)
268
287
  current_prompt = result.next_prompt
269
288
 
270
289
  if result.executed_tool == "task_complete":
271
- self._emit_event(
272
- "task_complete",
273
- {
274
- "response": result.answer,
275
- },
276
- )
277
- answer = result.answer
290
+ self._emit_event("task_complete", {"response": result.answer})
291
+ answer = result.answer or "" # Ensure answer is never None
278
292
  done = True
279
293
 
280
294
  self._update_session_memory(current_prompt, content)
281
-
282
295
  self.current_iteration += 1
283
296
  if self.current_iteration >= self.max_iterations:
284
297
  done = True
285
298
  self._emit_event("error_max_iterations_reached")
286
299
 
287
300
  except Exception as e:
288
- logger.error(f"Error during task solving: {str(e)}")
289
- # Optionally, decide to continue or break based on exception type
301
+ logger.error(f"Error during async task solving: {str(e)}")
290
302
  answer = f"Error: {str(e)}"
291
303
  done = True
292
304
 
293
- # Emit event: Task Solve End
294
305
  self._emit_event("task_solve_end")
306
+ return answer
295
307
 
296
- logger.debug(f"Task solved: {answer}")
308
+ def _observe_response(self, content: str, iteration: int = 1) -> ObserveResponseResult:
309
+ """Analyze the assistant's response and determine next steps (synchronous wrapper).
310
+
311
+ Args:
312
+ content: The response content to analyze
313
+ iteration: Current iteration number
314
+
315
+ Returns:
316
+ ObserveResponseResult with next steps information
317
+ """
318
+ try:
319
+ loop = asyncio.get_event_loop()
320
+ except RuntimeError:
321
+ loop = asyncio.new_event_loop()
322
+ asyncio.set_event_loop(loop)
297
323
 
298
- return answer
324
+ return loop.run_until_complete(self._async_observe_response(content, iteration))
299
325
 
300
- def _reset_session(self, task_to_solve: str = "", max_iterations: int = 30, clear_memory: bool = True):
301
- """Reset the agent's session."""
302
- logger.debug("Resetting session...")
303
- self.task_to_solve = task_to_solve
304
- if clear_memory:
305
- logger.debug("Clearing memory...")
306
- self.memory.reset()
307
- self.variable_store.reset()
308
- self.total_tokens = 0
309
- self.current_iteration = 0
310
- self.max_output_tokens = self.model.get_model_max_output_tokens() or DEFAULT_MAX_OUTPUT_TOKENS
311
- self.max_input_tokens = self.model.get_model_max_input_tokens() or DEFAULT_MAX_INPUT_TOKENS
312
- self.max_iterations = max_iterations
326
+ async def _async_observe_response(self, content: str, iteration: int = 1) -> ObserveResponseResult:
327
+ """Analyze the assistant's response and determine next steps (asynchronous).
328
+
329
+ Args:
330
+ content: The response content to analyze
331
+ iteration: Current iteration number
332
+
333
+ Returns:
334
+ ObserveResponseResult with next steps information
335
+ """
336
+ try:
337
+ parsed_content = self._parse_tool_usage(content)
338
+ if not parsed_content:
339
+ return self._handle_no_tool_usage()
313
340
 
314
- def _update_total_tokens(self, message_history: list[Message], prompt: str) -> None:
315
- self.total_tokens = self.model.token_counter_with_history(message_history, prompt)
341
+ for tool_name, tool_input in parsed_content.items():
342
+ tool = self.tools.get(tool_name)
343
+ if not tool:
344
+ return self._handle_tool_not_found(tool_name)
316
345
 
317
- def _compact_memory_if_needed(self, current_prompt: str = ""):
318
- """Compacts the memory if it exceeds the maximum occupancy or token limit."""
346
+ arguments_with_values = self._parse_tool_arguments(tool, tool_input)
347
+ is_repeated_call = self._is_repeated_tool_call(tool_name, arguments_with_values)
348
+
349
+ if is_repeated_call:
350
+ executed_tool, response = self._handle_repeated_tool_call(tool_name, arguments_with_values)
351
+ else:
352
+ executed_tool, response = await self._async_execute_tool(tool_name, tool, arguments_with_values)
353
+
354
+ if not executed_tool:
355
+ return self._handle_tool_execution_failure(response)
356
+
357
+ variable_name = self.variable_store.add(response)
358
+ new_prompt = self._format_observation_response(response, executed_tool, variable_name, iteration)
359
+
360
+ return ObserveResponseResult(
361
+ next_prompt=new_prompt,
362
+ executed_tool=executed_tool,
363
+ answer=response if executed_tool == "task_complete" else None,
364
+ )
365
+ except Exception as e:
366
+ return self._handle_error(e)
367
+
368
+ def _execute_tool(self, tool_name: str, tool: Tool, arguments_with_values: dict) -> tuple[str, Any]:
369
+ """Execute a tool with validation if required (synchronous wrapper).
370
+
371
+ Args:
372
+ tool_name: Name of the tool to execute
373
+ tool: Tool instance
374
+ arguments_with_values: Tool arguments
375
+
376
+ Returns:
377
+ Tuple of (executed_tool_name, response)
378
+ """
379
+ try:
380
+ loop = asyncio.get_event_loop()
381
+ except RuntimeError:
382
+ loop = asyncio.new_event_loop()
383
+ asyncio.set_event_loop(loop)
384
+
385
+ return loop.run_until_complete(self._async_execute_tool(tool_name, tool, arguments_with_values))
386
+
387
+ async def _async_execute_tool(self, tool_name: str, tool: Tool, arguments_with_values: dict) -> tuple[str, Any]:
388
+ """Execute a tool with validation if required (asynchronous).
389
+
390
+ Args:
391
+ tool_name: Name of the tool to execute
392
+ tool: Tool instance
393
+ arguments_with_values: Tool arguments
394
+
395
+ Returns:
396
+ Tuple of (executed_tool_name, response)
397
+ """
398
+ if tool.need_validation:
399
+ question_validation = (
400
+ "Do you permit the execution of this tool?\n"
401
+ f"Tool: {tool_name}\nArguments:\n"
402
+ "<arguments>\n"
403
+ + "\n".join([f" <{key}>{value}</{key}>" for key, value in arguments_with_values.items()])
404
+ + "\n</arguments>\nYes or No"
405
+ )
406
+ permission_granted = self.ask_for_user_validation(question_validation)
407
+ if not permission_granted:
408
+ return "", f"Error: execution of tool '{tool_name}' was denied by the user."
409
+
410
+ self._emit_event("tool_execution_start", {"tool_name": tool_name, "arguments": arguments_with_values})
411
+
412
+ try:
413
+ arguments_with_values_interpolated = {
414
+ key: await self._async_interpolate_variables(value) for key, value in arguments_with_values.items()
415
+ }
416
+ if tool.need_variables:
417
+ arguments_with_values_interpolated["variables"] = self.variable_store
418
+ if tool.need_caller_context_memory:
419
+ arguments_with_values_interpolated["caller_context_memory"] = self.memory.memory
420
+
421
+ converted_args = self.tools.validate_and_convert_arguments(tool_name, arguments_with_values_interpolated)
422
+ injectable_properties = tool.get_injectable_properties_in_execution()
423
+ for key, value in injectable_properties.items():
424
+ converted_args[key] = value
425
+
426
+ if hasattr(tool, "async_execute") and callable(tool.async_execute):
427
+ response = await tool.async_execute(**converted_args)
428
+ else:
429
+ # Fall back to synchronous execution if async is not available
430
+ response = tool.execute(**converted_args)
431
+ executed_tool = tool.name
432
+ except Exception as e:
433
+ response = f"Error executing tool: {tool_name}: {str(e)}\n"
434
+ executed_tool = ""
435
+
436
+ self._emit_event(
437
+ "tool_execution_end", {"tool_name": tool_name, "arguments": arguments_with_values, "response": response}
438
+ )
439
+ return executed_tool, response
440
+
441
+ async def _async_interpolate_variables(self, text: str, depth: int = 0) -> str:
442
+ """Interpolate variables using $var$ syntax in the given text with recursion protection.
443
+
444
+ Args:
445
+ text: Text containing variable references
446
+ depth: Current recursion depth
447
+
448
+ Returns:
449
+ Text with variables interpolated
450
+ """
451
+ if not isinstance(text, str):
452
+ return str(text)
453
+
454
+ if depth > MAX_INTERPOLATION_DEPTH:
455
+ logger.warning(f"Max interpolation depth ({MAX_INTERPOLATION_DEPTH}) reached, stopping recursion")
456
+ return text
457
+
458
+ try:
459
+ import re
460
+
461
+ # Process each variable in the store
462
+ for var in self.variable_store.keys():
463
+ # Properly escape the variable name for regex using re.escape
464
+ # but handle $ characters separately since they're part of our syntax
465
+ escaped_var = re.escape(var).replace('\\$', '$')
466
+ pattern = f"\\${escaped_var}\\$"
467
+
468
+ # Get variable value as string
469
+ replacement = str(self.variable_store[var])
470
+
471
+ # Replace all occurrences
472
+ text = re.sub(pattern, lambda m: replacement, text)
473
+
474
+ # Check if there are still variables to interpolate (for nested variables)
475
+ if '$' in text and depth < MAX_INTERPOLATION_DEPTH:
476
+ return await self._async_interpolate_variables(text, depth + 1)
477
+
478
+ return text
479
+ except Exception as e:
480
+ logger.error(f"Error in _async_interpolate_variables: {str(e)}")
481
+ return text
482
+
483
+ def _interpolate_variables(self, text: str) -> str:
484
+ """Interpolate variables using $var$ syntax in the given text (synchronous wrapper).
485
+
486
+ Args:
487
+ text: Text containing variable references
488
+
489
+ Returns:
490
+ Text with variables interpolated
491
+ """
492
+ try:
493
+ loop = asyncio.get_event_loop()
494
+ except RuntimeError:
495
+ loop = asyncio.new_event_loop()
496
+ asyncio.set_event_loop(loop)
497
+
498
+ return loop.run_until_complete(self._async_interpolate_variables(text))
499
+
500
+ def _compact_memory_if_needed(self, current_prompt: str = "") -> None:
501
+ """Compacts the memory if it exceeds the maximum occupancy (synchronous wrapper).
502
+
503
+ Args:
504
+ current_prompt: Current prompt to calculate token usage
505
+ """
506
+ try:
507
+ loop = asyncio.get_event_loop()
508
+ except RuntimeError:
509
+ loop = asyncio.new_event_loop()
510
+ asyncio.set_event_loop(loop)
511
+
512
+ return loop.run_until_complete(self._async_compact_memory_if_needed(current_prompt))
513
+
514
+ async def _async_compact_memory_if_needed(self, current_prompt: str = "") -> None:
515
+ """Compacts the memory if it exceeds the maximum occupancy or token limit.
516
+
517
+ Args:
518
+ current_prompt: Current prompt to calculate token usage
519
+ """
319
520
  ratio_occupied = self._calculate_context_occupancy()
320
521
 
321
- # Compact memory if any of these conditions are met:
322
- # 1. Memory occupancy exceeds MAX_OCCUPANCY, or
323
- # 2. Current iteration is a multiple of compact_every_n_iterations, or
324
- # 3. Working memory exceeds max_tokens_working_memory (if set)
325
522
  should_compact_by_occupancy = ratio_occupied >= MAX_OCCUPANCY
326
523
  should_compact_by_iteration = (
327
524
  self.compact_every_n_iterations is not None
@@ -329,7 +526,8 @@ class Agent(BaseModel):
329
526
  and self.current_iteration % self.compact_every_n_iterations == 0
330
527
  )
331
528
  should_compact_by_token_limit = (
332
- self.max_tokens_working_memory is not None and self.total_tokens > self.max_tokens_working_memory
529
+ self.max_tokens_working_memory is not None
530
+ and self.total_tokens > self.max_tokens_working_memory
333
531
  )
334
532
 
335
533
  if should_compact_by_occupancy or should_compact_by_iteration or should_compact_by_token_limit:
@@ -341,83 +539,162 @@ class Agent(BaseModel):
341
539
  f"Memory compaction triggered: Iteration {self.current_iteration} is a multiple of {self.compact_every_n_iterations}"
342
540
  )
343
541
 
542
+ if should_compact_by_token_limit:
543
+ logger.debug(
544
+ f"Memory compaction triggered: Token count {self.total_tokens} exceeds limit {self.max_tokens_working_memory}"
545
+ )
546
+
344
547
  self._emit_event("memory_full")
345
- self.memory.compact()
548
+ await self._async_compact_memory()
346
549
  self.total_tokens = self.model.token_counter_with_history(self.memory.memory, current_prompt)
347
550
  self._emit_event("memory_compacted")
348
551
 
349
- def _emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
350
- """
351
- Emit an event with system context and optional additional data.
552
+ async def _async_compact_memory(self) -> None:
553
+ """Compact memory asynchronously."""
554
+ self.memory.compact()
352
555
 
353
- Why: Provides a standardized way to track and log system events
354
- with consistent contextual information.
556
+ async def _async_compact_memory_with_summary(self) -> str:
557
+ """Generate a summary and compact memory asynchronously.
558
+
559
+ Returns:
560
+ Generated summary text
355
561
  """
356
- # Use empty dict as default to avoid mutable default argument
357
- event_data = {
358
- "iteration": self.current_iteration,
359
- "total_tokens": self.total_tokens,
360
- "context_occupancy": self._calculate_context_occupancy(),
361
- "max_input_tokens": self.max_input_tokens,
362
- "max_output_tokens": self.max_output_tokens,
363
- }
562
+ prompt_summary = (
563
+ "Summarize the conversation concisely:\n"
564
+ "format in markdown:\n"
565
+ "<thinking>\n"
566
+ " - 1. **Completed Steps**: Briefly describe the steps.\n"
567
+ " - 2. **Variables Used**: List the variables.\n"
568
+ " - 3. **Progress Analysis**: Assess progress.\n"
569
+ "</thinking>\n"
570
+ "Keep the summary clear and actionable.\n"
571
+ )
364
572
 
365
- # Merge additional data if provided
366
- if data:
367
- event_data.update(data)
573
+ memory_copy = self.memory.memory.copy()
368
574
 
369
- self.event_emitter.emit(event_type, event_data)
575
+ if len(memory_copy) < 3:
576
+ logger.warning("Not enough messages to compact memory with summary")
577
+ return "Memory compaction skipped: not enough messages"
370
578
 
371
- def _observe_response(self, content: str, iteration: int = 1) -> ObserveResponseResult:
372
- """Analyze the assistant's response and determine next steps.
579
+ user_message = memory_copy.pop()
580
+ assistant_message = memory_copy.pop()
581
+ summary = await self.model.async_generate_with_history(messages_history=memory_copy, prompt=prompt_summary)
582
+
583
+ # Remove last system message if present
584
+ if memory_copy and memory_copy[-1].role == "system":
585
+ memory_copy.pop()
586
+
587
+ memory_copy.append(Message(role="user", content=summary.response))
588
+ memory_copy.append(assistant_message)
589
+ memory_copy.append(user_message)
590
+ self.memory.memory = memory_copy
591
+ return summary.response
373
592
 
593
+ def _generate_task_summary(self, content: str) -> str:
594
+ """Generate a concise task-focused summary (synchronous wrapper).
595
+
374
596
  Args:
375
- content (str): The assistant's response content.
376
- iteration (int, optional): The current iteration number of task solving.
377
- Helps track the progress and prevent infinite loops. Defaults to 1.
378
-
597
+ content: The content to summarize
598
+
379
599
  Returns:
380
- ObserveResponseResult: A result indicating if the task is done and the next prompt.
600
+ Generated task summary
381
601
  """
382
602
  try:
383
- parsed_content = self._parse_tool_usage(content)
384
- if not parsed_content:
385
- return self._handle_no_tool_usage()
603
+ loop = asyncio.get_event_loop()
604
+ except RuntimeError:
605
+ loop = asyncio.new_event_loop()
606
+ asyncio.set_event_loop(loop)
386
607
 
387
- for tool_name, tool_input in parsed_content.items():
388
- tool = self.tools.get(tool_name)
389
- if not tool:
390
- return self._handle_tool_not_found(tool_name)
608
+ return loop.run_until_complete(self._async_generate_task_summary(content))
391
609
 
392
- arguments_with_values = self._parse_tool_arguments(tool, tool_input)
393
- is_repeated_call = self._is_repeated_tool_call(tool_name, arguments_with_values)
610
+ async def _async_generate_task_summary(self, content: str) -> str:
611
+ """Generate a concise task-focused summary using the generative model.
394
612
 
395
- if is_repeated_call:
396
- executed_tool, response = self._handle_repeated_tool_call(tool_name, arguments_with_values)
397
- else:
398
- executed_tool, response = self._execute_tool(tool_name, tool, arguments_with_values)
613
+ Args:
614
+ content: The content to summarize
399
615
 
400
- if not executed_tool:
401
- return self._handle_tool_execution_failure(response)
616
+ Returns:
617
+ Generated task summary
618
+ """
619
+ try:
620
+ if len(content) < 1024 * 4:
621
+ return content
622
+
623
+ prompt = (
624
+ "Create a task summary that captures ONLY: \n"
625
+ "1. Primary objective/purpose\n"
626
+ "2. Core actions/requirements\n"
627
+ "3. Desired end-state/outcome\n\n"
628
+ "Guidelines:\n"
629
+ "- Use imperative voice\n"
630
+ f"Input Task Description:\n{content}\n\n"
631
+ )
632
+ result = await self.model.async_generate(prompt=prompt)
633
+ logger.debug(f"Generated summary: {result.response}")
634
+ return result.response.strip() + "\n🚨 The FULL task is in <task> tag in the previous messages.\n"
635
+ except Exception as e:
636
+ logger.error(f"Error generating summary: {str(e)}")
637
+ return f"Summary generation failed: {str(e)}"
402
638
 
403
- variable_name = self.variable_store.add(response)
404
- new_prompt = self._format_observation_response(response, executed_tool, variable_name, iteration)
639
+ def _reset_session(self, task_to_solve: str = "", max_iterations: int = 30, clear_memory: bool = True) -> None:
640
+ """Reset the agent's session.
641
+
642
+ Args:
643
+ task_to_solve: New task to solve
644
+ max_iterations: Maximum number of iterations
645
+ clear_memory: Whether to clear memory
646
+ """
647
+ logger.debug("Resetting session...")
648
+ self.task_to_solve = task_to_solve
649
+ if clear_memory:
650
+ logger.debug("Clearing memory...")
651
+ self.memory.reset()
652
+ self.variable_store.reset()
653
+ self.total_tokens = 0
654
+ self.current_iteration = 0
655
+ self.max_output_tokens = self.model.get_model_max_output_tokens() or DEFAULT_MAX_OUTPUT_TOKENS
656
+ self.max_input_tokens = self.model.get_model_max_input_tokens() or DEFAULT_MAX_INPUT_TOKENS
657
+ self.max_iterations = max_iterations
405
658
 
406
- return ObserveResponseResult(
407
- next_prompt=new_prompt,
408
- executed_tool=executed_tool,
409
- answer=response if executed_tool == "task_complete" else None,
410
- )
659
+ def _update_total_tokens(self, message_history: list[Message], prompt: str) -> None:
660
+ """Update the total tokens count based on message history and prompt.
661
+
662
+ Args:
663
+ message_history: List of messages
664
+ prompt: Current prompt
665
+ """
666
+ self.total_tokens = self.model.token_counter_with_history(message_history, prompt)
411
667
 
412
- except Exception as e:
413
- return self._handle_error(e)
668
+ def _emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
669
+ """Emit an event with system context and optional additional data.
670
+
671
+ Args:
672
+ event_type: Type of event
673
+ data: Additional event data
674
+ """
675
+ event_data = {
676
+ "iteration": self.current_iteration,
677
+ "total_tokens": self.total_tokens,
678
+ "context_occupancy": self._calculate_context_occupancy(),
679
+ "max_input_tokens": self.max_input_tokens,
680
+ "max_output_tokens": self.max_output_tokens,
681
+ }
682
+ if data:
683
+ event_data.update(data)
684
+ self.event_emitter.emit(event_type, event_data)
414
685
 
415
686
  def _parse_tool_usage(self, content: str) -> dict:
416
- """Extract tool usage from the response content."""
687
+ """Extract tool usage from the response content.
688
+
689
+ Args:
690
+ content: Response content
691
+
692
+ Returns:
693
+ Dictionary mapping tool names to inputs
694
+ """
417
695
  if not content or not isinstance(content, str):
418
696
  return {}
419
697
 
420
- # Extract action
421
698
  xml_parser = ToleranceXMLParser()
422
699
  action = xml_parser.extract_elements(text=content, element_names=["action"])
423
700
 
@@ -426,17 +703,31 @@ class Agent(BaseModel):
426
703
  if action:
427
704
  return xml_parser.extract_elements(text=action["action"], element_names=tool_names)
428
705
  else:
429
- # Fallback to extracting tool usage directly
430
706
  return xml_parser.extract_elements(text=content, element_names=tool_names)
431
707
 
432
-
433
- def _parse_tool_arguments(self, tool, tool_input: str) -> dict:
434
- """Parse the tool arguments from the tool input."""
708
+ def _parse_tool_arguments(self, tool: Tool, tool_input: str) -> dict:
709
+ """Parse the tool arguments from the tool input.
710
+
711
+ Args:
712
+ tool: Tool instance
713
+ tool_input: Raw tool input text
714
+
715
+ Returns:
716
+ Dictionary of parsed arguments
717
+ """
435
718
  tool_parser = ToolParser(tool=tool)
436
719
  return tool_parser.parse(tool_input)
437
720
 
438
721
  def _is_repeated_tool_call(self, tool_name: str, arguments_with_values: dict) -> bool:
439
- """Check if the tool call is repeated."""
722
+ """Check if the tool call is repeated.
723
+
724
+ Args:
725
+ tool_name: Name of the tool
726
+ arguments_with_values: Tool arguments
727
+
728
+ Returns:
729
+ True if call is repeated, False otherwise
730
+ """
440
731
  current_call = {
441
732
  "tool_name": tool_name,
442
733
  "arguments": arguments_with_values,
@@ -455,16 +746,27 @@ class Agent(BaseModel):
455
746
  current_call["count"] = 1
456
747
 
457
748
  self.last_tool_call = current_call
458
- return is_repeated_call and repeat_count >= 2
749
+ return is_repeated_call and current_call.get("count", 0) >= 2
459
750
 
460
751
  def _handle_no_tool_usage(self) -> ObserveResponseResult:
461
- """Handle the case where no tool usage is found in the response."""
752
+ """Handle the case where no tool usage is found in the response.
753
+
754
+ Returns:
755
+ ObserveResponseResult with error message
756
+ """
462
757
  return ObserveResponseResult(
463
758
  next_prompt="Error: No tool usage found in response.", executed_tool=None, answer=None
464
759
  )
465
760
 
466
761
  def _handle_tool_not_found(self, tool_name: str) -> ObserveResponseResult:
467
- """Handle the case where the tool is not found."""
762
+ """Handle the case where the tool is not found.
763
+
764
+ Args:
765
+ tool_name: Name of the tool
766
+
767
+ Returns:
768
+ ObserveResponseResult with error message
769
+ """
468
770
  logger.warning(f"Tool '{tool_name}' not found in tool manager.")
469
771
  return ObserveResponseResult(
470
772
  next_prompt=f"Error: Tool '{tool_name}' not found in tool manager.",
@@ -472,8 +774,16 @@ class Agent(BaseModel):
472
774
  answer=None,
473
775
  )
474
776
 
475
- def _handle_repeated_tool_call(self, tool_name: str, arguments_with_values: dict) -> (str, str):
476
- """Handle the case where a tool call is repeated."""
777
+ def _handle_repeated_tool_call(self, tool_name: str, arguments_with_values: dict) -> tuple[str, str]:
778
+ """Handle the case where a tool call is repeated.
779
+
780
+ Args:
781
+ tool_name: Name of the tool
782
+ arguments_with_values: Tool arguments
783
+
784
+ Returns:
785
+ Tuple of (executed_tool_name, error_message)
786
+ """
477
787
  repeat_count = self.last_tool_call.get("count", 0)
478
788
  error_message = (
479
789
  "Error: Detected repeated identical tool call pattern.\n"
@@ -489,7 +799,14 @@ class Agent(BaseModel):
489
799
  return tool_name, error_message
490
800
 
491
801
  def _handle_tool_execution_failure(self, response: str) -> ObserveResponseResult:
492
- """Handle the case where tool execution fails."""
802
+ """Handle the case where tool execution fails.
803
+
804
+ Args:
805
+ response: Error response
806
+
807
+ Returns:
808
+ ObserveResponseResult with error message
809
+ """
493
810
  return ObserveResponseResult(
494
811
  next_prompt=response,
495
812
  executed_tool="",
@@ -497,7 +814,14 @@ class Agent(BaseModel):
497
814
  )
498
815
 
499
816
  def _handle_error(self, error: Exception) -> ObserveResponseResult:
500
- """Handle any exceptions that occur during response observation."""
817
+ """Handle any exceptions that occur during response observation.
818
+
819
+ Args:
820
+ error: Exception that occurred
821
+
822
+ Returns:
823
+ ObserveResponseResult with error message
824
+ """
501
825
  logger.error(f"Error in _observe_response: {str(error)}")
502
826
  return ObserveResponseResult(
503
827
  next_prompt=f"An error occurred while processing the response: {str(error)}",
@@ -506,9 +830,19 @@ class Agent(BaseModel):
506
830
  )
507
831
 
508
832
  def _format_observation_response(
509
- self, response: str, last_exectured_tool: str, variable_name: str, iteration: int
833
+ self, response: str, last_executed_tool: str, variable_name: str, iteration: int
510
834
  ) -> str:
511
- """Format the observation response with the given response, variable name, and iteration."""
835
+ """Format the observation response with the given response, variable name, and iteration.
836
+
837
+ Args:
838
+ response: Tool execution response
839
+ last_executed_tool: Name of last executed tool
840
+ variable_name: Name of variable storing response
841
+ iteration: Current iteration number
842
+
843
+ Returns:
844
+ Formatted observation response
845
+ """
512
846
  response_display = response
513
847
  if len(response) > MAX_RESPONSE_LENGTH:
514
848
  response_display = response[:MAX_RESPONSE_LENGTH]
@@ -516,8 +850,7 @@ class Agent(BaseModel):
516
850
  f"... content was truncated full content available by interpolation in variable {variable_name}"
517
851
  )
518
852
 
519
- # Format the response message
520
- formatted_response = formatted_response = (
853
+ formatted_response = (
521
854
  "# Analysis and Next Action Decision Point\n\n"
522
855
  f"📊 Progress: Iteration {iteration}/{self.max_iterations}\n\n"
523
856
  "## Global Task summary:\n"
@@ -535,7 +868,7 @@ class Agent(BaseModel):
535
868
  "1. Your analysis of the progression resulting from the execution of the tool in <thinking> tags, don't include <context_analysis/>\n"
536
869
  "2. Your tool execution plan in <tool_name> tags\n\n"
537
870
  "## Last executed action result\n"
538
- f"Last executed tool {last_exectured_tool} Execution Result:\n"
871
+ f"Last executed tool {last_executed_tool} Execution Result:\n"
539
872
  f"\n<{variable_name}>\n{response_display}\n</{variable_name}>\n"
540
873
  "## Response Format\n"
541
874
  "```xml\n"
@@ -561,132 +894,14 @@ class Agent(BaseModel):
561
894
 
562
895
  return formatted_response
563
896
 
564
- def _execute_tool(self, tool_name: str, tool, arguments_with_values: dict) -> tuple[str, Any]:
565
- """Execute a tool with validation if required.
566
-
567
- Args:
568
- tool_name: Name of the tool to execute
569
- tool: Tool instance
570
- arguments_with_values: Dictionary of argument names and values
571
-
572
- Returns:
573
- tuple containing:
574
- - executed_tool name (str)
575
- - tool execution response (Any)
576
-
577
- Note:
578
- Predefined variable properties take precedence over dynamically provided values.
579
- This ensures consistent behavior when tools have both predefined properties
580
- and runtime-provided arguments. The precedence order is:
581
- 1. Predefined properties from tool.get_injectable_properties_in_execution()
582
- 2. Runtime-provided arguments from arguments_with_values
583
- """
584
- # Handle tool validation if required
585
- if tool.need_validation:
586
- logger.debug(f"Tool '{tool_name}' requires validation.")
587
- self._emit_event(
588
- "tool_execute_validation_start",
589
- {"tool_name": tool_name, "arguments": arguments_with_values},
590
- )
591
-
592
- question_validation: str = (
593
- "Do you permit the execution of this tool?\n"
594
- f"Tool: {tool_name}\n"
595
- "Arguments:\n"
596
- "<arguments>\n"
597
- + "\n".join([f" <{key}>{value}</{key}>" for key, value in arguments_with_values.items()])
598
- + "\n</arguments>\n"
599
- "Yes or No"
600
- )
601
- permission_granted = self.ask_for_user_validation(question_validation)
602
-
603
- self._emit_event(
604
- "tool_execute_validation_end",
605
- {"tool_name": tool_name, "arguments": arguments_with_values},
606
- )
607
-
608
- if not permission_granted:
609
- logger.debug(f"Execution of tool '{tool_name}' was denied by the user.")
610
- return "", f"Error: execution of tool '{tool_name}' was denied by the user."
611
-
612
- # Emit event: Tool Execution Start
613
- self._emit_event(
614
- "tool_execution_start",
615
- {"tool_name": tool_name, "arguments": arguments_with_values},
616
- )
617
-
618
- try:
619
- # Execute the tool synchronously
620
- arguments_with_values_interpolated = {
621
- key: self._interpolate_variables(value) for key, value in arguments_with_values.items()
622
- }
623
-
624
- arguments_with_values_interpolated = arguments_with_values_interpolated
625
-
626
- # test if tool need variables in context
627
- if tool.need_variables:
628
- # Inject variables into the tool if needed
629
- arguments_with_values_interpolated["variables"] = self.variable_store
630
- if tool.need_caller_context_memory:
631
- # Inject caller context into the tool if needed
632
- arguments_with_values_interpolated["caller_context_memory"] = self.memory.memory
633
-
634
- try:
635
- # Convert arguments to proper types
636
- converted_args = self.tools.validate_and_convert_arguments(
637
- tool_name, arguments_with_values_interpolated
638
- )
639
- except ValueError as e:
640
- return "", f"Argument Error: {str(e)}"
641
-
642
- # Add injectable variables
643
- injectable_properties = tool.get_injectable_properties_in_execution()
644
- for key, value in injectable_properties.items():
645
- converted_args[key] = value
646
-
647
- # Call tool execute with named arguments
648
- response = tool.execute(**converted_args)
649
- executed_tool = tool.name
650
- except Exception as e:
651
- response = f"Error executing tool: {tool_name}: {str(e)}\n"
652
- executed_tool = ""
653
-
654
- # Emit event: Tool Execution End
655
- self._emit_event(
656
- "tool_execution_end",
657
- {
658
- "tool_name": tool_name,
659
- "arguments": arguments_with_values,
660
- "response": response,
661
- },
662
- )
663
-
664
- return executed_tool, response
665
-
666
- def _interpolate_variables(self, text: str) -> str:
667
- """Interpolate variables using $var$ syntax in the given text."""
668
- try:
669
- import re
670
-
671
- for var in self.variable_store.keys():
672
- # Create safe pattern without double-escaping backslashes
673
- safe_var = re.sub(r"([\\\.\^\$\*\+\?\{\}\[\]\|\(\)])", r"\\\1", var)
674
- pattern = rf"\${safe_var}\$"
675
- replacement = self.variable_store[var]
676
- text = re.sub(pattern, replacement, text)
677
- return text
678
- except Exception as e:
679
- logger.error(f"Error in _interpolate_variables: {str(e)}")
680
- return text
681
-
682
897
  def _prepare_prompt_task(self, task: str) -> str:
683
898
  """Prepare the initial prompt for the task.
684
899
 
685
900
  Args:
686
- task (str): The task description.
901
+ task: The task description
687
902
 
688
903
  Returns:
689
- str: The formatted task prompt.
904
+ The formatted task prompt
690
905
  """
691
906
  prompt_task: str = (
692
907
  "## Your task to solve:\n"
@@ -702,7 +917,11 @@ class Agent(BaseModel):
702
917
  return prompt_task
703
918
 
704
919
  def _get_tools_names_prompt(self) -> str:
705
- """Construct a detailed prompt that lists the available tools for task execution."""
920
+ """Construct a detailed prompt that lists the available tools for task execution.
921
+
922
+ Returns:
923
+ Formatted tools prompt
924
+ """
706
925
  prompt_use_tools: str = (
707
926
  "To accomplish this task, you have access to these tools:\n"
708
927
  "\n"
@@ -712,15 +931,22 @@ class Agent(BaseModel):
712
931
  "1. Select ONE tool per message\n"
713
932
  "2. You will receive the tool's output in the next user response\n"
714
933
  "3. Choose the most appropriate tool for each step\n"
715
- "4. Use task_complete tool to confirm task completion\n"
934
+ "4. If it's not asked to write on files, don't use write_file tool\n"
935
+ "5. If files are written, then use tool to display the prepared download link\n"
936
+ "6. Give the final full answer using all the variables\n"
937
+ "7. Use task_complete tool to confirm task completion with the full content of the final answer\n"
716
938
  )
717
939
  return prompt_use_tools
718
940
 
719
941
  def _get_variable_prompt(self) -> str:
720
- """Construct a prompt that explains how to use variables."""
942
+ """Construct a prompt that explains how to use variables.
943
+
944
+ Returns:
945
+ Formatted variables prompt
946
+ """
721
947
  prompt_use_variables: str = (
722
948
  "To use a variable interpolation, use the format $variable_name$ in function arguments.\n"
723
- "Example: <write_file><file_path>/path/to/file.txt</file_path><content>$var1$</write_file>\n"
949
+ "Example: <write_file><file_path>/path/to/file.txt</file_path><content>$var1$</content></write_file>\n"
724
950
  "\n"
725
951
  "Available variables:\n"
726
952
  "\n"
@@ -731,96 +957,110 @@ class Agent(BaseModel):
731
957
  return prompt_use_variables
732
958
 
733
959
  def _calculate_context_occupancy(self) -> float:
734
- """Calculate the number of tokens in percentages for prompt and completion."""
960
+ """Calculate the number of tokens in percentages for prompt and completion.
961
+
962
+ Returns:
963
+ Percentage of context window occupied
964
+ """
735
965
  total_tokens = self.total_tokens
736
- # Calculate token usage of prompt
737
966
  max_tokens = self.model.get_model_max_input_tokens()
738
967
 
739
- # Handle None value and prevent division by zero
740
968
  if max_tokens is None or max_tokens <= 0:
741
969
  logger.warning(f"Invalid max tokens value: {max_tokens}. Using default of {DEFAULT_MAX_INPUT_TOKENS}.")
742
970
  max_tokens = DEFAULT_MAX_INPUT_TOKENS
743
971
 
744
972
  return round((total_tokens / max_tokens) * 100, 2)
745
973
 
746
- def _compact_memory_with_summary(self) -> str:
747
- prompt_summary = (
748
- "Summarize the conversation concisely:\n"
749
- "format in markdown:\n"
750
- "<thinking>\n"
751
- " - 1. **Completed Steps**: Briefly describe the steps.\n"
752
- " - 2. **Variables Used**: List the variables.\n"
753
- " - 3. **Progress Analysis**: Assess progress.\n"
754
- "</thinking>\n"
755
- "Keep the summary clear and actionable.\n"
756
- )
757
-
758
- # Get all message system, except the last assistant / user message
759
- memory_copy = self.memory.memory.copy()
760
-
761
- # Remove the last assistant / user message
762
- user_message = memory_copy.pop()
763
- assistant_message = memory_copy.pop()
764
- summary = self.model.generate_with_history(messages_history=memory_copy, prompt=prompt_summary)
765
- # Remove user message
766
- memory_copy.pop()
767
- # Replace by summary
768
- memory_copy.append(Message(role="user", content=summary.response))
769
- memory_copy.append(assistant_message)
770
- memory_copy.append(user_message)
771
- self.memory.memory = memory_copy
772
- return summary.response
773
-
774
- def _generate_task_summary(self, content: str) -> str:
775
- """Generate a concise task-focused summary using the generative model.
776
-
777
- Args:
778
- content (str): The content to summarize
779
-
780
- Returns:
781
- str: Generated task summary
782
- """
783
- try:
784
- if len(content) < 1024*4:
785
- return content
786
- prompt = (
787
- "Create a task summary that captures ONLY: \n"
788
- "1. Primary objective/purpose\n"
789
- "2. Core actions/requirements\n"
790
- "3. Desired end-state/outcome\n\n"
791
- "Guidelines:\n"
792
- "- Use imperative voice\n"
793
- f"Input Task Description:\n{content}\n\n"
794
- )
795
- result = self.model.generate(prompt=prompt)
796
- logger.debug(f"Generated summary: {result.response}")
797
- return result.response.strip() + "\n🚨 The FULL task is in <task> tag in the previous messages.\n"
798
- except Exception as e:
799
- logger.error(f"Error generating summary: {str(e)}")
800
- return f"Summary generation failed: {str(e)}"
801
-
802
974
  def _update_session_memory(self, user_content: str, assistant_content: str) -> None:
803
- """
804
- Log session messages to memory and emit events.
975
+ """Log session messages to memory and emit events.
805
976
 
806
977
  Args:
807
- user_content (str): The user's content.
808
- assistant_content (str): The assistant's content.
978
+ user_content: The user's content
979
+ assistant_content: The assistant's content
809
980
  """
810
981
  self.memory.add(Message(role="user", content=user_content))
811
- self._emit_event(
812
- "session_add_message",
813
- {"role": "user", "content": user_content},
814
- )
982
+ self._emit_event("session_add_message", {"role": "user", "content": user_content})
815
983
 
816
984
  self.memory.add(Message(role="assistant", content=assistant_content))
817
-
818
- self._emit_event(
819
- "session_add_message",
820
- {"role": "assistant", "content": assistant_content},
821
- )
985
+ self._emit_event("session_add_message", {"role": "assistant", "content": assistant_content})
822
986
 
823
987
  def update_model(self, new_model_name: str) -> None:
824
- """Update the model name and recreate the model instance."""
988
+ """Update the model name and recreate the model instance.
989
+
990
+ Args:
991
+ new_model_name: New model name to use
992
+ """
825
993
  self.model_name = new_model_name
826
994
  self.model = GenerativeModel(model=new_model_name, event_emitter=self.event_emitter)
995
+
996
+ def add_tool(self, tool: Tool) -> None:
997
+ """Add a new tool to the agent's tool manager.
998
+
999
+ Args:
1000
+ tool: The tool instance to add
1001
+
1002
+ Raises:
1003
+ ValueError: If a tool with the same name already exists
1004
+ """
1005
+ if tool.name in self.tools.tool_names():
1006
+ raise ValueError(f"Tool with name '{tool.name}' already exists")
1007
+
1008
+ self.tools.add(tool)
1009
+ # Update tools markdown in config
1010
+ self.config = AgentConfig(
1011
+ environment_details=self.config.environment_details,
1012
+ tools_markdown=self.tools.to_markdown(),
1013
+ system_prompt=self.config.system_prompt,
1014
+ )
1015
+ logger.debug(f"Added tool: {tool.name}")
1016
+
1017
+ def remove_tool(self, tool_name: str) -> None:
1018
+ """Remove a tool from the agent's tool manager.
1019
+
1020
+ Args:
1021
+ tool_name: Name of the tool to remove
1022
+
1023
+ Raises:
1024
+ ValueError: If tool doesn't exist or is TaskCompleteTool
1025
+ """
1026
+ if tool_name not in self.tools.tool_names():
1027
+ raise ValueError(f"Tool '{tool_name}' does not exist")
1028
+
1029
+ tool = self.tools.get(tool_name)
1030
+ if isinstance(tool, TaskCompleteTool):
1031
+ raise ValueError("Cannot remove TaskCompleteTool as it is required")
1032
+
1033
+ self.tools.remove(tool_name)
1034
+ # Update tools markdown in config
1035
+ self.config = AgentConfig(
1036
+ environment_details=self.config.environment_details,
1037
+ tools_markdown=self.tools.to_markdown(),
1038
+ system_prompt=self.config.system_prompt,
1039
+ )
1040
+ logger.debug(f"Removed tool: {tool_name}")
1041
+
1042
+ def set_tools(self, tools: list[Tool]) -> None:
1043
+ """Set/replace all tools for the agent.
1044
+
1045
+ Args:
1046
+ tools: List of tool instances to set
1047
+
1048
+ Note:
1049
+ TaskCompleteTool will be automatically added if not present
1050
+ """
1051
+ # Ensure TaskCompleteTool is present
1052
+ if not any(isinstance(t, TaskCompleteTool) for t in tools):
1053
+ tools.append(TaskCompleteTool())
1054
+
1055
+ # Create new tool manager and add tools
1056
+ tool_manager = ToolManager()
1057
+ tool_manager.add_list(tools)
1058
+ self.tools = tool_manager
1059
+
1060
+ # Update config with new tools markdown
1061
+ self.config = AgentConfig(
1062
+ environment_details=self.config.environment_details,
1063
+ tools_markdown=self.tools.to_markdown(),
1064
+ system_prompt=self.config.system_prompt,
1065
+ )
1066
+ logger.debug(f"Set {len(tools)} tools")