janito 2.3.0__py3-none-any.whl → 2.4.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.
Files changed (150) hide show
  1. janito/__init__.py +6 -6
  2. janito/_version.py +57 -0
  3. janito/agent/setup_agent.py +92 -18
  4. janito/agent/templates/profiles/system_prompt_template_developer.txt.j2 +44 -0
  5. janito/cli/chat_mode/bindings.py +21 -2
  6. janito/cli/chat_mode/chat_entry.py +2 -3
  7. janito/cli/chat_mode/prompt_style.py +5 -0
  8. janito/cli/chat_mode/session.py +80 -94
  9. janito/cli/chat_mode/session_profile_select.py +80 -0
  10. janito/cli/chat_mode/shell/autocomplete.py +21 -21
  11. janito/cli/chat_mode/shell/commands/__init__.py +13 -7
  12. janito/cli/chat_mode/shell/commands/_priv_check.py +5 -0
  13. janito/cli/chat_mode/shell/commands/clear.py +12 -12
  14. janito/cli/chat_mode/shell/commands/conversation_restart.py +30 -0
  15. janito/cli/chat_mode/shell/commands/execute.py +42 -0
  16. janito/cli/chat_mode/shell/commands/help.py +6 -3
  17. janito/cli/chat_mode/shell/commands/model.py +28 -0
  18. janito/cli/chat_mode/shell/commands/multi.py +51 -51
  19. janito/cli/chat_mode/shell/commands/read.py +37 -0
  20. janito/cli/chat_mode/shell/commands/tools.py +45 -18
  21. janito/cli/chat_mode/shell/commands/write.py +37 -0
  22. janito/cli/chat_mode/shell/commands.bak.zip +0 -0
  23. janito/cli/chat_mode/shell/input_history.py +62 -62
  24. janito/cli/chat_mode/shell/session.bak.zip +0 -0
  25. janito/cli/chat_mode/toolbar.py +44 -27
  26. janito/cli/cli_commands/list_models.py +35 -35
  27. janito/cli/cli_commands/list_providers.py +9 -9
  28. janito/cli/cli_commands/list_tools.py +86 -53
  29. janito/cli/cli_commands/model_selection.py +50 -50
  30. janito/cli/cli_commands/set_api_key.py +19 -19
  31. janito/cli/cli_commands/show_config.py +51 -51
  32. janito/cli/cli_commands/show_system_prompt.py +105 -62
  33. janito/cli/config.py +5 -6
  34. janito/cli/core/__init__.py +4 -4
  35. janito/cli/core/event_logger.py +59 -59
  36. janito/cli/core/runner.py +25 -18
  37. janito/cli/core/setters.py +10 -1
  38. janito/cli/core/unsetters.py +54 -54
  39. janito/cli/main_cli.py +28 -5
  40. janito/cli/prompt_core.py +18 -2
  41. janito/cli/prompt_setup.py +56 -0
  42. janito/cli/single_shot_mode/__init__.py +6 -6
  43. janito/cli/single_shot_mode/handler.py +14 -73
  44. janito/cli/verbose_output.py +1 -1
  45. janito/config.py +5 -5
  46. janito/config_manager.py +13 -0
  47. janito/drivers/anthropic/driver.py +113 -113
  48. janito/drivers/dashscope.bak.zip +0 -0
  49. janito/drivers/openai/README.md +20 -0
  50. janito/drivers/openai_responses.bak.zip +0 -0
  51. janito/event_bus/event.py +2 -2
  52. janito/formatting_token.py +54 -54
  53. janito/i18n/__init__.py +35 -35
  54. janito/i18n/messages.py +23 -23
  55. janito/i18n/pt.py +46 -47
  56. janito/llm/README.md +23 -0
  57. janito/llm/__init__.py +5 -5
  58. janito/llm/agent.py +507 -443
  59. janito/llm/driver.py +8 -0
  60. janito/llm/driver_config_builder.py +34 -34
  61. janito/llm/driver_input.py +12 -12
  62. janito/llm/message_parts.py +60 -60
  63. janito/llm/model.py +38 -38
  64. janito/llm/provider.py +196 -196
  65. janito/provider_registry.py +8 -6
  66. janito/providers/anthropic/model_info.py +22 -22
  67. janito/providers/anthropic/provider.py +2 -0
  68. janito/providers/azure_openai/provider.py +3 -0
  69. janito/providers/dashscope.bak.zip +0 -0
  70. janito/providers/deepseek/__init__.py +1 -1
  71. janito/providers/deepseek/model_info.py +16 -16
  72. janito/providers/deepseek/provider.py +94 -91
  73. janito/providers/google/provider.py +3 -0
  74. janito/providers/mistralai/provider.py +3 -0
  75. janito/providers/openai/provider.py +4 -0
  76. janito/providers/registry.py +26 -26
  77. janito/shell.bak.zip +0 -0
  78. janito/tools/DOCSTRING_STANDARD.txt +33 -0
  79. janito/tools/README.md +3 -0
  80. janito/tools/__init__.py +20 -6
  81. janito/tools/adapters/__init__.py +1 -1
  82. janito/tools/adapters/local/__init__.py +65 -62
  83. janito/tools/adapters/local/adapter.py +18 -35
  84. janito/tools/adapters/local/ask_user.py +101 -102
  85. janito/tools/adapters/local/copy_file.py +84 -84
  86. janito/tools/adapters/local/create_directory.py +69 -69
  87. janito/tools/adapters/local/create_file.py +82 -82
  88. janito/tools/adapters/local/delete_text_in_file.py +2 -2
  89. janito/tools/adapters/local/fetch_url.py +97 -97
  90. janito/tools/adapters/local/find_files.py +139 -138
  91. janito/tools/adapters/local/get_file_outline/__init__.py +1 -1
  92. janito/tools/adapters/local/get_file_outline/core.py +117 -117
  93. janito/tools/adapters/local/get_file_outline/java_outline.py +40 -40
  94. janito/tools/adapters/local/get_file_outline/markdown_outline.py +14 -14
  95. janito/tools/adapters/local/get_file_outline/python_outline.py +303 -303
  96. janito/tools/adapters/local/get_file_outline/python_outline_v2.py +156 -156
  97. janito/tools/adapters/local/get_file_outline/search_outline.py +33 -33
  98. janito/tools/adapters/local/move_file.py +2 -2
  99. janito/tools/adapters/local/open_html_in_browser.py +2 -1
  100. janito/tools/adapters/local/open_url.py +2 -2
  101. janito/tools/adapters/local/python_code_run.py +166 -166
  102. janito/tools/adapters/local/python_command_run.py +164 -164
  103. janito/tools/adapters/local/python_file_run.py +163 -163
  104. janito/tools/adapters/local/remove_directory.py +2 -2
  105. janito/tools/adapters/local/remove_file.py +2 -2
  106. janito/tools/adapters/local/replace_text_in_file.py +2 -2
  107. janito/tools/adapters/local/run_bash_command.py +176 -176
  108. janito/tools/adapters/local/run_powershell_command.py +219 -219
  109. janito/tools/adapters/local/search_text/__init__.py +1 -1
  110. janito/tools/adapters/local/search_text/core.py +201 -201
  111. janito/tools/adapters/local/search_text/pattern_utils.py +73 -73
  112. janito/tools/adapters/local/search_text/traverse_directory.py +145 -145
  113. janito/tools/adapters/local/validate_file_syntax/__init__.py +1 -1
  114. janito/tools/adapters/local/validate_file_syntax/core.py +106 -106
  115. janito/tools/adapters/local/validate_file_syntax/css_validator.py +35 -35
  116. janito/tools/adapters/local/validate_file_syntax/html_validator.py +93 -93
  117. janito/tools/adapters/local/validate_file_syntax/js_validator.py +27 -27
  118. janito/tools/adapters/local/validate_file_syntax/json_validator.py +6 -6
  119. janito/tools/adapters/local/validate_file_syntax/markdown_validator.py +109 -109
  120. janito/tools/adapters/local/validate_file_syntax/ps1_validator.py +32 -32
  121. janito/tools/adapters/local/validate_file_syntax/python_validator.py +5 -5
  122. janito/tools/adapters/local/validate_file_syntax/xml_validator.py +11 -11
  123. janito/tools/adapters/local/validate_file_syntax/yaml_validator.py +6 -6
  124. janito/tools/adapters/local/view_file.py +168 -167
  125. janito/tools/inspect_registry.py +17 -17
  126. janito/tools/outline_file.bak.zip +0 -0
  127. janito/tools/permissions.py +45 -0
  128. janito/tools/permissions_parse.py +12 -0
  129. janito/tools/tool_base.py +118 -105
  130. janito/tools/tool_events.py +58 -58
  131. janito/tools/tool_run_exception.py +12 -12
  132. janito/tools/tool_use_tracker.py +81 -81
  133. janito/tools/tool_utils.py +43 -45
  134. janito/tools/tools_adapter.py +25 -20
  135. janito/tools/tools_schema.py +104 -104
  136. {janito-2.3.0.dist-info → janito-2.4.0.dist-info}/METADATA +425 -388
  137. janito-2.4.0.dist-info/RECORD +195 -0
  138. janito/agent/templates/profiles/system_prompt_template_base_pt.txt.j2 +0 -13
  139. janito/agent/templates/profiles/system_prompt_template_main.txt.j2 +0 -37
  140. janito/cli/chat_mode/shell/commands/edit.py +0 -25
  141. janito/cli/chat_mode/shell/commands/exec.py +0 -27
  142. janito/cli/chat_mode/shell/commands/termweb_log.py +0 -92
  143. janito/cli/termweb_starter.py +0 -122
  144. janito/termweb/app.py +0 -95
  145. janito/version.py +0 -4
  146. janito-2.3.0.dist-info/RECORD +0 -181
  147. {janito-2.3.0.dist-info → janito-2.4.0.dist-info}/WHEEL +0 -0
  148. {janito-2.3.0.dist-info → janito-2.4.0.dist-info}/entry_points.txt +0 -0
  149. {janito-2.3.0.dist-info → janito-2.4.0.dist-info}/licenses/LICENSE +0 -0
  150. {janito-2.3.0.dist-info → janito-2.4.0.dist-info}/top_level.txt +0 -0
