mini-code-cli 0.0.8.2__py3-none-any.whl → 0.0.8.3__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.
@@ -19,11 +19,15 @@ import argparse
19
19
  import sys
20
20
 
21
21
  from . import prompts
22
+ from . import shell_safety_utils
22
23
  from . import tools
23
24
  from . import utils
25
+ from . import ui
24
26
 
25
27
 
26
- # ANSI escape codes for colors and bold
28
+ # ANSI escape codes for colors and bold (kept for the couple of ad-hoc spots
29
+ # that still build their own strings; the rest of the package renders through
30
+ # the ui.Console in ui.py).
27
31
  BOLD = '\033[1m'
28
32
  RED = '\033[91m'
29
33
  GREEN = '\033[92m'
@@ -33,17 +37,60 @@ MAGENTA = '\033[95m'
33
37
  CYAN = '\033[96m'
34
38
  RESET = '\033[0m'
35
39
 
40
+ DEFAULT_CACHE_LOCATION=os.path.expanduser("~/.cache/mini_code/sessions/")
41
+ DEFAULT_MODEL_URL="http://0.0.0.0:30000"
42
+ DEFAULT_TEMPERATURE=1.0
43
+ DEFAULT_TOOL_TIMEOUT=120 # seconds before a single tool call is aborted
44
+ DEFAULT_HTTP_TIMEOUT=300 # seconds before an LLM HTTP request is aborted
45
+ DEFAULT_MAX_MESSAGES=150 # messages in history before it is compacted to a summary
46
+
36
47
  def save_history(messages, args, session_name=""):
37
48
  # Save messages to history.json
38
49
  if args.cache_dir:
39
50
  cache_dir = args.cache_dir
40
51
  else:
41
- cache_dir = os.path.expanduser("~/.cache/mini_code/sessions/")
52
+ cache_dir = DEFAULT_CACHE_LOCATION
42
53
  os.makedirs(cache_dir, exist_ok=True)
43
- history_path = os.path.join(cache_dir, f"history_{session_name}.json")
54
+ os.makedirs(os.path.join(cache_dir,session_name), exist_ok=True)
55
+
56
+ history_path = os.path.join(cache_dir, session_name, f"history.json")
44
57
  with open(history_path, 'w') as f:
45
58
  json.dump(messages, f, indent=2)
46
59
 
60
+
61
+ def accumulate_usage(token_stats, usage):
62
+ """Add a single LLM call's token usage into the running session totals."""
63
+ if not usage:
64
+ return
65
+ token_stats["prompt_tokens"] += int(usage.get("prompt_tokens", 0) or 0)
66
+ token_stats["completion_tokens"] += int(usage.get("completion_tokens", 0) or 0)
67
+ token_stats["total_tokens"] += int(usage.get("total_tokens", 0) or 0)
68
+ token_stats["llm_calls"] += 1
69
+
70
+
71
+ def format_usage(usage):
72
+ """Human-readable one-line summary of a single call's token usage."""
73
+ if not usage:
74
+ return "tokens: n/a (server did not report usage)"
75
+ return (f"tokens: input={usage.get('prompt_tokens', 0)} "
76
+ f"output={usage.get('completion_tokens', 0)} "
77
+ f"total={usage.get('total_tokens', 0)}")
78
+
79
+
80
+ def save_usage(token_stats, cache_dir, session_name=""):
81
+ """Persist the accumulated token usage to the session folder."""
82
+ os.makedirs(cache_dir, exist_ok=True)
83
+ usage_path = os.path.join(cache_dir, session_name, f"usage.json")
84
+ with open(usage_path, 'w') as f:
85
+ json.dump(token_stats, f, indent=2)
86
+ return usage_path
87
+
88
+
89
+ def print_usage_summary(token_stats):
90
+ """Print the accumulated token usage for the session so far."""
91
+ ui.console.usage_summary(token_stats)
92
+
93
+
47
94
  def generate_unique_id():
48
95
  """Generate a unique ID based on current time and a random component."""
49
96
  timestamp = int(time.time() * 1000000) # microseconds
@@ -52,14 +99,14 @@ def generate_unique_id():
52
99
  def parse_args():
53
100
  parser = argparse.ArgumentParser(description="Agent loop for interacting with a language model")
54
101
  # MODEL / LLM related
