camel-ai 0.2.70__py3-none-any.whl → 0.2.71a2__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.

@@ -81,9 +81,10 @@ class TerminalToolkit(BaseToolkit):
81
81
  self.terminal_ready = threading.Event()
82
82
  self.gui_thread = None
83
83
  self.safe_mode = safe_mode
84
-
84
+ self._file_initialized = False
85
85
  self.cloned_env_path = None
86
86
  self.use_shell_mode = use_shell_mode
87
+ self._human_takeover_active = False
87
88
 
88
89
  self.python_executable = sys.executable
89
90
  self.is_macos = platform.system() == 'Darwin'
@@ -129,25 +130,20 @@ class TerminalToolkit(BaseToolkit):
129
130
 
130
131
  self.log_file = os.path.join(os.getcwd(), "camel_terminal.txt")
131
132
 
132
- if os.path.exists(self.log_file):
133
- with open(self.log_file, "w") as f:
134
- f.truncate(0)
135
- f.write("CAMEL Terminal Session\n")
136
- f.write("=" * 50 + "\n")
137
- f.write(f"Working Directory: {os.getcwd()}\n")
138
- f.write("=" * 50 + "\n\n")
139
- else:
140
- with open(self.log_file, "w") as f:
141
- f.write("CAMEL Terminal Session\n")
142
- f.write("=" * 50 + "\n")
143
- f.write(f"Working Directory: {os.getcwd()}\n")
144
- f.write("=" * 50 + "\n\n")
145
-
146
133
  # Inform the user
147
- logger.info(f"Terminal output redirected to: {self.log_file}")
134
+ logger.info(f"Terminal output will be redirected to: {self.log_file}")
148
135
 
149
136
  def file_update(output: str):
150
137
  try:
138
+ # Initialize file on first write
139
+ if not self._file_initialized:
140
+ with open(self.log_file, "w") as f:
141
+ f.write("CAMEL Terminal Session\n")
142
+ f.write("=" * 50 + "\n")
143
+ f.write(f"Working Directory: {os.getcwd()}\n")
144
+ f.write("=" * 50 + "\n\n")
145
+ self._file_initialized = True
146
+
151
147
  # Directly append to the end of the file
152
148
  with open(self.log_file, "a") as f:
153
149
  f.write(output)
@@ -165,6 +161,12 @@ class TerminalToolkit(BaseToolkit):
165
161
  def _clone_current_environment(self):
166
162
  r"""Create a new Python virtual environment."""
167
163
  try:
164
+ if self.cloned_env_path is None:
165
+ self._update_terminal_output(
166
+ "Error: No environment path specified\n"
167
+ )
168
+ return
169
+
168
170
  if os.path.exists(self.cloned_env_path):
