quantalogic 0.58.0__py3-none-any.whl → 0.59.1__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.
quantalogic/agent.py CHANGED
@@ -1,4 +1,4 @@
1
- """Enhanced QuantaLogic agent implementing the ReAct framework."""
1
+ """Enhanced QuantaLogic agent implementing the ReAct framework with optional chat mode."""
2
2
 
3
3
  import asyncio
4
4
  import os
@@ -6,7 +6,7 @@ import uuid
6
6
  from collections.abc import Awaitable, Callable
7
7
  from datetime import datetime
8
8
  from pathlib import Path
9
- from typing import Any
9
+ from typing import Any, Optional
10
10
 
11
11
  from jinja2 import Environment, FileSystemLoader
12
12
  from loguru import logger
@@ -58,19 +58,21 @@ class ObserveResponseResult(BaseModel):
58
58
 
59
59
 
60
60
  class Agent(BaseModel):
61
- """Enhanced QuantaLogic agent implementing ReAct framework.
61
+ """Enhanced QuantaLogic agent supporting both ReAct goal-solving and conversational chat modes.
62
62
 
63
- Supports both synchronous and asynchronous operations for task solving.
64
- Use `solve_task` for synchronous contexts (e.g., CLI tools) and `async_solve_task`
65
- for asynchronous contexts (e.g., web servers).
63
+ Use `solve_task`/`async_solve_task` for goal-oriented ReAct mode (backward compatible).
64
+ Use `chat`/`async_chat` for conversational mode with a customizable persona.
65
+
66
+ Supports both synchronous and asynchronous operations. Use synchronous methods for CLI tools
67
+ and asynchronous methods for web servers or async contexts.
66
68
  """
67
69
 
68
70
  model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid")
69
71
 
70
72
  specific_expertise: str
71
73
  model: GenerativeModel
72
- memory: AgentMemory = AgentMemory() # A list User / Assistant Messages
73
- variable_store: VariableMemory = VariableMemory() # A dictionary of variables
74
+ memory: AgentMemory = AgentMemory() # List of User/Assistant Messages
75
+ variable_store: VariableMemory = VariableMemory() # Dictionary of variables
74
76
  tools: ToolManager = ToolManager()
75
77
  event_emitter: EventEmitter = EventEmitter()
76
78
  config: AgentConfig
@@ -87,6 +89,8 @@ class Agent(BaseModel):
87
89
  compact_every_n_iterations: int | None = None
88
90
  max_tokens_working_memory: int | None = None
89
91
  _model_name: str = PrivateAttr(default="")
92
+ chat_system_prompt: str # Base persona prompt for chat mode
93
+ tool_mode: Optional[str] = None # Tool or toolset to prioritize in chat mode
90
94
 
91
95
  def __init__(
92
96
  self,
@@ -101,30 +105,32 @@ class Agent(BaseModel):
101
105
  compact_every_n_iterations: int | None = None,
102
106
  max_tokens_working_memory: int | None = None,
103
107
  event_emitter: EventEmitter | None = None,
108
+ chat_system_prompt: str | None = None,
109
+ tool_mode: Optional[str] = None,
104
110
  ):
105
111
  """Initialize the agent with model, memory, tools, and configurations.
106
-
112
+
107
113
  Args:
108
114
  model_name: Name of the model to use
109
115
  memory: AgentMemory instance for storing conversation history
110
116
  variable_store: VariableMemory instance for storing variables
111
- tools: List of Tool instances
117
+ tools: List of Tool instances
112
118
  ask_for_user_validation: Function to ask for user validation
113
- task_to_solve: Initial task to solve
119
+ task_to_solve: Initial task to solve (for ReAct mode)
114
120
  specific_expertise: Description of the agent's expertise
115
121
  get_environment: Function to get environment details
116
122
  compact_every_n_iterations: How often to compact memory
117
123
  max_tokens_working_memory: Maximum token count for working memory
118
124
  event_emitter: EventEmitter instance for event handling