55
- parser.add_argument("--url", type=str, default="http://0.0.0.0:30000", help="API host")
56
- parser.add_argument("--max-tokens", type=int, help="Maximum tokens for LLM response")
57
- parser.add_argument("--temperature", type=float, default=0.0, help="Temperature for LLM")
58
- parser.add_argument("--model", type=str, default="default", help="Model name")
102
+ parser.add_argument("--url", type=str, default=f"{DEFAULT_MODEL_URL}", help=f"API host, default={DEFAULT_MODEL_URL}")
103
+ parser.add_argument("--max-tokens", type=int, help="Maximum tokens for LLM response, default=None")
104
+ parser.add_argument("--temperature", type=float, default=DEFAULT_TEMPERATURE, help=f"Temperature for LLM, default={DEFAULT_TEMPERATURE}")
105
+ parser.add_argument("--model", type=str, help="Model name, default=None")
59
106
  parser.add_argument("--api-key", type=str, default=None, help="API key for authentication. If not set, it tries to find it in env variable: MINI_CODE_API_KEY.")
60
107
 
61
108
  # Other settings:
62
- parser.add_argument("--cache-dir", type=str, help="The cache dir for the session histories.")
109
+ parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
63
110
 
64
111
  # Agent behaviour related
65
112
  parser.add_argument("--system-prompt", type=str, help="Replace system prompt, with a custom system prompt.")
@@ -69,70 +116,40 @@ def parse_args():
69
116
 
70
117
  # Agent permissions related
71
118
  parser.add_argument("--ask-permission", action="store_true", help="Ask for permission before any tool call.")
72
- parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations")
73
- parser.add_argument("--enable-shell", action="store_true", help="Allow shell execution. Default: False") #todo
119
+ parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations, default='./'")
120
+ parser.add_argument("--enable-shell", action="store_true", help="Allow shell execution. default= False")
121
+ parser.add_argument("--llm-timeout", type=int, default=DEFAULT_HTTP_TIMEOUT, help=f"HTTP timeout in seconds for LLM requests (reasoning models can be slow). default={DEFAULT_HTTP_TIMEOUT}")
122
+ parser.add_argument("--tool-timeout", type=int, default=DEFAULT_TOOL_TIMEOUT, help=f"Timeout in seconds for a single tool call. default={DEFAULT_TOOL_TIMEOUT}")
74
123
 
75
124
 
76
125
  args = parser.parse_args()
77
126
  return args
78
127
 
79
128
 
80
- def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir):
81
- # Calculate the length of the longest line inside the box
82
- version = __import__('mini_code_cli').__version__
83
-
84
- permission_mode = "All tool calls require permission." if args.ask_permission else "Tool calls will execute without asking for permission."
85
- agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
86
- shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
87
-
88
- lines = [
89
- f" MiniCodeCLI (v{version})",
90
- f" The session-id is: {unique_id}" ,
91
- f" It will be saved under: `{cache_dir}`",
92
- " Type 'quit' or 'exit' to exit the loop.",
93
- f" Allowed dir: {allowed_dir}",
94
- f" Server URL: {server_url}",
95
- f" {permission_mode}",
96
- f" {agent_mode}",
97
- f" {shell_mode}"
98
- ]
99
- max_len = max(len(line) for line in lines)
100
- margin = 2 # Space on each side inside the box
101
- inner_width = max_len + 2 * margin
102
- # Build the box
103
- top_bottom = "╔" + "═" * inner_width + "╗"
104
- print(top_bottom)
105
- for line in lines:
106
- # Pad the line to inner_width characters
107
- padded = line.ljust(inner_width)
108
- print(f"║{padded}║")
109
- bottom = "╚" + "═" * inner_width + "╝"
110
- print(bottom)
111
-
112
- def print_help(allowed_dir, server_url, args, unique_id, cache_dir):
113
- print_welcome(allowed_dir, server_url, args, unique_id, cache_dir)
114
-
115
- help_message = ""
116
- help_message += "Manual-mode means the agent will ask for user input after each LLM prediction." if not args.auto_mode else "Auto-mode means the agent will keep calling the LLM until no further LLM calls are possible."
117
-
118
- print(f"{BOLD}{GREEN}HELP:{RESET} {help_message}")
129
+ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
130
+ ui.console.rule("help", fg="cyan")
131
+ if not args.auto_mode:
132
+ help_message = "Manual-mode: the agent asks for your input after each LLM prediction."
133
+ else:
134
+ help_message = "Auto-mode: the agent keeps calling the LLM until no further tool calls are possible."
135
+ ui.console.note(help_message, "info")
136
+ ui.console.rule(char="═", fg="cyan")
119
137
 
120
- def call_function_with_timeout(func, tool_params, name):
121
- """calls function with params"""
138
+ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
139
+ """calls function with params, aborting after `timeout_seconds`."""
122
140
  class TimeoutError(Exception):
123
141
  pass
124
142
 
125
143
  def timeout_handler(signum, frame):
126
144
  raise TimeoutError("Function call timed out")
127
145
 
