mojentic 0.5.7__py3-none-any.whl → 0.6.0__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.
@@ -21,7 +21,8 @@ async def demonstrate_async():
21
21
  3. Running multiple problem-solving tasks concurrently
22
22
  """
23
23
  # Initialize the LLM broker with your preferred model
24
- llm = LLMBroker(model="llama3.3-70b-32k")
24
+ # llm = LLMBroker(model="llama3.3-70b-32k")
25
+ llm = LLMBroker(model="qwen3:30b-a3b-q4_K_M")
25
26
 
26
27
  # Create the agent with a maximum of 3 iterations
27
28
  agent = SimpleRecursiveAgent(llm=llm, max_iterations=3)
@@ -0,0 +1,109 @@
1
+ """
2
+ Example script demonstrating the tracer system with ChatSession and tools.
3
+
4
+ This example shows how to use the tracer system to monitor an interactive
5
+ chat session with LLMBroker and tools. When the user exits the session,
6
+ the script displays a summary of all traced events.
7
+ """
8
+ from datetime import datetime
9
+
10
+ from mojentic.tracer import TracerSystem
11
+ from mojentic.tracer.tracer_events import LLMCallTracerEvent, LLMResponseTracerEvent, ToolCallTracerEvent
12
+ from mojentic.llm import ChatSession, LLMBroker
13
+ from mojentic.llm.tools.date_resolver import ResolveDateTool
14
+
15
+
16
+ def print_tracer_events(events):
17
+ """Print tracer events using their printable_summary method."""
18
+ print(f"\n{'-'*80}")
19
+ print("Tracer Events:")
20
+ print(f"{'-'*80}")
21
+
22
+ for i, event in enumerate(events, 1):
23
+ print(f"{i}. {event.printable_summary()}")
24
+ print()
25
+
26
+
27
+ def main():
28
+ """Run a chat session with tracer system to monitor interactions."""
29
+ # Create a tracer system to monitor all interactions
30
+ tracer = TracerSystem()
31
+
32
+ # Create an LLM broker with the tracer
33
+ llm_broker = LLMBroker(model="llama3.3-70b-32k", tracer=tracer)
34
+
35
+ # Create a date resolver tool that will also use the tracer
36
+ date_tool = ResolveDateTool(llm_broker=llm_broker, tracer=tracer)
37
+
38
+ # Create a chat session with the broker and tool
39
+ chat_session = ChatSession(llm_broker, tools=[date_tool])
40
+
41
+ print("Welcome to the chat session with tracer demonstration!")
42
+ print("Ask questions about dates (e.g., 'What day is next Friday?') or anything else.")
43
+ print("Behind the scenes, the tracer system is recording all interactions.")
44
+ print("Press Enter with no input to exit and see the trace summary.")
45
+ print("-" * 80)
46
+
47
+ # Interactive chat session
48
+ while True:
49
+ query = input("You: ")
50
+ if not query:
51
+ print("Exiting chat session...")
52
+ break
53
+ else:
54
+ print("Assistant: ", end="")
55
+ response = chat_session.send(query)
56
+ print(response)
57
+
58
+ # After the user exits, display tracer event summary
59
+ print("\nTracer System Summary")
60
+ print("=" * 80)
61
+ print(f"You just had a conversation with an LLM, and the tracer recorded everything!")
62
+
63
+ # Get all events
64
+ all_events = tracer.get_events()
65
+ print(f"Total events recorded: {len(all_events)}")
66
+ print_tracer_events(all_events)
67
+
68
+ # Show how to filter events by type
69
+ print("\nYou can filter events by type:")
70
+
71
+ llm_calls = tracer.get_events(event_type=LLMCallTracerEvent)
72
+ print(f"LLM Call Events: {len(llm_calls)}")
73
+ if llm_calls:
74
+ print(f"Example: {llm_calls[0].printable_summary()}")
75
+
76
+ llm_responses = tracer.get_events(event_type=LLMResponseTracerEvent)
77
+ print(f"LLM Response Events: {len(llm_responses)}")
78
+ if llm_responses:
79
+ print(f"Example: {llm_responses[0].printable_summary()}")
80
+
81
+ tool_calls = tracer.get_events(event_type=ToolCallTracerEvent)
82
+ print(f"Tool Call Events: {len(tool_calls)}")
83
+ if tool_calls:
84
+ print(f"Example: {tool_calls[0].printable_summary()}")
85
+
86
+ # Show the last few events
87
+ print("\nThe last few events:")
88
+ last_events = tracer.get_last_n_tracer_events(3)
89
+ print_tracer_events(last_events)
90
+
91
+ # Show how to use time-based filtering
92
+ print("\nYou can also filter events by time range:")
93
+ print("Example: tracer.get_events(start_time=start_timestamp, end_time=end_timestamp)")
94
+
95
+ # Show how to extract specific information from events
96
+ if tool_calls:
97
+ print("\nDetailed analysis example - Tool usage stats:")
98
+ tool_names = {}
99
+ for event in tool_calls:
100
+ tool_name = event.tool_name
101
+ tool_names[tool_name] = tool_names.get(tool_name, 0) + 1
102
+
103
+ print("Tool usage frequency:")
104
+ for tool_name, count in tool_names.items():
105
+ print(f" - {tool_name}: {count} calls")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
mojentic/dispatcher.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import threading
3
3
  from time import sleep
4
+ from typing import Optional, Type
4
5
  from uuid import uuid4
5
6
 
6
7
  import structlog
@@ -11,12 +12,16 @@ logger = structlog.get_logger()
11
12
 
12
13
 
13
14
  class Dispatcher:
14
- def __init__(self, router, shared_working_memory=None, batch_size=5):
15
+ def __init__(self, router, shared_working_memory=None, batch_size=5, tracer=None):
15
16
  self.router = router
16
17
  self.batch_size = batch_size
17
18
  self.event_queue = []
18
19
  self._stop_event = threading.Event()
19
20
  self._thread = threading.Thread(target=self._dispatch_events)
21
+
22
+ # Use null_tracer if no tracer is provided
23
+ from mojentic.tracer import null_tracer
24
+ self.tracer = tracer or null_tracer
20
25
 
21
26
  logger.debug("Starting event dispatch thread")
22
27
  self._thread.start()
@@ -44,6 +49,17 @@ class Dispatcher:
44
49
  events = []
45
50
  for agent in agents:
46
51
  logger.debug(f"Sending event to agent {agent}")
52
+
53
+ # Record agent interaction in tracer system
54
+ self.tracer.record_agent_interaction(
55
+ from_agent=str(event.source),
56
+ to_agent=str(type(agent)),
57
+ event_type=str(type(event).__name__),
58
+ event_id=event.correlation_id,
59
+ source=type(self)
60
+ )
61
+
62
+ # Process the event through the agent
47
63
  received_events = agent.receive_event(event)
48
64
  logger.debug(f"Agent {agent} returned {len(events)} events")
49
65
  events.extend(received_events)
@@ -92,13 +92,13 @@ class DescribeOpenAIMessagesAdapter:
92
92
  When adapting to OpenAI format
93
93
  Then it should convert to the correct format with structured content array
94
94
  """