125
+ chat_system_prompt: Optional base system prompt for chat mode persona
126
+ tool_mode: Optional tool or toolset to prioritize in chat mode
119
127
  """
120
128
  try:
121
129
  logger.debug("Initializing agent...")
122
130
 
123
- # Create or use provided event emitter
124
131
  if event_emitter is None:
125
132
  event_emitter = EventEmitter()
126
133
 
127
- # Add TaskCompleteTool to the tools list if not already present
128
134
  if not any(isinstance(t, TaskCompleteTool) for t in tools):
129
135
  tools.append(TaskCompleteTool())
130
136
 
@@ -145,7 +151,11 @@ class Agent(BaseModel):
145
151
  system_prompt=system_prompt_text,
146
152
  )
147
153
 
148
- # Initialize using Pydantic's model_validate
154
+ chat_system_prompt = chat_system_prompt or (
155
+ "You are a friendly, helpful AI assistant. Engage in natural conversation, "
156
+ "answer questions, and use tools when explicitly requested or when they enhance your response."
157
+ )
158
+
149
159
  super().__init__(
150
160
  specific_expertise=specific_expertise,
151
161
  model=GenerativeModel(model=model_name, event_emitter=event_emitter),
@@ -166,12 +176,15 @@ class Agent(BaseModel):
166
176
  system_prompt="",
167
177
  compact_every_n_iterations=compact_every_n_iterations or 30,
168
178
  max_tokens_working_memory=max_tokens_working_memory,
179
+ chat_system_prompt=chat_system_prompt,
180
+ tool_mode=tool_mode,
169
181
  )
170
182
 
171
183
  self._model_name = model_name
172
184
 
173
185
  logger.debug(f"Memory will be compacted every {self.compact_every_n_iterations} iterations")
174
186
  logger.debug(f"Max tokens for working memory set to: {self.max_tokens_working_memory}")
187
+ logger.debug(f"Tool mode set to: {self.tool_mode}")
175
188
  logger.debug("Agent initialized successfully.")
176
189
  except Exception as e:
177
190
  logger.error(f"Failed to initialize agent: {str(e)}")
@@ -186,7 +199,6 @@ class Agent(BaseModel):
186
199
  def model_name(self, value: str) -> None:
187
200
  """Set the model name and update the model instance."""
188
201
  self._model_name = value
189
- # Update the model instance with the new name
190
202
  self.model = GenerativeModel(model=value, event_emitter=self.event_emitter)
191
203
 
192
204
  def clear_memory(self) -> None:
@@ -213,7 +225,6 @@ class Agent(BaseModel):
213
225
  try:
214
226
  loop = asyncio.get_event_loop()
215
227
  except RuntimeError:
216
- # Create a new event loop if one doesn't exist
217
228
  loop = asyncio.new_event_loop()
218
229
  asyncio.set_event_loop(loop)
219
230
 
@@ -278,7 +289,6 @@ class Agent(BaseModel):
278
289
  messages_history=self.memory.memory,
279
290
  prompt=current_prompt,
280
291
  streaming=False,
281
- # Removed stop_words parameter to allow complete responses
282
292
  )
283
293
 
284
294
  content = result.response
@@ -292,7 +302,7 @@ class Agent(BaseModel):
292
302
 
293
303
  if result.executed_tool == "task_complete":
294
304
  self._emit_event("task_complete", {"response": result.answer})
295
- answer = result.answer or "" # Ensure answer is never None
305
+ answer = result.answer or ""
296
306
  done = True
297
307
 
298
308
  self._update_session_memory(current_prompt, content)
@@ -309,13 +319,166 @@ class Agent(BaseModel):
309
319
  self._emit_event("task_solve_end")
310
320
  return answer
311
321
 
322
+ def chat(
323
+ self,
324
+ message: str,
325
+ streaming: bool = False,
326
+ clear_memory: bool = False,
327
+ auto_tool_call: bool = True,
328
+ ) -> str:
329
+ """Engage in a conversational chat with the user (synchronous version).
330
+
331
+ Ideal for synchronous applications. For asynchronous contexts, use `async_chat`.
332
+
333
+ Args:
334
+ message: The user's input message
335
+ streaming: Whether to stream the response
336
+ clear_memory: Whether to clear memory before starting
337
+ auto_tool_call: Whether to automatically execute detected tool calls and interpret results
338
+
339
+ Returns:
340
+ The assistant's response
341
+ """
342
+ logger.debug(f"Chatting synchronously with message: {message}, auto_tool_call: {auto_tool_call}")
343
+ try:
344
+ loop = asyncio.get_event_loop()
345
+ except RuntimeError:
346
+ loop = asyncio.new_event_loop()
347
+ asyncio.set_event_loop(loop)
348
+
349
+ return loop.run_until_complete(self.async_chat(message, streaming, clear_memory, auto_tool_call))
350
+
351
+ async def async_chat(
352
+ self,
353
+ message: str,
354
+ streaming: bool = False,
355
+ clear_memory: bool = False,
356
+ auto_tool_call: bool = True,
357
+ ) -> str:
358
+ """Engage in a conversational chat with the user (asynchronous version).
359
+
360
+ Ideal for asynchronous applications. For synchronous contexts, use `chat`.
361
+
362
+ Args:
363
+ message: The user's input message
364
+ streaming: Whether to stream the response
365
+ clear_memory: Whether to clear memory before starting
366
+ auto_tool_call: Whether to automatically execute detected tool calls and interpret results
367
+
368
+ Returns:
369
+ The assistant's response
370
+ """
371
+ logger.debug(f"Chatting asynchronously with message: {message}, auto_tool_call: {auto_tool_call}")
372
+ if clear_memory:
373
+ self.clear_memory()
374
+
375
+ # Prepare chat system prompt with tool information
376
+ tools_prompt = self._get_tools_names_prompt()
377
+ if self.tool_mode:
378
+ tools_prompt += f"\nPrioritized tool mode: {self.tool_mode}. Prefer tools related to {self.tool_mode} when applicable."
379
+
380
+ full_chat_prompt = self._render_template(
381
+ 'chat_system_prompt.j2',
382
+ persona=self.chat_system_prompt,
383
+ tools_prompt=tools_prompt
384
+ )
385
+
386
+ if not self.memory.memory or self.memory.memory[0].role != "system":
387
+ self.memory.add(Message(role="system", content=full_chat_prompt))
388
+
389
+ self._emit_event("chat_start", {"message": message})
390
+
391
+ # Add user message to memory
392
+ self.memory.add(Message(role="user", content=message))
393
+ self._update_total_tokens(self.memory.memory, "")
394
+
395
+ # Iterative tool usage with auto-execution
396
+ current_prompt = message
397
+ response_content = ""
398
+ max_tool_iterations = 5 # Prevent infinite tool loops
399
+ tool_iteration = 0
400
+
401
+ while tool_iteration < max_tool_iterations:
402
+ try:
403
+ if streaming:
404
+ content = ""
405
+ # When streaming is enabled, the GenerativeModel._async_stream_response method
406
+ # already emits the stream_chunk events, so we don't need to emit them again here
407
+ async_stream = await self.model.async_generate_with_history(
408
+ messages_history=self.memory.memory,
409
+ prompt=current_prompt,
410
+ streaming=True,
411
+ )
412
+ # Just collect the chunks without re-emitting events
413
+ async for chunk in async_stream:
414
+ content += chunk
415
+ response = ResponseStats(
416
+ response=content,
417
+ usage=TokenUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
418
+ model=self.model.model,
419
+ finish_reason="stop",
420
+ )
421
+ else:
422
+ response = await self.model.async_generate_with_history(
423
+ messages_history=self.memory.memory,
424
+ prompt=current_prompt,
425
+ streaming=False,
426
+ )
427
+ content = response.response
428
+
429
+ self.total_tokens = response.usage.total_tokens if not streaming else self.total_tokens
430
+
431
+ # Observe response for tool calls
432
+ observation = await self._async_observe_response(content)
433
+ if observation.executed_tool and auto_tool_call:
434
+ # Tool was executed; process result and continue
435
+ current_prompt = observation.next_prompt
436
+
437
+ # In chat mode, format the response with clear tool call visualization
438
+ if not self.task_to_solve.strip(): # We're in chat mode
439
+ # Format the response to clearly show the tool call and result
440
+ # Use a format that task_runner.py can parse and display nicely
441
+
442
+ # For a cleaner look, insert a special delimiter that task_runner.py can recognize
443
+ # to separate tool call from result
444
+ response_content = f"{content}\n\n__TOOL_RESULT_SEPARATOR__{observation.executed_tool}__\n{observation.next_prompt}"
445
+ else:
446
+ # In task mode, keep the original behavior
447
+ response_content = observation.next_prompt
448
+
449
+ tool_iteration += 1
450
+ self.memory.add(Message(role="assistant", content=content)) # Original tool call
451
+ self.memory.add(Message(role="user", content=observation.next_prompt)) # Tool result
452
+ logger.debug(f"Tool executed: {observation.executed_tool}, iteration: {tool_iteration}")
453
+ elif not observation.executed_tool and "<action>" in content and auto_tool_call:
454
+ # Detected malformed tool call attempt; provide feedback and exit loop
455
+ response_content = (
456
+ f"{content}\n\n⚠️ Error: Invalid tool call format detected. "
457
+ "Please use the exact XML structure as specified in the system prompt:\n"
458
+ "```xml\n<action>\n<tool_name>\n <parameter_name>value</parameter_name>\n</tool_name>\n</action>\n```"
459
+ )
460
+ break
461
+ else:
462
+ # No tool executed or auto_tool_call is False; final response
463
+ response_content = content
464
+ break
465
+
466
+ except Exception as e:
467
+ logger.error(f"Error during async chat: {str(e)}")
468
+ response_content = f"Error: {str(e)}"
469
+ break
470
+
471
+ self._update_session_memory(message, response_content)
472
+ self._emit_event("chat_response", {"response": response_content})
473
+ return response_content
474
+
312
475
  def _observe_response(self, content: str, iteration: int = 1) -> ObserveResponseResult:
313
476
  """Analyze the assistant's response and determine next steps (synchronous wrapper).