janito/llm/agent.py CHANGED
@@ -1,443 +1,507 @@
1
- from janito.llm.driver_input import DriverInput
2
- from janito.llm.driver_config import LLMDriverConfig
3
- from janito.conversation_history import LLMConversationHistory
4
- from janito.tools.tools_adapter import ToolsAdapterBase
5
- from queue import Queue, Empty
6
- from janito.driver_events import RequestStatus
7
- from typing import Any, Optional, List, Iterator, Union
8
- import threading
9
- import logging
10
- from jinja2 import Environment, FileSystemLoader, select_autoescape
11
- from pathlib import Path
12
- import time
13
- from janito.event_bus.bus import event_bus
14
-
15
-
16
- class LLMAgent:
17
- _event_lock: threading.Lock
18
- _latest_event: Optional[str]
19
-
20
- @property
21
- def template_vars(self):
22
- if not hasattr(self, "_template_vars"):
23
- self._template_vars = {}
24
- return self._template_vars
25
-
26
- """
27
- Represents an agent that interacts with an LLM driver to generate responses.
28
- Maintains conversation history as required by the new driver interface.
29
- """
30
-
31
- def __init__(
32
- self,
33
- llm_provider,
34
- tools_adapter: ToolsAdapterBase,
35
- agent_name: Optional[str] = None,
36
- system_prompt: Optional[str] = None,
37
- temperature: Optional[float] = None,
38
- conversation_history: Optional[LLMConversationHistory] = None,
39
- input_queue: Queue = None,
40
- output_queue: Queue = None,
41
- verbose_agent: bool = False,
42
- **kwargs: Any,
43
- ):
44
- self.llm_provider = llm_provider
45
- self.tools_adapter = tools_adapter
46
- self.agent_name = agent_name
47
- self.system_prompt = system_prompt
48
- self.temperature = temperature
49
- self.conversation_history = conversation_history or LLMConversationHistory()
50
- self.input_queue = input_queue if input_queue is not None else Queue()
51
- self.output_queue = output_queue if output_queue is not None else Queue()
52
- self._event_lock = threading.Lock()
53
- self._latest_event = None
54
- self.verbose_agent = verbose_agent
55
- self.driver = None # Will be set by setup_agent if available
56
-
57
- def get_provider_name(self):
58
- # Try to get provider name from driver, fallback to llm_provider, else '?'
59
- if self.driver and hasattr(self.driver, "name"):
60
- return self.driver.name
61
- elif hasattr(self.llm_provider, "name"):
62
- return self.llm_provider.name
63
- return "?"
64
-
65
- def get_model_name(self):
66
- # Try to get model name from driver, fallback to llm_provider, else '?'
67
- if self.driver and hasattr(self.driver, "model_name"):
68
- return self.driver.model_name
69
- elif hasattr(self.llm_provider, "model_name"):
70
- return self.llm_provider.model_name
71
- return "?"
72
-
73
- def set_template_var(self, key: str, value: str) -> None:
74
- """Set a variable for system prompt templating."""
75
- if not hasattr(self, "_template_vars"):
76
- self._template_vars = {}
77
- self._template_vars[key] = value
78
-
79
- def set_system_prompt(self, prompt: str) -> None:
80
- self.system_prompt = prompt
81
-
82
- def set_system_using_template(self, template_path: str, **kwargs) -> None:
83
- env = Environment(
84
- loader=FileSystemLoader(Path(template_path).parent),
85
- autoescape=select_autoescape(),
86
- )
87
- template = env.get_template(Path(template_path).name)
88
- self.system_prompt = template.render(**kwargs)
89
-
90
- def _refresh_system_prompt_from_template(self):
91
- if hasattr(self, "_template_vars") and hasattr(self, "system_prompt_template"):
92
- env = Environment(
93
- loader=FileSystemLoader(Path(self.system_prompt_template).parent),
94
- autoescape=select_autoescape(),
95
- )
96
- template = env.get_template(Path(self.system_prompt_template).name)
97
- self.system_prompt = template.render(**self._template_vars)
98
-
99
- def get_system_prompt(self) -> str:
100
- return self.system_prompt
101
-
102
- def _add_prompt_to_history(self, prompt_or_messages, role):
103
- if isinstance(prompt_or_messages, str):
104
- self.conversation_history.add_message(role, prompt_or_messages)
105
- elif isinstance(prompt_or_messages, list):
106
- for msg in prompt_or_messages:
107
- self.conversation_history.add_message(
108
- msg.get("role", role), msg.get("content", "")
109
- )
110
-
111
- def _ensure_system_prompt(self):
112
- if self.system_prompt and (
113
- not self.conversation_history._history
114
- or self.conversation_history._history[0]["role"] != "system"
115
- ):
116
- self.conversation_history._history.insert(
117
- 0, {"role": "system", "content": self.system_prompt}
118
- )
119
-
120
- def _validate_and_update_history(
121
- self,
122
- prompt: str = None,
123
- messages: Optional[List[dict]] = None,
124
- role: str = "user",
125
- ):
126
- if prompt is None and not messages:
127
- raise ValueError(
128
- "Either prompt or messages must be provided to Agent.chat."
129
- )
130
- if prompt is not None:
131
- self._add_prompt_to_history(prompt, role)
132
- elif messages:
133
- self._add_prompt_to_history(messages, role)
134
-
135
- def _log_event_verbose(self, event):
136
- if getattr(self, "verbose_agent", False):
137
- if hasattr(event, "parts"):
138
- for i, part in enumerate(getattr(event, "parts", [])):
139
- pass # Add detailed logging here if needed
140
- else:
141
- pass # Add detailed logging here if needed
142
-
143
- def _handle_event_type(self, event):
144
- event_class = getattr(event, "__class__", None)
145
- if event_class is not None and event_class.__name__ == "ResponseReceived":
146
- added_tool_results = self._handle_response_received(event)
147
- return event, added_tool_results
148
- # For all other events (including RequestFinished with status='error', RequestStarted), do not exit loop
149
- return None, False
150
-
151
- def _prepare_driver_input(self, config, cancel_event=None):
152
- return DriverInput(
153
- config=config,
154
- conversation_history=self.conversation_history,
155
- cancel_event=cancel_event,
156
- )
157
-
158
- def _process_next_response(
159
- self, poll_timeout: float = 1.0, max_wait_time: float = 300.0
160
- ):
161
- """
162
- Wait for a single event from the output queue (with timeout), process it, and return the result.
163
- This function is intended to be called from the main agent loop, which controls the overall flow.
164
- """
165
- if getattr(self, "verbose_agent", False):
166
- print("[agent] [DEBUG] Entered _process_next_response")
167
- elapsed = 0.0
168
- try:
169
- if getattr(self, "verbose_agent", False):
170
- print("[agent] [DEBUG] Waiting for event from output_queue...")
171
- return self._poll_for_event(poll_timeout, max_wait_time)
172
- except KeyboardInterrupt:
173
- self._handle_keyboard_interrupt()
174
- return None, False
175
-
176
- def _poll_for_event(self, poll_timeout, max_wait_time):
177
- elapsed = 0.0
178
- while True:
179
- event = self._get_event_from_output_queue(poll_timeout)
180
- if event is None:
181
- elapsed += poll_timeout
182
- if elapsed >= max_wait_time:
183
- error_msg = f"[ERROR] No output from driver in agent.chat() after {max_wait_time} seconds (timeout exit)"
184
- print(error_msg)
185
- print("[DEBUG] Exiting _process_next_response due to timeout")
186
- return None, False
187
- continue
188
- if getattr(self, "verbose_agent", False):
189
- print(f"[agent] [DEBUG] Received event from output_queue: {event}")
190
- event_bus.publish(event)
191
- self._log_event_verbose(event)
192
- event_class = getattr(event, "__class__", None)
193
- event_name = event_class.__name__ if event_class else None
194
- if event_name == "ResponseReceived":
195
- result = self._handle_event_type(event)
196
- return result
197
- elif event_name == "RequestFinished" and getattr(event, "status", None) in [
198
- RequestStatus.ERROR,
199
- RequestStatus.EMPTY_RESPONSE,
200
- RequestStatus.TIMEOUT,
201
- ]:
202
- return (event, False)
203
-
204
- def _handle_keyboard_interrupt(self):
205
- if hasattr(self, "input_queue") and self.input_queue is not None:
206
- from janito.driver_events import RequestFinished
207
-
208
- cancel_event = RequestFinished(
209
- status=RequestStatus.CANCELLED,
210
- reason="User interrupted (KeyboardInterrupt)",
211
- )
212
- self.input_queue.put(cancel_event)
213
-
214
- def _get_event_from_output_queue(self, poll_timeout):
215
- try:
216
- return self.output_queue.get(timeout=poll_timeout)
217
- except Empty:
218
- return None
219
-
220
- def _handle_response_received(self, event) -> bool:
221
- """
222
- Handle a ResponseReceived event: execute tool calls if present, update history.
223
- Returns True if the agent loop should continue (tool calls found), False otherwise.
224
- """
225
- if getattr(self, "verbose_agent", False):
226
- print("[agent] [INFO] Handling ResponseReceived event.")
227
- from janito.llm.message_parts import FunctionCallMessagePart
228
-
229
- tool_calls = []
230
- tool_results = []
231
- for part in event.parts:
232
- if isinstance(part, FunctionCallMessagePart):
233
- if getattr(self, "verbose_agent", False):
234
- print(
235
- f"[agent] [DEBUG] Tool call detected: {getattr(part, 'name', repr(part))} with arguments: {getattr(part, 'arguments', None)}"
236
- )
237
- tool_calls.append(part)
238
- result = self.tools_adapter.execute_function_call_message_part(part)
239
- tool_results.append(result)
240
- if tool_calls:
241
- # Prepare tool_calls message for assistant
242
- tool_calls_list = []
243
- tool_results_list = []
244
- for call, result in zip(tool_calls, tool_results):
245
- function_name = (
246
- getattr(call, "name", None)
247
- or (
248
- getattr(call, "function", None)
249
- and getattr(call.function, "name", None)
250
- )
251
- or "function"
252
- )
253
- arguments = getattr(call, "function", None) and getattr(
254
- call.function, "arguments", None
255
- )
256
- tool_call_id = getattr(call, "tool_call_id", None)
257
- tool_calls_list.append(
258
- {
259
- "id": tool_call_id,
260
- "type": "function",
261
- "function": {
262
- "name": function_name,
263
- "arguments": (
264
- arguments
265
- if isinstance(arguments, str)
266
- else str(arguments) if arguments else ""
267
- ),
268
- },
269
- }
270
- )
271
- tool_results_list.append(
272
- {
273
- "name": function_name,
274
- "content": str(result),
275
- "tool_call_id": tool_call_id,
276
- }
277
- )
278
- # Add assistant tool_calls message
279
- import json
280
-
281
- self.conversation_history.add_message(
282
- "tool_calls", json.dumps(tool_calls_list)
283
- )
284
- # Add tool_results message
285
- self.conversation_history.add_message(
286
- "tool_results", json.dumps(tool_results_list)
287
- )
288
- return True # Continue the loop
289
- else:
290
- return False # No tool calls, return event
291
-
292
- def chat(
293
- self,
294
- prompt: str = None,
295
- messages: Optional[List[dict]] = None,
296
- role: str = "user",
297
- config=None,
298
- ):
299
- if (
300
- hasattr(self, "driver")
301
- and self.driver
302
- and hasattr(self.driver, "clear_output_queue")
303
- ):
304
- self.driver.clear_output_queue()
305
- """
306
- Main agent conversation loop supporting function/tool calls and conversation history extension, now as a blocking event-driven loop with event publishing.
307
-
308
- Args:
309
- prompt: The user prompt as a string (optional if messages is provided).
310
- messages: A list of message dicts (optional if prompt is provided).
311
- role: The role for the prompt (default: 'user').
312
- config: Optional driver config (defaults to provider config).
313
-
314
- Returns:
315
- The final ResponseReceived event (or error event) when the conversation is complete.
316
- """
317
- self._validate_and_update_history(prompt, messages, role)
318
- self._ensure_system_prompt()
319
- if config is None:
320
- config = self.llm_provider.driver_config
321
- loop_count = 1
322
- import threading
323
-
324
- cancel_event = threading.Event()
325
- while True:
326
- self._print_verbose_chat_loop(loop_count)
327
- driver_input = self._prepare_driver_input(config, cancel_event=cancel_event)
328
- self.input_queue.put(driver_input)
329
- try:
330
- result, added_tool_results = self._process_next_response()
331
- except KeyboardInterrupt:
332
- cancel_event.set()
333
- raise
334
- if getattr(self, "verbose_agent", False):
335
- print(
336
- f"[agent] [DEBUG] Returned from _process_next_response: result={result}, added_tool_results={added_tool_results}"
337
- )
338
- if result is None:
339
- if getattr(self, "verbose_agent", False):
340
- print(
341
- f"[agent] [INFO] Exiting chat loop: _process_next_response returned None result (likely timeout or error). Returning (None, False)."
342
- )
343
- return None, False
344
- if not added_tool_results:
345
- if getattr(self, "verbose_agent", False):
346
- print(
347
- f"[agent] [INFO] Exiting chat loop: _process_next_response returned added_tool_results=False (final response or no more tool calls). Returning result: {result}"
348
- )
349
- return result
350
- loop_count += 1
351
-
352
- def _print_verbose_chat_loop(self, loop_count):
353
- if getattr(self, "verbose_agent", False):
354
- print(
355
- f"[agent] [DEBUG] Preparing new driver_input (loop_count={loop_count}) with updated conversation history:"
356
- )
357
- for msg in self.conversation_history.get_history():
358
- print(" ", msg)
359
-
360
- def set_latest_event(self, event: str) -> None:
361
- with self._event_lock:
362
- self._latest_event = event
363
-
364
- def get_latest_event(self) -> Optional[str]:
365
- with self._event_lock:
366
- return self._latest_event
367
-
368
- def get_history(self) -> LLMConversationHistory:
369
- """Get the agent's interaction history."""
370
- return self.conversation_history
371
-
372
- def reset_conversation_history(self) -> None:
373
- """Reset/clear the interaction history."""
374
- self.conversation_history = LLMConversationHistory()
375
-
376
- def get_provider_name(self) -> str:
377
- """Return the provider name, if available."""
378
- if hasattr(self.llm_provider, "name"):
379
- return getattr(self.llm_provider, "name", "?")
380
- if self.driver and hasattr(self.driver, "name"):
381
- return getattr(self.driver, "name", "?")
382
- return "?"
383
-
384
- def get_model_name(self) -> str:
385
- """Return the model name, if available."""
386
- if self.driver and hasattr(self.driver, "model_name"):
387
- return getattr(self.driver, "model_name", "?")
388
- return "?"
389
-
390
- def get_name(self) -> Optional[str]:
391
- return self.agent_name
392
-
393
- def get_provider_name(self) -> str:
394
- """
395
- Return the provider name for this agent, if available.
396
- """
397
- if hasattr(self, "llm_provider") and hasattr(self.llm_provider, "name"):
398
- return self.llm_provider.name
399
- if (
400
- hasattr(self, "driver")
401
- and self.driver
402
- and hasattr(self.driver, "provider_name")
403
- ):
404
- return self.driver.provider_name
405
- if hasattr(self, "driver") and self.driver and hasattr(self.driver, "name"):
406
- return self.driver.name
407
- return "?"
408
-
409
- def get_model_name(self) -> str:
410
- """
411
- Return the model name for this agent, if available.
412
- """
413
- if (
414
- hasattr(self, "driver")
415
- and self.driver
416
- and hasattr(self.driver, "model_name")
417
- ):
418
- return self.driver.model_name
419
- if hasattr(self, "llm_provider") and hasattr(self.llm_provider, "model_name"):
420
- return self.llm_provider.model_name
421
- return "?"
422
-
423
- def join_driver(self, timeout=None):
424
- """
425
- Wait for the driver's background thread to finish. Call this before exiting to avoid daemon thread shutdown errors.
426
- :param timeout: Optional timeout in seconds.
427
- Handles KeyboardInterrupt gracefully.
428
- """
429
- if (
430
- hasattr(self, "driver")
431
- and self.driver
432
- and hasattr(self.driver, "_thread")
433
- and self.driver._thread
434
- ):
435
- try:
436
- self.driver._thread.join(timeout)
437
- except KeyboardInterrupt:
438
- print(
439
- "\n[INFO] Interrupted by user during driver shutdown. Cleaning up..."
440
- )
441
- # Optionally, perform additional cleanup here
442
- # Do not re-raise to suppress traceback and exit gracefully
443
- return
1
+ from janito.llm.driver_input import DriverInput
2
+ from janito.llm.driver_config import LLMDriverConfig
3
+ from janito.conversation_history import LLMConversationHistory
4
+ from janito.tools.tools_adapter import ToolsAdapterBase
5
+ from queue import Queue, Empty
6
+ from janito.driver_events import RequestStatus
7
+ from typing import Any, Optional, List, Iterator, Union
8
+ import threading
9
+ import logging
10
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
11
+ from pathlib import Path
12
+ import time
13
+ from janito.event_bus.bus import event_bus
14
+
15
+
16
+ class LLMAgent:
17
+ _event_lock: threading.Lock
18
+ _latest_event: Optional[str]
19
+
20
+ @property
21
+ def template_vars(self):
22
+ if not hasattr(self, "_template_vars"):
23
+ self._template_vars = {}
24
+ return self._template_vars
25
+
26
+ """
27
+ Represents an agent that interacts with an LLM driver to generate responses.
28
+ Maintains conversation history as required by the new driver interface.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ llm_provider,
34
+ tools_adapter: ToolsAdapterBase,
35
+ agent_name: Optional[str] = None,
36
+ system_prompt: Optional[str] = None,
37
+ temperature: Optional[float] = None,
38
+ conversation_history: Optional[LLMConversationHistory] = None,
39
+ input_queue: Queue = None,
40
+ output_queue: Queue = None,
41
+ verbose_agent: bool = False,
42
+ **kwargs: Any,
43
+ ):
44
+ self.llm_provider = llm_provider
45
+ self.tools_adapter = tools_adapter
46
+ self.agent_name = agent_name
47
+ self.system_prompt = system_prompt
48
+ self.temperature = temperature
49
+ self.conversation_history = conversation_history or LLMConversationHistory()
50
+ self.input_queue = input_queue if input_queue is not None else Queue()
51
+ self.output_queue = output_queue if output_queue is not None else Queue()
52
+ self._event_lock = threading.Lock()
53
+ self._latest_event = None
54
+ self.verbose_agent = verbose_agent
55
+ self.driver = None # Will be set by setup_agent if available
56
+
57
+ def get_provider_name(self):
58
+ # Try to get provider name from driver, fallback to llm_provider, else '?'
59
+ if self.driver and hasattr(self.driver, "name"):
60
+ return self.driver.name
61
+ elif hasattr(self.llm_provider, "name"):
62
+ return self.llm_provider.name
63
+ return "?"
64
+
65
+ def get_model_name(self):
66
+ # Try to get model name from driver, fallback to llm_provider, else '?'
67
+ if self.driver and hasattr(self.driver, "model_name"):
68
+ return self.driver.model_name
69
+ elif hasattr(self.llm_provider, "model_name"):
70
+ return self.llm_provider.model_name
71
+ return "?"
72
+
73
+ def set_template_var(self, key: str, value: str) -> None:
74
+ """Set a variable for system prompt templating."""
75
+ if not hasattr(self, "_template_vars"):
76
+ self._template_vars = {}
77
+ self._template_vars[key] = value
78
+
79
+ def set_system_prompt(self, prompt: str) -> None:
80
+ self.system_prompt = prompt
81
+
82
+ def set_system_using_template(self, template_path: str, **kwargs) -> None:
83
+ env = Environment(
84
+ loader=FileSystemLoader(Path(template_path).parent),
85
+ autoescape=select_autoescape(),
86
+ )
87
+ template = env.get_template(Path(template_path).name)
88
+ self.system_prompt = template.render(**kwargs)
89
+
90
+ def _refresh_system_prompt_from_template(self):
91
+ if hasattr(self, "_template_vars") and hasattr(self, "system_prompt_template"):
92
+ env = Environment(
93
+ loader=FileSystemLoader(Path(self.system_prompt_template).parent),
94
+ autoescape=select_autoescape(),
95
+ )
96
+ template = env.get_template(Path(self.system_prompt_template).name)
97
+ # Refresh allowed_permissions in context before rendering
98
+ from janito.tools.permissions import get_global_allowed_permissions
99
+ from janito.tools.tool_base import ToolPermissions
100
+ perms = get_global_allowed_permissions()
101
+ if isinstance(perms, ToolPermissions):
102
+ perm_str = ""
103
+ if perms.read:
104
+ perm_str += "r"
105
+ if perms.write:
106
+ perm_str += "w"
107
+ if perms.execute:
108
+ perm_str += "x"
109
+ self._template_vars["allowed_permissions"] = perm_str or None
110
+ else:
111
+ self._template_vars["allowed_permissions"] = perms
112
+ self.system_prompt = template.render(**self._template_vars)
113
+
114
+ def get_system_prompt(self) -> str:
115
+ return self.system_prompt
116
+
117
+ def _add_prompt_to_history(self, prompt_or_messages, role):
118
+ if isinstance(prompt_or_messages, str):
119
+ self.conversation_history.add_message(role, prompt_or_messages)
120
+ elif isinstance(prompt_or_messages, list):
121
+ for msg in prompt_or_messages:
122
+ self.conversation_history.add_message(
123
+ msg.get("role", role), msg.get("content", "")
124
+ )
125
+
126
+ def _ensure_system_prompt(self):
127
+ if self.system_prompt and (
128
+ not self.conversation_history._history
129
+ or self.conversation_history._history[0]["role"] != "system"
130
+ ):
131
+ self.conversation_history._history.insert(
132
+ 0, {"role": "system", "content": self.system_prompt}
133
+ )
134
+
135
+ def _validate_and_update_history(
136
+ self,
137
+ prompt: str = None,
138
+ messages: Optional[List[dict]] = None,
139
+ role: str = "user",
140
+ ):
141
+ if prompt is None and not messages:
142
+ raise ValueError(
143
+ "Either prompt or messages must be provided to Agent.chat."
144
+ )
145
+ if prompt is not None:
146
+ self._add_prompt_to_history(prompt, role)
147
+ elif messages:
148
+ self._add_prompt_to_history(messages, role)
149
+
150
+ def _log_event_verbose(self, event):
151
+ if getattr(self, "verbose_agent", False):
152
+ if hasattr(event, "parts"):
153
+ for i, part in enumerate(getattr(event, "parts", [])):
154
+ pass # Add detailed logging here if needed
155
+ else:
156
+ pass # Add detailed logging here if needed
157
+
158
+ def _handle_event_type(self, event):
159
+ event_class = getattr(event, "__class__", None)
160
+ if event_class is not None and event_class.__name__ == "ResponseReceived":
161
+ added_tool_results = self._handle_response_received(event)
162
+ return event, added_tool_results
163
+ # For all other events (including RequestFinished with status='error', RequestStarted), do not exit loop
164
+ return None, False
165
+
166
+ def _prepare_driver_input(self, config, cancel_event=None):
167
+ return DriverInput(
168
+ config=config,
169
+ conversation_history=self.conversation_history,
170
+ cancel_event=cancel_event,
171
+ )
172
+
173
+ def _process_next_response(
174
+ self, poll_timeout: float = 1.0, max_wait_time: float = 300.0
175
+ ):
176
+ """
177
+ Wait for a single event from the output queue (with timeout), process it, and return the result.
178
+ This function is intended to be called from the main agent loop, which controls the overall flow.
179
+ """
180
+ if getattr(self, "verbose_agent", False):
181
+ print("[agent] [DEBUG] Entered _process_next_response")
182
+ elapsed = 0.0
183
+ if getattr(self, "verbose_agent", False):
184
+ print("[agent] [DEBUG] Waiting for event from output_queue...")
185
+ # Let KeyboardInterrupt propagate to caller
186
+ return self._poll_for_event(poll_timeout, max_wait_time)
187
+
188
+ def _poll_for_event(self, poll_timeout, max_wait_time):
189
+ elapsed = 0.0
190
+ while True:
191
+ event = self._get_event_from_output_queue(poll_timeout)
192
+ if event is None:
193
+ elapsed += poll_timeout
194
+ if elapsed >= max_wait_time:
195
+ error_msg = f"[ERROR] No output from driver in agent.chat() after {max_wait_time} seconds (timeout exit)"
196
+ print(error_msg)
197
+ print("[DEBUG] Exiting _process_next_response due to timeout")
198
+ return None, False
199
+ continue
200
+ if getattr(self, "verbose_agent", False):
201
+ print(f"[agent] [DEBUG] Received event from output_queue: {event}")
202
+ event_bus.publish(event)
203
+ self._log_event_verbose(event)
204
+ event_class = getattr(event, "__class__", None)
205
+ event_name = event_class.__name__ if event_class else None
206
+ if event_name == "ResponseReceived":
207
+ result = self._handle_event_type(event)
208
+ return result
209
+ elif event_name == "RequestFinished" and getattr(event, "status", None) in [
210
+ RequestStatus.ERROR,
211
+ RequestStatus.EMPTY_RESPONSE,
212
+ RequestStatus.TIMEOUT,
213
+ ]:
214
+ return (event, False)
215
+
216
+
217
+ def _get_event_from_output_queue(self, poll_timeout):
218
+ try:
219
+ return self.output_queue.get(timeout=poll_timeout)
220
+ except Empty:
221
+ return None
222
+
223
+ def _handle_response_received(self, event) -> bool:
224
+ """
225
+ Handle a ResponseReceived event: execute tool calls if present, update history.
226
+ Returns True if the agent loop should continue (tool calls found), False otherwise.
227
+ """
228
+ if getattr(self, "verbose_agent", False):
229
+ print("[agent] [INFO] Handling ResponseReceived event.")
230
+ from janito.llm.message_parts import FunctionCallMessagePart
231
+
232
+ tool_calls = []
233
+ tool_results = []
234
+ for part in event.parts:
235
+ if isinstance(part, FunctionCallMessagePart):
236
+ if getattr(self, "verbose_agent", False):
237
+ print(
238
+ f"[agent] [DEBUG] Tool call detected: {getattr(part, 'name', repr(part))} with arguments: {getattr(part, 'arguments', None)}"
239
+ )
240
+ tool_calls.append(part)
241
+ result = self.tools_adapter.execute_function_call_message_part(part)
242
+ tool_results.append(result)
243
+ if tool_calls:
244
+ # Prepare tool_calls message for assistant
245
+ tool_calls_list = []
246
+ tool_results_list = []
247
+ for call, result in zip(tool_calls, tool_results):
248
+ function_name = (
249
+ getattr(call, "name", None)
250
+ or (
251
+ getattr(call, "function", None)
252
+ and getattr(call.function, "name", None)
253
+ )
254
+ or "function"
255
+ )
256
+ arguments = getattr(call, "function", None) and getattr(
257
+ call.function, "arguments", None
258
+ )
259
+ tool_call_id = getattr(call, "tool_call_id", None)
260
+ tool_calls_list.append(
261
+ {
262
+ "id": tool_call_id,
263
+ "type": "function",
264
+ "function": {
265
+ "name": function_name,
266
+ "arguments": (
267
+ arguments
268
+ if isinstance(arguments, str)
269
+ else str(arguments) if arguments else ""
270
+ ),
271
+ },
272
+ }
273
+ )
274
+ tool_results_list.append(
275
+ {
276
+ "name": function_name,
277
+ "content": str(result),
278
+ "tool_call_id": tool_call_id,
279
+ }
280
+ )
281
+ # Add assistant tool_calls message
282
+ import json
283
+
284
+ self.conversation_history.add_message(
285
+ "tool_calls", json.dumps(tool_calls_list)
286
+ )
287
+ # Add tool_results message
288
+ self.conversation_history.add_message(
289
+ "tool_results", json.dumps(tool_results_list)
290
+ )
291
+ return True # Continue the loop
292
+ else:
293
+ return False # No tool calls, return event
294
+
295
+ def chat(
296
+ self,
297
+ prompt: str = None,
298
+ messages: Optional[List[dict]] = None,
299
+ role: str = "user",
300
+ config=None,
301
+ ):
302
+ if (
303
+ hasattr(self, "driver")
304
+ and self.driver
305
+ and hasattr(self.driver, "clear_output_queue")
306
+ ):
307
+ self.driver.clear_output_queue()
308
+ # Drain input queue before sending new messages
309
+ if (
310
+ hasattr(self, "driver")
311
+ and self.driver
312
+ and hasattr(self.driver, "clear_input_queue")
313
+ ):
314
+ self.driver.clear_input_queue()
315
+ """
316
+ Main agent conversation loop supporting function/tool calls and conversation history extension, now as a blocking event-driven loop with event publishing.
317
+
318
+ Args:
319
+ prompt: The user prompt as a string (optional if messages is provided).
320
+ messages: A list of message dicts (optional if prompt is provided).
321
+ role: The role for the prompt (default: 'user').
322
+ config: Optional driver config (defaults to provider config).
323
+
324
+ Returns:
325
+ The final ResponseReceived event (or error event) when the conversation is complete.
326
+ """
327
+ self._validate_and_update_history(prompt, messages, role)
328
+ self._ensure_system_prompt()
329
+ if config is None:
330
+ config = self.llm_provider.driver_config
331
+ loop_count = 1
332
+ import threading
333
+
334
+ cancel_event = threading.Event()
335
+ while True:
336
+ self._print_verbose_chat_loop(loop_count)
337
+ driver_input = self._prepare_driver_input(config, cancel_event=cancel_event)
338
+ self.input_queue.put(driver_input)
339
+ try:
340
+ result, added_tool_results = self._process_next_response()
341
+ except KeyboardInterrupt:
342
+ # Propagate the interrupt to the caller, but signal the driver to cancel first
343
+ cancel_event.set()
344
+ raise
345
+ if getattr(self, "verbose_agent", False):
346
+ print(
347
+ f"[agent] [DEBUG] Returned from _process_next_response: result={result}, added_tool_results={added_tool_results}"
348
+ )
349
+ if result is None:
350
+ if getattr(self, "verbose_agent", False):
351
+ print(
352
+ f"[agent] [INFO] Exiting chat loop: _process_next_response returned None result (likely timeout or error). Returning (None, False)."
353
+ )
354
+ return None, False
355
+ if not added_tool_results:
356
+ if getattr(self, "verbose_agent", False):
357
+ print(
358
+ f"[agent] [INFO] Exiting chat loop: _process_next_response returned added_tool_results=False (final response or no more tool calls). Returning result: {result}"
359
+ )
360
+ return result
361
+ loop_count += 1
362
+
363
+ def _print_verbose_chat_loop(self, loop_count):
364
+ if getattr(self, "verbose_agent", False):
365
+ print(
366
+ f"[agent] [DEBUG] Preparing new driver_input (loop_count={loop_count}) with updated conversation history:"
367
+ )
368
+ for msg in self.conversation_history.get_history():
369
+ print(" ", msg)
370
+
371
+ def set_latest_event(self, event: str) -> None:
372
+ with self._event_lock:
373
+ self._latest_event = event
374
+
375
+ def get_latest_event(self) -> Optional[str]:
376
+ with self._event_lock:
377
+ return self._latest_event
378
+
379
+ def get_history(self) -> LLMConversationHistory:
380
+ """Get the agent's interaction history."""
381
+ return self.conversation_history
382
+
383
+ def reset_conversation_history(self) -> None:
384
+ """Reset/clear the interaction history."""
385
+ self.conversation_history = LLMConversationHistory()
386
+
387
+ def get_provider_name(self) -> str:
388
+ """Return the provider name, if available."""
389
+ if hasattr(self.llm_provider, "name"):
390
+ return getattr(self.llm_provider, "name", "?")
391
+ if self.driver and hasattr(self.driver, "name"):
392
+ return getattr(self.driver, "name", "?")
393
+ return "?"
394
+
395
+ def get_model_name(self) -> str:
396
+ """Return the model name, if available."""
397
+ if self.driver and hasattr(self.driver, "model_name"):
398
+ return getattr(self.driver, "model_name", "?")
399
+ return "?"
400
+
401
+ def get_name(self) -> Optional[str]:
402
+ return self.agent_name
403
+
404
+ def get_provider_name(self) -> str:
405
+ """
406
+ Return the provider name for this agent, if available.
407
+ """
408
+ if hasattr(self, "llm_provider") and hasattr(self.llm_provider, "name"):
409
+ return self.llm_provider.name
410
+ if (
411
+ hasattr(self, "driver")
412
+ and self.driver
413
+ and hasattr(self.driver, "provider_name")
414
+ ):
415
+ return self.driver.provider_name
416
+ if hasattr(self, "driver") and self.driver and hasattr(self.driver, "name"):
417
+ return self.driver.name
418
+ return "?"
419
+
420
+ def get_model_name(self) -> str:
421
+ """
422
+ Return the model name for this agent, if available.
423
+ """
424
+ if (
425
+ hasattr(self, "driver")
426
+ and self.driver
427
+ and hasattr(self.driver, "model_name")
428
+ ):
429
+ return self.driver.model_name
430
+ if hasattr(self, "llm_provider") and hasattr(self.llm_provider, "model_name"):
431
+ return self.llm_provider.model_name
432
+ return "?"
433
+
434
+ def reset_driver_config_to_model_defaults(self, model_name: str):
435
+ """
436
+ Reset all driver config fields to the model's defaults for the current provider (overwriting any user customizations).
437
+ """
438
+ provider = self.llm_provider
439
+ # Find model spec
440
+ model_spec = None
441
+ if hasattr(provider, "MODEL_SPECS"):
442
+ model_spec = provider.MODEL_SPECS.get(model_name)
443
+ if not model_spec:
444
+ raise ValueError(f"Model '{model_name}' not found in provider MODEL_SPECS.")
445
+ # Overwrite all config fields with model defaults
446
+ config = getattr(provider, "driver_config", None)
447
+ if config is None:
448
+ return
449
+ config.model = model_name
450
+ # Standard fields, with safe conversion for int fields
451
+ def safe_int(val):
452
+ try:
453
+ if val is None or val == "N/A":
454
+ return None
455
+ return int(val)
456
+ except Exception:
457
+ return None
458
+ def safe_float(val):
459
+ try:
460
+ if val is None or val == "N/A":
461
+ return None
462
+ return float(val)
463
+ except Exception:
464
+ return None
465
+ config.temperature = safe_float(getattr(model_spec, "default_temp", None))
466
+ config.max_tokens = safe_int(getattr(model_spec, "max_response", None))
467
+ config.max_completion_tokens = safe_int(getattr(model_spec, "max_cot", None))
468
+ # Optionally reset other fields to None/defaults
469
+ config.top_p = None
470
+ config.presence_penalty = None
471
+ config.frequency_penalty = None
472
+ config.stop = None
473
+ config.reasoning_effort = None
474
+ # Update driver if present
475
+ if self.driver is not None:
476
+ if hasattr(self.driver, "model_name"):
477
+ self.driver.model_name = model_name
478
+ if hasattr(self.driver, "config"):
479
+ self.driver.config = config
480
+
481
+ def change_model(self, model_name: str):
482
+ """
483
+ Change the model for the agent's provider and driver config, and update the driver if present.
484
+ """
485
+ self.reset_driver_config_to_model_defaults(model_name)
486
+
487
+ def join_driver(self, timeout=None):
488
+ """
489
+ Wait for the driver's background thread to finish. Call this before exiting to avoid daemon thread shutdown errors.
490
+ :param timeout: Optional timeout in seconds.
491
+ Handles KeyboardInterrupt gracefully.
492
+ """
493
+ if (
494
+ hasattr(self, "driver")
495
+ and self.driver
496
+ and hasattr(self.driver, "_thread")
497
+ and self.driver._thread
498
+ ):
499
+ try:
500
+ self.driver._thread.join(timeout)
501
+ except KeyboardInterrupt:
502
+ print(
503
+ "\n[INFO] Interrupted by user during driver shutdown. Cleaning up..."
504
+ )
505
+ # Optionally, perform additional cleanup here
506
+ # Do not re-raise to suppress traceback and exit gracefully
507
+ return