95
- # Mock the open function to avoid reading actual files
96
- mock_file = mocker.mock_open(read_data=b'fake_image_data')
97
- mocker.patch('builtins.open', mock_file)
98
-
99
- # Mock base64 encoding to return a predictable value
100
- mock_b64encode = mocker.patch('base64.b64encode')
101
- mock_b64encode.return_value = b'ZmFrZV9pbWFnZV9kYXRhX2VuY29kZWQ=' # 'fake_image_data_encoded' in base64
95
+ # Patch our own methods that encapsulate external library calls
96
+ mocker.patch('mojentic.llm.gateways.openai_messages_adapter.read_file_as_binary',
97
+ return_value=b'fake_image_data')
98
+ mocker.patch('mojentic.llm.gateways.openai_messages_adapter.encode_base64',
99
+ return_value='ZmFrZV9pbWFnZV9kYXRhX2VuY29kZWQ=')
100
+ mocker.patch('mojentic.llm.gateways.openai_messages_adapter.get_image_type',
101
+ side_effect=lambda path: 'jpg' if path.endswith('.jpg') else 'png')
102
102
 
103
103
  image_paths = ["/path/to/image1.jpg", "/path/to/image2.png"]
104
104
  messages = [LLMMessage(role=MessageRole.User, content="What's in these images?", image_paths=image_paths)]
