mojentic 0.5.7__py3-none-any.whl → 0.6.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.
@@ -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)
@@ -41,7 +42,7 @@ async def demonstrate_async():
41
42
  print(f"\nProblem (With Event Handling): {problem2}")
42
43
 
43
44
  # Set up event handlers for monitoring the solution process
44
- from mojentic.agents.simple_recursive_agent import ProblemSolvedEvent, IterationCompletedEvent
45
+ from mojentic.agents.simple_recursive_agent import GoalAchievedEvent, IterationCompletedEvent
45
46
 
46
47
  # Define event handlers
47
48
  def on_iteration_completed(event):
@@ -52,7 +53,7 @@ async def demonstrate_async():
52
53
 
53
54
  # Subscribe to events
54
55
  unsubscribe_iteration = agent.emitter.subscribe(IterationCompletedEvent, on_iteration_completed)
55
- unsubscribe_solved = agent.emitter.subscribe(ProblemSolvedEvent, on_problem_solved)
56
+ unsubscribe_solved = agent.emitter.subscribe(GoalAchievedEvent, on_problem_solved)
56
57
 
57
58
  # Solve the problem
58
59
  solution2 = await agent.solve(problem2)
@@ -0,0 +1,170 @@
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
+ It also demonstrates how correlation_id is used to trace related events
9
+ across the system, allowing you to track the flow of a request from start to finish.
10
+ """
11
+ import uuid
12
+ from datetime import datetime
13
+
14
+ from mojentic.tracer import TracerSystem
15
+ from mojentic.tracer.tracer_events import LLMCallTracerEvent, LLMResponseTracerEvent, ToolCallTracerEvent
16
+ from mojentic.llm import ChatSession, LLMBroker
17
+ from mojentic.llm.gateways.models import LLMMessage, MessageRole
18
+ from mojentic.llm.tools.date_resolver import ResolveDateTool
19
+
20
+
21
+ def print_tracer_events(events):
22
+ """Print tracer events using their printable_summary method."""
23
+ print(f"\n{'-'*80}")
24
+ print("Tracer Events:")
25
+ print(f"{'-'*80}")
26
+
27
+ for i, event in enumerate(events, 1):
28
+ print(f"{i}. {event.printable_summary()}")
29
+ print()
30
+
31
+
32
+ def main():
33
+ """Run a chat session with tracer system to monitor interactions."""
34
+ # Create a tracer system to monitor all interactions
35
+ tracer = TracerSystem()
36
+
37
+ # Create an LLM broker with the tracer
38
+ llm_broker = LLMBroker(model="llama3.3-70b-32k", tracer=tracer)
39
+
40
+ # Create a date resolver tool that will also use the tracer
41
+ date_tool = ResolveDateTool(llm_broker=llm_broker, tracer=tracer)
42
+
43
+ # Create a chat session with the broker and tool
44
+ chat_session = ChatSession(llm_broker, tools=[date_tool])
45
+
46
+ # Dictionary to store correlation_ids for each conversation turn
47
+ # This allows us to track related events across the system
48
+ conversation_correlation_ids = {}
49
+
50
+ print("Welcome to the chat session with tracer demonstration!")
51
+ print("Ask questions about dates (e.g., 'What day is next Friday?') or anything else.")
52
+ print("Behind the scenes, the tracer system is recording all interactions.")
53
+ print("Each interaction is assigned a unique correlation_id to trace related events.")
54
+ print("Press Enter with no input to exit and see the trace summary.")
55
+ print("-" * 80)
56
+
57
+ # Interactive chat session
58
+ turn_counter = 0
59
+ while True:
60
+ query = input("You: ")
61
+ if not query:
62
+ print("Exiting chat session...")
63
+ break
64
+ else:
65
+ # Generate a unique correlation_id for this conversation turn
66
+ # In a real system, this would be passed from the initiating event
67
+ # to all downstream events to maintain the causal chain
68
+ correlation_id = str(uuid.uuid4())
69
+ turn_counter += 1
70
+ conversation_correlation_ids[turn_counter] = correlation_id
71
+
72
+ print(f"[Turn {turn_counter}, correlation_id: {correlation_id[:8]}...]")
73
+ print("Assistant: ", end="")
74
+
75
+ # For demonstration purposes, we'll use the chat_session normally
76
+ # In a production system, you would modify ChatSession to accept and use correlation_id
77
+ response = chat_session.send(query)
78
+
79
+ # Alternatively, you could use the LLMBroker directly with correlation_id:
80
+ # messages = [LLMMessage(role=MessageRole.User, content=query)]
81
+ # response = llm_broker.generate(messages, tools=[date_tool], correlation_id=correlation_id)
82
+
83
+ print(response)
84
+
85
+ # After the user exits, display tracer event summary
86
+ print("\nTracer System Summary")
87
+ print("=" * 80)
88
+ print(f"You just had a conversation with an LLM, and the tracer recorded everything!")
89
+
90
+ # Get all events
91
+ all_events = tracer.get_events()
92
+ print(f"Total events recorded: {len(all_events)}")
93
+ print_tracer_events(all_events)
94
+
95
+ # Show how to filter events by type
96
+ print("\nYou can filter events by type:")
97
+
98
+ llm_calls = tracer.get_events(event_type=LLMCallTracerEvent)
99
+ print(f"LLM Call Events: {len(llm_calls)}")
100
+ if llm_calls:
101
+ print(f"Example: {llm_calls[0].printable_summary()}")
102
+
103
+ llm_responses = tracer.get_events(event_type=LLMResponseTracerEvent)
104
+ print(f"LLM Response Events: {len(llm_responses)}")
105
+ if llm_responses:
106
+ print(f"Example: {llm_responses[0].printable_summary()}")
107
+
108
+ tool_calls = tracer.get_events(event_type=ToolCallTracerEvent)
109
+ print(f"Tool Call Events: {len(tool_calls)}")
110
+ if tool_calls:
111
+ print(f"Example: {tool_calls[0].printable_summary()}")
112
+
113
+ # Show the last few events
114
+ print("\nThe last few events:")
115
+ last_events = tracer.get_last_n_tracer_events(3)
116
+ print_tracer_events(last_events)
117
+
118
+ # Show how to use time-based filtering
119
+ print("\nYou can also filter events by time range:")
120
+ print("Example: tracer.get_events(start_time=start_timestamp, end_time=end_timestamp)")
121
+
122
+ # Demonstrate filtering events by correlation_id
123
+ print("\nFiltering events by correlation_id:")
124
+ print("This is a powerful feature that allows you to trace all events related to a specific request")
125
+
126
+ # If we have any conversation turns, show events for the first turn
127
+ if conversation_correlation_ids:
128
+ # Get the correlation_id for the first turn
129
+ first_turn_id = 1
130
+ first_correlation_id = conversation_correlation_ids.get(first_turn_id)
131
+
132
+ if first_correlation_id:
133
+ print(f"\nEvents for conversation turn {first_turn_id} (correlation_id: {first_correlation_id[:8]}...):")
134
+
135
+ # Define a filter function that checks the correlation_id
136
+ def filter_by_correlation_id(event):
137
+ return event.correlation_id == first_correlation_id
138
+
139
+ # Get all events with this correlation_id
140
+ related_events = tracer.get_events(filter_func=filter_by_correlation_id)
141
+
142
+ if related_events:
143
+ print(f"Found {len(related_events)} related events")
144
+ print_tracer_events(related_events)
145
+
146
+ # Show how this helps trace the flow of a request
147
+ print("\nThe correlation_id allows you to trace the complete flow of a request:")
148
+ print("1. From the initial LLM call")
149
+ print("2. To the LLM response")
150
+ print("3. To any tool calls triggered by the LLM")
151
+ print("4. And any subsequent LLM calls with the tool results")
152
+ print("\nThis creates a complete audit trail for debugging and observability.")
153
+ else:
154
+ print("No events found with this correlation_id. This is unexpected and may indicate an issue.")
155
+
156
+ # Show how to extract specific information from events
157
+ if tool_calls:
158
+ print("\nDetailed analysis example - Tool usage stats:")
159
+ tool_names = {}
160
+ for event in tool_calls:
161
+ tool_name = event.tool_name
162
+ tool_names[tool_name] = tool_names.get(tool_name, 0) + 1
163
+
164
+ print("Tool usage frequency:")
165
+ for tool_name, count in tool_names.items():
166
+ print(f" - {tool_name}: {count} calls")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
@@ -8,17 +8,16 @@ from typing import List, Optional
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
- from mojentic.llm.gateways.models import LLMMessage
11
+ from mojentic.llm.chat_session import ChatSession
12
12
  from mojentic.llm.llm_broker import LLMBroker
13
13
  from mojentic.llm.tools.llm_tool import LLMTool
14
- from mojentic.llm.chat_session import ChatSession
15
14
 
16
15
 
17
- class ProblemState(BaseModel):
16
+ class GoalState(BaseModel):
18
17
  """