169
171
  self._update_terminal_output(
170
172
  f"Using existing environment: {self.cloned_env_path}\n"
@@ -704,59 +706,35 @@ class TerminalToolkit(BaseToolkit):
704
706
  elif command.startswith('pip'):
705
707
  command = command.replace('pip', pip_path, 1)
706
708
 
707
- if self.is_macos:
708
- # Type safe version - macOS uses subprocess.run
709
- process = subprocess.run(
710
- command,
711
- shell=True,
712
- cwd=self.working_dir,
713
- capture_output=True,
714
- text=True,
715
- env=os.environ.copy(),
716
- )
717
-
718
- # Process the output
719
- output = process.stdout or ""
720
- if process.stderr:
721
- output += f"\nStderr Output:\n{process.stderr}"
722
-
723
- # Update session information and terminal
724
- self.shell_sessions[id]["output"] = output
725
- self._update_terminal_output(output + "\n")
726
-
727
- return output
728
-
729
- else:
730
- # Non-macOS systems use the Popen method
731
- proc = subprocess.Popen(
732
- command,
733
- shell=True,
734
- cwd=self.working_dir,
735
- stdout=subprocess.PIPE,
736
- stderr=subprocess.PIPE,
737
- stdin=subprocess.PIPE,
738
- text=True,
739
- bufsize=1,
740
- universal_newlines=True,
741
- env=os.environ.copy(),
742
- )
709
+ proc = subprocess.Popen(
710
+ command,
711
+ shell=True,
712
+ cwd=self.working_dir,
713
+ stdout=subprocess.PIPE,
714
+ stderr=subprocess.PIPE,
715
+ stdin=subprocess.PIPE,
716
+ text=True,
717
+ bufsize=1,
718
+ universal_newlines=True,
719
+ env=os.environ.copy(),
720
+ )
743
721
 
744
- # Store the process and mark it as running
745
- self.shell_sessions[id]["process"] = proc
746
- self.shell_sessions[id]["running"] = True
722
+ # Store the process and mark it as running
723
+ self.shell_sessions[id]["process"] = proc
724
+ self.shell_sessions[id]["running"] = True
747
725
 
748
- # Get output
749
- stdout, stderr = proc.communicate()
726
+ # Get output
727
+ stdout, stderr = proc.communicate()
750
728
 
751
- output = stdout or ""
752
- if stderr:
753
- output += f"\nStderr Output:\n{stderr}"
729
+ output = stdout or ""
730
+ if stderr:
731
+ output += f"\nStderr Output:\n{stderr}"
754
732
 
755
- # Update session information and terminal
756
- self.shell_sessions[id]["output"] = output
757
- self._update_terminal_output(output + "\n")
733
+ # Update session information and terminal
734
+ self.shell_sessions[id]["output"] = output
735
+ self._update_terminal_output(output + "\n")
758
736
 
759
- return output
737
+ return output
760
738
 
761
739
  except Exception as e:
762
740
  error_msg = f"Command execution error: {e!s}"
@@ -960,6 +938,169 @@ class TerminalToolkit(BaseToolkit):
960
938
  logger.error(f"Error killing process: {e}")
961
939
  return f"Error killing process: {e!s}"
962
940
 
941
+ def ask_user_for_help(self, id: str) -> str:
942
+ r"""Pauses agent execution to ask a human for help in the terminal.
943
+
944
+ This function should be called when an agent is stuck or needs
945
+ assistance with a task that requires manual intervention (e.g.,
946
+ solving a CAPTCHA or complex debugging). The human will take over the
947
+ specified terminal session to execute commands and then return control
948
+ to the agent.
949
+
950
+ Args:
951
+ id (str): Identifier of the shell session for the human to
952
+ interact with. If the session does not yet exist, it will be
953
+ created automatically.
954
+
955
+ Returns:
956
+ str: A status message indicating that the human has finished,
957
+ including the number of commands executed.
958
+ """
959
+ # Input validation
960
+ if not id or not isinstance(id, str):
961
+ return "Error: Invalid session ID provided"
962
+
963
+ # Prevent concurrent human takeovers
964
+ if (
965
+ hasattr(self, '_human_takeover_active')
966
+ and self._human_takeover_active
967
+ ):
968
+ return "Error: Human takeover already in progress"
969
+
970
+ try:
971
+ self._human_takeover_active = True
972
+
973
+ # Ensure the session exists so that the human can reuse it
974
+ if id not in self.shell_sessions:
975
+ self.shell_sessions[id] = {
976
+ "process": None,
977
+ "output": "",
978
+ "running": False,
979
+ }
980
+
981
+ command_count = 0
982
+ error_occurred = False
983
+
984
+ # Create clear banner message for user
985
+ takeover_banner = (
986
+ f"\n{'='*60}\n"
987
+ f"🤖 CAMEL Agent needs human help! Session: {id}\n"
988
+ f"📂 Working directory: {self.working_dir}\n"
989
+ f"{'='*60}\n"
990
+ f"💡 Type commands or '/exit' to return control to agent.\n"
991
+ f"{'='*60}\n"
992
+ )
993
+
994
+ # Print once to console for immediate visibility
995
+ print(takeover_banner, flush=True)
996
+ # Log for terminal output tracking
997
+ self._update_terminal_output(takeover_banner)
998
+
999
+ # Helper flag + event for coordination
1000
+ done_event = threading.Event()
1001
+
1002
+ def _human_loop() -> None:
1003
+ r"""Blocking loop that forwards human input to shell_exec."""
1004
+ nonlocal command_count, error_occurred
1005
+ try:
1006
+ while True:
1007
+ try:
1008
+ # Clear, descriptive prompt for user input
1009
+ user_cmd = input(f"🧑‍💻 [{id}]> ")
1010
+ if (
1011
+ user_cmd.strip()
1012
+ ): # Only count non-empty commands
1013
+ command_count += 1
1014
+ except EOFError:
1015
+ # e.g. Ctrl_D / stdin closed, treat as exit.
1016
+ break
1017
+ except (KeyboardInterrupt, Exception) as e:
1018
+ logger.warning(
1019
+ f"Input error during human takeover: {e}"
1020
+ )
1021
+ error_occurred = True
1022
+ break
1023
+
1024
+ if user_cmd.strip() in {"/exit", "exit", "quit"}:
1025
+ break
1026
+
1027
+ try:
1028
+ exec_result = self.shell_exec(id, user_cmd)
1029
+ # Show the result immediately to the user
1030
+ if exec_result.strip():
1031
+ print(exec_result)
1032
+ logger.info(
1033
+ f"Human command executed: {user_cmd[:50]}..."
1034
+ )
1035
+ # Auto-exit after successful command
1036
+ break
1037
+ except Exception as e:
1038
+ error_msg = f"Error executing command: {e}"
1039
+ logger.error(f"Error executing human command: {e}")
1040
+ print(error_msg) # Show error to user immediately
1041
+ self._update_terminal_output(f"{error_msg}\n")
1042
+ error_occurred = True
1043
+
1044
+ except Exception as e:
1045
+ logger.error(f"Unexpected error in human loop: {e}")
1046
+ error_occurred = True
1047
+ finally:
1048
+ # Notify completion clearly
1049
+ finish_msg = (
1050
+ f"\n{'='*60}\n"
1051
+ f"✅ Human assistance completed! "
1052
+ f"Commands: {command_count}\n"
1053
+ f"🤖 Returning control to CAMEL agent...\n"
1054
+ f"{'='*60}\n"
1055
+ )
1056
+ print(finish_msg, flush=True)
1057
+ self._update_terminal_output(finish_msg)
1058
+ done_event.set()
1059
+
1060
+ # Start interactive thread (non-daemon for proper cleanup)
1061
+ thread = threading.Thread(target=_human_loop, daemon=False)
1062
+ thread.start()
1063
+
1064
+ # Block until human signals completion with timeout
1065
+ if done_event.wait(timeout=600): # 10 minutes timeout
1066
+ thread.join(timeout=10) # Give thread time to cleanup
1067
+
1068
+ # Generate detailed status message
1069
+ status = "completed successfully"
1070
+ if error_occurred:
1071
+ status = "completed with some errors"
1072
+
1073
+ result_msg = (
1074
+ f"Human assistance {status} for session '{id}'. "
1075
+ f"Total commands executed: {command_count}. "
1076
+ f"Working directory: {self.working_dir}"
1077
+ )
1078
+ logger.info(result_msg)
1079
+ return result_msg
1080
+ else:
1081
+ timeout_msg = (
1082
+ f"Human takeover for session '{id}' timed out after 10 "
1083
+ "minutes"
1084
+ )
1085
+ logger.warning(timeout_msg)
1086
+ return timeout_msg
1087
+
1088
+ except Exception as e:
1089
+ error_msg = f"Error during human takeover for session '{id}': {e}"
1090
+ logger.error(error_msg)
1091
+ # Notify user of the error clearly
1092
+ error_banner = (
1093
+ f"\n{'='*60}\n"
1094
+ f"❌ Error in human takeover! Session: {id}\n"
1095
+ f"❗ {e}\n"
1096
+ f"{'='*60}\n"
1097
+ )
1098
+ print(error_banner, flush=True)
1099
+ return error_msg
1100
+ finally:
1101
+ # Always reset the flag
1102
+ self._human_takeover_active = False
1103
+
963
1104
  def __del__(self):
964
1105
  r"""Clean up resources when the object is being destroyed.
965
1106
  Terminates all running processes and closes any open file handles.
@@ -1041,4 +1182,5 @@ class TerminalToolkit(BaseToolkit):
1041
1182
  FunctionTool(self.shell_wait),
1042
1183
  FunctionTool(self.shell_write_to_process),
1043
1184
  FunctionTool(self.shell_kill_process),
1185
+ FunctionTool(self.ask_user_for_help),
1044
1186
  ]
@@ -26,7 +26,7 @@ from PIL import Image
26
26
  from camel.logger import get_logger
27
27
  from camel.toolkits.base import BaseToolkit
28
28
  from camel.toolkits.function_tool import FunctionTool
29
- from camel.utils import MCPServer, dependencies_required
29
+ from camel.utils import dependencies_required
30
30
 
31
31
  logger = get_logger(__name__)
32
32
 
@@ -57,7 +57,6 @@ def _capture_screenshot(video_file: str, timestamp: float) -> Image.Image:
57
57
  return Image.open(io.BytesIO(out))
58
58
 
59
59
 
60
- @MCPServer()
61
60
  class VideoDownloaderToolkit(BaseToolkit):
62
61
  r"""A class for downloading videos and optionally splitting them into
63
62
  chunks.
@@ -123,6 +122,9 @@ class VideoDownloaderToolkit(BaseToolkit):
123
122
  yt-dlp will detect if the video is downloaded automatically so there
124
123
  is no need to check if the video exists.
125
124
 
125
+ Args:
126
+ url (str): The URL of the video to download.
127
+
126
128
  Returns:
127
129
  str: The path to the downloaded video file.
128
130
  """
@@ -175,7 +177,8 @@ class VideoDownloaderToolkit(BaseToolkit):
175
177
  dividing the video into equal parts if an integer is provided.
176
178
 
177
179
  Args:
178
- video_url (str): The URL of the video to take screenshots.
180
+ video_path (str): The local path or URL of the video to take
181
+ screenshots.
179
182
  amount (int): the amount of evenly split screenshots to capture.
180
183
 
181
184
  Returns:
@@ -0,0 +1,148 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ from typing import List, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from camel.agents import ChatAgent
19
+ from camel.messages import BaseMessage
20
+ from camel.models import BaseModelBackend, ModelFactory
21
+ from camel.types import ModelPlatformType, ModelType
22
+
23
+
24
+ class MessageSummary(BaseModel):
25
+ r"""Schema for structured message summaries.
26
+
27
+ Attributes:
28
+ summary (str): A brief, one-sentence summary of the conversation.
29
+ participants (List[str]): The roles of participants involved.
30
+ key_topics_and_entities (List[str]): Important topics, concepts, and
31
+ entities discussed.
32
+ decisions_and_outcomes (List[str]): Key decisions, conclusions, or
33
+ outcomes reached.
34
+ action_items (List[str]): A list of specific tasks or actions to be
35
+ taken, with assignees if mentioned.
36
+ progress_on_main_task (str): A summary of progress made on the
37
+ primary task.
38
+ """
39
+
40
+ summary: str = Field(
41
+ description="A brief, one-sentence summary of the conversation."
42
+ )
43
+ participants: List[str] = Field(
44
+ description="The roles of participants involved."
45
+ )
46
+ key_topics_and_entities: List[str] = Field(
47
+ description="Important topics, concepts, and entities discussed."
48
+ )
49
+ decisions_and_outcomes: List[str] = Field(
50
+ description="Key decisions, conclusions, or outcomes reached."
51
+ )
52
+ action_items: List[str] = Field(
53
+ description=(
54
+ "A list of specific tasks or actions to be taken, with assignees "
55
+ "if mentioned."
56
+ )
57
+ )
58
+ progress_on_main_task: str = Field(
59
+ description="A summary of progress made on the primary task."
60
+ )
61
+
62
+
63
+ class MessageSummarizer:
64
+ r"""Utility class for generating structured summaries of chat messages.
65
+
66
+ Args:
67
+ model_backend (Optional[BaseModelBackend], optional):
68
+ The model backend to use for summarization.
69
+ If not provided, a default model backend will be created.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ model_backend: Optional[BaseModelBackend] = None,
75
+ ):
76
+ if model_backend is None:
77
+ self.model_backend = ModelFactory.create(
78
+ model_platform=ModelPlatformType.DEFAULT,
79
+ model_type=ModelType.GPT_4O_MINI,
80
+ )
81
+ else:
82
+ self.model_backend = model_backend
83
+ self.agent = ChatAgent(
84
+ BaseMessage.make_assistant_message(
85
+ role_name="Message Summarizer",
86
+ content=(
87
+ "You are an expert conversation summarizer. Your task is "
88
+ "to analyze chat messages and create a structured summary "
89
+ "in JSON format. The summary should capture:\n"
90
+ "- summary: A brief, one-sentence summary of the "
91
+ "conversation.\n"
92
+ "- participants: The roles of participants involved.\n"
93
+ "- key_topics_and_entities: Important topics, concepts, "
94
+ "and entities discussed.\n"
95
+ "- decisions_and_outcomes: Key decisions, conclusions, or "
96
+ "outcomes reached.\n"
97
+ "- action_items: A list of specific tasks or actions to "
98
+ "be taken, with assignees if mentioned.\n"
99
+ "- progress_on_main_task: A summary of progress made on "
100
+ "the primary task.\n\n"
101
+ "Your response must be a JSON object that strictly "
102
+ "adheres to this structure. Be concise and accurate."
103
+ ),
104
+ ),
105
+ model=self.model_backend,
106
+ )
107
+
108
+ def summarize(self, messages: List[BaseMessage]) -> MessageSummary:
109
+ r"""Generate a structured summary of the provided messages.
110
+
111
+ Args:
112
+ messages (List[BaseMessage]): List of messages to summarize.
113
+
114
+ Returns:
115
+ MessageSummary: Structured summary of the conversation.
116
+
117
+ Raises:
118
+ ValueError: If the messages list is empty or if the model's
119
+ response cannot be parsed as valid JSON.
120
+ """
121
+ if not messages:
122
+ raise ValueError("Cannot summarize an empty list of messages.")
123
+
124
+ # Construct prompt from messages
125
+ message_text = "\n".join(
126
+ f"{msg.role_name}: {msg.content}" for msg in messages
127
+ )
128
+ prompt = (
129
+ "Please analyze the following chat messages and generate a "
130
+ "structured summary.\n\n"
131
+ f"MESSAGES:\n\"\"\"\n{message_text}\n\"\"\"\n\n"
132
+ "Your response must be a JSON object that strictly adheres to the "
133
+ "required format."
134
+ )
135
+
136
+ # Get structured summary from model with forced JSON response
137
+ response = self.agent.step(prompt, response_format=MessageSummary)
138
+
139
+ if response.msg is None or response.msg.parsed is None:
140
+ raise ValueError(
141
+ "Failed to get a structured summary from the model."
142
+ )
143
+
144
+ summary = response.msg.parsed
145
+ if not isinstance(summary, MessageSummary):
146
+ raise ValueError("The parsed response is not a MessageSummary.")
147
+
148
+ return summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: camel-ai
3
- Version: 0.2.70
3
+ Version: 0.2.71a2
4
4
  Summary: Communicative Agents for AI Society Study