@@ -10,6 +10,53 @@ from mojentic.llm.gateways.models import LLMMessage, MessageRole
10
10
  logger = structlog.get_logger()
11
11
 
12
12
 
13
+ def read_file_as_binary(file_path: str) -> bytes:
14
+ """Read a file as binary data.
15
+
16
+ This function encapsulates the external library call to open() so it can be mocked in tests.
17
+
18
+ Args:
19
+ file_path: Path to the file to read
20
+
21
+ Returns:
22
+ Binary content of the file
23
+ """
24
+ with open(file_path, "rb") as file:
25
+ return file.read()
26
+
27
+
28
+ def encode_base64(data: bytes) -> str:
29
+ """Encode binary data as base64 string.
30
+
31
+ This function encapsulates the external library call to base64.b64encode() so it can be mocked in tests.
32
+
33
+ Args:
34
+ data: Binary data to encode
35
+
36
+ Returns:
37
+ Base64-encoded string
38
+ """
39
+ return base64.b64encode(data).decode('utf-8')
40
+
41
+
42
+ def get_image_type(file_path: str) -> str:
43
+ """Determine image type from file extension.
44
+
45
+ This function encapsulates the external library call to os.path.splitext() so it can be mocked in tests.
46
+
47
+ Args:
48
+ file_path: Path to the image file
49
+
50
+ Returns:
51
+ Image type (e.g., 'jpg', 'png')
52
+ """
53
+ _, ext = os.path.splitext(file_path)
54
+ image_type = ext.lstrip('.').lower()
55
+ if image_type not in ['jpeg', 'jpg', 'png', 'gif', 'webp']:
56
+ image_type = 'jpeg' # Default to jpeg if unknown extension
57
+ return image_type
58
+
59
+
13
60
  def adapt_messages_to_openai(messages: List[LLMMessage]):
14
61
  new_messages: List[dict[str, Any]] = []
15
62
  for m in messages:
@@ -25,21 +72,17 @@ def adapt_messages_to_openai(messages: List[LLMMessage]):
25
72
  # Add each image as a base64-encoded URL
26
73
  for image_path in m.image_paths:
27
74
  try:
28
- with open(image_path, "rb") as image_file:
29
- base64_image = base64.b64encode(image_file.read()).decode('utf-8')
30
-
31
- # Determine image type from file extension
32
- _, ext = os.path.splitext(image_path)
33
- image_type = ext.lstrip('.').lower()
34
- if image_type not in ['jpeg', 'jpg', 'png', 'gif', 'webp']:
35
- image_type = 'jpeg' # Default to jpeg if unknown extension
36
-
37
- content.append({
38
- "type": "image_url",
39
- "image_url": {
40
- "url": f"data:image/{image_type};base64,{base64_image}"
41
- }
42
- })
75
+ # Use our encapsulated methods instead of direct library calls
76
+ binary_data = read_file_as_binary(image_path)
77
+ base64_image = encode_base64(binary_data)
78
+ image_type = get_image_type(image_path)
79
+
80
+ content.append({
81
+ "type": "image_url",
82
+ "image_url": {
83
+ "url": f"data:image/{image_type};base64,{base64_image}"
84
+ }
85
+ })
43
86
  except Exception as e:
44
87
  logger.error("Failed to encode image", error=str(e), image_path=image_path)
45
88
 
@@ -1,9 +1,11 @@
1
1
  import json
2
+ import time
2
3
  from typing import List, Optional, Type
3
4
 
4
5
  import structlog
5
6
  from pydantic import BaseModel
6
7
 
8
+ from mojentic.tracer.tracer_system import TracerSystem
7
9
  from mojentic.llm.gateways.llm_gateway import LLMGateway