128
- timeout_seconds = 30 # Set your desired timeout in seconds
129
146
  signal.signal(signal.SIGALRM, timeout_handler)
130
147
  signal.alarm(timeout_seconds)
131
148
  try:
132
149
  result = func(**tool_params)
133
150
  except TimeoutError:
134
- print(f"{BOLD}{RED}Error:{RESET} Function {name} timed out after {timeout_seconds} seconds.")
135
- result = "Error:Function {name} timed out after {timeout_seconds} seconds."
151
+ ui.console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
152
+ result = f"Error: Function {name} timed out after {timeout_seconds} seconds."
136
153
  finally:
137
154
  signal.alarm(0) # Disable the alarm
138
155
 
@@ -140,221 +157,433 @@ def call_function_with_timeout(func, tool_params, name):
140
157
 
141
158
 
142
159
 
143
- def main():
144
- args = parse_args()
160
+ def is_within_allowed_dir(file_path, allowed_dir):
161
+ """Return True if the resolved `file_path` lives inside `allowed_dir`.
145
162
 
146
- # Get session unique ID:
147
- unique_id = generate_unique_id()
148
- cache_dir = args.cache_dir if args.cache_dir else "~/.cache/mini_code/sessions/"
163
+ Uses os.path.realpath to resolve symlinks and '..' so path-traversal or
164
+ symlink tricks cannot escape the allowed directory.
165
+ """
166
+ target = os.path.realpath(file_path)
167
+ root = os.path.realpath(allowed_dir)
168
+ try:
169
+ return os.path.commonpath([target, root]) == root
170
+ except (ValueError, TypeError):
171
+ # Different drives (Windows) or a non-absolute path: fail closed.
172
+ return False
173
+
174
+
175
+ # Maps each filesystem tool to the names of its path-bearing arguments, so the
176
+ # --allowed-dir sandbox can be enforced uniformly. Keeping this in one place
177
+ # means a future tool cannot silently bypass the sandbox by using a different
178
+ # argument name (e.g. glob's `pattern` instead of `file_path`).
179
+ _FILE_TOOL_PATH_ARGS = {
180
+ "read_file": ["file_path"],
181
+ "write_file": ["file_path"],
182
+ "grep": ["file_path"],
183
+ "glob": ["pattern"],
184
+ }
185
+
186
+
187
+ def tool_paths_are_allowed(name, tool_params, allowed_dir):
188
+ """Return True if every filesystem path argument of `name` stays inside
189
+ `allowed_dir`.
190
+
191
+ Tools not listed in _FILE_TOOL_PATH_ARGS have no path to check and pass
192
+ through (return True). A single path argument may itself be a list (e.g. a
193
+ batch of files), so each entry is validated.
194
+ """
195
+ path_args = _FILE_TOOL_PATH_ARGS.get(name, [])
196
+ for arg_name in path_args:
197
+ value = tool_params.get(arg_name, "")
198
+ values = value if isinstance(value, (list, tuple)) else [value]
199
+ for path in values:
200
+ if path and not is_within_allowed_dir(path, allowed_dir):
201
+ return False
202
+ return True
203
+
204
+
205
+ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, max_messages=DEFAULT_MAX_MESSAGES):
206
+ """Compact the conversation history once it grows beyond `max_messages`.
207
+
208
+ Long (especially auto-mode) sessions can overflow the model's context window.
209
+ When the history gets too large we ask the LLM to summarize it and replace the
210
+ old turns with that summary, keeping the original system prompt as the first
211
+ message. The compaction call itself is a single, tool-free request, so it
212
+ cannot trigger further tool calls or recurse into compaction.
213
+ """
214
+ if len(messages) <= max_messages:
215
+ return messages
149
216
 