314
-
477
+
315
478
  Args:
316
479
  content: The response content to analyze
317
480
  iteration: Current iteration number
318
-
481
+
319
482
  Returns:
320
483
  ObserveResponseResult with next steps information
321
484
  """
@@ -329,20 +492,34 @@ class Agent(BaseModel):
329
492
 
330
493
  async def _async_observe_response(self, content: str, iteration: int = 1) -> ObserveResponseResult:
331
494
  """Analyze the assistant's response and determine next steps (asynchronous).
332
-
495
+
333
496
  Args:
334
497
  content: The response content to analyze
335
498
  iteration: Current iteration number
336
-
499
+
337
500
  Returns:
338
501
  ObserveResponseResult with next steps information
339
502
  """
340
503
  try:
504
+ # Detect if we're in chat mode by checking if task_to_solve is empty
505
+ is_chat_mode = not self.task_to_solve.strip()
506
+
507
+ # Use specialized chat mode observation method if in chat mode
508
+ if is_chat_mode:
509
+ return await self._async_observe_response_chat(content, iteration)
510
+
511
+ # Parse content for tool usage
341
512
  parsed_content = self._parse_tool_usage(content)
342
513
  if not parsed_content:
343
- return self._handle_no_tool_usage()
344
-
345
- for tool_name, tool_input in parsed_content.items():
514
+ logger.debug("No tool usage detected in response")
515
+ return ObserveResponseResult(next_prompt=content, executed_tool=None, answer=None)
516
+
517
+ # Process tools for regular ReAct mode
518
+ tool_names = list(parsed_content.keys())
519
+ for tool_name in tool_names:
520
+ if tool_name not in parsed_content:
521
+ continue
522
+ tool_input = parsed_content[tool_name]
346
523
  tool = self.tools.get(tool_name)
347
524
  if not tool:
348
525
  return self._handle_tool_not_found(tool_name)
@@ -361,22 +538,28 @@ class Agent(BaseModel):
361
538
  variable_name = self.variable_store.add(response)
362
539
  new_prompt = self._format_observation_response(response, executed_tool, variable_name, iteration)
363
540
 
541
+ # In chat mode, don't set answer; in task mode, set answer only for task_complete
542
+ is_task_complete_answer = executed_tool == "task_complete" and not is_chat_mode
364
543
  return ObserveResponseResult(
365
544
  next_prompt=new_prompt,
366
545
  executed_tool=executed_tool,
367
- answer=response if executed_tool == "task_complete" else None,
546
+ answer=response if is_task_complete_answer else None,
368
547
  )
548
+
549
+ # If no tools were executed, return original content
550
+ return ObserveResponseResult(next_prompt=content, executed_tool=None, answer=None)
551
+
369
552
  except Exception as e:
370
553
  return self._handle_error(e)
371
554
 
372
555
  def _execute_tool(self, tool_name: str, tool: Tool, arguments_with_values: dict) -> tuple[str, Any]:
373
556
  """Execute a tool with validation if required (synchronous wrapper).
374
-
557
+
375
558
  Args:
376
559
  tool_name: Name of the tool to execute
377
560
  tool: Tool instance
378
561
  arguments_with_values: Tool arguments
379
-
562
+
380
563
  Returns:
381
564
  Tuple of (executed_tool_name, response)
382
565
  """
@@ -390,12 +573,12 @@ class Agent(BaseModel):
390
573
 
391
574
  async def _async_execute_tool(self, tool_name: str, tool: Tool, arguments_with_values: dict) -> tuple[str, Any]:
392
575
  """Execute a tool with validation if required (asynchronous).
393
-
576
+
394
577
  Args:
395
578
  tool_name: Name of the tool to execute
396
579
  tool: Tool instance
397
580
  arguments_with_values: Tool arguments
398
-
581
+
399
582
  Returns:
400
583
  Tuple of (executed_tool_name, response)
