janito 1.2.1__py3-none-any.whl → 1.3.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 (43) hide show
  1. janito/__init__.py +1 -1
  2. janito/agent/agent.py +23 -10
  3. janito/agent/config.py +37 -9
  4. janito/agent/conversation.py +8 -0
  5. janito/agent/runtime_config.py +1 -0
  6. janito/agent/tool_handler.py +154 -52
  7. janito/agent/tools/__init__.py +8 -5
  8. janito/agent/tools/ask_user.py +2 -1
  9. janito/agent/tools/fetch_url.py +27 -35
  10. janito/agent/tools/file_ops.py +72 -67
  11. janito/agent/tools/find_files.py +47 -26
  12. janito/agent/tools/get_lines.py +58 -0
  13. janito/agent/tools/py_compile.py +26 -0
  14. janito/agent/tools/python_exec.py +47 -0
  15. janito/agent/tools/remove_directory.py +38 -0
  16. janito/agent/tools/replace_text_in_file.py +67 -0
  17. janito/agent/tools/run_bash_command.py +134 -0
  18. janito/agent/tools/search_files.py +52 -0
  19. janito/cli/_print_config.py +1 -1
  20. janito/cli/arg_parser.py +6 -1
  21. janito/cli/config_commands.py +56 -8
  22. janito/cli/runner.py +21 -9
  23. janito/cli_chat_shell/chat_loop.py +5 -3
  24. janito/cli_chat_shell/commands.py +34 -37
  25. janito/cli_chat_shell/config_shell.py +1 -1
  26. janito/cli_chat_shell/load_prompt.py +1 -1
  27. janito/cli_chat_shell/session_manager.py +11 -15
  28. janito/cli_chat_shell/ui.py +17 -8
  29. janito/render_prompt.py +3 -1
  30. janito/web/app.py +1 -1
  31. janito-1.3.0.dist-info/METADATA +142 -0
  32. janito-1.3.0.dist-info/RECORD +51 -0
  33. janito/agent/tools/bash_exec.py +0 -58
  34. janito/agent/tools/file_str_replace.py +0 -48
  35. janito/agent/tools/search_text.py +0 -41
  36. janito/agent/tools/view_file.py +0 -34
  37. janito/templates/system_instructions.j2 +0 -38
  38. janito-1.2.1.dist-info/METADATA +0 -85
  39. janito-1.2.1.dist-info/RECORD +0 -49
  40. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/WHEEL +0 -0
  41. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/entry_points.txt +0 -0
  42. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/licenses/LICENSE +0 -0
  43. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/top_level.txt +0 -0
janito/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.2.1"
1
+ __version__ = "1.3.0"
janito/agent/agent.py CHANGED
@@ -7,7 +7,7 @@ from janito.agent.conversation import ConversationHandler
7
7
  from janito.agent.tool_handler import ToolHandler
8
8
 
9
9
  class Agent:
10
- """LLM Agent capable of handling conversations and tool calls."""
10
+ """Agent capable of handling conversations and tool calls."""
11
11
 
12
12
  REFERER = "www.janito.dev"
13
13
  TITLE = "Janito"
@@ -19,10 +19,22 @@ class Agent:
19
19
  system_prompt: str | None = None,
20
20
  verbose_tools: bool = False,
21
21
  tool_handler = None,
22
- base_url: str = "https://openrouter.ai/api/v1"
22
+ base_url: str = "https://openrouter.ai/api/v1",
23
+ azure_openai_api_version: str = "2023-05-15",
24
+ use_azure_openai: bool = False
23
25
  ):