150
- # LLM Related
151
- server_url = f"{args.url}"
152
- api_key = None
153
- if args.api_key:
154
- api_key = args.api_key
155
- else:
156
- api_key = os.environ.get("MINI_CODE_API_KEY", "")
157
- if api_key:
158
- print(f"{YELLOW}API Key found at MINI_CODE_API_KEY{RESET}")
217
+ try:
218
+ print(f"{BOLD}{YELLOW}System:{RESET} Compacting conversation history ({len(messages)} messages -> summary)...")
219
+ summary_prompt = prompts.compaction_prompt(messages)
220
+ summary, _, compact_usage = utils.call_openai_server(
221
+ [{"role": "user", "content": summary_prompt}],
222
+ max_tokens=1024,
223
+ temperature=0.0,
224
+ model=model,
225
+ server_url=server_url,
226
+ api_key=api_key,
227
+ timeout=llm_timeout,
228
+ )
229
+ accumulate_usage(token_stats, compact_usage)
230
+ if summary:
231
+ system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
232
+ compacted = []
233
+ if system_content:
234
+ compacted.append({"role": "system", "content": system_content})
235
+ compacted.append({
236
+ "role": "user",
237
+ "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
238
+ })
239
+ print(f"{BOLD}{YELLOW}System:{RESET} History compacted to {len(compacted)} messages.")
240
+ return compacted
241
+ except Exception as e:
242
+ print(f"{BOLD}{RED}Warning:{RESET} Failed to compact history: {e}")
243
+
244
+ return messages
245
+
246
+
247
+ def shell_forces_ask_permission(args):
248
+ """Whether enabling the shell should force per-command permission prompts.
249
+
250
+ Running shell commands without asking is dangerous (the agent can run any
251
+ command and escape --allowed-dir), so whenever the shell is enabled we
252
+ force ask-permission for the whole session. Exposed as a pure function so it
253
+ can be unit-tested independently of argparse / stdin.
254
+ """
255
+ return bool(getattr(args, "enable_shell", False))
256
+
257
+
258
+ class AgentSession:
259
+ """A single interactive agent session.
260
+
261
+ The whole loop used to live in one ~300 line ``main()``. Splitting it into
262
+ small, named methods keeps each step (read input -> call the LLM -> run tool
263
+ calls -> persist) easy to follow and gives the interesting bits somewhere to
264
+ be unit-tested.
265
+ """
266
+
267
+ EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
268
+
269
+ def __init__(self, args):
270
+ self.args = args
271
+ self.allowed_dir = os.path.abspath(args.allowed_dir)
272
+ self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
273
+
274
+ # The console that renders everything the agent prints. Unit tests /
275
+ # other callers can swap it out (e.g. AgentSession(args, console=...) with
276
+ # a color=False console) but it defaults to the shared ui.console.
277
+ self.console = ui.console
278
+
279
+ # LLM / server configuration.
280
+ self.server_url = f"{args.url}"
281
+ model_name = utils.determine_vllm_model(self.server_url)
282
+ self.model = model_name if model_name else args.model
283
+ self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
284
+
285
+ # Which tools the LLM may call. The shell tool is only available when
286
+ # --enable-shell was passed.
287
+ forbidden = None if args.enable_shell else tools.shell_tools
288
+ self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
289
+ self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
290
+
291
+ # Token accounting for the whole session.
292
+ self.token_stats = {
293
+ "prompt_tokens": 0,
294
+ "completion_tokens": 0,
295
+ "total_tokens": 0,
296
+ "llm_calls": 0,
297
+ "tool_calls": 0,
298
+ }
299
+
300
+ self.unique_id = generate_unique_id()
301
+
302
+ # Conversation state. Starts with the system message (+ optional Agent.md).
303
+ self.messages = self._build_initial_messages()
304
+
305
+ def _build_initial_messages(self):
306
+ """Assemble the system prompt and optional Agent.md into the first turns."""
307
+ if self.args.system_prompt:
308
+ system_prompt = self.args.system_prompt
159
309
  else:
160
- print(f"{YELLOW}NO API Key found at MINI_CODE_API_KEY. Set it with export MINI_CODE_API_KEY='sk-api-key'{RESET}")
310
+ system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir)
161
311
 
162
-
163
- # Permissions related
164
- allowed_dir = os.path.abspath(args.allowed_dir)
312
+ messages = [{"role": "system", "content": system_prompt}]
165
313
 
166
- if not args.enable_shell:
167
- forbidden_tools = tools.shell_tools
168
- else:
169
- forbidden_tools = None
314
+ if self.args.agent_md:
315
+ self.console.note(f"AGENT.md '{self.args.agent_md}' specified.", "warn")
316
+ if not os.path.isfile(self.args.agent_md):
317
+ self.console.note(f"Error: The AGENT.md '{self.args.agent_md}' does not exist.", "error")
318
+ else:
319
+ with open(self.args.agent_md, "r", encoding="utf-8") as f:
320
+ agent_md_content = f.read()
321
+ self.console.note(f"AGENT.md '{self.args.agent_md}' loaded.", "ok")
322
+ # Append Agent.md as a user turn, right after the system prompt.
323
+ messages.insert(1, {"role": "user", "content": agent_md_content})
324
+
325
+ return messages
326
+
327
+ def print_announcements(self):
328
+ """Print the info / warning banners that depend on the CLI flags."""
329
+ if self.api_key:
330
+ self.console.announce_api_key(present=True)
331
+ else:
332
+ self.console.announce_api_key(present=False)
333
+
334
+ if shell_forces_ask_permission(self.args):
335
+ self.console.announce_shell()
336
+
337
+ def run(self, initial_prompt=None):
338
+ """Drive the agent loop until the user quits."""
339
+ self.print_announcements()
340
+ print_welcome(self.allowed_dir, self.server_url, self.args,
341
+ self.unique_id, self.cache_dir, self.model)
342
+
343
+ # The --prompt value is a one-shot initial turn: it is sent verbatim and
344
+ # then cleared, so later turns come from the user (manual mode) or are
345
+ # produced automatically by the loop (auto mode).
346
+ pending = initial_prompt
347
+
348
+ while True:
349
+ try:
350
+ # 1) Get the user's turn: the one-shot preset, or a fresh line.
351
+ if pending is not None:
352
+ print("=" * 30)
353
+ print(f"{BOLD}{CYAN}You (preset): {RESET}{pending}")
354
+ user_input = pending
355
+ pending = None
356
+ else:
357
+ user_input = self._read_user_line()
358
+ if user_input is None: # quit / exit
359
+ self.shutdown()
360
+ break
170
361
 