19
18
  Represents the state of a problem-solving process.
20
19
  """
21
- problem: str
20
+ goal: str
22
21
  iteration: int = 0
23
22
  max_iterations: int = 5
24
23
  solution: Optional[str] = None
@@ -29,10 +28,10 @@ class SolverEvent(BaseModel):
29
28
  """
30
29
  Base class for solver events.
31
30
  """
32
- state: ProblemState
31
+ state: GoalState
33
32
 
34
33
 
35
- class ProblemSubmittedEvent(SolverEvent):
34
+ class GoalSubmittedEvent(SolverEvent):
36
35
  """
37
36
  Event triggered when a problem is submitted for solving.
38
37
  """
@@ -46,14 +45,14 @@ class IterationCompletedEvent(SolverEvent):
46
45
  response: str
47
46
 
48
47
 
49
- class ProblemSolvedEvent(SolverEvent):
48
+ class GoalAchievedEvent(SolverEvent):
50
49
  """
51
50
  Event triggered when a problem is solved.
52
51
  """
53
52
  pass
54
53
 
55
54
 
56
- class ProblemFailedEvent(SolverEvent):
55
+ class GoalFailedEvent(SolverEvent):
57
56
  """
58
57
  Event triggered when a problem cannot be solved.
59
58
  """
@@ -168,7 +167,7 @@ class SimpleRecursiveAgent:
168
167
  )
169
168
 
170
169
  # Set up event handlers
171
- self.emitter.subscribe(ProblemSubmittedEvent, self._handle_problem_submitted)
170
+ self.emitter.subscribe(GoalSubmittedEvent, self._handle_problem_submitted)
172
171
  self.emitter.subscribe(IterationCompletedEvent, self._handle_iteration_completed)
173
172
 
174
173
  async def solve(self, problem: str) -> str:
@@ -189,7 +188,7 @@ class SimpleRecursiveAgent:
189
188
  solution_future = asyncio.Future()
190
189
 
191
190
  # Create the initial problem state
192
- state = ProblemState(problem=problem, max_iterations=self.max_iterations)
191
+ state = GoalState(goal=problem, max_iterations=self.max_iterations)
193
192
 
194
193
  # Define handlers for completion events
195
194
  async def handle_solution_event(event):
@@ -197,12 +196,12 @@ class SimpleRecursiveAgent:
197
196
  solution_future.set_result(event.state.solution)
198
197
 
199
198
  # Subscribe to completion events
200
- self.emitter.subscribe(ProblemSolvedEvent, handle_solution_event)
201
- self.emitter.subscribe(ProblemFailedEvent, handle_solution_event)
199
+ self.emitter.subscribe(GoalAchievedEvent, handle_solution_event)
200
+ self.emitter.subscribe(GoalFailedEvent, handle_solution_event)
202
201
  self.emitter.subscribe(TimeoutEvent, handle_solution_event)
203
202
 
204
203
  # Start the solving process
205
- self.emitter.emit(ProblemSubmittedEvent(state=state))
204
+ self.emitter.emit(GoalSubmittedEvent(state=state))
206
205
 
207
206
  # Wait for the solution or timeout
208
207
  try:
@@ -215,13 +214,13 @@ class SimpleRecursiveAgent:
215
214
  self.emitter.emit(TimeoutEvent(state=state))
216
215
  return timeout_message
217
216
 
218
- async def _handle_problem_submitted(self, event: ProblemSubmittedEvent):
217
+ async def _handle_problem_submitted(self, event: GoalSubmittedEvent):
219
218
  """