401
584
  """
@@ -403,12 +586,12 @@ class Agent(BaseModel):
403
586
  logger.info(f"Tool '{tool_name}' requires validation.")
404
587
  validation_id = str(uuid.uuid4())
405
588
  logger.info(f"Validation ID: {validation_id}")
406
-
589
+
407
590
  self._emit_event(
408
591
  "tool_execute_validation_start",
409
592
  {
410
593
  "validation_id": validation_id,
411
- "tool_name": tool_name,
594
+ "tool_name": tool_name,
412
595
  "arguments": arguments_with_values
413
596
  },
414
597
  )
@@ -453,8 +636,11 @@ class Agent(BaseModel):
453
636
  if hasattr(tool, "async_execute") and callable(tool.async_execute):
454
637
  response = await tool.async_execute(**converted_args)
455
638
  else:
456
- # Fall back to synchronous execution if async is not available
457
639
  response = tool.execute(**converted_args)
640
+
641
+ # Post-process tool response if needed
642
+ response = self._post_process_tool_response(tool_name, response)
643
+
458
644
  executed_tool = tool.name
459
645
  except Exception as e:
460
646
  response = f"Error executing tool: {tool_name}: {str(e)}\n"
@@ -467,41 +653,33 @@ class Agent(BaseModel):
467
653
 
468
654
  async def _async_interpolate_variables(self, text: str, depth: int = 0) -> str:
469
655
  """Interpolate variables using $var$ syntax in the given text with recursion protection.
470
-
656
+
471
657
  Args:
472
658
  text: Text containing variable references
473
659
  depth: Current recursion depth
474
-
660
+
475
661
  Returns:
476
662
  Text with variables interpolated
477
663
  """
478
664
  if not isinstance(text, str):
479
665
  return str(text)
480
-
666
+
481
667
  if depth > MAX_INTERPOLATION_DEPTH:
482
668
  logger.warning(f"Max interpolation depth ({MAX_INTERPOLATION_DEPTH}) reached, stopping recursion")
483
669
  return text
484
-
670
+
485
671
  try:
486
672
  import re
487
-
488
- # Process each variable in the store
673
+
489
674
  for var in self.variable_store.keys():
490
- # Properly escape the variable name for regex using re.escape
491
- # but handle $ characters separately since they're part of our syntax
492
675
  escaped_var = re.escape(var).replace('\\$', '$')
493
676
  pattern = f"\\${escaped_var}\\$"
494
-
495
- # Get variable value as string
496
677
  replacement = str(self.variable_store[var])
497
-
498
- # Replace all occurrences
499
678
  text = re.sub(pattern, lambda m: replacement, text)
500
-
501
- # Check if there are still variables to interpolate (for nested variables)
679
+
502
680
  if '$' in text and depth < MAX_INTERPOLATION_DEPTH:
503
681
  return await self._async_interpolate_variables(text, depth + 1)
504
-
682
+
505
683
  return text
506
684
  except Exception as e:
507
685
  logger.error(f"Error in _async_interpolate_variables: {str(e)}")
@@ -509,10 +687,10 @@ class Agent(BaseModel):
509
687
 
510
688
  def _interpolate_variables(self, text: str) -> str:
511
689
  """Interpolate variables using $var$ syntax in the given text (synchronous wrapper).
512
-
690
+
513
691
  Args:
514
692
  text: Text containing variable references
515
-
693
+
516
694
  Returns:
517
695
  Text with variables interpolated
518
696
  """
@@ -526,7 +704,7 @@ class Agent(BaseModel):
526
704
 
527
705
  def _compact_memory_if_needed(self, current_prompt: str = "") -> None:
528
706
  """Compacts the memory if it exceeds the maximum occupancy (synchronous wrapper).
529
-
707
+
530
708
  Args:
531
709
  current_prompt: Current prompt to calculate token usage
532
710
  """
@@ -540,7 +718,7 @@ class Agent(BaseModel):
540
718
 
541
719
  async def _async_compact_memory_if_needed(self, current_prompt: str = "") -> None:
542
720
  """Compacts the memory if it exceeds the maximum occupancy or token limit.
543
-
721
+
544
722
  Args:
545
723
  current_prompt: Current prompt to calculate token usage
546
724
  """
@@ -553,7 +731,7 @@ class Agent(BaseModel):
553
731
  and self.current_iteration % self.compact_every_n_iterations == 0
554
732
  )
555
733
  should_compact_by_token_limit = (
556
- self.max_tokens_working_memory is not None
734
+ self.max_tokens_working_memory is not None
557
735
  and self.total_tokens > self.max_tokens_working_memory
558
736
  )
559
737
 
@@ -582,11 +760,10 @@ class Agent(BaseModel):
582
760
 
583
761
  async def _async_compact_memory_with_summary(self) -> str:
584
762
  """Generate a summary and compact memory asynchronously.
585
-
763
+
586
764
  Returns:
587
765
  Generated summary text
588
766
  """
589
- # Format conversation history for the template
590
767
  memory_copy = self.memory.memory.copy()
591
768
 
592
769
  if len(memory_copy) < 3:
@@ -595,20 +772,18 @@ class Agent(BaseModel):
595
772
 
596
773
  user_message = memory_copy.pop()
597
774
  assistant_message = memory_copy.pop()
598
-
599
- # Create summarization prompt using template
600
- prompt_summary = self._render_template('memory_compaction_prompt.j2',
775
+
776
+ prompt_summary = self._render_template('memory_compaction_prompt.j2',
601
777
  conversation_history="\n\n".join(
602
- f"[{msg.role.upper()}]: {msg.content}"
778
+ f"[{msg.role.upper()}]: {msg.content}"
603
779
  for msg in memory_copy
604
780
  ))
605
-
781
+
606
782
  summary = await self.model.async_generate_with_history(messages_history=memory_copy, prompt=prompt_summary)
607
-
608
- # Remove last system message if present
783
+
609
784
  if memory_copy and memory_copy[-1].role == "system":
610
785
  memory_copy.pop()
611
-
786
+
612
787
  memory_copy.append(Message(role="user", content=summary.response))
613
788
  memory_copy.append(assistant_message)
614
789
  memory_copy.append(user_message)
@@ -617,10 +792,10 @@ class Agent(BaseModel):
617
792
 
618
793
  def _generate_task_summary(self, content: str) -> str:
619
794
  """Generate a concise task-focused summary (synchronous wrapper).
620
-
795
+
621
796
  Args:
622
797
  content: The content to summarize
623
-
798
+
624
799
  Returns:
625
800
  Generated task summary
