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.
- mini_code_cli/agent_loop.py +483 -242
- 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.1.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.1.dist-info → mini_code_cli-0.0.8.3.dist-info}/WHEEL +1 -1
- mini_code_cli-0.0.8.1.dist-info/RECORD +0 -11
- {mini_code_cli-0.0.8.1.dist-info → mini_code_cli-0.0.8.3.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.0.8.1.dist-info → mini_code_cli-0.0.8.3.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.0.8.1.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,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="
|
|
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.")
|
|
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.
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
260
|
-
print(f"{BOLD}{GREEN}Assistant:{RESET} {response_text}")
|
|
407
|
+
self._print_assistant(response_text)
|
|
261
408
|
|
|
262
409
|
if tool_calls:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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()
|