clap-agents 0.1.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.
@@ -0,0 +1,230 @@
1
+ # --- START OF agentic_patterns/tools/email_tools.py ---
2
+
3
+ import os
4
+ import smtplib
5
+ import imaplib
6
+ import email
7
+ import json # For potential result formatting
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ from email.mime.base import MIMEBase
11
+ from email import encoders
12
+ from email.header import decode_header
13
+ from dotenv import load_dotenv
14
+ import anyio
15
+ import functools
16
+ import requests
17
+ from typing import Optional
18
+
19
+ from clap.tool_pattern.tool import tool
20
+
21
+ load_dotenv()
22
+
23
+ SMTP_HOST = "smtp.gmail.com"
24
+ SMTP_PORT = 587
25
+ IMAP_HOST = "imap.gmail.com"
26
+ SMTP_USERNAME = os.getenv("SMTP_USERNAME")
27
+ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
28
+
29
+
30
+ def _send_email_sync(recipient: str, subject: str, body: str, attachment_path: Optional[str] = None) -> str:
31
+ """Synchronous helper to send email."""
32
+ if not SMTP_USERNAME or not SMTP_PASSWORD:
33
+ return "Error: SMTP username or password not configured in environment."
34
+ try:
35
+ msg = MIMEMultipart()
36
+ msg["From"] = SMTP_USERNAME
37
+ msg["To"] = recipient
38
+ msg["Subject"] = subject
39
+ msg.attach(MIMEText(body, "plain"))
40
+ if attachment_path and os.path.exists(attachment_path):
41
+ with open(attachment_path, "rb") as attachment:
42
+ part = MIMEBase("application", "octet-stream")
43
+ part.set_payload(attachment.read())
44
+ encoders.encode_base64(part)
45
+ part.add_header("Content-Disposition", f"attachment; filename={os.path.basename(attachment_path)}")
46
+ msg.attach(part)
47
+ # Use context manager for SMTP connection
48
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
49
+ server.starttls()
50
+ server.login(SMTP_USERNAME, SMTP_PASSWORD)
51
+ server.sendmail(SMTP_USERNAME, recipient, msg.as_string())
52
+ # Clean up downloaded attachment if it was temporary
53
+ if attachment_path and attachment_path.startswith("temp_attachments"):
54
+ try: os.remove(attachment_path)
55
+ except OSError: pass # Ignore if deletion fails
56
+ return "Email sent successfully."
57
+ except Exception as e:
58
+ return f"Failed to send email: {e}"
59
+
60
+ def _download_attachment_sync(attachment_url: str, attachment_filename: str) -> str:
61
+ """Synchronous helper to download an attachment."""
62
+ temp_dir = "temp_attachments" # Consider using tempfile module for more robustness
63
+ os.makedirs(temp_dir, exist_ok=True)
64
+ file_path = os.path.join(temp_dir, attachment_filename)
65
+ # Use streaming for potentially large files
66
+ with requests.get(attachment_url, stream=True) as r:
67
+ r.raise_for_status()
68
+ with open(file_path, "wb") as f:
69
+ for chunk in r.iter_content(chunk_size=8192):
70
+ f.write(chunk)
71
+ return file_path
72
+
73
+ def _get_pre_staged_attachment_sync(attachment_name: str) -> Optional[str]:
74
+ """Synchronous helper to get a pre-staged attachment."""
75
+ attachment_dir = "available_attachments" # User needs to create this folder
76
+ file_path = os.path.join(attachment_dir, attachment_name)
77
+ return file_path if os.path.exists(file_path) else None
78
+
79
+ def _fetch_emails_sync(folder: str, limit: int) -> str:
80
+ """Synchronous helper to fetch emails."""
81
+ if not SMTP_USERNAME or not SMTP_PASSWORD:
82
+ return "Error: Email username or password not configured in environment."
83
+ emails_data = []
84
+ try:
85
+ mail = imaplib.IMAP4_SSL(IMAP_HOST)
86
+ mail.login(SMTP_USERNAME, SMTP_PASSWORD)
87
+ status, messages = mail.select(folder)
88
+ if status != 'OK':
89
+ mail.logout()
90
+ return f"Error selecting folder '{folder}': {messages}"
91
+
92
+ result, data = mail.search(None, "ALL")
93
+ if status != 'OK' or not data or not data[0]:
94
+ mail.logout()
95
+ return f"No emails found in folder '{folder}'."
96
+
97
+ email_ids = data[0].split()
98
+ # Fetch in reverse order up to the limit
99
+ ids_to_fetch = email_ids[-(limit):]
100
+
101
+ for email_id_bytes in reversed(ids_to_fetch):
102
+ status, msg_data = mail.fetch(email_id_bytes, "(RFC822)")
103
+ if status == 'OK':
104
+ for response_part in msg_data:
105
+ if isinstance(response_part, tuple):
106
+ msg = email.message_from_bytes(response_part[1])
107
+ subject, encoding = decode_header(msg["Subject"])[0]
108
+ if isinstance(subject, bytes):
109
+ subject = subject.decode(encoding or "utf-8")
110
+ from_ = msg.get("From", "")
111
+ date_ = msg.get("Date", "")
112
+ # Basic snippet extraction (first text part)
113
+ snippet = ""
114
+ if msg.is_multipart():
115
+ for part in msg.walk():
116
+ ctype = part.get_content_type()
117
+ cdisp = str(part.get("Content-Disposition"))
118
+ if ctype == "text/plain" and "attachment" not in cdisp:
119
+ try:
120
+ body = part.get_payload(decode=True)
121
+ snippet = body.decode(part.get_content_charset() or 'utf-8')
122
+ snippet = " ".join(snippet.splitlines()) # Remove newlines
123
+ snippet = snippet[:150] + "..." # Truncate
124
+ break
125
+ except Exception:
126
+ snippet = "[Could not decode body]"
127
+ break
128
+ else:
129
+ try:
130
+ body = msg.get_payload(decode=True)
131
+ snippet = body.decode(msg.get_content_charset() or 'utf-8')
132
+ snippet = " ".join(snippet.splitlines())
133
+ snippet = snippet[:150] + "..."
134
+ except Exception:
135
+ snippet = "[Could not decode body]"
136
+
137
+ emails_data.append({
138
+ "id": email_id_bytes.decode(),
139
+ "from": from_,
140
+ "subject": subject,
141
+ "date": date_,
142
+ "snippet": snippet
143
+ })
144
+ if len(emails_data) >= limit: # Should not exceed limit due to slicing, but safety check
145
+ break
146
+
147
+ mail.logout()
148
+
149
+ if not emails_data:
150
+ return f"No emails found in folder '{folder}'."
151
+
152
+ # Format result (maybe JSON is better for agents?)
153
+ result_text = f"Recent emails from {folder} (up to {limit}):\n\n"
154
+ for i, email_data in enumerate(emails_data, 1):
155
+ result_text += f"{i}. From: {email_data['from']}\n"
156
+ result_text += f" Subject: {email_data['subject']}\n"
157
+ result_text += f" Date: {email_data['date']}\n"
158
+ result_text += f" Snippet: {email_data['snippet']}\n\n"
159
+ # result_text += f" ID: {email_data['id']}\n\n" # ID might not be useful for LLM
160
+ return result_text.strip()
161
+ except Exception as e:
162
+ return f"Failed to fetch emails: {e}"
163
+
164
+ # --- Asynchronous Tool Wrappers ---
165
+
166
+ @tool
167
+ async def send_email(recipient: str, subject: str, body: str,
168
+ attachment_path: Optional[str] = None,
169
+ attachment_url: Optional[str] = None,
170
+ attachment_name: Optional[str] = None) -> str:
171
+ """
172
+ Sends an email using configured Gmail account. Can handle attachments via local path, URL, or pre-staged name.
173
+
174
+ Args:
175
+ recipient: The email address to send the email to.
176
+ subject: The email subject.
177
+ body: The email body text.
178
+ attachment_path: Optional direct file path for an attachment.
179
+ attachment_url: Optional URL from which to download an attachment (requires attachment_name).
180
+ attachment_name: Optional filename for the attachment (used with URL or pre-staged).
181
+
182
+ Returns:
183
+ Success or error message string.
184
+ """
185
+ final_attachment_path = attachment_path
186
+ if attachment_url and attachment_name:
187
+ try:
188
+ # Run synchronous download in thread
189
+ print(f"[Email Tool] Downloading attachment from {attachment_url}...")
190
+ final_attachment_path = await anyio.to_thread.run_sync(
191
+ _download_attachment_sync, attachment_url, attachment_name
192
+ )
193
+ print(f"[Email Tool] Attachment downloaded to {final_attachment_path}")
194
+ except Exception as e:
195
+ return f"Failed to download attachment from URL: {e}"
196
+ elif attachment_name:
197
+ try:
198
+ # Run synchronous file check in thread
199
+ print(f"[Email Tool] Checking for pre-staged attachment: {attachment_name}...")
200
+ final_attachment_path = await anyio.to_thread.run_sync(
201
+ _get_pre_staged_attachment_sync, attachment_name
202
+ )
203
+ if not final_attachment_path:
204
+ return f"Error: Attachment '{attachment_name}' not found in pre-staged directory 'available_attachments'."
205
+ print(f"[Email Tool] Using pre-staged attachment: {final_attachment_path}")
206
+ except Exception as e:
207
+ return f"Error accessing pre-staged attachment: {e}"
208
+
209
+ # Run synchronous email sending in thread
210
+ print(f"[Email Tool] Sending email to {recipient}...")
211
+ return await anyio.to_thread.run_sync(
212
+ _send_email_sync, recipient, subject, body, final_attachment_path
213
+ )
214
+
215
+ @tool
216
+ async def fetch_recent_emails(folder: str = "INBOX", limit: int = 5) -> str:
217
+ """
218
+ Fetches subject, sender, date, and a snippet of recent emails (up to limit) from a specified folder.
219
+
220
+ Args:
221
+ folder: The email folder to fetch from (default: "INBOX"). Common options: "INBOX", "Sent", "Drafts", "[Gmail]/Spam", "[Gmail]/Trash".
222
+ limit: Maximum number of emails to fetch (default: 5).
223
+
224
+ Returns:
225
+ A formatted string containing details of the recent emails or an error message.
226
+ """
227
+ # Run synchronous IMAP fetching in thread
228
+ print(f"[Email Tool] Fetching up to {limit} emails from folder '{folder}'...")
229
+ return await anyio.to_thread.run_sync(_fetch_emails_sync, folder, limit)
230
+
@@ -0,0 +1,82 @@
1
+
2
+ import asyncio
3
+ import json
4
+ import os
5
+ from dotenv import load_dotenv
6
+
7
+ from clap.tool_pattern.tool import tool
8
+
9
+ try:
10
+ from crawl4ai import AsyncWebCrawler
11
+ except ImportError:
12
+ raise ImportError("crawl4ai library not found. Please install it using: pip install crawl4ai")
13
+
14
+ load_dotenv()
15
+
16
+ @tool
17
+ async def scrape_url(url: str) -> str:
18
+ """
19
+ Scrape a webpage and return its raw markdown content.
20
+
21
+ Args:
22
+ url: The URL of the webpage to scrape.
23
+
24
+ Returns:
25
+ The webpage content in markdown format or an error message.
26
+ """
27
+ try:
28
+ async with AsyncWebCrawler() as crawler:
29
+ result = await crawler.arun(url=url)
30
+ return result.markdown.raw_markdown if result.markdown else "No content found"
31
+ except Exception as e:
32
+ return f"Error scraping URL '{url}': {str(e)}"
33
+
34
+ @tool
35
+ async def extract_text_by_query(url: str, query: str, context_size: int = 300) -> str:
36
+ """
37
+ Extract relevant text snippets containing a query from a webpage's markdown content.
38
+
39
+ Args:
40
+ url: The URL of the webpage to search.
41
+ query: The search query (case-insensitive) to look for.
42
+ context_size: Number of characters around the match to include.
43
+
44
+ Returns:
45
+ Relevant text snippets containing the query or a message indicating no matches/content.
46
+ """
47
+ try:
48
+ markdown_content = await scrape_url.run(url=url)
49
+
50
+ if not markdown_content or markdown_content == "No content found" or markdown_content.startswith("Error"):
51
+ # Pass through the error message from scrape_url if it failed
52
+ return markdown_content if markdown_content.startswith("Error") else f"Could not retrieve content from URL: {url}"
53
+
54
+ lower_query = query.lower()
55
+ lower_content = markdown_content.lower()
56
+ matches = []
57
+ start_index = 0
58
+
59
+ while len(matches) < 5: # Limit matches
60
+ pos = lower_content.find(lower_query, start_index)
61
+ if pos == -1:
62
+ break
63
+
64
+ start = max(0, pos - context_size)
65
+ end = min(len(markdown_content), pos + len(lower_query) + context_size)
66
+ context_snippet = markdown_content[start:end]
67
+ prefix = "..." if start > 0 else ""
68
+ suffix = "..." if end < len(markdown_content) else ""
69
+ matches.append(f"{prefix}{context_snippet}{suffix}")
70
+
71
+ start_index = pos + len(lower_query)
72
+
73
+ if matches:
74
+ result_text = "\n\n---\n\n".join([f"Match {i+1}:\n{match}" for i, match in enumerate(matches)])
75
+ return f"Found {len(matches)} matches for '{query}' on the page:\n\n{result_text}"
76
+ else:
77
+ return f"No matches found for '{query}' on the page."
78
+
79
+ except Exception as e:
80
+ # Catch potential errors during the find/string manipulation logic
81
+ return f"Error processing content from '{url}' for query '{query}': {str(e)}"
82
+
@@ -0,0 +1,24 @@
1
+ import os
2
+ from duckduckgo_search import DDGS
3
+ from ..tool_pattern.tool import tool
4
+
5
+
6
+
7
+ @tool
8
+ def duckduckgo_search(query: str, num_results: int = 5) -> str:
9
+ """Performs a web search using the DuckDuckGo Search API."""
10
+ try:
11
+ with DDGS() as ddgs:
12
+ results = ddgs.text(keywords=query, max_results=num_results)
13
+ if results:
14
+ results_str = f"DuckDuckGo search results for '{query}':\n"
15
+ for i, result in enumerate(results):
16
+ title = result.get('title', 'No Title')
17
+ snippet = result.get('body', 'No Snippet')
18
+ url = result.get('href', 'No URL')
19
+ results_str += f"{i+1}. {title}\n URL: {url}\n Snippet: {snippet}\n\n"
20
+ return results_str.strip()
21
+ else:
22
+ return f"No DuckDuckGo results found for '{query}'."
23
+ except Exception as e:
24
+ return f"Error during DuckDuckGo web search for '{query}': {e}"
clap/utils/__init__.py ADDED
File without changes
@@ -0,0 +1,173 @@
1
+
2
+
3
+ import asyncio
4
+ from typing import Optional, List, Dict, Any
5
+ # Assuming Groq client and specific API response types might be needed
6
+ # from groq import Groq # Already imported elsewhere, ensure available
7
+ # from groq.types.chat.chat_completion import ChatCompletion # Example type hint
8
+ # from groq.types.chat.chat_completion_message import ChatCompletionMessage # Example type hint
9
+ from groq import AsyncGroq
10
+
11
+
12
+ GroqClient = Any
13
+ ChatCompletionMessage = Any
14
+
15
+
16
+ async def completions_create(
17
+ client: AsyncGroq,
18
+ messages: List[Dict[str, Any]], # Use more specific types if available
19
+ model: str,
20
+ tools: Optional[List[Dict[str, Any]]] = None, # Added tools parameter
21
+ tool_choice: str = "auto" # Added tool_choice parameter ("auto", "none", or {"type": "function", "function": {"name": "my_function"}})
22
+ ) -> ChatCompletionMessage: # Changed return type
23
+ """
24
+ Sends an asynchronous request to the client's completions endpoint, supporting tool use.
25
+
26
+ Args:
27
+ client: The API client object (e.g., Groq) supporting async operations.
28
+ messages: A list of message objects for the chat history.
29
+ model: The model to use.
30
+ tools: A list of tool schemas the model can use.
31
+ tool_choice: Controls how the model uses tools.
32
+
33
+ Returns:
34
+ The message object from the API response, which might contain content or tool calls.
35
+ """
36
+ try:
37
+ # Prepare arguments, only include tools/tool_choice if tools are provided
38
+ api_kwargs = {
39
+ "messages": messages,
40
+ "model": model,
41
+ }
42
+ if tools:
43
+ api_kwargs["tools"] = tools
44
+ api_kwargs["tool_choice"] = tool_choice
45
+
46
+ # Changed .acreate to .create based on Groq async documentation
47
+ response = await client.chat.completions.create(**api_kwargs)
48
+ # Return the entire message object from the first choice
49
+ return response.choices[0].message
50
+ except Exception as e:
51
+ # Handle potential API errors
52
+ print(f"Error calling LLM API asynchronously: {e}")
53
+ # Return a custom message or re-raise depending on desired error handling
54
+ # Returning a placeholder error message object might be useful
55
+ class ErrorMessage:
56
+ content = f"Error communicating with LLM: {e}"
57
+ tool_calls = None
58
+ role = "assistant"
59
+ return ErrorMessage()
60
+
61
+
62
+ def build_prompt_structure(
63
+ role: str,
64
+ content: Optional[str] = None, # Content is optional now
65
+ tag: str = "",
66
+ tool_calls: Optional[List[Dict[str, Any]]] = None, # Added for assistant messages
67
+ tool_call_id: Optional[str] = None # Added for tool messages
68
+ ) -> dict:
69
+ """
70
+ Builds a structured message dictionary for the chat API.
71
+
72
+ Args:
73
+ role: The role ('system', 'user', 'assistant', 'tool').
74
+ content: The text content of the message (required for user, system, tool roles).
75
+ tag: An optional tag to wrap the content (legacy, consider removing).
76
+ tool_calls: A list of tool calls requested by the assistant.
77
+ tool_call_id: The ID of the tool call this message is a response to (for role 'tool').
78
+
79
+ Returns:
80
+ A dictionary representing the structured message.
81
+ """
82
+ message: Dict[str, Any] = {"role": role}
83
+ if content is not None:
84
+ if tag: # Apply legacy tag if provided
85
+ content = f"<{tag}>{content}</{tag}>"
86
+ message["content"] = content
87
+
88
+ # Add tool_calls if provided (only for assistant role)
89
+ if role == "assistant" and tool_calls:
90
+ message["tool_calls"] = tool_calls
91
+
92
+ # Add tool_call_id if provided (only for tool role)
93
+ if role == "tool" and tool_call_id:
94
+ message["tool_call_id"] = tool_call_id
95
+ if content is None: # Tool role requires content
96
+ raise ValueError("Content is required for role 'tool'.")
97
+
98
+ # Basic validation
99
+ if role == "tool" and not tool_call_id:
100
+ raise ValueError("tool_call_id is required for role 'tool'.")
101
+ if role != "assistant" and tool_calls:
102
+ raise ValueError("tool_calls can only be added to 'assistant' role messages.")
103
+
104
+ return message
105
+
106
+
107
+ def update_chat_history(
108
+ history: list,
109
+ message: ChatCompletionMessage | Dict[str, Any] # Accept API message object or manually created dict
110
+ ):
111
+ """
112
+ Updates the chat history by appending a message object or a manually created message dict.
113
+
114
+ Args:
115
+ history (list): The list representing the current chat history.
116
+ message: The message object from the API response or a dict created by build_prompt_structure.
117
+ """
118
+ # If it's an API message object, convert it to the expected dict format
119
+ if hasattr(message, "role"): # Basic check if it looks like an API message object
120
+ msg_dict = {"role": message.role}
121
+ if hasattr(message, "content") and message.content is not None:
122
+ msg_dict["content"] = message.content
123
+ if hasattr(message, "tool_calls") and message.tool_calls:
124
+ # Assuming message.tool_calls is already in the correct list[dict] format
125
+ msg_dict["tool_calls"] = message.tool_calls
126
+ # Add other relevant fields if needed
127
+ history.append(msg_dict)
128
+ elif isinstance(message, dict) and "role" in message:
129
+ # If it's already a dictionary (e.g., from build_prompt_structure)
130
+ history.append(message)
131
+ else:
132
+ raise TypeError("Invalid message type provided to update_chat_history.")
133
+
134
+
135
+ class ChatHistory(list):
136
+ def __init__(self, messages: Optional[List[Dict[str, Any]]] = None, total_length: int = -1): # Type hint messages
137
+ if messages is None:
138
+ messages = []
139
+ super().__init__(messages)
140
+ self.total_length = total_length # Note: total_length logic might need adjustment for tool calls/responses
141
+
142
+ def append(self, msg: Dict[str, Any]): # Expecting message dictionaries now
143
+ if not isinstance(msg, dict) or "role" not in msg:
144
+ raise TypeError("ChatHistory can only append message dictionaries with a 'role'.")
145
+
146
+ # Simple length check, might need refinement based on token count or message types
147
+ if self.total_length > 0 and len(self) == self.total_length:
148
+ self.pop(0) # Remove the oldest message (index 0)
149
+ super().append(msg)
150
+
151
+
152
+ class FixedFirstChatHistory(ChatHistory):
153
+ def __init__(self, messages: Optional[List[Dict[str, Any]]] = None, total_length: int = -1):
154
+ super().__init__(messages, total_length)
155
+
156
+ def append(self, msg: Dict[str, Any]):
157
+ if not isinstance(msg, dict) or "role" not in msg:
158
+ raise TypeError("ChatHistory can only append message dictionaries with a 'role'.")
159
+
160
+ # Keep the first message (system prompt) fixed
161
+ if self.total_length > 0 and len(self) == self.total_length:
162
+ if len(self) > 1: # Ensure there's more than just the system prompt to remove
163
+ self.pop(1) # Remove the second oldest message (index 1)
164
+ else:
165
+ # Cannot append if length is 1 and fixed
166
+ print("Warning: Cannot append to FixedFirstChatHistory of size 1.")
167
+ return
168
+ # Only call super().append if there's space or an item was removed
169
+ if self.total_length <= 0 or len(self) < self.total_length:
170
+ super().append(msg)
171
+
172
+
173
+ # --- END OF ASYNC MODIFIED completions.py ---
@@ -0,0 +1,42 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class TagContentResult:
7
+ """
8
+ A data class to represent the result of extracting tag content.
9
+
10
+ Attributes:
11
+ content (List[str]): A list of strings containing the content found between the specified tags.
12
+ found (bool): A flag indicating whether any content was found for the given tag.
13
+ """
14
+
15
+ content: list[str]
16
+ found: bool
17
+
18
+
19
+ def extract_tag_content(text: str, tag: str) -> TagContentResult:
20
+ """
21
+ Extracts all content enclosed by specified tags (e.g., <thought>, <response>, etc.).
22
+
23
+ Parameters:
24
+ text (str): The input string containing multiple potential tags.
25
+ tag (str): The name of the tag to search for (e.g., 'thought', 'response').
26
+
27
+ Returns:
28
+ dict: A dictionary with the following keys:
29
+ - 'content' (list): A list of strings containing the content found between the specified tags.
30
+ - 'found' (bool): A flag indicating whether any content was found for the given tag.
31
+ """
32
+ # Build the regex pattern dynamically to find multiple occurrences of the tag
33
+ tag_pattern = rf"<{tag}>(.*?)</{tag}>"
34
+
35
+ # Use findall to capture all content between the specified tag
36
+ matched_contents = re.findall(tag_pattern, text, re.DOTALL)
37
+
38
+ # Return the dataclass instance with the result
39
+ return TagContentResult(
40
+ content=[content.strip() for content in matched_contents],
41
+ found=bool(matched_contents),
42
+ )
clap/utils/logging.py ADDED
@@ -0,0 +1,28 @@
1
+ import time
2
+
3
+ from colorama import Fore
4
+ from colorama import Style
5
+
6
+
7
+ def fancy_print(message: str) -> None:
8
+ """
9
+ Displays a fancy print message.
10
+
11
+ Args:
12
+ message (str): The message to display.
13
+ """
14
+ print(Style.BRIGHT + Fore.CYAN + f"\n{'=' * 50}")
15
+ print(Fore.MAGENTA + f"{message}")
16
+ print(Style.BRIGHT + Fore.CYAN + f"{'=' * 50}\n")
17
+ time.sleep(0.5)
18
+
19
+
20
+ def fancy_step_tracker(step: int, total_steps: int) -> None:
21
+ """
22
+ Displays a fancy step tracker for each iteration of the generation-reflection loop.
23
+
24
+ Args:
25
+ step (int): The current step in the loop.
26
+ total_steps (int): The total number of steps in the loop.
27
+ """
28
+ fancy_print(f"STEP {step + 1}/{total_steps}")