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.
- clap/__init__.py +57 -0
- clap/llm_services/__init__.py +0 -0
- clap/llm_services/base.py +68 -0
- clap/llm_services/google_openai_compat_service.py +122 -0
- clap/llm_services/groq_service.py +100 -0
- clap/mcp_client/__init__.py +0 -0
- clap/mcp_client/client.py +208 -0
- clap/multiagent_pattern/__init__.py +0 -0
- clap/multiagent_pattern/agent.py +128 -0
- clap/multiagent_pattern/team.py +154 -0
- clap/react_pattern/__init__.py +0 -0
- clap/react_pattern/react_agent.py +265 -0
- clap/tool_pattern/__init__.py +0 -0
- clap/tool_pattern/tool.py +229 -0
- clap/tool_pattern/tool_agent.py +241 -0
- clap/tools/__init__.py +13 -0
- clap/tools/email_tools.py +230 -0
- clap/tools/web_crawler.py +82 -0
- clap/tools/web_search.py +24 -0
- clap/utils/__init__.py +0 -0
- clap/utils/completions.py +173 -0
- clap/utils/extraction.py +42 -0
- clap/utils/logging.py +28 -0
- clap_agents-0.1.1.dist-info/METADATA +346 -0
- clap_agents-0.1.1.dist-info/RECORD +27 -0
- clap_agents-0.1.1.dist-info/WHEEL +4 -0
- clap_agents-0.1.1.dist-info/licenses/LICENSE +202 -0
@@ -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
|
+
|
clap/tools/web_search.py
ADDED
@@ -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 ---
|
clap/utils/extraction.py
ADDED
@@ -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}")
|