220
219
  Handle a problem submitted event.
221
220
 
222
221
  Parameters
223
222
  ----------
224
- event : ProblemSubmittedEvent
223
+ event : GoalSubmittedEvent
225
224
  The problem submitted event to handle
226
225
  """
227
226
  # Start the first iteration
@@ -243,31 +242,31 @@ class SimpleRecursiveAgent:
243
242
  if "FAIL".lower() in response.lower():
244
243
  state.solution = f"Failed to solve after {state.iteration} iterations:\n{response}"
245
244
  state.is_complete = True
246
- self.emitter.emit(ProblemFailedEvent(state=state))
245
+ self.emitter.emit(GoalFailedEvent(state=state))
247
246
  return
248
247
  elif "DONE".lower() in response.lower():
249
248
  state.solution = response
250
249
  state.is_complete = True
251
- self.emitter.emit(ProblemSolvedEvent(state=state))
250
+ self.emitter.emit(GoalAchievedEvent(state=state))
252
251
  return
253
252
 
254
253
  # Check if we've reached the maximum number of iterations
255
254
  if state.iteration >= state.max_iterations:
256
255
  state.solution = f"Best solution after {state.max_iterations} iterations:\n{response}"
257
256
  state.is_complete = True
258
- self.emitter.emit(ProblemSolvedEvent(state=state))
257
+ self.emitter.emit(GoalAchievedEvent(state=state))
259
258
  return
260
259
 
261
260
  # If the problem is not solved and we haven't reached max_iterations, continue with next iteration
262
261
  await self._process_iteration(state)
263
262
 
264
- async def _process_iteration(self, state: ProblemState):
263
+ async def _process_iteration(self, state: GoalState):
265
264
  """