5
5
  Project-URL: Homepage, https://www.camel-ai.org/
6
6
  Project-URL: Repository, https://github.com/camel-ai/camel
@@ -54,7 +54,6 @@ Requires-Dist: ffmpeg-python<0.3,>=0.2.0; extra == 'all'
54
54
  Requires-Dist: firecrawl-py<2,>=1.0.0; extra == 'all'
55
55
  Requires-Dist: fish-audio-sdk<2025,>=2024.12.5; extra == 'all'
56
56
  Requires-Dist: flask>=2.0; extra == 'all'
57
- Requires-Dist: fpdf>=1.7.2; extra == 'all'
58
57
  Requires-Dist: google-api-python-client==2.166.0; extra == 'all'
59
58
  Requires-Dist: google-auth-httplib2==0.2.0; extra == 'all'
60
59
  Requires-Dist: google-auth-oauthlib==1.2.1; extra == 'all'
@@ -99,6 +98,7 @@ Requires-Dist: pygithub<3,>=2.6.0; extra == 'all'
99
98
  Requires-Dist: pylatex>=1.4.2; extra == 'all'
100
99
  Requires-Dist: pymilvus<3,>=2.4.0; extra == 'all'
101
100
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'all'
101
+ Requires-Dist: pymupdf>=1.26.1; extra == 'all'
102
102
  Requires-Dist: pyobvector>=0.1.18; extra == 'all'