626
801
  """
@@ -644,7 +819,7 @@ class Agent(BaseModel):
644
819
  try:
645
820
  if len(content) < 1024 * 4:
646
821
  return content
647
-
822
+
648
823
  prompt = self._render_template('task_summary_prompt.j2', content=content)
649
824
  result = await self.model.async_generate(prompt=prompt)
650
825
  logger.debug(f"Generated summary: {result.response}")
@@ -655,7 +830,7 @@ class Agent(BaseModel):
655
830
 
656
831
  def _reset_session(self, task_to_solve: str = "", max_iterations: int = 30, clear_memory: bool = True) -> None:
657
832
  """Reset the agent's session.
658
-
833
+
659
834
  Args:
660
835
  task_to_solve: New task to solve
661
836
  max_iterations: Maximum number of iterations
@@ -675,7 +850,7 @@ class Agent(BaseModel):
675
850
 
676
851
  def _update_total_tokens(self, message_history: list[Message], prompt: str) -> None:
677
852
  """Update the total tokens count based on message history and prompt.
678
-
853
+
679
854
  Args:
680
855
  message_history: List of messages
681
856
  prompt: Current prompt
@@ -684,7 +859,7 @@ class Agent(BaseModel):
684
859
 
685
860
  def _emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
686
861
  """Emit an event with system context and optional additional data.
687
-
862
+
688
863
  Args:
689
864
  event_type: Type of event
690
865
  data: Additional event data
@@ -702,10 +877,10 @@ class Agent(BaseModel):
702
877
 
703
878
  def _parse_tool_usage(self, content: str) -> dict:
704
879
  """Extract tool usage from the response content.
705
-
880
+
706
881
  Args:
707
882
  content: Response content
708
-
883
+
709
884
  Returns:
710
885
  Dictionary mapping tool names to inputs
711
886
  """
@@ -718,30 +893,39 @@ class Agent(BaseModel):
718
893
  tool_names = self.tools.tool_names()
719
894
 
720
895
  if action:
721
- return xml_parser.extract_elements(text=action["action"], element_names=tool_names)
896
+ tool_data = xml_parser.extract_elements(text=action["action"], element_names=tool_names)
897
+ # Handle nested parameters within action tags
898
+ for tool_name in tool_data:
899
+ if "<parameter_name>" in tool_data[tool_name]:
900
+ params = xml_parser.extract_elements(text=tool_data[tool_name], element_names=["parameter_name", "parameter_value"])
901
+ if "parameter_name" in params and "parameter_value" in params:
902
+ tool_data[tool_name] = {params["parameter_name"]: params["parameter_value"]}
903
+ return tool_data
722
904
  else:
723
905
  return xml_parser.extract_elements(text=content, element_names=tool_names)
724
906
 
725
- def _parse_tool_arguments(self, tool: Tool, tool_input: str) -> dict:
907
+ def _parse_tool_arguments(self, tool: Tool, tool_input: str | dict) -> dict:
726
908
  """Parse the tool arguments from the tool input.
727
-
909
+
728
910
  Args:
729
911
  tool: Tool instance
730
- tool_input: Raw tool input text
731
-
912
+ tool_input: Raw tool input text or pre-parsed dict
913
+
732
914
  Returns:
733
915
  Dictionary of parsed arguments
734
916
  """
917
+ if isinstance(tool_input, dict):
918
+ return tool_input # Already parsed from XML
735
919
  tool_parser = ToolParser(tool=tool)
736
920
  return tool_parser.parse(tool_input)
737
921
 
738
922
  def _is_repeated_tool_call(self, tool_name: str, arguments_with_values: dict) -> bool:
739
923
  """Check if the tool call is repeated.
740
-
924
+
741
925
  Args:
742
926
  tool_name: Name of the tool
743
927
  arguments_with_values: Tool arguments
744
-
928
+
745
929
  Returns:
746
930
  True if call is repeated, False otherwise
747
931
  """
@@ -767,7 +951,7 @@ class Agent(BaseModel):
767
951
 
768
952
  def _handle_no_tool_usage(self) -> ObserveResponseResult:
769
953
  """Handle the case where no tool usage is found in the response.
770
-
954
+
771
955
  Returns:
772
956
  ObserveResponseResult with error message
773
957
  """
@@ -777,10 +961,10 @@ class Agent(BaseModel):
777
961
 
778
962
  def _handle_tool_not_found(self, tool_name: str) -> ObserveResponseResult:
779
963
  """Handle the case where the tool is not found.
780
-
964
+
781
965
  Args:
782
966
  tool_name: Name of the tool
783
-
967
+
784
968
  Returns:
785
969
  ObserveResponseResult with error message
786
970
  """
@@ -793,11 +977,11 @@ class Agent(BaseModel):
793
977
 
794
978
  def _handle_repeated_tool_call(self, tool_name: str, arguments_with_values: dict) -> tuple[str, str]:
795
979
  """Handle the case where a tool call is repeated.
796
-
980
+
797
981
  Args:
798
982
  tool_name: Name of the tool
799
983
  arguments_with_values: Tool arguments
800
-
984
+
801
985
  Returns:
802
986
  Tuple of (executed_tool_name, error_message)
803
987
  """
@@ -812,10 +996,10 @@ class Agent(BaseModel):
812
996
 
813
997
  def _handle_tool_execution_failure(self, response: str) -> ObserveResponseResult:
814
998
  """Handle the case where tool execution fails.
815
-
999
+
816
1000
  Args:
817
1001
  response: Error response
818
-
1002
+
819
1003
  Returns:
820
1004
  ObserveResponseResult with error message
821
1005
  """
@@ -827,10 +1011,10 @@ class Agent(BaseModel):
827
1011
 
828
1012
  def _handle_error(self, error: Exception) -> ObserveResponseResult:
829
1013
  """Handle any exceptions that occur during response observation.
830
-
1014
+
831
1015
  Args:
832
1016
  error: Exception that occurred
833
-
1017
+
834
1018
  Returns:
835
1019
  ObserveResponseResult with error message
836
1020
  """
@@ -840,18 +1024,202 @@ class Agent(BaseModel):
840
1024
  executed_tool=None,
841
1025
  answer=None,
842
1026
  )