266
265
  Process a single iteration of the problem-solving process.
267
266
 
268
267
  Parameters
269
268
  ----------
270
- state : ProblemState
269
+ state : GoalState
271
270
  The current state of the problem-solving process
272
271
  """
273
272
  # Increment the iteration counter
@@ -276,7 +275,7 @@ class SimpleRecursiveAgent:
276
275
  # Create a prompt for the LLM
277
276
  prompt = f"""
278
277
  Given the user request:
279
- {state.problem}
278
+ {state.goal}
280
279
 
281
280
  Use the tools at your disposal to act on their request. You may wish to create a step-by-step plan for more complicated requests.
282
281
 
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:
@@ -47,7 +58,8 @@ class LLMBroker():
47
58
  else:
48
59
  self.adapter = gateway
49
60
 
50
- def generate(self, messages: List[LLMMessage], tools=None, temperature=1.0, num_ctx=32768, num_predict=-1) -> str:
61
+ def generate(self, messages: List[LLMMessage], tools=None, temperature=1.0, num_ctx=32768, num_predict=-1,
62
+ correlation_id: str = None) -> str:
51
63
  """
52
64
  Generate a text response from the LLM.
53
65
 
@@ -64,6 +76,8 @@ class LLMBroker():
64
76
  The number of context tokens to use. Defaults to 32768.
65
77
  num_predict : int
66
78
  The number of tokens to predict. Defaults to no limit.
79
+ correlation_id : str
80
+ UUID string that is copied from cause-to-affect for tracing events.
67
81
 
68
82
  Returns
69
83
  -------
@@ -73,6 +87,23 @@ class LLMBroker():
73
87
  approximate_tokens = len(self.tokenizer.encode(self._content_to_count(messages)))
74
88
  logger.info(f"Requesting llm response with approx {approximate_tokens} tokens")
75
89
 
90
+ # Convert messages to serializable dict for audit
91
+ messages_for_tracer = [m.dict() for m in messages]
92
+
93
+ # Record LLM call in tracer
94
+ tools_for_tracer = [{"name": t.name, "description": t.description} for t in tools] if tools else None
95
+ self.tracer.record_llm_call(
96
+ self.model,
97
+ messages_for_tracer,
98
+ temperature,
99
+ tools=tools_for_tracer,
100
+ source=type(self),
101
+ correlation_id=correlation_id
102
+ )
103
+
104
+ # Measure call duration for audit
105
+ start_time = time.time()
106
+
76
107
  result: LLMGatewayResponse = self.adapter.complete(
77
108
  model=self.model,
78
109
  messages=messages,
@@ -81,6 +112,19 @@ class LLMBroker():
81
112
  num_ctx=num_ctx,
82
113
  num_predict=num_predict)
83
114
 
115
+ call_duration_ms = (time.time() - start_time) * 1000
116
+
117
+ # Record LLM response in tracer
118
+ tool_calls_for_tracer = [tc.dict() for tc in result.tool_calls] if result.tool_calls else None
119
+ self.tracer.record_llm_response(
120
+ self.model,
121
+ result.content,
122
+ tool_calls=tool_calls_for_tracer,
123
+ call_duration_ms=call_duration_ms,
124
+ source=type(self),
125
+ correlation_id=correlation_id
126
+ )
127
+
84
128
  if result.tool_calls and tools is not None:
85
129
  logger.info("Tool call requested")
86
130
  for tool_call in result.tool_calls:
@@ -89,13 +133,29 @@ class LLMBroker():
89
133
  None):
90
134
  logger.info('Calling function', function=tool_call.name)
91
135
  logger.info('Arguments:', arguments=tool_call.arguments)
136
+
137
+ # Get the arguments before calling the tool
138
+ tool_arguments = tool_call.arguments
139
+
140
+ # Call the tool
92
141
  output = tool.run(**tool_call.arguments)
142
+
143
+ # Record tool call in tracer
144
+ self.tracer.record_tool_call(
145
+ tool_call.name,
146
+ tool_arguments,
147
+ output,
148
+ caller="LLMBroker",
149
+ source=type(self),
150
+ correlation_id=correlation_id
151
+ )
152
+
93
153
  logger.info('Function output', output=output)
94
154
  messages.append(LLMMessage(role=MessageRole.Assistant, tool_calls=[tool_call]))
95
155
  messages.append(
96
156
  LLMMessage(role=MessageRole.Tool, content=json.dumps(output), tool_calls=[tool_call]))
97
157
  # {'role': 'tool', 'content': str(output), 'name': tool_call.name, 'tool_call_id': tool_call.id})
98
- return self.generate(messages, tools, temperature, num_ctx, num_predict)
158
+ return self.generate(messages, tools, temperature, num_ctx, num_predict, correlation_id=correlation_id)
99
159
  else:
100
160
  logger.warn('Function not found', function=tool_call.name)
101
161
  logger.info('Expected usage of missing function', expected_usage=tool_call)
@@ -111,7 +171,7 @@ class LLMBroker():
111
171
  return content
112
172
 
113
173
  def generate_object(self, messages: List[LLMMessage], object_model: Type[BaseModel], temperature=1.0, num_ctx=32768,
114
- num_predict=-1) -> BaseModel:
174
+ num_predict=-1, correlation_id: str = None) -> BaseModel:
115
175
  """