171
- # TOOLs
172
- tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden_tools)
173
- llm_tools_dict = tools.util_get_tools(forbidden_tools=forbidden_tools)
362
+ # 2) Hand the user turn to the model and act on its tool calls.
363
+ self.messages.append({"role": "user", "content": user_input})
364
+ while True:
365
+ should_continue = self._step()
366
+ self._persist()
367
+ if not should_continue:
368
+ break
369
+ self.console.note("Continuing with LLM calls until no more tool calls.", "info")
370
+ except _Abort:
371
+ # Ctrl-C during a permission prompt already shut the session
372
+ # down; just unwind the loop quietly.
373
+ break
374
+ except KeyboardInterrupt:
375
+ self.console.note("Interrupting: Mini Code Exiting...", "error")
376
+ # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
377
+ self._persist()
378
+
379
+ def _step(self):
380
+ """Run a single LLM turn: call the model, render its text, run its tool
381
+ calls. Returns True if the LLM made tool calls and we're in auto-mode,
382
+ signalling the loop to continue without asking the user."""
383
+ # Keep long sessions within the model's context window.
384
+ self.messages = maybe_compact(
385
+ self.messages, self.server_url, self.model, self.api_key,
386
+ self.args.llm_timeout, self.token_stats,
387
+ )
388
+
389
+ response_text, tool_calls, usage = utils.call_openai_server(
390
+ self.messages,
391
+ max_tokens=self.args.max_tokens,
392
+ temperature=self.args.temperature,
393
+ model=self.model,
394
+ server_url=self.server_url,
395
+ tools=self.llm_tools,
396
+ api_key=self.api_key,
397
+ timeout=self.args.llm_timeout,
398
+ )
399
+ accumulate_usage(self.token_stats, usage)
400
+ self.console.emit(self.console.usage_inline(usage))
401
+
402
+ made_tool_calls = bool(tool_calls)
403
+ if made_tool_calls:
404
+ self.token_stats["tool_calls"] += len(tool_calls)
405
+
406
+ if response_text:
407
+ self._print_assistant(response_text)
408
+
409
+ if tool_calls:
410
+ self.messages.append({
411
+ "role": "assistant",
412
+ "content": response_text,
413
+ "tool_calls": tool_calls,
414
+ })
415
+ self._run_tool_calls(tool_calls)
416
+ else:
417
+ self.messages.append({"role": "assistant", "content": response_text})
174
418
 
419
+ if not response_text and not made_tool_calls:
420
+ self.console.note("Warning: no response was given by the LLM.", "error")
175
421
 
176
- # System Message and Agent.md
177
- if args.system_prompt:
178
- system_prompt = args.system_prompt
179
- else:
180
- system_prompt = prompts.system_prompt(tools_dict=tools_dict, allowed_dir=allowed_dir)
422
+ return made_tool_calls and self.args.auto_mode
181
423
 
182
- messages = [{"role": "system", "content": system_prompt}]
424
+ def _read_user_line(self):
425
+ """Read one turn from the user. Returns the text to send, or None to quit."""
426
+ try:
427
+ print("=" * 30)
428
+ user_input = input(f"{BOLD}{CYAN}You:{RESET} ")
429
+ except KeyboardInterrupt:
430
+ print("\nThank you. Goodbye! Saving history...")
431
+ return None
183
432
 
184
- if args.agent_md:
185
- print(f"{YELLOW}AGENT.md '{args.agent_md}' specified.{RESET}")
433
+ if user_input.lower() in self.EXIT_COMMANDS:
434
+ return None
186
435
 
