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.
- _examples/recursive_agent.py +4 -3
- _examples/tracer_demo.py +170 -0
- mojentic/agents/simple_recursive_agent.py +20 -21
- mojentic/dispatcher.py +17 -1
- mojentic/llm/gateways/openai_message_adapter_spec.py +7 -7
- mojentic/llm/gateways/openai_messages_adapter.py +58 -15
- mojentic/llm/llm_broker.py +97 -4
- mojentic/llm/message_composers_spec.py +43 -25
- mojentic/llm/tools/llm_tool.py +28 -1
- mojentic/llm/tools/llm_tool_spec.py +2 -1
- mojentic/tracer/__init__.py +16 -0
- mojentic/tracer/event_store.py +111 -0
- mojentic/tracer/event_store_spec.py +210 -0
- mojentic/tracer/null_tracer.py +203 -0
- mojentic/tracer/tracer_events.py +138 -0
- mojentic/tracer/tracer_events_spec.py +116 -0
- mojentic/tracer/tracer_system.py +301 -0
- mojentic/tracer/tracer_system_spec.py +266 -0
- {mojentic-0.5.7.dist-info → mojentic-0.6.1.dist-info}/METADATA +20 -10
- {mojentic-0.5.7.dist-info → mojentic-0.6.1.dist-info}/RECORD +23 -16
- {mojentic-0.5.7.dist-info → mojentic-0.6.1.dist-info}/WHEEL +1 -1
- mojentic/audit/event_store.py +0 -6
- mojentic/audit/event_store_spec.py +0 -26
- {mojentic-0.5.7.dist-info → mojentic-0.6.1.dist-info}/licenses/LICENSE.md +0 -0
- {mojentic-0.5.7.dist-info → mojentic-0.6.1.dist-info}/top_level.txt +0 -0
_examples/recursive_agent.py
CHANGED
|
@@ -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
|
|
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(
|
|
56
|
+
unsubscribe_solved = agent.emitter.subscribe(GoalAchievedEvent, on_problem_solved)
|
|
56
57
|
|
|
57
58
|
# Solve the problem
|
|
58
59
|
solution2 = await agent.solve(problem2)
|
_examples/tracer_demo.py
ADDED
|
@@ -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.
|
|
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
|
|
16
|
+
class GoalState(BaseModel):
|
|
18
17
|
"""
|
|
19
18
|
Represents the state of a problem-solving process.
|
|
20
19
|
"""
|
|
21
|
-
|
|
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:
|
|
31
|
+
state: GoalState
|
|
33
32
|
|
|
34
33
|
|
|
35
|
-
class
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
201
|
-
self.emitter.subscribe(
|
|
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(
|
|
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:
|
|
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 :
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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 :
|
|
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.
|
|
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
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
mojentic/llm/llm_broker.py
CHANGED
|
@@ -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
|
|
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
|