24
26
  """
25
- Initialize the Agent.
27
+ Initialize Agent,
28
+
29
+ Args:
30
+ api_key: API key for OpenAI-compatible service.
31
+ model: Model name to use.
32
+ system_prompt: Optional system prompt override.
33
+ verbose_tools: Enable verbose tool call logging.
34
+ tool_handler: Optional custom ToolHandler instance.
35
+ base_url: API base URL.
36
+ azure_openai_api_version: Azure OpenAI API version (default: "2023-05-15").
37
+ use_azure_openai: Whether to use Azure OpenAI client (default: False).
26
38
 
27
39
  Args:
28
40
  api_key: API key for OpenAI-compatible service.
@@ -35,12 +47,12 @@ class Agent:
35
47
  self.api_key = api_key
36
48
  self.model = model
37
49
  self.system_prompt = system_prompt
38
- if os.environ.get("USE_AZURE_OPENAI"):
50
+ if use_azure_openai:
39
51
  from openai import AzureOpenAI
40
52
  self.client = AzureOpenAI(
41
53
  api_key=api_key,
42
- azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
43
- api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2023-05-15"),
54
+ azure_endpoint=base_url,
55
+ api_version=azure_openai_api_version,
44
56
  )
45
57
  else:
46
58
  self.client = OpenAI(
@@ -64,7 +76,7 @@ class Agent:
64
76
  def usage_history(self):
65
77
  return self.conversation_handler.usage_history
66
78
 
67
- def chat(self, messages, on_content=None, on_tool_progress=None, verbose_response=False, spinner=False, max_tokens=None):
79
+ def chat(self, messages, on_content=None, on_tool_progress=None, verbose_response=False, spinner=False, max_tokens=None, max_rounds=50):
68
80
  import time
69
81
  from janito.agent.conversation import ProviderError
70
82
 
@@ -73,11 +85,12 @@ class Agent:
73
85
  try:
74
86
  return self.conversation_handler.handle_conversation(
75
87
  messages,
76
- max_tokens=max_tokens,
88
+ max_rounds=max_rounds,
77
89
  on_content=on_content,
78
90
  on_tool_progress=on_tool_progress,
79
91
  verbose_response=verbose_response,
80
- spinner=spinner
92
+ spinner=spinner,
93
+ max_tokens=max_tokens
81
94
  )
82
95
  except ProviderError as e:
83
96
  error_data = getattr(e, 'error_data', {}) or {}
@@ -96,5 +109,5 @@ class Agent:
96
109
  else:
97
110
  print("Max retries reached. Raising error.")
98
111
  raise
99
- except Exception:
112
+
100
113
  raise
janito/agent/config.py CHANGED
@@ -40,14 +40,12 @@ class FileConfig(BaseConfig):
40
40
 
41
41
  def load(self):
42
42
  if self.path.exists():
43
- try:
44
- with open(self.path, 'r') as f:
45
- self._data = json.load(f)
46
- # Remove keys with value None (null in JSON)
47
- self._data = {k: v for k, v in self._data.items() if v is not None}
48
- except Exception as e:
49
- print(f"Warning: Failed to load config file {self.path}: {e}")
50
- self._data = {}
43
+ with open(self.path, 'r') as f:
44
+ self._data = json.load(f)
45
+ # Remove keys with value None (null in JSON)
46
+ self._data = {k: v for k, v in self._data.items() if v is not None}
47
+
48
+
51
49
  else:
52
50
  self._data = {}
53
51
 
@@ -57,6 +55,7 @@ class FileConfig(BaseConfig):
57
55
  json.dump(self._data, f, indent=2)
58
56
 
59
57
 
58
+
60
59
  CONFIG_OPTIONS = {
61
60
  "api_key": "API key for OpenAI-compatible service (required)",
62
61
  "model": "Model name to use (e.g., 'openai/gpt-4.1')",
@@ -64,9 +63,38 @@ CONFIG_OPTIONS = {
64
63
  "role": "Role description for the system prompt (e.g., 'software engineer')",
65
64
  "system_prompt": "Override the entire system prompt text",
66
65
  "temperature": "Sampling temperature (float, e.g., 0.0 - 2.0)",
67
- "max_tokens": "Maximum tokens for model response (int)"
66
+ "max_tokens": "Maximum tokens for model response (int)",
67
+ # Accept template.* keys as valid config keys (for CLI validation, etc.)
68
+ "template": "Template context dictionary for prompt rendering (nested)",
69
+ # Note: template.* keys are validated dynamically, not statically here
68
70
  }
69
71
 
72
+ class BaseConfig:
73
+ def __init__(self):
74
+ self._data = {}
75
+
76
+ def get(self, key, default=None):
77
+ return self._data.get(key, default)
78
+
79
+ def set(self, key, value):
80
+ self._data[key] = value
81
+
82
+ def all(self):
83
+ return self._data
84
+
85
+
86
+ """
87
+ Returns a dictionary suitable for passing as Jinja2 template variables.
88
+ Merges the nested 'template' dict (if present) and all flat 'template.*' keys.
89
+ Flat keys override nested dict keys if there is a conflict.
90
+ """
91
+ template_vars = dict(self._data.get("template", {}))
92
+ for k, v in self._data.items():
93
+ if k.startswith("template.") and k != "template":
94
+ template_vars[k[9:]] = v
95
+ return template_vars
96
+
97
+
70
98
  # Import defaults for reference
71
99
  from .config_defaults import CONFIG_DEFAULTS
72
100
 
@@ -19,6 +19,9 @@ class ConversationHandler:
19
19
  self.usage_history = []
20
20
 
21
21
  def handle_conversation(self, messages, max_rounds=50, on_content=None, on_tool_progress=None, verbose_response=False, spinner=False, max_tokens=None):
22
+ from janito.agent.runtime_config import runtime_config
23
+ max_tools = runtime_config.get('max_tools', None)
24
+ tool_calls_made = 0
22
25
  if not messages:
23
26
  raise ValueError("No prompt provided in messages")
24
27
 
@@ -105,10 +108,15 @@ class ConversationHandler:
105
108
  "usage_history": self.usage_history
106
109
  }
107
110
 
111
+ from janito.agent.runtime_config import runtime_config
108
112
  tool_responses = []
113
+ # Sequential tool execution (default, only mode)
109
114
  for tool_call in choice.message.tool_calls:
115
+ if max_tools is not None and tool_calls_made >= max_tools:
116
+ raise MaxRoundsExceededError(f"Maximum number of tool calls ({max_tools}) reached in this chat session.")
110
117
  result = self.tool_handler.handle_tool_call(tool_call, on_progress=on_tool_progress)
111
118
  tool_responses.append({"tool_call_id": tool_call.id, "content": result})
119
+ tool_calls_made += 1
112
120
 
113
121
  # Store usage info in usage_history, linked to the next assistant message index
114
122
  assistant_idx = len([m for m in messages if m.get('role') == 'assistant'])
@@ -16,6 +16,7 @@ class UnifiedConfig:
16
16
  self.runtime_cfg = runtime_cfg
17
17
  self.effective_cfg = effective_cfg
18
18
 
19
+
19
20
  def get(self, key, default=None):
20
21
  val = self.runtime_cfg.get(key)
21
22
  if val is not None:
@@ -8,6 +8,8 @@ class ToolHandler:
8
8
  @classmethod
9
9
  def register_tool(cls, func):
10
10
  import inspect
11
+ import typing
12
+ from typing import get_origin, get_args
11
13
 
12
14
  name = func.__name__
13
15
  description = func.__doc__ or ""
@@ -23,18 +25,126 @@ class ToolHandler:
23
25
  if param.annotation is param.empty:
24
26
  raise TypeError(f"Parameter '{param_name}' in tool '{name}' is missing a type hint.")
25
27
  param_type = param.annotation
26
- json_type = "string"
27
- if param_type == int:
28
- json_type = "integer"
29
- elif param_type == float:
30
- json_type = "number"
31
- elif param_type == bool:
32
- json_type = "boolean"
33
- elif param_type == dict:
34
- json_type = "object"
35
- elif param_type == list:
36
- json_type = "array"
37
- params_schema["properties"][param_name] = {"type": json_type}
28
+ schema = {}
29
+
30
+ # Handle typing.Optional, typing.List, typing.Literal, etc.
31
+ origin = get_origin(param_type)
32
+ args = get_args(param_type)
33
+
34
+ if origin is typing.Union and type(None) in args:
35
+ # Optional[...] type
36
+ main_type = args[0] if args[1] is type(None) else args[1]
37
+ origin = get_origin(main_type)
38
+ args = get_args(main_type)
39
+ param_type = main_type
40
+ else:
41
+ main_type = param_type
42
+
43
+ if origin is list or origin is typing.List:
44
+ item_type = args[0] if args else str
45
+ item_schema = {"type": _pytype_to_json_type(item_type)}
46
+ schema = {"type": "array", "items": item_schema}
47
+ elif origin is typing.Literal:
48
+ schema = {"type": _pytype_to_json_type(type(args[0])), "enum": list(args)}
49
+ elif main_type == int:
50
+ schema = {"type": "integer"}
51
+ elif main_type == float:
52
+ schema = {"type": "number"}
53
+ elif main_type == bool:
54
+ schema = {"type": "boolean"}
55
+ elif main_type == dict:
56
+ schema = {"type": "object"}
57
+ elif main_type == list:
58
+ schema = {"type": "array", "items": {"type": "string"}}
59
+ else:
60
+ schema = {"type": "string"}
61
+
62
+ # Optionally add description if available in docstring (not implemented here)
63
+ params_schema["properties"][param_name] = schema
64
+ if param.default is param.empty:
65
+ params_schema["required"].append(param_name)
66
+
67
+ cls._tool_registry[name] = {
68
+ "function": func,
69
+ "description": description,
70
+ "parameters": params_schema
71
+ }
72
+ return func
73
+
74
+ def _pytype_to_json_type(pytype):
75
+ import typing
76
+ if pytype == int:
77
+ return "integer"
78
+ elif pytype == float:
79
+ return "number"
80
+ elif pytype == bool:
81
+ return "boolean"
82
+ elif pytype == dict:
83
+ return "object"
84
+ elif pytype == list or pytype == typing.List:
85
+ return "array"
86
+ else:
87
+ return "string"
88
+
89
+ class ToolHandler:
90
+ _tool_registry = {}
91
+
92
+ @classmethod
93
+ def register_tool(cls, func):
94
+ import inspect
95
+ import typing
96
+ from typing import get_origin, get_args
97
+
98
+ name = func.__name__
99
+ description = func.__doc__ or ""
100
+
101
+ sig = inspect.signature(func)
102
+ params_schema = {
103
+ "type": "object",
104
+ "properties": {},
105
+ "required": []
106
+ }
107
+
108
+ for param_name, param in sig.parameters.items():
109
+ if param.annotation is param.empty:
110
+ raise TypeError(f"Parameter '{param_name}' in tool '{name}' is missing a type hint.")
111
+ param_type = param.annotation
112
+ schema = {}
113
+
114
+ # Handle typing.Optional, typing.List, typing.Literal, etc.
115
+ origin = get_origin(param_type)
116
+ args = get_args(param_type)
117
+
118
+ if origin is typing.Union and type(None) in args:
119
+ # Optional[...] type
120
+ main_type = args[0] if args[1] is type(None) else args[1]
121
+ origin = get_origin(main_type)
122
+ args = get_args(main_type)
123
+ param_type = main_type
124
+ else:
125
+ main_type = param_type
126
+
127
+ if origin is list or origin is typing.List:
128
+ item_type = args[0] if args else str
129
+ item_schema = {"type": _pytype_to_json_type(item_type)}
130
+ schema = {"type": "array", "items": item_schema}
131
+ elif origin is typing.Literal:
132
+ schema = {"type": _pytype_to_json_type(type(args[0])), "enum": list(args)}
133
+ elif main_type == int:
134
+ schema = {"type": "integer"}
135
+ elif main_type == float:
136
+ schema = {"type": "number"}
137
+ elif main_type == bool:
138
+ schema = {"type": "boolean"}
139
+ elif main_type == dict:
140
+ schema = {"type": "object"}
141
+ elif main_type == list:
142
+ schema = {"type": "array", "items": {"type": "string"}}
143
+ else:
144
+ schema = {"type": "string"}
145
+
146
+ # Optionally add description if available in docstring (not implemented here)
147
+ params_schema["properties"][param_name] = schema
38
148
  if param.default is param.empty:
39
149
  params_schema["required"].append(param_name)
40
150
 
@@ -82,46 +192,38 @@ class ToolHandler:
82
192
  args = json.loads(tool_call.function.arguments)
83
193
  if self.verbose:
84
194
  print(f"[Tool Call] {tool_call.function.name} called with arguments: {args}")
195
+ import inspect
196
+ sig = inspect.signature(func)
197
+ if on_progress:
198
+ on_progress({
199
+ 'event': 'start',
200
+ 'call_id': call_id,
201
+ 'tool': tool_call.function.name,
202
+ 'args': args
203
+ })
204
+ if 'on_progress' in sig.parameters and on_progress is not None:
205
+ args['on_progress'] = on_progress
85
206
  try:
86
- import inspect
87
- sig = inspect.signature(func)
88
- if on_progress:
89
- on_progress({
90
- 'event': 'start',
91
- 'call_id': call_id,
92
- 'tool': tool_call.function.name,
93
- 'args': args
94
- })
95
- if 'on_progress' in sig.parameters and on_progress is not None:
96
- args['on_progress'] = on_progress
97
207
  result = func(**args)
98
- if self.verbose:
99
- preview = result
100
- if isinstance(result, str):
101
- lines = result.splitlines()
102
- if len(lines) > 10:
103
- preview = "\n".join(lines[:10]) + "\n... (truncated)"
104
- elif len(result) > 500:
105
- preview = result[:500] + "... (truncated)"
106
- print(f"[Tool Result] {tool_call.function.name} returned:\n{preview}")
107
- if on_progress:
108
- on_progress({
109
- 'event': 'finish',
110
- 'call_id': call_id,
111
- 'tool': tool_call.function.name,
112
- 'args': args,
113
- 'result': result
114
- })
115
- return result
116
208
  except Exception as e:
117
- tb = traceback.format_exc()
118
- if on_progress:
119
- on_progress({
120
- 'event': 'finish',
121
- 'call_id': call_id,
122
- 'tool': tool_call.function.name,
123
- 'args': args,
124
- 'error': str(e),
125
- 'traceback': tb
126
- })
127
- return f"Error running tool {tool_call.function.name}: {e}"
209
+ import traceback
210
+ error_message = f"[Tool Error] {type(e).__name__}: {e}\n" + traceback.format_exc()
211
+ result = error_message
212
+ if self.verbose:
213
+ preview = result
214
+ if isinstance(result, str):
215
+ lines = result.splitlines()
216
+ if len(lines) > 10:
217
+ preview = "\n".join(lines[:10]) + "\n... (truncated)"
218
+ elif len(result) > 500:
219
+ preview = result[:500] + "... (truncated)"
220
+ print(f"[Tool Result] {tool_call.function.name} returned:\n{preview}")
221
+ if on_progress:
222
+ on_progress({
223
+ 'event': 'finish',
224
+ 'call_id': call_id,
225
+ 'tool': tool_call.function.name,
226
+ 'args': args,
227
+ 'result': result
228
+ })
229
+ return result
@@ -1,9 +1,12 @@
1
1
  from .ask_user import ask_user
2
- from .file_ops import create_directory, create_file, remove_file, move_file
3
- from .view_file import view_file
2
+ from .file_ops import create_file, create_directory, remove_file, move_file
3
+ from .get_lines import get_lines
4
+ from .replace_text_in_file import replace_text_in_file
4
5
  from .find_files import find_files
5
- from .search_text import search_text
6
- from .bash_exec import bash_exec
6
+ from .run_bash_command import run_bash_command
7
7
  from .fetch_url import fetch_url
8
+ from .python_exec import python_exec
9
+ from .py_compile import py_compile_file
10
+ from .search_files import search_files
11
+ from .remove_directory import remove_directory
8
12
 
9
- from .file_str_replace import file_str_replace
@@ -11,7 +11,8 @@ def ask_user(question: str) -> str:
11
11
  """