1027
+
1028
+ async def _async_observe_response_chat(self, content: str, iteration: int = 1) -> ObserveResponseResult:
1029
+ """Specialized observation method for chat mode with tool handling.
1030
+
1031
+ This method processes responses in chat mode, identifying and executing tool calls
1032
+ while providing appropriate default parameters when needed. Prevents task_complete usage.
1033
+
1034
+ Args:
1035
+ content: The response content to analyze
1036
+ iteration: Current iteration number
1037
+
1038
+ Returns:
1039
+ ObserveResponseResult with next steps information
1040
+ """
1041
+ try:
1042
+ # Check for tool call patterns in the content
1043
+ if "<action>" not in content:
1044
+ logger.debug("No tool usage detected in chat response")
1045
+ return ObserveResponseResult(next_prompt=content, executed_tool=None, answer=None)
1046
+
1047
+ # Parse content for tool usage
1048
+ parsed_content = self._parse_tool_usage(content)
1049
+ if not parsed_content:
1050
+ # Malformed tool call in chat mode; return feedback
1051
+ error_prompt = (
1052
+ "⚠️ Error: Invalid tool call format detected. "
1053
+ "Please use the exact XML structure:\n"
1054
+ "```xml\n<action>\n<tool_name>\n <parameter_name>value</parameter_name>\n</tool_name>\n</action>\n```"
1055
+ )
1056
+ return ObserveResponseResult(next_prompt=error_prompt, executed_tool=None, answer=None)
1057
+
1058
+ # Check for task_complete attempt and block it with feedback
1059
+ if "task_complete" in parsed_content:
1060
+ feedback = (
1061
+ "⚠️ Note: The 'task_complete' tool is not available in chat mode. "
1062
+ "This is a conversational mode; tasks are not completed here. "
1063
+ "Please use other tools or continue the conversation."
1064
+ )
1065
+ return ObserveResponseResult(next_prompt=feedback, executed_tool=None, answer=None)
1066
+
1067
+ # Process tools with prioritization based on tool_mode
1068
+ tool_names = list(parsed_content.keys())
1069
+ # Prioritize specific tools if tool_mode is set and the tool is available
1070
+ if self.tool_mode and self.tool_mode in self.tools.tool_names() and self.tool_mode in tool_names:
1071
+ tool_names = [self.tool_mode] + [t for t in tool_names if t != self.tool_mode]
1072
+
1073
+ for tool_name in tool_names:
1074
+ if tool_name not in parsed_content:
1075
+ continue
1076
+
1077
+ tool_input = parsed_content[tool_name]
1078
+ tool = self.tools.get(tool_name)
1079
+ if not tool:
1080
+ return self._handle_tool_not_found(tool_name)
1081
+
1082
+ # Parse tool arguments from the input
1083
+ arguments_with_values = self._parse_tool_arguments(tool, tool_input)
1084
+
1085
+ # Apply default parameters based on tool schema if missing
1086
+ self._apply_default_parameters(tool, arguments_with_values)
1087
+
1088
+ # Check for repeated calls
1089
+ is_repeated_call = self._is_repeated_tool_call(tool_name, arguments_with_values)
1090
+ if is_repeated_call:
1091
+ executed_tool, response = self._handle_repeated_tool_call(tool_name, arguments_with_values)
1092
+ else:
1093
+ executed_tool, response = await self._async_execute_tool(tool_name, tool, arguments_with_values)
1094
+
1095
+ if not executed_tool:
1096
+ # Tool execution failed
1097
+ return self._handle_tool_execution_failure(response)
1098
+
1099
+ # Store result in variable memory for potential future reference
1100
+ variable_name = f"result_{executed_tool}_{iteration}"
1101
+ self.variable_store[variable_name] = response
1102
+
1103
+ # Truncate response if too long for display
1104
+ response_display = response
1105
+ if len(response) > MAX_RESPONSE_LENGTH:
1106
+ response_display = response[:MAX_RESPONSE_LENGTH]
1107
+ response_display += f"... (truncated, full content available in ${variable_name})"
1108
+
1109
+ # Format result in a user-friendly way
1110
+ return ObserveResponseResult(
1111
+ next_prompt=response_display,
1112
+ executed_tool=executed_tool,
1113
+ answer=None
1114
+ )
1115
+
1116
+ # If we get here, no tool was successfully executed
1117
+ return ObserveResponseResult(
1118
+ next_prompt="I tried to use a tool, but encountered an issue. Please try again with a different request.",
1119
+ executed_tool=None,
1120
+ answer=None
1121
+ )
1122
+
1123
+ except Exception as e:
1124
+ return self._handle_error(e)
1125
+
1126
+ def _apply_default_parameters(self, tool: Tool, arguments_with_values: dict) -> None:
1127
+ """Apply default parameters to tool arguments based on tool schema.
1128
+
1129
+ This method examines the tool's schema and fills in any missing required parameters
1130
+ with sensible defaults based on the tool type.
1131
+
1132
+ Args:
1133
+ tool: The tool instance
1134
+ arguments_with_values: Dictionary of current arguments
1135
+ """
1136
+ try:
1137
+ # Add defaults for common search tools
1138
+ if tool.name == "duckduckgo_tool" and "max_results" not in arguments_with_values:
1139
+ logger.debug(f"Adding default max_results=5 for {tool.name}")
1140
+ arguments_with_values["max_results"] = "5"
1141
+
1142
+ # Check tool schema for required parameters
1143
+ if hasattr(tool, "schema") and hasattr(tool.schema, "parameters"):
1144
+ for param_name, param_info in tool.schema.parameters.items():
1145
+ # If required parameter is missing, try to add a default
1146
+ if param_info.get("required", False) and param_name not in arguments_with_values:
1147
+ if "default" in param_info:
1148
+ logger.debug(f"Adding default value for {param_name} in {tool.name}")
1149
+ arguments_with_values[param_name] = param_info["default"]
1150
+ except Exception as e:
1151
+ logger.debug(f"Error applying default parameters: {str(e)}")
1152
+ # Continue without defaults rather than failing the whole operation
1153
+
1154
+ def _post_process_tool_response(self, tool_name: str, response: Any) -> str:
1155
+ """Process tool response for better presentation to the user.
1156
+
1157
+ This generic method handles common tool response formats:
1158
+ - Parses JSON strings into structured data
1159
+ - Formats search results into readable text
1160
+ - Handles different response types appropriately
1161
+
1162
+ Args:
1163
+ tool_name: Name of the tool that produced the response
1164
+ response: Raw tool response
1165
+
1166
+ Returns:
1167
+ Processed response as a string
1168
+ """
1169
+ # Immediately return if response is not a string
1170
+ if not isinstance(response, str):
1171
+ return response
1172
+
1173
+ # Try to parse as JSON if it looks like JSON
1174
+ if response.strip().startswith(("{" , "[")) and response.strip().endswith(("}", "]")):
1175
+ try:
1176
+ # Use lazy import for json to maintain dependency structure
1177
+ import json
1178
+ parsed = json.loads(response)
1179
+
1180
+ # Handle list-type responses (common for search tools)
1181
+ if isinstance(parsed, list) and parsed:
1182
+ # Detect if this is a search result by checking for common fields
1183
+ search_result_fields = ['title', 'href', 'url', 'body', 'content', 'snippet']
1184
+ if isinstance(parsed[0], dict) and any(field in parsed[0] for field in search_result_fields):
1185
+ # Format as search results
1186
+ formatted_results = []
1187
+ for idx, result in enumerate(parsed, 1):
1188
+ if not isinstance(result, dict):
1189
+ continue
1190
+
1191
+ # Extract common fields with fallbacks
1192
+ title = result.get('title', 'No title')
1193
+ url = result.get('href', result.get('url', 'No link'))
1194
+ description = result.get('body', result.get('content',
1195
+ result.get('snippet', result.get('description', 'No description'))))
1196
+
1197
+ formatted_results.append(f"{idx}. {title}\n URL: {url}\n {description}\n")
1198
+
1199
+ if formatted_results:
1200
+ return "\n".join(formatted_results)
1201
+
1202
+ # If not handled as a special case, just pretty-print
1203
+ return json.dumps(parsed, indent=2, ensure_ascii=False)
1204
+
1205
+ except json.JSONDecodeError:
1206
+ # Not valid JSON after all
1207
+ pass
1208
+
1209
+ # Return original response if no special handling applies
1210
+ return response
843
1211
 