8
10
  from mojentic.llm.gateways.models import MessageRole, LLMMessage, LLMGatewayResponse
9
11
  from mojentic.llm.gateways.ollama import OllamaGateway
@@ -21,8 +23,10 @@ class LLMBroker():
21
23
  adapter: LLMGateway
22
24
  tokenizer: TokenizerGateway
23
25
  model: str
26
+ tracer: Optional[TracerSystem]
24
27
 
25
- def __init__(self, model: str, gateway: Optional[LLMGateway] = None, tokenizer: Optional[TokenizerGateway] = None):
28
+ def __init__(self, model: str, gateway: Optional[LLMGateway] = None, tokenizer: Optional[TokenizerGateway] = None,
29
+ tracer: Optional[TracerSystem] = None):
26
30
  """
27
31
  Create an instance of the LLMBroker.
28
32
 
@@ -36,8 +40,15 @@ class LLMBroker():
36
40
  tokenizer
37
41
  The gateway to use for tokenization. This is used to log approximate token counts for the LLM calls. If
38
42
  None, `mxbai-embed-large` is used on a local Ollama server.
43
+ tracer
44
+ Optional tracer system to record LLM calls and responses.
39
45
  """
40
46
  self.model = model
47
+
48
+ # Use null_tracer if no tracer is provided
49
+ from mojentic.tracer import null_tracer
50
+ self.tracer = tracer or null_tracer
51
+
41
52
  if tokenizer is None:
42
53
  self.tokenizer = TokenizerGateway()
43
54
  else:
@@ -72,7 +83,23 @@ class LLMBroker():
72
83
  """
73
84
  approximate_tokens = len(self.tokenizer.encode(self._content_to_count(messages)))
74
85
  logger.info(f"Requesting llm response with approx {approximate_tokens} tokens")
75
-
86
+
87
+ # Convert messages to serializable dict for audit
88
+ messages_for_tracer = [m.dict() for m in messages]
89
+
90
+ # Record LLM call in tracer
91
+ tools_for_tracer = [{"name": t.name, "description": t.description} for t in tools] if tools else None
92
+ self.tracer.record_llm_call(
93
+ self.model,
94
+ messages_for_tracer,
95
+ temperature,
96
+ tools=tools_for_tracer,
97
+ source=type(self)
98
+ )
99
+
100
+ # Measure call duration for audit
101
+ start_time = time.time()
102
+
76
103
  result: LLMGatewayResponse = self.adapter.complete(
77
104
  model=self.model,
78
105
  messages=messages,
@@ -80,6 +107,18 @@ class LLMBroker():
80
107
  temperature=temperature,
81
108
  num_ctx=num_ctx,
82
109
  num_predict=num_predict)
110
+
111
+ call_duration_ms = (time.time() - start_time) * 1000
112
+
113
+ # Record LLM response in tracer
114
+ tool_calls_for_tracer = [tc.dict() for tc in result.tool_calls] if result.tool_calls else None
115
+ self.tracer.record_llm_response(
116
+ self.model,
117
+ result.content,
118
+ tool_calls=tool_calls_for_tracer,
119
+ call_duration_ms=call_duration_ms,
120
+ source=type(self)
121
+ )
83
122
 
84
123
  if result.tool_calls and tools is not None:
85
124
  logger.info("Tool call requested")
@@ -89,7 +128,22 @@ class LLMBroker():
89
128
  None):
90
129
  logger.info('Calling function', function=tool_call.name)
91
130
  logger.info('Arguments:', arguments=tool_call.arguments)
131
+
132
+ # Get the arguments before calling the tool
133
+ tool_arguments = tool_call.arguments
134
+
135
+ # Call the tool
92
136
  output = tool.run(**tool_call.arguments)
137
+
138
+ # Record tool call in tracer
139
+ self.tracer.record_tool_call(
140
+ tool_call.name,
141
+ tool_arguments,
142
+ output,
143
+ caller="LLMBroker",
144
+ source=type(self)
145
+ )
146
+
93
147
  logger.info('Function output', output=output)
94
148
  messages.append(LLMMessage(role=MessageRole.Assistant, tool_calls=[tool_call]))
95
149
  messages.append(
@@ -135,6 +189,35 @@ class LLMBroker():
135
189
  """