116
176
  Generate a structured response from the LLM and return it as an object.
117
177
 
@@ -127,6 +187,8 @@ class LLMBroker():
127
187
  The number of context tokens to use. Defaults to 32768.
128
188
  num_predict : int
129
189
  The number of tokens to predict. Defaults to no limit.
190
+ correlation_id : str
191
+ UUID string that is copied from cause-to-affect for tracing events.
130
192
 
131
193
  Returns
132
194
  -------
@@ -135,6 +197,37 @@ class LLMBroker():
135
197
  """
136
198
  approximate_tokens = len(self.tokenizer.encode(self._content_to_count(messages)))
137
199
  logger.info(f"Requesting llm response with approx {approximate_tokens} tokens")
200
+
201
+ # Convert messages to serializable dict for audit
202
+ messages_for_tracer = [m.dict() for m in messages]
203
+
204
+ # Record LLM call in tracer
205
+ self.tracer.record_llm_call(
206
+ self.model,
207
+ messages_for_tracer,
208
+ temperature,
209
+ tools=None,
210
+ source=type(self),
211
+ correlation_id=correlation_id
212
+ )
213
+
214
+ # Measure call duration for audit
215
+ start_time = time.time()
216
+
138
217
  result = self.adapter.complete(model=self.model, messages=messages, object_model=object_model,
139
218
  temperature=temperature, num_ctx=num_ctx, num_predict=num_predict)
219
+
220
+ call_duration_ms = (time.time() - start_time) * 1000
221
+
222
+ # Record LLM response in tracer with object representation
223
+ # Convert object to string for tracer
224
+ object_str = str(result.object.dict()) if hasattr(result.object, "dict") else str(result.object)
225
+ self.tracer.record_llm_response(
226
+ self.model,
227
+ f"Structured response: {object_str}",
228
+ call_duration_ms=call_duration_ms,
229
+ source=type(self),
230
+ correlation_id=correlation_id
231
+ )
232
+
140
233
  return result.object