mini-code-cli 0.0.8.1__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,15 +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.")
63
-
109
+ parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
64
110
 
65
111
  # Agent behaviour related
66
112
  parser.add_argument("--system-prompt", type=str, help="Replace system prompt, with a custom system prompt.")
@@ -70,70 +116,40 @@ def parse_args():
70
116
 
71
117
  # Agent permissions related
72
118
  parser.add_argument("--ask-permission", action="store_true", help="Ask for permission before any tool call.")
73
- parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations")
74
- 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}")
75
123
 
76
124
 
77
125
  args = parser.parse_args()
78
126
  return args
79
127
 
80
128
 
81
- def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir):
82
- # Calculate the length of the longest line inside the box
83
- version = __import__('mini_code_cli').__version__
84
-
85
- permission_mode = "All tool calls require permission." if args.ask_permission else "Tool calls will execute without asking for permission."
86
- agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
87
- shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
88
-
89
- lines = [
90
- f" MiniCodeCLI (v{version})",
91
- f" The session-id is: {unique_id}" ,
92
- f" It will be saved under: `{cache_dir}`",
93
- " Type 'quit' or 'exit' to exit the loop.",
94
- f" Allowed dir: {allowed_dir}",
95
- f" Server URL: {server_url}",
96
- f" {permission_mode}",
97
- f" {agent_mode}",
98
- f" {shell_mode}"
99
- ]
100
- max_len = max(len(line) for line in lines)
101
- margin = 2 # Space on each side inside the box
102
- inner_width = max_len + 2 * margin
103
- # Build the box
104
- top_bottom = "╔" + "═" * inner_width + "╗"
105
- print(top_bottom)
106
- for line in lines:
107
- # Pad the line to inner_width characters
108
- padded = line.ljust(inner_width)
109
- print(f"║{padded}║")
110
- bottom = "╚" + "═" * inner_width + "╝"
111
- print(bottom)
112
-
113
- def print_help(allowed_dir, server_url, args, unique_id, cache_dir):
114
- print_welcome(allowed_dir, server_url, args, unique_id, cache_dir)
115
-
116
- help_message = ""
117
- 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."
118
-
119
- 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")
120
137
 
121
- def call_function_with_timeout(func, tool_params, name):
122
- """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`."""
123
140
  class TimeoutError(Exception):
124
141
  pass
125
142
 
126
143
  def timeout_handler(signum, frame):
127
144
  raise TimeoutError("Function call timed out")
128
145
 
129
- timeout_seconds = 30 # Set your desired timeout in seconds
130
146
  signal.signal(signal.SIGALRM, timeout_handler)
131
147
  signal.alarm(timeout_seconds)
132
148
  try:
133
149
  result = func(**tool_params)
134
150
  except TimeoutError:
135
- print(f"{BOLD}{RED}Error:{RESET} Function {name} timed out after {timeout_seconds} seconds.")
136
- 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."
137
153
  finally:
138
154
  signal.alarm(0) # Disable the alarm
139
155
 
@@ -141,208 +157,433 @@ def call_function_with_timeout(func, tool_params, name):
141
157
 
142
158
 
143
159
 
