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.
- mini_code_cli/agent_loop.py +477 -248
- mini_code_cli/shell_safety_utils.py +168 -0
- mini_code_cli/tools.py +28 -17
- mini_code_cli/ui.py +326 -0
- mini_code_cli/utils.py +72 -11
- {mini_code_cli-0.0.8.2.dist-info → mini_code_cli-0.0.8.3.dist-info}/METADATA +52 -5
- mini_code_cli-0.0.8.3.dist-info/RECORD +13 -0
- {mini_code_cli-0.0.8.2.dist-info → mini_code_cli-0.0.8.3.dist-info}/WHEEL +1 -1
- mini_code_cli-0.0.8.2.dist-info/RECORD +0 -11
- {mini_code_cli-0.0.8.2.dist-info → mini_code_cli-0.0.8.3.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.0.8.2.dist-info → mini_code_cli-0.0.8.3.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.0.8.2.dist-info → mini_code_cli-0.0.8.3.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -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 =
|
|
52
|
+
cache_dir = DEFAULT_CACHE_LOCATION
|
|
42
53
|
os.makedirs(cache_dir, exist_ok=True)
|
|
43
|
-
|
|
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="
|
|
56
|
-
parser.add_argument("--max-tokens", type=int, help="Maximum tokens for LLM response")
|
|
57
|
-
parser.add_argument("--temperature", type=float, default=
|
|
58
|
-
parser.add_argument("--model", type=str,
|
|
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.
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
433
|
+
if user_input.lower() in self.EXIT_COMMANDS:
|
|
434
|
+
return None
|
|
186
435
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
messages.append({"role": "user", "content": agent_md_content})
|
|
442
|
+
return user_input
|
|
198
443
|
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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()
|