844
1212
  def _format_observation_response(
845
1213
  self, response: str, last_executed_tool: str, variable_name: str, iteration: int
846
1214
  ) -> str:
847
1215
  """Format the observation response with the given response, variable name, and iteration.
848
-
1216
+
849
1217
  Args:
850
1218
  response: Tool execution response
851
1219
  last_executed_tool: Name of last executed tool
852
1220
  variable_name: Name of variable storing response
853
1221
  iteration: Current iteration number
854
-
1222
+
855
1223
  Returns:
856
1224
  Formatted observation response
857
1225
  """
@@ -864,7 +1232,7 @@ class Agent(BaseModel):
864
1232
 
865
1233
  tools_prompt = self._get_tools_names_prompt()
866
1234
  variables_prompt = self._get_variable_prompt()
867
-
1235
+
868
1236
  formatted_response = self._render_template(
869
1237
  'observation_response_format.j2',
870
1238
  iteration=iteration,
@@ -890,7 +1258,7 @@ class Agent(BaseModel):
890
1258
  """
891
1259
  tools_prompt = self._get_tools_names_prompt()
892
1260
  variables_prompt = self._get_variable_prompt()
893
-
1261
+
894
1262
  prompt_task = self._render_template(
895
1263
  'task_prompt.j2',
896
1264
  task=task,
@@ -901,16 +1269,77 @@ class Agent(BaseModel):
901
1269
 
902
1270
  def _get_tools_names_prompt(self) -> str:
903
1271
  """Construct a detailed prompt that lists the available tools for task execution.
904
-
1272
+
905
1273
  Returns:
906
1274
  Formatted tools prompt
907
1275
  """
1276
+ # Check if we're in chat mode
1277
+ is_chat_mode = not self.task_to_solve.strip()
1278
+
1279
+ if is_chat_mode:
1280
+ return self._get_tools_names_prompt_for_chat()
1281
+
1282
+ # Default task mode behavior
908
1283
  tool_names = ', '.join(self.tools.tool_names())
909
1284
  return self._render_template('tools_prompt.j2', tool_names=tool_names)
1285
+
1286
+ def _get_tools_names_prompt_for_chat(self) -> str:
1287
+ """Construct a detailed prompt for chat mode that includes tool parameters, excluding task_complete.
1288
+
1289
+ Returns:
1290
+ Formatted tools prompt with parameter details
1291
+ """
1292
+ tool_descriptions = []
1293
+
1294
+ try:
1295
+ for tool_name in self.tools.tool_names():
1296
+ if tool_name == "task_complete":
1297
+ continue # Explicitly exclude task_complete in chat mode
1298
+
1299
+ try:
1300
+ tool = self.tools.get(tool_name)
1301
+ params = []
1302
+
1303
+ # Get parameter details if available
1304
+ try:
1305
+ if hasattr(tool, "schema") and hasattr(tool.schema, "parameters"):
1306
+ schema_params = getattr(tool.schema, "parameters", {})
1307
+ if isinstance(schema_params, dict):
1308
+ for param_name, param_info in schema_params.items():
1309
+ if not isinstance(param_info, dict):
1310
+ continue
1311
+
1312
+ required = "(required)" if param_info.get("required", False) else "(optional)"
1313
+ default = f" default: {param_info['default']}" if "default" in param_info else ""
1314
+ param_desc = f"{param_name} {required}{default}"
1315
+ params.append(param_desc)
1316
+ except Exception as e:
1317
+ logger.debug(f"Error parsing schema for {tool_name}: {str(e)}")
1318
+
1319
+ # Special case for duckduckgo_tool
1320
+ if tool_name == "duckduckgo_tool" and not any(p.startswith("max_results ") for p in params):
1321
+ params.append("max_results (required) default: 5")
1322
+
1323
+ # Special case for other search tools that might need max_results
1324
+ if "search" in tool_name.lower() and not any(p.startswith("max_results ") for p in params):
1325
+ params.append("max_results (optional) default: 5")
1326
+
1327
+ param_str = ", ".join(params) if params else "No parameters required"
1328
+ tool_descriptions.append(f"{tool_name}: {param_str}")
1329
+ except Exception as e:
1330
+ logger.debug(f"Error processing tool {tool_name}: {str(e)}")
1331
+ # Still include the tool in the list, but with minimal info
1332
+ tool_descriptions.append(f"{tool_name}: Error retrieving parameters")
1333
+ except Exception as e:
1334
+ logger.debug(f"Error generating tool descriptions: {str(e)}")
1335
+ return "Error retrieving tool information"
1336
+
1337
+ formatted_tools = "\n".join(tool_descriptions) if tool_descriptions else "No tools available."
1338
+ return formatted_tools
910
1339
 
911
1340
  def _get_variable_prompt(self) -> str:
912
1341
  """Construct a prompt that explains how to use variables.
913
-
1342
+
914
1343
  Returns:
915
1344
  Formatted variables prompt
916
1345
  """
@@ -919,7 +1348,7 @@ class Agent(BaseModel):
919
1348
 
920
1349
  def _calculate_context_occupancy(self) -> float:
921
1350
  """Calculate the number of tokens in percentages for prompt and completion.
922
-
1351
+
923
1352
  Returns:
924
1353
  Percentage of context window occupied
925
1354
  """
@@ -947,7 +1376,7 @@ class Agent(BaseModel):
947
1376
 
948
1377
  def update_model(self, new_model_name: str) -> None:
949
1378
  """Update the model name and recreate the model instance.
950
-
1379
+
951
1380
  Args:
952
1381
  new_model_name: New model name to use
953
1382
  """
@@ -956,98 +1385,86 @@ class Agent(BaseModel):
956
1385
 
957
1386
  def add_tool(self, tool: Tool) -> None:
958
1387
  """Add a new tool to the agent's tool manager.
959
-
1388
+
960
1389
  Args:
961
1390
  tool: The tool instance to add
962
-
1391
+
963
1392
  Raises:
964
1393
  ValueError: If a tool with the same name already exists
965
1394
  """
966
1395
  if tool.name in self.tools.tool_names():
967
1396
  raise ValueError(f"Tool with name '{tool.name}' already exists")
968
-
1397
+
969
1398
  self.tools.add(tool)
970
- # Update tools markdown in config
971
1399
  self.config = AgentConfig(
972
1400
  environment_details=self.config.environment_details,
973
1401
  tools_markdown=self.tools.to_markdown(),
974
1402
  system_prompt=self.config.system_prompt,
975
1403
  )
976
1404
  logger.debug(f"Added tool: {tool.name}")
977
-
1405
+
978
1406
  def remove_tool(self, tool_name: str) -> None:
979
1407
  """Remove a tool from the agent's tool manager.
980
-
1408
+
981
1409
  Args:
982
1410
  tool_name: Name of the tool to remove
983
-
1411
+
984
1412
  Raises:
985
1413
  ValueError: If tool doesn't exist or is TaskCompleteTool
986
1414
  """
987
1415
  if tool_name not in self.tools.tool_names():
988
1416
  raise ValueError(f"Tool '{tool_name}' does not exist")
989
-
1417
+
990
1418
  tool = self.tools.get(tool_name)
991
1419
  if isinstance(tool, TaskCompleteTool):
992
1420
  raise ValueError("Cannot remove TaskCompleteTool as it is required")
993
-
1421
+
994
1422
  self.tools.remove(tool_name)
995
- # Update tools markdown in config
996
1423
  self.config = AgentConfig(
997
1424
  environment_details=self.config.environment_details,
998
1425
  tools_markdown=self.tools.to_markdown(),
999
1426
  system_prompt=self.config.system_prompt,
1000
1427
  )
1001
1428
  logger.debug(f"Removed tool: {tool_name}")
1002
-
1429
+
1003
1430
  def set_tools(self, tools: list[Tool]) -> None:
1004
1431
  """Set/replace all tools for the agent.
1005
-
1432
+
1006
1433
  Args:
1007
1434
  tools: List of tool instances to set
1008
-
1435
+
1009
1436
  Note:
1010
1437
  TaskCompleteTool will be automatically added if not present
1011
1438
  """
1012
- # Ensure TaskCompleteTool is present
1013
1439
  if not any(isinstance(t, TaskCompleteTool) for t in tools):
1014
1440
  tools.append(TaskCompleteTool())
1015
-
1016
- # Create new tool manager and add tools
1441
+
1017
1442
  tool_manager = ToolManager()
1018
1443
  tool_manager.add_list(tools)
1019
1444
  self.tools = tool_manager
1020
-
1021
- # Update config with new tools markdown
1445
+
1022
1446
  self.config = AgentConfig(
1023
1447
  environment_details=self.config.environment_details,
1024
1448
  tools_markdown=self.tools.to_markdown(),
1025
1449
  system_prompt=self.config.system_prompt,
1026
1450
  )
1027
1451
  logger.debug(f"Set {len(tools)} tools")
1028
-
1452
+
1029
1453
  def _render_template(self, template_name: str, **kwargs) -> str:
1030
1454
  """Render a Jinja2 template with the provided variables.
1031
-
1455
+
1032
1456
  Args:
1033
1457
  template_name: Name of the template file (without directory path)
1034
1458
  **kwargs: Variables to pass to the template
1035
-
1459
+
1036
1460
  Returns:
1037
1461
  str: The rendered template
1038
1462
  """
1039
1463
  try:
1040
- # Get the directory where this file is located
1041
1464
  current_dir = Path(os.path.dirname(os.path.abspath(__file__)))
1042
-
1043
- # Set up Jinja2 environment
1044
1465
  template_dir = current_dir / 'prompts'
1045
1466
  env = Environment(loader=FileSystemLoader(template_dir))
1046
-
1047
- # Load the template
1048
1467
  template = env.get_template(template_name)
1049
-
1050
- # Render the template with the provided variables
1051
1468
  return template.render(**kwargs)
1052
1469
  except Exception as e:
1053
1470
  logger.error(f"Error rendering template {template_name}: {str(e)}")