187
- # Check if the file exists
188
- if not os.path.isfile(args.agent_md):
189
- print(f"{RED}Error: The AGENT.md '{args.agent_md}' does not exist.{RESET}")
190
- else:
191
- # Load the content of agent-md file and add it after system message
192
- with open(args.agent_md, 'r', encoding='utf-8') as f:
193
- agent_md_content = f.read()
194
- print(f"{YELLOW}AGENT.md '{args.agent_md}' loaded.{RESET}")
436
+ if user_input == "/help":
437
+ print_help(self.allowed_dir, self.server_url, self.args,
438
+ self.unique_id, self.cache_dir, self.model)
439
+ # Nothing to send to the model; ask again.
440
+ return self._read_user_line()
195
441
 
196
- # Append the content as a user message after the system prompt
197
- messages.append({"role": "user", "content": agent_md_content})
442
+ return user_input
198
443
 
199
- print_welcome(allowed_dir, server_url, args, unique_id, cache_dir)
444
+ def _run_tool_calls(self, tool_calls):
445
+ self.console.note(f"LLM is trying to call {len(tool_calls)} tool(s).", "info")
446
+ for call in tool_calls:
447
+ self._run_one_tool_call(call)
200
448
 
201
- # Agent Logic Flags
202
- llm_response_flag = False
203
- llm_tool_response_flag = False
204
- llm_repeat_flag = False
205
- tool_count = 0
206
-
207
- user_input = args.prompt
449
+ def _run_one_tool_call(self, call):
450
+ call_id = call.get("id", "")
451
+ name = call.get("function", {}).get("name", "")
452
+ params_str = call.get("function", {}).get("arguments", "{}")
208
453
 
209
- while True:
210
454
  try:
455
+ tool_params = json.loads(params_str)
456
+ except json.JSONDecodeError:
457
+ self.console.note(f"Error parsing arguments for tool {name}", "error")
458
+ return
459
+
460
+ if name not in self.tools_dict:
461
+ self.console.note(f"Unknown tool: {name}, params {params_str}", "error")
462
+ self._append_tool_result(call_id,
463
+ f"Error: Unknown tool: {name}, params {params_str}")
464
+ return
465
+
466
+ # 1) Sandbox: keep every filesystem tool inside --allowed-dir.
467
+ if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
468
+ self.console.note(
469
+ f"File operation not allowed outside allowed directory: {self.allowed_dir}", "error")
470
+ self._append_tool_result(call_id,
471
+ f"Error: File operation not allowed outside allowed directory: {self.allowed_dir}")
472
+ return
473
+
474
+ # 2) Shell-safety classifier (DENY / ASK / ALLOW), always enforced.
475
+ shell_cmd = None
476
+ if name == "run_shell_command":
477
+ shell_cmd = str(tool_params.get("command", ""))
478
+ tier, reason = shell_safety_utils.classify_command(shell_cmd)
479
+ if shell_safety_utils.is_deny(tier):
480
+ self.console.note(f"Refusing to run dangerous command: {shell_cmd!r} ({reason})", "error")
481
+ self._append_tool_result(call_id,
482
+ f"Command refused for safety reasons ({reason}): {shell_cmd!r}. Please choose a safer command.")
483
+ return
484
+ else:
485
+ tier = reason = None
211
486
 