103
103
  Requires-Dist: pyowm<4,>=3.3.0; extra == 'all'
104
104
  Requires-Dist: pytelegrambotapi<5,>=4.18.0; extra == 'all'
@@ -206,7 +206,6 @@ Requires-Dist: chunkr-ai>=0.0.50; extra == 'document-tools'
206
206
  Requires-Dist: crawl4ai>=0.3.745; extra == 'document-tools'
207
207
  Requires-Dist: docx2txt<0.9,>=0.8; extra == 'document-tools'
208
208
  Requires-Dist: docx>=0.2.4; extra == 'document-tools'
209
- Requires-Dist: fpdf>=1.7.2; extra == 'document-tools'
210
209
  Requires-Dist: markitdown==0.1.1; extra == 'document-tools'
211
210
  Requires-Dist: numpy<=2.2,>=1.2; extra == 'document-tools'
212
211
  Requires-Dist: openapi-spec-validator<0.8,>=0.7.1; extra == 'document-tools'
@@ -215,6 +214,7 @@ Requires-Dist: pandasai<3,>=2.3.0; extra == 'document-tools'
215
214
  Requires-Dist: prance<24,>=23.6.21.0; extra == 'document-tools'
216
215
  Requires-Dist: pylatex>=1.4.2; extra == 'document-tools'