144
- def main():
145
- 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`.
146
162
 
147
- # Get session unique ID:
148
- unique_id = generate_unique_id()
149
- 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
150
216
 
151
- # LLM Related
152
- server_url = f"{args.url}"
153
- api_key = None
154
- if args.api_key:
155
- api_key = args.api_key
156
- else:
157
- api_key = os.environ.get("MINI_CODE_API_KEY", "")
158
- if api_key:
159
- 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
160
309
  else:
161
- print(f"{YELLOW}NO API Key found at MINI_CODE_API_KEY. Set it with export MINI_CODE_API_KEY='sk-api-key'{RESET}")
162
-
163
-
164
- # Permissions related
165
- allowed_dir = os.path.abspath(args.allowed_dir)
166
-
167
- if not args.enable_shell:
168
- forbidden_tools = tools.shell_tools
169
- else:
170
- forbidden_tools = None
310
+ system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir)
171
311
 
172
- # TOOLs
173
- tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden_tools)
174
- llm_tools_dict = tools.util_get_tools(forbidden_tools=forbidden_tools)
312
+ messages = [{"role": "system", "content": system_prompt}]
175
313
 
176
-
177
- # System Message and Agent.md
178
- if args.system_prompt:
179
- system_prompt = args.system_prompt
180
- else:
181
- system_prompt = prompts.system_prompt(tools_dict=tools_dict, allowed_dir=allowed_dir)
182
-
183
- messages = [{"role": "system", "content": system_prompt}]
184
-
185
- if args.agent_md:
186
- print(f"{YELLOW}AGENT.md '{args.agent_md}' specified.{RESET}")
187
-
188
- # Check if the file exists
189
- if not os.path.isfile(args.agent_md):
190
- print(f"{RED}Error: The AGENT.md '{args.agent_md}' does not exist.{RESET}")
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)
191
331
  else:
192
- # Load the content of agent-md file and add it after system message
193
- with open(args.agent_md, 'r', encoding='utf-8') as f:
194
- agent_md_content = f.read()
195
- print(f"{YELLOW}AGENT.md '{args.agent_md}' loaded.{RESET}")
196
-
197
- # Append the content as a user message after the system prompt
198
- messages.append({"role": "user", "content": agent_md_content})
199
-
200
- print_welcome(allowed_dir, server_url, args, unique_id, cache_dir)
201
-
202
- # Agent Logic Flags
203
- llm_response_flag = False
204
- llm_tool_response_flag = False
205
- llm_repeat_flag = False
206
- tool_count = 0
207
-
208
- user_input = args.prompt
209
-
210
- while True:
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
211
361
 
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! Saving history...")
227
- save_history(messages, args, unique_id)
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:
228
368
  break
229
- except KeyboardInterrupt:
230
- print("\nThank you. Goodbye! Saving history...")
231
- save_history(messages, args, unique_id)
232
- break
233
- else:
234
- print("-"*30)
235
- print(f"{BOLD}{CYAN}You (preset): {RESET}{user_input}")
236
-
237
- if user_input == "/help":
238
- print_help(allowed_dir, server_url, args, unique_id, cache_dir)
239
- user_input = ""
240
- continue
241
-
242
- messages.append({"role": "user", "content": user_input})
243
- user_input = ""
244
-
245
- llm_response_flag = False
246
- llm_tool_response_flag = False
247
-
248
- response_text, tool_calls = utils.call_openai_server(
249
- messages,
250
- max_tokens=args.max_tokens,
251
- temperature=args.temperature,
252
- model=args.model,
253
- server_url=server_url,
254
- tools=llm_tools_dict,
255
- api_key=api_key,
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,
256
387
  )
257
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
+
258
406
  if response_text:
259
- llm_response_flag = True
260
- print(f"{BOLD}{GREEN}Assistant:{RESET} {response_text}")
407
+ self._print_assistant(response_text)
261
408
 
262
409
  if tool_calls:
263
- llm_tool_response_flag = True
264
-
265
- messages.append({
266
- "role": "assistant",
267
- "content": response_text,
268
- "tool_calls": tool_calls
410
+ self.messages.append({
411
+ "role": "assistant",
412
+ "content": response_text,
413
+ "tool_calls": tool_calls,
269
414
  })
270
- # Tool call parsing and execution
271
- print(f"{BOLD}{YELLOW}Tool Call:{RESET} LLM is trying to call {len(tool_calls)} tools.")
272
-
273
- for call in tool_calls:
274
- tool_count += 1
275
-
276
- call_id = call.get("id", "")
277
- name = call.get("function", {}).get("name", "")
278
- params_str = call.get("function", {}).get("arguments", "{}")
279
- try:
280
- tool_params = json.loads(params_str)
281
- except json.JSONDecodeError:
282
- print(f"{BOLD}{RED}Warning:{RESET} Error parsing arguments for tool {name}")
283
- continue
284
-
285
- if name in tools_dict:
286
- # Check allowed directory for file operations
287
- if name in ["read_file", "write_file", "grep", "glob"]:
288
- filepath = tool_params.get("filepath", "")
289
- if name == "write_file":
290
- filepath = tool_params.get("filepath", "")
291
- if filepath and not os.path.abspath(filepath).startswith(allowed_dir):
292
- print(f"{BOLD}{RED}Warning:{RESET} File operation not allowed outside allowed directory: {allowed_dir}")
293
- continue
294
-
295
- func = tools_dict[name]["function"]
296
- # asking permissions
297
- if args.ask_permission:
298
- print(f"{BOLD}{YELLOW}Tool permission:{RESET} Are you sure you want to call the tool?")
299
- print(f"name: {name}, parameters: {params_str}")
300
- try:
301
- permission_input = input("Type: 'y' or 'yes'\n")
302
- except KeyboardInterrupt:
303
- print("\nThank you. Goodbye! Saving history...")
304
- save_history(messages, args, unique_id)
305
- sys.exit(0)
306
-
307
- if not permission_input in ['y', 'yes']:
308
- print(f"{BOLD}{YELLOW}Tool permission denied:{RESET}.")
309
- messages.append({
310
- "role": "user",
311
- "content": f"User denied permission for tool {name} and params {params_str}."
312
- })
313
- continue
314
-
315
- print(f"{BOLD}{YELLOW}Tool Call:{RESET} LLM is trying to call: {name} with {tool_params}.")
316
- result = call_function_with_timeout(func, tool_params, name)
317
- # Limiting output to 300 characters for readability
318
- result_str = str(result)
319
- if len(result_str) > 300:
320
- result_str = result_str[:300] + "... (truncated to 300 chars)"
321
- print(f"{BOLD}{MAGENTA}Tool '{name}' returned:{RESET} {result_str}")
322
-
323
- messages.append({
324
- "role": "tool",
325
- "tool_call_id": call_id,
326
- "content": str(result)
327
- })
328
- else:
329
- print(f"{BOLD}{RED}Error:{RESET} Unknown tool: {name}, params {params_str}")
330
- messages.append({
331
- "role": "tool",
332
- "tool_call_id": call_id,
333
- "content": f"Error: Unknown tool: {name}, params {params_str}"
334
- })
415
+ self._run_tool_calls(tool_calls)
335
416
  else:
336
- messages.append({
337
- "role": "assistant",
338
- "content": response_text,
339
- # "tool_calls": tool_calls
340
- })
341
-
342
- if not llm_response_flag and not llm_tool_response_flag:
343
- print(f"{BOLD}{RED}Warning:{RESET} No response was given by the LLM.")
417
+ self.messages.append({"role": "assistant", "content": response_text})
418
+
419
+ if not response_text and not made_tool_calls:
420
+ self.console.note("Warning: no response was given by the LLM.", "error")
421
+
422
+ return made_tool_calls and self.args.auto_mode
423
+
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
432
+
433
+ if user_input.lower() in self.EXIT_COMMANDS:
434
+ return None
435
+
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()
441
+
442
+ return user_input
443
+
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)
448
+
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", "{}")
453
+
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
486
+
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)
492
+ else:
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
518
+
519
+ return permission_input.lower() in ["y", "yes"]
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: ")
527
+ except KeyboardInterrupt:
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)
549
+
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."
344
578
 
345
- save_history(messages, args, unique_id)
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)
346
587
 
347
588
  if __name__ == "__main__":
348
- main()
589
+ main()