camel-ai 0.2.64__py3-none-any.whl → 0.2.66__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of camel-ai might be problematic. Click here for more details.
- camel/__init__.py +1 -1
- camel/agents/chat_agent.py +19 -7
- camel/agents/mcp_agent.py +1 -5
- camel/benchmarks/mock_website/README.md +96 -0
- camel/benchmarks/mock_website/mock_web.py +299 -0
- camel/benchmarks/mock_website/requirements.txt +3 -0
- camel/benchmarks/mock_website/shopping_mall/app.py +465 -0
- camel/benchmarks/mock_website/task.json +104 -0
- camel/datasets/models.py +1 -1
- camel/datasets/static_dataset.py +6 -0
- camel/models/openai_model.py +1 -0
- camel/societies/workforce/role_playing_worker.py +9 -1
- camel/societies/workforce/single_agent_worker.py +8 -1
- camel/societies/workforce/workforce.py +13 -4
- camel/tasks/task.py +69 -1
- camel/toolkits/async_browser_toolkit.py +97 -54
- camel/toolkits/browser_toolkit.py +65 -18
- camel/toolkits/function_tool.py +81 -9
- camel/toolkits/mcp_toolkit.py +70 -19
- camel/toolkits/playwright_mcp_toolkit.py +16 -3
- camel/toolkits/task_planning_toolkit.py +134 -0
- camel/types/enums.py +5 -1
- camel/utils/mcp_client.py +1 -35
- {camel_ai-0.2.64.dist-info → camel_ai-0.2.66.dist-info}/METADATA +4 -12
- {camel_ai-0.2.64.dist-info → camel_ai-0.2.66.dist-info}/RECORD +27 -21
- {camel_ai-0.2.64.dist-info → camel_ai-0.2.66.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.64.dist-info → camel_ai-0.2.66.dist-info}/licenses/LICENSE +0 -0
camel/tasks/task.py
CHANGED
|
@@ -19,15 +19,66 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Union
|
|
|
19
19
|
from pydantic import BaseModel
|
|
20
20
|
|
|
21
21
|
from camel.agents import ChatAgent
|
|
22
|
+
from camel.logger import get_logger
|
|
22
23
|
from camel.messages import BaseMessage
|
|
23
24
|
from camel.prompts import TextPrompt
|
|
24
25
|
|
|
26
|
+
# Note: validate_task_content moved here to avoid circular imports
|
|
25
27
|
from .task_prompt import (
|
|
26
28
|
TASK_COMPOSE_PROMPT,
|
|
27
29
|
TASK_DECOMPOSE_PROMPT,
|
|
28
30
|
TASK_EVOLVE_PROMPT,
|
|
29
31
|
)
|
|
30
32
|
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_task_content(
|
|
37
|
+
content: str, task_id: str = "unknown", min_length: int = 10
|
|
38
|
+
) -> bool:
|
|
39
|
+
r"""Validates task result content to avoid silent failures.
|
|
40
|
+
It performs basic checks to ensure the content meets minimum
|
|
41
|
+
quality standards.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
content (str): The task result content to validate.
|
|
45
|
+
task_id (str): Task ID for logging purposes.
|
|
46
|
+
(default: :obj:`"unknown"`)
|
|
47
|
+
min_length (int): Minimum content length after stripping whitespace.
|
|
48
|
+
(default: :obj:`10`)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
bool: True if content passes validation, False otherwise.
|
|
52
|
+
"""
|
|
53
|
+
# 1: Content must not be None
|
|
54
|
+
if content is None:
|
|
55
|
+
logger.warning(f"Task {task_id}: None content rejected")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# 2: Content must not be empty after stripping whitespace
|
|
59
|
+
stripped_content = content.strip()
|
|
60
|
+
if not stripped_content:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Task {task_id}: Empty or whitespace-only content rejected."
|
|
63
|
+
)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# 3: Content must meet minimum meaningful length
|
|
67
|
+
if len(stripped_content) < min_length:
|
|
68
|
+
logger.warning(
|
|
69
|
+
f"Task {task_id}: Content too short ({len(stripped_content)} "
|
|
70
|
+
f"chars < {min_length} minimum). Content preview: "
|
|
71
|
+
f"'{stripped_content[:50]}...'"
|
|
72
|
+
)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# All validation checks passed
|
|
76
|
+
logger.debug(
|
|
77
|
+
f"Task {task_id}: Content validation passed "
|
|
78
|
+
f"({len(stripped_content)} chars)"
|
|
79
|
+
)
|
|
80
|
+
return True
|
|
81
|
+
|
|
31
82
|
|
|
32
83
|
def parse_response(
|
|
33
84
|
response: str, task_id: Optional[str] = None
|
|
@@ -49,7 +100,16 @@ def parse_response(
|
|
|
49
100
|
if task_id is None:
|
|
50
101
|
task_id = "0"
|
|
51
102
|
for i, content in enumerate(tasks_content):
|
|
52
|
-
|
|
103
|
+
stripped_content = content.strip()
|
|
104
|
+
# validate subtask content before creating the task
|
|
105
|
+
if validate_task_content(stripped_content, f"{task_id}.{i}"):
|
|
106
|
+
tasks.append(Task(content=stripped_content, id=f"{task_id}.{i}"))
|
|
107
|
+
else:
|
|
108
|
+
logger.warning(
|
|
109
|
+
f"Skipping invalid subtask {task_id}.{i} "
|
|
110
|
+
f"during decomposition: "
|
|
111
|
+
f"Content '{stripped_content[:50]}...' failed validation"
|
|
112
|
+
)
|
|
53
113
|
return tasks
|
|
54
114
|
|
|
55
115
|
|
|
@@ -106,6 +166,14 @@ class Task(BaseModel):
|
|
|
106
166
|
|
|
107
167
|
additional_info: Optional[Dict[str, Any]] = None
|
|
108
168
|
|
|
169
|
+
def __repr__(self) -> str:
|
|
170
|
+
r"""Return a string representation of the task."""
|
|
171
|
+
content_preview = self.content
|
|
172
|
+
return (
|
|
173
|
+
f"Task(id='{self.id}', content='{content_preview}', "
|
|
174
|
+
f"state='{self.state.value}')"
|
|
175
|
+
)
|
|
176
|
+
|
|
109
177
|
@classmethod
|
|
110
178
|
def from_message(cls, message: BaseMessage) -> "Task":
|
|
111
179
|
r"""Create a task from a message.
|
|
@@ -123,6 +123,7 @@ class AsyncBaseBrowser:
|
|
|
123
123
|
cache_dir: Optional[str] = None,
|
|
124
124
|
channel: Literal["chrome", "msedge", "chromium"] = "chromium",
|
|
125
125
|
cookie_json_path: Optional[str] = None,
|
|
126
|
+
user_data_dir: Optional[str] = None,
|
|
126
127
|
):
|
|
127
128
|
r"""
|
|
128
129
|
Initialize the asynchronous browser core.
|
|
@@ -136,7 +137,11 @@ class AsyncBaseBrowser:
|
|
|
136
137
|
cookie_json_path (Optional[str]): Path to a JSON file containing
|
|
137
138
|
authentication cookies and browser storage state. If provided
|
|
138
139
|
and the file exists, the browser will load this state to
|
|
139
|
-
maintain authenticated sessions
|
|
140
|
+
maintain authenticated sessions. This is primarily used when
|
|
141
|
+
`user_data_dir` is not set.
|
|
142
|
+
user_data_dir (Optional[str]): The directory to store user data
|
|
143
|
+
for persistent context. If None, a fresh browser instance
|
|
144
|
+
is used without saving data. (default: :obj:`None`)
|
|
140
145
|
|
|
141
146
|
Returns:
|
|
142
147
|
None
|
|
@@ -151,6 +156,7 @@ class AsyncBaseBrowser:
|
|
|
151
156
|
self.playwright = async_playwright()
|
|
152
157
|
self.page_history: list[Any] = []
|
|
153
158
|
self.cookie_json_path = cookie_json_path
|
|
159
|
+
self.user_data_dir = user_data_dir
|
|
154
160
|
self.playwright_server: Any = None
|
|
155
161
|
self.playwright_started: bool = False
|
|
156
162
|
self.browser: Any = None
|
|
@@ -163,6 +169,10 @@ class AsyncBaseBrowser:
|
|
|
163
169
|
self.cache_dir = "tmp/" if cache_dir is None else cache_dir
|
|
164
170
|
os.makedirs(self.cache_dir, exist_ok=True)
|
|
165
171
|
|
|
172
|
+
# Create user data directory only if specified
|
|
173
|
+
if self.user_data_dir:
|
|
174
|
+
os.makedirs(self.user_data_dir, exist_ok=True)
|
|
175
|
+
|
|
166
176
|
# Load the page script
|
|
167
177
|
abs_dir_path = os.path.dirname(os.path.abspath(__file__))
|
|
168
178
|
page_script_path = os.path.join(abs_dir_path, "page_script.js")
|
|
@@ -183,23 +193,56 @@ class AsyncBaseBrowser:
|
|
|
183
193
|
await self._ensure_browser_installed()
|
|
184
194
|
self.playwright_server = await self.playwright.start()
|
|
185
195
|
self.playwright_started = True
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
196
|
+
|
|
197
|
+
browser_launch_args = [
|
|
198
|
+
"--disable-blink-features=AutomationControlled", # Basic stealth
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
user_agent_string = (
|
|
202
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
203
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
204
|
+
"Chrome/91.0.4472.124 Safari/537.36"
|
|
189
205
|
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
206
|
+
|
|
207
|
+
if self.user_data_dir:
|
|
208
|
+
self.context = await (
|
|
209
|
+
self.playwright_server.chromium.launch_persistent_context(
|
|
210
|
+
user_data_dir=self.user_data_dir,
|
|
211
|
+
headless=self.headless,
|
|
212
|
+
channel=self.channel,
|
|
213
|
+
accept_downloads=True,
|
|
214
|
+
user_agent=user_agent_string,
|
|
215
|
+
java_script_enabled=True,
|
|
216
|
+
args=browser_launch_args,
|
|
217
|
+
)
|
|
196
218
|
)
|
|
219
|
+
self.browser = None # Not using a separate browser instance
|
|
220
|
+
if len(self.context.pages) > 0: # Persistent context might
|
|
221
|
+
# reopen pages
|
|
222
|
+
self.page = self.context.pages[0]
|
|
223
|
+
else:
|
|
224
|
+
self.page = await self.context.new_page()
|
|
197
225
|
else:
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
# Launch a fresh browser instance
|
|
227
|
+
self.browser = await self.playwright_server.chromium.launch(
|
|
228
|
+
headless=self.headless,
|
|
229
|
+
channel=self.channel,
|
|
230
|
+
args=browser_launch_args,
|
|
200
231
|
)
|
|
201
|
-
|
|
202
|
-
|
|
232
|
+
|
|
233
|
+
new_context_kwargs: Dict[str, Any] = {
|
|
234
|
+
"accept_downloads": True,
|
|
235
|
+
"user_agent": user_agent_string,
|
|
236
|
+
"java_script_enabled": True,
|
|
237
|
+
}
|
|
238
|
+
if self.cookie_json_path and os.path.exists(self.cookie_json_path):
|
|
239
|
+
new_context_kwargs["storage_state"] = self.cookie_json_path
|
|
240
|
+
|
|
241
|
+
self.context = await self.browser.new_context(**new_context_kwargs)
|
|
242
|
+
self.page = await self.context.new_page()
|
|
243
|
+
|
|
244
|
+
assert self.context is not None
|
|
245
|
+
assert self.page is not None
|
|
203
246
|
|
|
204
247
|
def init(self) -> Coroutine[Any, Any, None]:
|
|
205
248
|
r"""Initialize the browser asynchronously."""
|
|
@@ -827,7 +870,14 @@ class AsyncBaseBrowser:
|
|
|
827
870
|
|
|
828
871
|
async def async_close(self) -> None:
|
|
829
872
|
r"""Asynchronously close the browser."""
|
|
830
|
-
|
|
873
|
+
if self.context is not None:
|
|
874
|
+
await self.context.close()
|
|
875
|
+
if self.browser is not None: # Only close browser if it was
|
|
876
|
+
# launched separately
|
|
877
|
+
await self.browser.close()
|
|
878
|
+
if self.playwright_server and self.playwright_started:
|
|
879
|
+
await self.playwright_server.stop()
|
|
880
|
+
self.playwright_started = False
|
|
831
881
|
|
|
832
882
|
def close(self) -> Coroutine[Any, Any, None]:
|
|
833
883
|
r"""Close the browser."""
|
|
@@ -943,6 +993,7 @@ class AsyncBrowserToolkit(BaseToolkit):
|
|
|
943
993
|
planning_agent_model: Optional[BaseModelBackend] = None,
|
|
944
994
|
output_language: str = "en",
|
|
945
995
|
cookie_json_path: Optional[str] = None,
|
|
996
|
+
user_data_dir: Optional[str] = None,
|
|
946
997
|
):
|
|
947
998
|
r"""Initialize the BrowserToolkit instance.
|
|
948
999
|
|
|
@@ -966,6 +1017,8 @@ class AsyncBrowserToolkit(BaseToolkit):
|
|
|
966
1017
|
maintain authenticated sessions without requiring manual
|
|
967
1018
|
login.
|
|
968
1019
|
(default: :obj:`None`)
|
|
1020
|
+
user_data_dir (Optional[str]): The directory to store user data
|
|
1021
|
+
for persistent context. (default: :obj:`"user_data_dir/"`)
|
|
969
1022
|
"""
|
|
970
1023
|
super().__init__()
|
|
971
1024
|
self.browser = AsyncBaseBrowser(
|
|
@@ -973,6 +1026,7 @@ class AsyncBrowserToolkit(BaseToolkit):
|
|
|
973
1026
|
cache_dir=cache_dir,
|
|
974
1027
|
channel=channel,
|
|
975
1028
|
cookie_json_path=cookie_json_path,
|
|
1029
|
+
user_data_dir=user_data_dir,
|
|
976
1030
|
)
|
|
977
1031
|
|
|
978
1032
|
self.history_window = history_window
|
|
@@ -991,7 +1045,7 @@ class AsyncBrowserToolkit(BaseToolkit):
|
|
|
991
1045
|
os.makedirs(self.browser.cache_dir, exist_ok=True)
|
|
992
1046
|
|
|
993
1047
|
def _initialize_agent(self) -> Tuple["ChatAgent", "ChatAgent"]:
|
|
994
|
-
r"""Initialize the
|
|
1048
|
+
r"""Initialize the planning and web agents."""
|
|
995
1049
|
from camel.agents.chat_agent import ChatAgent
|
|
996
1050
|
|
|
997
1051
|
if self.web_agent_model is None:
|
|
@@ -1060,7 +1114,7 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1060
1114
|
)
|
|
1061
1115
|
# Reset the history message of web_agent.
|
|
1062
1116
|
self.web_agent.reset()
|
|
1063
|
-
resp = self.web_agent.
|
|
1117
|
+
resp = await self.web_agent.astep(message)
|
|
1064
1118
|
|
|
1065
1119
|
resp_content = resp.msgs[0].content
|
|
1066
1120
|
|
|
@@ -1196,43 +1250,29 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1196
1250
|
f"correct identifier.",
|
|
1197
1251
|
)
|
|
1198
1252
|
|
|
1199
|
-
def
|
|
1200
|
-
r"""
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
current viewport.
|
|
1204
|
-
"""
|
|
1205
|
-
|
|
1206
|
-
prompt = GET_FINAL_ANSWER_PROMPT_TEMPLATE.format(
|
|
1207
|
-
history=self.history, task_prompt=task_prompt
|
|
1208
|
-
)
|
|
1209
|
-
|
|
1210
|
-
message = BaseMessage.make_user_message(
|
|
1211
|
-
role_name='user',
|
|
1212
|
-
content=prompt,
|
|
1253
|
+
async def _async_get_final_answer(self, task_prompt: str) -> str:
|
|
1254
|
+
r"""Generate the final answer based on the task prompt."""
|
|
1255
|
+
final_answer_prompt = GET_FINAL_ANSWER_PROMPT_TEMPLATE.format(
|
|
1256
|
+
task_prompt=task_prompt, history=self.history
|
|
1213
1257
|
)
|
|
1258
|
+
response = await self.planning_agent.astep(final_answer_prompt)
|
|
1259
|
+
if response.msgs is None or len(response.msgs) == 0:
|
|
1260
|
+
raise RuntimeError("Got empty final answer from planning agent.")
|
|
1261
|
+
return response.msgs[0].content
|
|
1214
1262
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
r"""Plan the task based on the given task prompt."""
|
|
1220
|
-
|
|
1221
|
-
# Here are the available browser functions we can
|
|
1222
|
-
# use: {AVAILABLE_ACTIONS_PROMPT}
|
|
1223
|
-
|
|
1263
|
+
async def _async_task_planning(
|
|
1264
|
+
self, task_prompt: str, start_url: str
|
|
1265
|
+
) -> str:
|
|
1266
|
+
r"""Generate a detailed plan for the given task."""
|
|
1224
1267
|
planning_prompt = TASK_PLANNING_PROMPT_TEMPLATE.format(
|
|
1225
1268
|
task_prompt=task_prompt, start_url=start_url
|
|
1226
1269
|
)
|
|
1270
|
+
response = await self.planning_agent.astep(planning_prompt)
|
|
1271
|
+
if response.msgs is None or len(response.msgs) == 0:
|
|
1272
|
+
raise RuntimeError("Got empty plan from planning agent.")
|
|
1273
|
+
return response.msgs[0].content
|
|
1227
1274
|
|
|
1228
|
-
|
|
1229
|
-
role_name='user', content=planning_prompt
|
|
1230
|
-
)
|
|
1231
|
-
|
|
1232
|
-
resp = self.planning_agent.step(message)
|
|
1233
|
-
return resp.msgs[0].content
|
|
1234
|
-
|
|
1235
|
-
def _task_replanning(
|
|
1275
|
+
async def _async_task_replanning(
|
|
1236
1276
|
self, task_prompt: str, detailed_plan: str
|
|
1237
1277
|
) -> Tuple[bool, str]:
|
|
1238
1278
|
r"""Replan the task based on the given task prompt.
|
|
@@ -1252,12 +1292,11 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1252
1292
|
replanning_prompt = TASK_REPLANNING_PROMPT_TEMPLATE.format(
|
|
1253
1293
|
task_prompt=task_prompt,
|
|
1254
1294
|
detailed_plan=detailed_plan,
|
|
1255
|
-
history_window=self.history_window,
|
|
1256
1295
|
history=self.history[-self.history_window :],
|
|
1257
1296
|
)
|
|
1258
1297
|
# Reset the history message of planning_agent.
|
|
1259
1298
|
self.planning_agent.reset()
|
|
1260
|
-
resp = self.planning_agent.
|
|
1299
|
+
resp = await self.planning_agent.astep(replanning_prompt)
|
|
1261
1300
|
resp_dict = _parse_json_output(resp.msgs[0].content, logger)
|
|
1262
1301
|
|
|
1263
1302
|
if_need_replan = resp_dict.get("if_need_replan", False)
|
|
@@ -1287,7 +1326,7 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1287
1326
|
|
|
1288
1327
|
self._reset()
|
|
1289
1328
|
task_completed = False
|
|
1290
|
-
detailed_plan = self.
|
|
1329
|
+
detailed_plan = await self._async_task_planning(task_prompt, start_url)
|
|
1291
1330
|
logger.debug(f"Detailed plan: {detailed_plan}")
|
|
1292
1331
|
|
|
1293
1332
|
await self.browser.async_init()
|
|
@@ -1331,7 +1370,11 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1331
1370
|
self.history.append(trajectory_info)
|
|
1332
1371
|
|
|
1333
1372
|
# replan the task if necessary
|
|
1334
|
-
|
|
1373
|
+
(
|
|
1374
|
+
if_need_replan,
|
|
1375
|
+
replanned_schema,
|
|
1376
|
+
# ruff: noqa: E501
|
|
1377
|
+
) = await self._async_task_replanning(
|
|
1335
1378
|
task_prompt, detailed_plan
|
|
1336
1379
|
)
|
|
1337
1380
|
if if_need_replan:
|
|
@@ -1343,11 +1386,11 @@ Here is a plan about how to solve the task step-by-step which you must follow:
|
|
|
1343
1386
|
The task is not completed within the round limit. Please check
|
|
1344
1387
|
the last round {self.history_window} information to see if
|
|
1345
1388
|
there is any useful information:
|
|
1346
|
-
<history>{self.history[-self.history_window
|
|
1389
|
+
<history>{self.history[-self.history_window:]}</history>
|
|
1347
1390
|
"""
|
|
1348
1391
|
|
|
1349
1392
|
else:
|
|
1350
|
-
simulation_result = self.
|
|
1393
|
+
simulation_result = await self._async_get_final_answer(task_prompt)
|
|
1351
1394
|
|
|
1352
1395
|
await self.browser.close()
|
|
1353
1396
|
return simulation_result
|
|
@@ -125,6 +125,7 @@ class BaseBrowser:
|
|
|
125
125
|
cache_dir: Optional[str] = None,
|
|
126
126
|
channel: Literal["chrome", "msedge", "chromium"] = "chromium",
|
|
127
127
|
cookie_json_path: Optional[str] = None,
|
|
128
|
+
user_data_dir: Optional[str] = None,
|
|
128
129
|
):
|
|
129
130
|
r"""Initialize the WebBrowser instance.
|
|
130
131
|
|
|
@@ -137,8 +138,11 @@ class BaseBrowser:
|
|
|
137
138
|
cookie_json_path (Optional[str]): Path to a JSON file containing
|
|
138
139
|
authentication cookies and browser storage state. If provided
|
|
139
140
|
and the file exists, the browser will load this state to
|
|
140
|
-
maintain
|
|
141
|
-
|
|
141
|
+
maintain authenticated sessions. This is primarily used when
|
|
142
|
+
`user_data_dir` is not set.
|
|
143
|
+
user_data_dir (Optional[str]): The directory to store user data
|
|
144
|
+
for persistent context. If None, a fresh browser instance
|
|
145
|
+
is used without saving data. (default: :obj:`None`)
|
|
142
146
|
|
|
143
147
|
Returns:
|
|
144
148
|
None
|
|
@@ -156,11 +160,16 @@ class BaseBrowser:
|
|
|
156
160
|
str
|
|
157
161
|
] = [] # stores the history of visited pages
|
|
158
162
|
self.cookie_json_path = cookie_json_path
|
|
163
|
+
self.user_data_dir = user_data_dir
|
|
159
164
|
|
|
160
165
|
# Set the cache directory
|
|
161
166
|
self.cache_dir = "tmp/" if cache_dir is None else cache_dir
|
|
162
167
|
os.makedirs(self.cache_dir, exist_ok=True)
|
|
163
168
|
|
|
169
|
+
# Create user data directory only if specified
|
|
170
|
+
if self.user_data_dir:
|
|
171
|
+
os.makedirs(self.user_data_dir, exist_ok=True)
|
|
172
|
+
|
|
164
173
|
# Load the page script
|
|
165
174
|
abs_dir_path = os.path.dirname(os.path.abspath(__file__))
|
|
166
175
|
page_script_path = os.path.join(abs_dir_path, "page_script.js")
|
|
@@ -183,27 +192,56 @@ class BaseBrowser:
|
|
|
183
192
|
|
|
184
193
|
def init(self) -> None:
|
|
185
194
|
r"""Initialize the browser."""
|
|
186
|
-
# Launch the browser, if headless is False, the browser will display
|
|
187
195
|
assert self.playwright is not None
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
|
|
197
|
+
browser_launch_args = [
|
|
198
|
+
"--disable-blink-features=AutomationControlled", # Basic stealth
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
user_agent_string = (
|
|
202
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
203
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
204
|
+
"Chrome/91.0.4472.124 Safari/537.36"
|
|
190
205
|
)
|
|
191
206
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
if self.user_data_dir:
|
|
208
|
+
self.context = self.playwright.chromium.launch_persistent_context(
|
|
209
|
+
user_data_dir=self.user_data_dir,
|
|
210
|
+
headless=self.headless,
|
|
211
|
+
channel=self.channel,
|
|
212
|
+
accept_downloads=True,
|
|
213
|
+
user_agent=user_agent_string,
|
|
214
|
+
java_script_enabled=True,
|
|
215
|
+
args=browser_launch_args,
|
|
199
216
|
)
|
|
217
|
+
self.browser = None # Not using a separate browser instance
|
|
218
|
+
if (
|
|
219
|
+
len(self.context.pages) > 0
|
|
220
|
+
): # Persistent context might reopen pages
|
|
221
|
+
self.page = self.context.pages[0]
|
|
222
|
+
else:
|
|
223
|
+
self.page = self.context.new_page()
|
|
200
224
|
else:
|
|
201
|
-
|
|
202
|
-
|
|
225
|
+
# Launch a fresh browser instance
|
|
226
|
+
self.browser = self.playwright.chromium.launch(
|
|
227
|
+
headless=self.headless,
|
|
228
|
+
channel=self.channel,
|
|
229
|
+
args=browser_launch_args,
|
|
203
230
|
)
|
|
204
|
-
|
|
231
|
+
|
|
232
|
+
new_context_kwargs: Dict[str, Any] = {
|
|
233
|
+
"accept_downloads": True,
|
|
234
|
+
"user_agent": user_agent_string,
|
|
235
|
+
"java_script_enabled": True,
|
|
236
|
+
}
|
|
237
|
+
if self.cookie_json_path and os.path.exists(self.cookie_json_path):
|
|
238
|
+
new_context_kwargs["storage_state"] = self.cookie_json_path
|
|
239
|
+
|
|
240
|
+
self.context = self.browser.new_context(**new_context_kwargs)
|
|
241
|
+
self.page = self.context.new_page()
|
|
242
|
+
|
|
205
243
|
assert self.context is not None
|
|
206
|
-
self.page
|
|
244
|
+
assert self.page is not None
|
|
207
245
|
|
|
208
246
|
def clean_cache(self) -> None:
|
|
209
247
|
r"""Delete the cache directory and its contents."""
|
|
@@ -690,8 +728,12 @@ class BaseBrowser:
|
|
|
690
728
|
self._wait_for_load()
|
|
691
729
|
|
|
692
730
|
def close(self):
|
|
693
|
-
|
|
694
|
-
|
|
731
|
+
if self.context is not None:
|
|
732
|
+
self.context.close()
|
|
733
|
+
if (
|
|
734
|
+
self.browser is not None
|
|
735
|
+
): # Only close browser if it was launched separately
|
|
736
|
+
self.browser.close()
|
|
695
737
|
if self.playwright:
|
|
696
738
|
self.playwright.stop() # Stop playwright instance
|
|
697
739
|
|
|
@@ -781,6 +823,7 @@ class BrowserToolkit(BaseToolkit):
|
|
|
781
823
|
planning_agent_model: Optional[BaseModelBackend] = None,
|
|
782
824
|
output_language: str = "en",
|
|
783
825
|
cookie_json_path: Optional[str] = None,
|
|
826
|
+
user_data_dir: Optional[str] = None,
|
|
784
827
|
):
|
|
785
828
|
r"""Initialize the BrowserToolkit instance.
|
|
786
829
|
|
|
@@ -804,6 +847,9 @@ class BrowserToolkit(BaseToolkit):
|
|
|
804
847
|
maintain
|
|
805
848
|
authenticated sessions without requiring manual login.
|
|
806
849
|
(default: :obj:`None`)
|
|
850
|
+
user_data_dir (Optional[str]): The directory to store user data
|
|
851
|
+
for persistent context. If None, a fresh browser instance
|
|
852
|
+
is used without saving data. (default: :obj:`None`)
|
|
807
853
|
"""
|
|
808
854
|
super().__init__() # Call to super().__init__() added
|
|
809
855
|
self.browser = BaseBrowser(
|
|
@@ -811,6 +857,7 @@ class BrowserToolkit(BaseToolkit):
|
|
|
811
857
|
cache_dir=cache_dir,
|
|
812
858
|
channel=channel,
|
|
813
859
|
cookie_json_path=cookie_json_path,
|
|
860
|
+
user_data_dir=user_data_dir,
|
|
814
861
|
)
|
|
815
862
|
self.browser.web_agent_model = web_agent_model # Pass model to
|
|
816
863
|
# BaseBrowser instance
|
camel/toolkits/function_tool.py
CHANGED
|
@@ -190,7 +190,9 @@ def sanitize_and_enforce_required(parameters_dict):
|
|
|
190
190
|
r"""Cleans and updates the function schema to conform with OpenAI's
|
|
191
191
|
requirements:
|
|
192
192
|
- Removes invalid 'default' fields from the parameters schema.
|
|
193
|
-
- Ensures all fields
|
|
193
|
+
- Ensures all fields are marked as required or have null type for optional
|
|
194
|
+
fields.
|
|
195
|
+
- Recursively adds additionalProperties: false to all nested objects.
|
|
194
196
|
|
|
195
197
|
Args:
|
|
196
198
|
parameters_dict (dict): The dictionary representing the function
|
|
@@ -198,8 +200,38 @@ def sanitize_and_enforce_required(parameters_dict):
|
|
|
198
200
|
|
|
199
201
|
Returns:
|
|
200
202
|
dict: The updated dictionary with invalid defaults removed and all
|
|
201
|
-
fields
|
|
203
|
+
fields properly configured for strict mode.
|
|
202
204
|
"""
|
|
205
|
+
|
|
206
|
+
def _add_additional_properties_false(obj):
|
|
207
|
+
r"""Recursively add additionalProperties: false to all objects."""
|
|
208
|
+
if isinstance(obj, dict):
|
|
209
|
+
if (
|
|
210
|
+
obj.get("type") == "object"
|
|
211
|
+
and "additionalProperties" not in obj
|
|
212
|
+
):
|
|
213
|
+
obj["additionalProperties"] = False
|
|
214
|
+
|
|
215
|
+
# Process nested structures
|
|
216
|
+
for key, value in obj.items():
|
|
217
|
+
if key == "properties" and isinstance(value, dict):
|
|
218
|
+
for prop_value in value.values():
|
|
219
|
+
_add_additional_properties_false(prop_value)
|
|
220
|
+
elif key in [
|
|
221
|
+
"items",
|
|
222
|
+
"allOf",
|
|
223
|
+
"oneOf",
|
|
224
|
+
"anyOf",
|
|
225
|
+
] and isinstance(value, (dict, list)):
|
|
226
|
+
if isinstance(value, dict):
|
|
227
|
+
_add_additional_properties_false(value)
|
|
228
|
+
elif isinstance(value, list):
|
|
229
|
+
for item in value:
|
|
230
|
+
_add_additional_properties_false(item)
|
|
231
|
+
elif key == "$defs" and isinstance(value, dict):
|
|
232
|
+
for def_value in value.values():
|
|
233
|
+
_add_additional_properties_false(def_value)
|
|
234
|
+
|
|
203
235
|
# Check if 'function' and 'parameters' exist
|
|
204
236
|
if (
|
|
205
237
|
'function' in parameters_dict
|
|
@@ -209,12 +241,52 @@ def sanitize_and_enforce_required(parameters_dict):
|
|
|
209
241
|
parameters = parameters_dict['function']['parameters']
|
|
210
242
|
properties = parameters.get('properties', {})
|
|
211
243
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
# Track which fields should be required vs optional
|
|
245
|
+
required_fields = []
|
|
246
|
+
|
|
247
|
+
# Process each property
|
|
248
|
+
for field_name, field_schema in properties.items():
|
|
249
|
+
# Check if this field had a default value (making it optional)
|
|
250
|
+
had_default = 'default' in field_schema
|
|
251
|
+
|
|
252
|
+
# Remove 'default' key from field schema as required by OpenAI
|
|
253
|
+
field_schema.pop('default', None)
|
|
254
|
+
|
|
255
|
+
if had_default:
|
|
256
|
+
# This field is optional - add null to its type
|
|
257
|
+
current_type = field_schema.get('type')
|
|
258
|
+
has_ref = '$ref' in field_schema
|
|
259
|
+
|
|
260
|
+
if has_ref:
|
|
261
|
+
# Fields with $ref shouldn't have additional type field
|
|
262
|
+
# The $ref itself defines the type structure
|
|
263
|
+
pass
|
|
264
|
+
elif current_type:
|
|
265
|
+
if isinstance(current_type, str):
|
|
266
|
+
# Single type - convert to array with null
|
|
267
|
+
field_schema['type'] = [current_type, 'null']
|
|
268
|
+
elif (
|
|
269
|
+
isinstance(current_type, list)
|
|
270
|
+
and 'null' not in current_type
|
|
271
|
+
):
|
|
272
|
+
# Array of types - add null if not present
|
|
273
|
+
field_schema['type'] = [*current_type, 'null']
|
|
274
|
+
else:
|
|
275
|
+
# No type specified, add null type
|
|
276
|
+
field_schema['type'] = ['null']
|
|
277
|
+
|
|
278
|
+
# Optional fields are still marked as required in strict mode
|
|
279
|
+
# but with null type to indicate they can be omitted
|
|
280
|
+
required_fields.append(field_name)
|
|
281
|
+
else:
|
|
282
|
+
# This field is required
|
|
283
|
+
required_fields.append(field_name)
|
|
284
|
+
|
|
285
|
+
# Set all fields as required (strict mode requirement)
|
|
286
|
+
parameters['required'] = required_fields
|
|
215
287
|
|
|
216
|
-
#
|
|
217
|
-
parameters
|
|
288
|
+
# Recursively add additionalProperties: false to all objects
|
|
289
|
+
_add_additional_properties_false(parameters)
|
|
218
290
|
|
|
219
291
|
return parameters_dict
|
|
220
292
|
|
|
@@ -547,7 +619,7 @@ class FunctionTool:
|
|
|
547
619
|
"""
|
|
548
620
|
self.openai_tool_schema["function"]["description"] = description
|
|
549
621
|
|
|
550
|
-
def
|
|
622
|
+
def get_parameter_description(self, param_name: str) -> str:
|
|
551
623
|
r"""Gets the description of a specific parameter from the function
|
|
552
624
|
schema.
|
|
553
625
|
|
|
@@ -563,7 +635,7 @@ class FunctionTool:
|
|
|
563
635
|
param_name
|
|
564
636
|
]["description"]
|
|
565
637
|
|
|
566
|
-
def
|
|
638
|
+
def set_parameter_description(
|
|
567
639
|
self,
|
|
568
640
|
param_name: str,
|
|
569
641
|
description: str,
|