217
216
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'document-tools'
217
+ Requires-Dist: pymupdf>=1.26.1; extra == 'document-tools'
218
218
  Requires-Dist: python-pptx>=1.0.2; extra == 'document-tools'
219
219
  Requires-Dist: tabulate>=0.9.0; extra == 'document-tools'
220
220
  Requires-Dist: unstructured==0.16.20; extra == 'document-tools'
@@ -254,7 +254,6 @@ Requires-Dist: docx>=0.2.4; extra == 'owl'
254
254
  Requires-Dist: duckduckgo-search<7,>=6.3.5; extra == 'owl'
255
255
  Requires-Dist: e2b-code-interpreter<2,>=1.0.3; extra == 'owl'
256
256
  Requires-Dist: ffmpeg-python<0.3,>=0.2.0; extra == 'owl'
257
- Requires-Dist: fpdf>=1.7.2; extra == 'owl'
258
257
  Requires-Dist: html2text>=2024.2.26; extra == 'owl'
259
258
  Requires-Dist: imageio[pyav]<3,>=2.34.2; extra == 'owl'
260
259
  Requires-Dist: markitdown==0.1.1; extra == 'owl'
@@ -272,6 +271,7 @@ Requires-Dist: pyautogui<0.10,>=0.9.54; extra == 'owl'
272
271
  Requires-Dist: pydub<0.26,>=0.25.1; extra == 'owl'
273
272
  Requires-Dist: pylatex>=1.4.2; extra == 'owl'
274
273
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'owl'
274
+ Requires-Dist: pymupdf>=1.26.1; extra == 'owl'
275
275
  Requires-Dist: pytesseract>=0.3.13; extra == 'owl'
276
276
  Requires-Dist: python-dotenv<2,>=1.0.0; extra == 'owl'
277
277
  Requires-Dist: python-pptx>=1.0.2; extra == 'owl'