12
12
  Ask the user a question and return their response.
13
13
 
14
- question: The question to ask the user
14
+ Args:
15
+ question (str): The question to ask the user.
15
16
  """
16
17
  from rich import print as rich_print
17
18
  from rich.panel import Panel
@@ -1,48 +1,40 @@
1
1
  import requests
2
+ from typing import Optional, Callable
2
3
  from bs4 import BeautifulSoup
3
4
  from janito.agent.tool_handler import ToolHandler
4
5
  from janito.agent.tools.rich_utils import print_info, print_success, print_error
5
6
 
6
7
  @ToolHandler.register_tool
7
- def fetch_url(url: str, search_strings: list[str] = None, on_progress: callable = None) -> str:
8
+ def fetch_url(url: str, search_strings: list[str] = None, on_progress: Optional[Callable[[dict], None]] = None) -> str:
8
9
  """
9
10
  Fetch the content of a web page and extract its text.
10
11
 
11
- url: The URL to fetch.
12
- search_strings: Optional list of strings to filter the extracted text around those strings.
13
- on_progress: Optional callback function for streaming progress updates.
12
+ Args:
13
+ url (str): The URL to fetch.
14
+ search_strings (list[str], optional): List of strings to filter the extracted text around those strings.
15
+ on_progress (callable, optional): Callback function for streaming progress updates.
14
16
  """
15
- if on_progress:
16
- on_progress({'event': 'start', 'url': url})
17
17
  print_info(f"\U0001F310 Fetching URL: {url} ... ")
18
- try:
19
- response = requests.get(url, timeout=10)
20
- response.raise_for_status()
21
- if on_progress:
22
- on_progress({'event': 'fetched', 'status_code': response.status_code})
23
- soup = BeautifulSoup(response.text, 'html.parser')
24
- text = soup.get_text(separator=' ', strip=True)
18
+ response = requests.get(url, timeout=10)
19
+ response.raise_for_status()
20
+ if on_progress:
21
+ on_progress({'event': 'fetched', 'status_code': response.status_code})
22
+ soup = BeautifulSoup(response.text, 'html.parser')
23
+ text = soup.get_text(separator='\n')
25
24
 
26
- if search_strings:
27
- filtered = []
28
- for s in search_strings:
29
- idx = text.find(s)
30
- if idx != -1:
31
- start = max(0, idx - 200)
32
- end = min(len(text), idx + len(s) + 200)
33
- snippet = text[start:end]
34
- filtered.append(snippet)
35
- if filtered:
36
- text = '\n...\n'.join(filtered)
37
- else:
38
- text = "No matches found for the provided search strings."
25
+ if search_strings:
26
+ filtered = []
27
+ for s in search_strings:
28
+ idx = text.find(s)
29
+ if idx != -1:
30
+ start = max(0, idx - 200)
31
+ end = min(len(text), idx + len(s) + 200)
32
+ snippet = text[start:end]
33
+ filtered.append(snippet)
34
+ if filtered:
35
+ text = '\n...\n'.join(filtered)
36
+ else:
37
+ text = "No matches found for the provided search strings."
39
38
 
40
- print_success("\u2705 Success")
41
- if on_progress:
42
- on_progress({'event': 'done'})
43
- return text
44
- except Exception as e:
45
- print_error(f"\u274c Error: {e}")
46
- if on_progress:
47
- on_progress({'event': 'error', 'error': str(e)})
48
- return f"\u274c Failed to fetch URL '{url}': {e}"
39
+ print_success("\u2705 Success")
40
+ return text