136
190
  approximate_tokens = len(self.tokenizer.encode(self._content_to_count(messages)))
137
191
  logger.info(f"Requesting llm response with approx {approximate_tokens} tokens")
192
+
193
+ # Convert messages to serializable dict for audit
194
+ messages_for_tracer = [m.dict() for m in messages]
195
+
196
+ # Record LLM call in tracer
197
+ self.tracer.record_llm_call(
198
+ self.model,
199
+ messages_for_tracer,
200
+ temperature,
201
+ tools=None,
202
+ source=type(self)
203
+ )
204
+
205
+ # Measure call duration for audit
206
+ start_time = time.time()
207
+
138
208
  result = self.adapter.complete(model=self.model, messages=messages, object_model=object_model,
139
209
  temperature=temperature, num_ctx=num_ctx, num_predict=num_predict)
210
+
211
+ call_duration_ms = (time.time() - start_time) * 1000
212
+
213
+ # Record LLM response in tracer with object representation
214
+ # Convert object to string for tracer
215
+ object_str = str(result.object.dict()) if hasattr(result.object, "dict") else str(result.object)
216
+ self.tracer.record_llm_response(
217
+ self.model,
218
+ f"Structured response: {object_str}",
219
+ call_duration_ms=call_duration_ms,
220
+ source=type(self)
221
+ )
222
+
140
223
  return result.object
@@ -176,39 +176,57 @@ class DescribeMessageBuilder:
176
176
  assert image_path2 in message_builder.image_paths
177
177
  assert result is message_builder # Returns self for method chaining
178
178
 
179
- def should_add_all_jpg_images_from_directory(self, message_builder, mocker):
180
- dir_path = Path("/path/to/images")
181
- jpg_files = [Path("/path/to/images/image1.jpg"), Path("/path/to/images/image2.jpg")]
182
-
183
- # Mock is_dir, glob, and exists methods
184
- mocker.patch.object(Path, 'is_dir', return_value=True)
185
- mocker.patch.object(Path, 'glob', return_value=jpg_files)
186
- mocker.patch.object(Path, 'exists', return_value=True)
187
-
179
+ def should_add_all_jpg_images_from_directory(self, message_builder, mocker, tmp_path):
180
+ # Create a temporary directory with test image files
181
+ dir_path = tmp_path / "images"
182
+ dir_path.mkdir()
183
+
184
+ # Create empty test image files
185
+ image1_path = dir_path / "image1.jpg"
186
+ image2_path = dir_path / "image2.jpg"
187
+ image1_path.touch()
188
+ image2_path.touch()
189
+
190
+ # Create a text file that should be ignored
191
+ text_file_path = dir_path / "text.txt"
192
+ text_file_path.touch()
193
+
194
+ # Use the real directory for the test
188
195
  message_builder.add_images(dir_path)
189
196
 
190
- assert jpg_files[0] in message_builder.image_paths
191
- assert jpg_files[1] in message_builder.image_paths
197
+ # Convert to strings for easier comparison
198
+ image_paths_str = [str(p) for p in message_builder.image_paths]
192
199
 
193
- def should_add_images_matching_glob_pattern(self, message_builder, mocker):
194
- pattern_path = Path("/path/to/*.jpg")
195
- matching_files = [Path("/path/to/image1.jpg"), Path("/path/to/image2.jpg")]
200
+ # Verify only jpg files were added
201
+ assert str(image1_path) in image_paths_str
202
+ assert str(image2_path) in image_paths_str
203
+ assert str(text_file_path) not in image_paths_str
196
204
 
197
- # Mock methods
198
- mocker.patch.object(Path, 'is_dir', return_value=False)
199
- mocker.patch.object(Path, 'glob', return_value=matching_files)
200
- mocker.patch.object(Path, 'is_file', return_value=True)
201
- mocker.patch.object(Path, 'exists', return_value=True)
205
+ def should_add_images_matching_glob_pattern(self, message_builder, tmp_path):
206
+ # Create a temporary directory with test image files
207
+ dir_path = tmp_path / "glob_test"
208
+ dir_path.mkdir()
202
209
 