212
- if llm_tool_response_flag and args.auto_mode: #skip user input and try to call the model again until there no more tool calls.
213
- print(f"{BOLD}{YELLOW}System:{RESET} Skipping user input. Continuing with LLM calls until no more tool calls. [Tool count={tool_count}]")
214
- llm_repeat_flag = True
215
- else:
216
- # get user input
217
- if llm_repeat_flag:
218
- print(f"{BOLD}{YELLOW}System:{RESET} Called {tool_count} tools in total. Exiting tool loop.")
219
-
220
- tool_count = 0
221
- if not user_input:
222
- try:
223
- print("-"*30)
224
- user_input = input(f"{BOLD}{CYAN}You:{RESET} ")
225
- if user_input.lower() in ["quit", "exit", "/quit", "/exit"]:
226
- print("\nThank you. Goodbye!")
227
- print(f"Saving history to: {cache_dir}/{unique_id}")
228
- save_history(messages, args, unique_id)
229
- break
230
- except KeyboardInterrupt:
231
- print("\nThank you. Goodbye!")
232
- print(f"Saving history to: {cache_dir}/{unique_id}")
233
- save_history(messages, args, unique_id)
234
- break
235
- else:
236
- print("-"*30)
237
- print(f"{BOLD}{CYAN}You (preset): {RESET}{user_input}")
238
-
239
- if user_input == "/help":
240
- print_help(allowed_dir, server_url, args, unique_id, cache_dir)
241
- user_input = ""
242
- continue
243
-
244
- messages.append({"role": "user", "content": user_input})
245
- user_input = ""
246
-
247
- llm_response_flag = False
248
- llm_tool_response_flag = False
249
-
250
- response_text, tool_calls = utils.call_openai_server(
251
- messages,
252
- max_tokens=args.max_tokens,
253
- temperature=args.temperature,
254
- model=args.model,
255
- server_url=server_url,
256
- tools=llm_tools_dict,
257
- api_key=api_key,
258
- )
259
-
260
- if response_text:
261
- llm_response_flag = True
262
- print(f"{BOLD}{GREEN}Assistant:{RESET} {response_text}")
263
-
264
- if tool_calls:
265
- llm_tool_response_flag = True
266
-
267
- messages.append({
268
- "role": "assistant",
269
- "content": response_text,
270
- "tool_calls": tool_calls
271
- })
272
- # Tool call parsing and execution
273
- print(f"{BOLD}{YELLOW}Tool Call:{RESET} LLM is trying to call {len(tool_calls)} tools.")
274
-
275
- for call in tool_calls:
276
- tool_count += 1
277
-
278
- call_id = call.get("id", "")
279
- name = call.get("function", {}).get("name", "")
280
- params_str = call.get("function", {}).get("arguments", "{}")
281
- try:
282
- tool_params = json.loads(params_str)
283
- except json.JSONDecodeError:
284
- print(f"{BOLD}{RED}Warning:{RESET} Error parsing arguments for tool {name}")
285
- continue
286
-
287
- if name in tools_dict:
288
- # Check allowed directory for file operations
289
- if name in ["read_file", "write_file", "grep", "glob"]:
290
- filepath = tool_params.get("filepath", "")
291
- if name == "write_file":
292
- filepath = tool_params.get("filepath", "")
293
- if filepath and not os.path.abspath(filepath).startswith(allowed_dir):
294
- print(f"{BOLD}{RED}Warning:{RESET} File operation not allowed outside allowed directory: {allowed_dir}")
295
- continue
296
-
297
- func = tools_dict[name]["function"]
298
- # asking permissions
299
- if args.ask_permission:
300
- print(f"{BOLD}{YELLOW}Tool permission:{RESET} Are you sure you want to call the tool?")
301
- print(f"name: {name}, parameters: {params_str}")
302
- try:
303
- permission_input = input("Type: 'y' or 'yes'\n")
304
- except KeyboardInterrupt:
305
- print("\nThank you. Goodbye! Saving history...")
306
- save_history(messages, args, unique_id)
307
- sys.exit(0)
308
-
309
- if not permission_input in ['y', 'yes']:
310
- print(f"{BOLD}{YELLOW}Tool permission denied:{RESET}.")
311
- messages.append({
312
- "role": "user",
313
- "content": f"User denied permission for tool {name} and params {params_str}."
314
- })
315
- continue
316
-
317
- print(f"{BOLD}{YELLOW}Tool Call:{RESET} LLM is trying to call: {name} with {tool_params}.")
318
- result = call_function_with_timeout(func, tool_params, name)
319
- # Limiting output to 300 characters for readability
320
- result_str = str(result)
321
- if len(result_str) > 300:
322
- result_str = result_str[:300] + "... (truncated to 300 chars)"
323
- print(f"{BOLD}{MAGENTA}Tool '{name}' returned:{RESET} {result_str}")
324
-
325
- messages.append({
326
- "role": "tool",
327
- "tool_call_id": call_id,
328
- "content": str(result)
329
- })
330
- else:
331
- print(f"{BOLD}{RED}Error:{RESET} Unknown tool: {name}, params {params_str}")
332
- messages.append({
333
- "role": "tool",
334
- "tool_call_id": call_id,
335
- "content": f"Error: Unknown tool: {name}, params {params_str}"
336
- })
487
+ # 3) Permission prompt. Either the user asked for prompts on every call,
488
+ # or the shell-safety classifier flagged this command as risky (ASK).
489
+ if needs_permission(self.args, shell_cmd, tier):
490
+ if self.args.ask_permission:
491
+ ok = self._ask_permission(name, params_str, shell_cmd, tier, reason)
337
492
  else:
338
- messages.append({
339
- "role": "assistant",
340
- "content": response_text,
341
- # "tool_calls": tool_calls
342
- })
343
-
344
- if not llm_response_flag and not llm_tool_response_flag:
345
- print(f"{BOLD}{RED}Warning:{RESET} No response was given by the LLM.")
493
+ ok = self._ask_risky_command(shell_cmd, reason)
494
+ if not ok:
495
+ self._append_tool_result(call_id,
496
+ f"User denied permission for tool {name} and params {params_str}.")
497
+ return
498
+
499
+ # 4) Execute the tool.
500
+ print(f"{BOLD}{YELLOW}Tool Call:{RESET} LLM is trying to call: {name} with {tool_params}.")
501
+ func = self.tools_dict[name]["function"]
502
+ result = call_function_with_timeout(func, tool_params, name, timeout_seconds=self.args.tool_timeout)
503
+
504
+ self.console.tool_group(name, tool_params, result)
505
+ self._append_tool_result(call_id, str(result))
506
+
507
+ def _ask_permission(self, name, params_str, shell_cmd, tier, reason):
508
+ """Prompt the user to confirm a tool call (ask-permission mode)."""
509
+ self.console.emit("")
510
+ if shell_cmd is not None and shell_safety_utils.is_ask(tier):
511
+ self.console.note(f"This command is flagged as risky: {shell_cmd!r} ({reason})", "warn")
512
+ try:
513
+ permission_input = input(f"{BOLD}{YELLOW}Tool permission:{RESET} Are you sure you want to call the tool? name: {name}, parameters: {params_str}\nType: 'y' or 'yes' ")
514
+ except KeyboardInterrupt:
515
+ print("\nThank you. Goodbye! Saving history...")
516
+ self.shutdown()
517
+ raise _Abort
346
518
 
347
- save_history(messages, args, unique_id)
519
+ return permission_input.lower() in ["y", "yes"]
348
520
 
521
+ def _ask_risky_command(self, shell_cmd, reason):
522
+ """Prompt only for a shell command the classifier flagged as risky
523
+ (used when --ask-permission is off). Returns True to proceed."""
524
+ self.console.note(f"This shell command is flagged as risky: {shell_cmd!r} ({reason})", "warn")
525
+ try:
526
+ permission_input = input(f"Type: 'y' or 'yes' to run this command: ")
349
527
  except KeyboardInterrupt:
350
- print(f"{BOLD}{RED}INTERRUPTING:{RESET} Mini Code Exiting...")
528
+ print("\nThank you. Goodbye! Saving history...")
529
+ self.shutdown()
530
+ raise _Abort
531
+
532
+ if permission_input.lower() not in ["y", "yes"]:
533
+ self.console.note("Command not run.", "warn")
534
+ self._append_tool_result(None, f"User declined to run risky command: {shell_cmd!r}.")
535
+ return False
536
+ return True
537
+
538
+ def _append_tool_result(self, call_id, content):
539
+ self.messages.append({"role": "tool", "tool_call_id": call_id, "content": str(content)})
540
+
541
+ def _print_assistant(self, text):
542
+ self.console.turn("Assistant", text, accent="green")
543
+
544
+ def _persist(self):
545
+ save_history(self.messages, self.args, self.unique_id)
546
+ usage_path = save_usage(self.token_stats, self.cache_dir, self.unique_id)
547
+ self.console.note(f"Saved usage to: {usage_path}", "info")
548
+ print_usage_summary(self.token_stats)
351
549
 
352
- except Exception as e:
353
- print(f"{BOLD}{RED}FATAL ERROR:{RESET} Mini Code Crashed...:\n{e}")
354
-
355
- finally:
356
- print(f"Saving history to: {cache_dir}/{unique_id}")
357
- save_history(messages, args, unique_id)
550
+ def shutdown(self):
551
+ """Persist and print a final summary (called on quit / exit)."""
552
+ self.console.note(f"Saving history to: {self.cache_dir}/history_{self.unique_id}.json", "info")
553
+ self._persist()
554
+
555
+
556
+ class _Abort(Exception):
557
+ """Raised to unwind the permission prompt after a Ctrl-C / shutdown."""
558
+
559
+
560
+ def needs_permission(args, shell_cmd, shell_tier):
561
+ """Whether a tool call needs a manual confirmation.
562
+
563
+ True when either --ask-permission was passed, or the shell-safety classifier
564
+ marked the command as risky (ASK tier) and we still need to confirm it.
565
+ """
566
+ if getattr(args, "ask_permission", False):
567
+ return True
568
+ return shell_cmd is not None and shell_safety_utils.is_ask(shell_tier)
569
+
570
+
571
+ def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir, model_name=None):
572
+ """Render the startup info banner through the UI."""
573
+ version = __import__('mini_code_cli').__version__
574
+
575
+ permission_mode = "All tool calls require permission." if args.ask_permission else "Tool calls execute without asking."
576
+ agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
577
+ shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
578
+
579
+ ui.console.welcome(version, unique_id, cache_dir, allowed_dir,
580
+ server_url, model_name, permission_mode, agent_mode, shell_mode)
581
+
582
+
583
+ def main():
584
+ args = parse_args()
585
+ session = AgentSession(args)
586
+ session.run(initial_prompt=args.prompt)
358
587
 
359
588
  if __name__ == "__main__":
360
- main()
589
+ main()