203
- # Mock the parent property and its glob method
204
- parent_mock = mocker.MagicMock()
205
- parent_mock.glob.return_value = matching_files
206
- mocker.patch.object(Path, 'parent', parent_mock)
210
+ # Create test image files
211
+ image1_path = dir_path / "image1.jpg"
212
+ image2_path = dir_path / "image2.jpg"
213
+ image3_path = dir_path / "other.png" # Should not match
214
+ image1_path.touch()
215
+ image2_path.touch()
216
+ image3_path.touch()
217
+
218
+ # Use a real glob pattern
219
+ pattern_path = dir_path / "*.jpg"
207
220
 
208
221
  message_builder.add_images(pattern_path)
209
222
 
210
- assert matching_files[0] in message_builder.image_paths
211
- assert matching_files[1] in message_builder.image_paths
223
+ # Convert to strings for easier comparison
224
+ image_paths_str = [str(p) for p in message_builder.image_paths]
225
+
226
+ # Verify only jpg files matching the pattern were added
227
+ assert str(image1_path) in image_paths_str
228
+ assert str(image2_path) in image_paths_str
229
+ assert str(image3_path) not in image_paths_str
212
230
 
213
231
  class DescribeLoadContentMethod:
214
232
  """
@@ -1,14 +1,40 @@
1
1
  import json
2
+ from typing import Any, Dict, Optional
2
3
 
4
+ from mojentic.tracer.tracer_system import TracerSystem
3
5
  from mojentic.llm.gateways.models import TextContent
4
6
 
5
7
 
6
8
  class LLMTool:
9
+ def __init__(self, tracer: Optional[TracerSystem] = None):
10
+ """
11
+ Initialize an LLM tool with optional tracer system.
12
+
13
+ Parameters
14
+ ----------
15
+ tracer : TracerSystem, optional
16
+ The tracer system to use for recording tool usage.
17
+ """
18
+ # Use null_tracer if no tracer is provided
19
+ from mojentic.tracer import null_tracer
20
+ self.tracer = tracer or null_tracer
21
+
7
22
  def run(self, **kwargs):
8
23
  raise NotImplementedError
9
24
 
10
25
  def call_tool(self, **kwargs):
26
+ # Execute the tool and capture the result
11
27
  result = self.run(**kwargs)
28
+
29
+ # Record the tool call in the tracer system (always safe to call with null_tracer)
30
+ self.tracer.record_tool_call(
31
+ tool_name=self.name,
32
+ arguments=kwargs,
33
+ result=result,
34
+ source=type(self)
35
+ )
36
+
37
+ # Format the result
12
38
  if isinstance(result, dict):
13
39
  result = json.dumps(result)
14
40
  return {
@@ -10,7 +10,8 @@ from mojentic.llm.tools.llm_tool import LLMTool
10
10
  class MockLLMTool(LLMTool):
11
11
  """A mock implementation of LLMTool for testing purposes."""
12
12
 
13
- def __init__(self, run_result=None):
13
+ def __init__(self, run_result=None, tracer=None):
14
+ super().__init__(tracer=tracer)
14
15
  self._run_result = run_result
15
16
  self._descriptor = {
16
17
  "function": {
@@ -0,0 +1,16 @@
1
+
2
+ """
3
+ Mojentic tracer module for tracking system operations.
4
+ """
5
+ from mojentic.tracer.tracer_events import (
6
+ TracerEvent,
7
+ LLMCallTracerEvent,
8
+ LLMResponseTracerEvent,
9
+ ToolCallTracerEvent,
10
+ AgentInteractionTracerEvent
11
+ )
12
+ from mojentic.tracer.tracer_system import TracerSystem
13
+ from mojentic.tracer.null_tracer import NullTracer
14
+
15
+ # Create a singleton NullTracer instance for use throughout the application
16
+ null_tracer = NullTracer()