mini-code-cli 0.0.8.2__py3-none-any.whl → 0.0.8.4__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.
@@ -1,360 +1,1095 @@
1
- """
1
+ """mini_code_cli.agent_loop -- the interactive agent loop.
2
+
2
3
  Usage:
3
4
  mini-code
4
5
 
5
- Prompt:
6
+ This module owns the top-level CLI: argument parsing, the welcome/help banners
7
+ and the :class:`AgentSession` that drives the read-input -> call-the-LLM -> run
8
+ tool-calls -> persist loop.
9
+
10
+ The pieces that had grown organically inside this file have been extracted into
11
+ smaller, focused modules so each concern is easy to read and test:
6
12
 
7
- write a similar model.py file but for a LSTM based language model. Make it minimal and call it model_lstm.py it should still be compatible with eval.py
13
+ * :mod:`mini_code_cli.persistence` -- session histories / recorder / usage on disk.
14
+ * :mod:`mini_code_cli.sandbox` -- the ``--allowed-dir`` path sandbox.
15
+ * :mod:`mini_code_cli.permissions` -- pure "does this need a prompt?" helpers.
16
+ * :mod:`mini_code_cli.runner` -- running a tool with a wall-clock timeout.
17
+ * :mod:`mini_code_cli.config` -- shared default settings.
18
+ * :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
8
19
 
20
+ For backward compatibility (and the test-suite) the most commonly imported
21
+ helpers are re-exported from here, so ``from mini_code_cli.agent_loop import ...``
22
+ keeps working.
9
23
  """
10
24
 
11
25
  import os
12
26
  import json
13
- import signal
14
-
15
- import time
16
- import uuid
17
-
18
27
  import argparse
19
- import sys
20
28
 
21
29
  from . import prompts
30
+ from . import shell_safety_utils
22
31
  from . import tools
23
32
  from . import utils
33
+ from . import ui
34
+ from . import subagents
35
+ from . import plan as plan_utils
36
+ from .config import (
37
+ DEFAULT_CACHE_LOCATION,
38
+ DEFAULT_MODEL_URL,
39
+ DEFAULT_TEMPERATURE,
40
+ DEFAULT_TOOL_TIMEOUT,
41
+ DEFAULT_HTTP_TIMEOUT,
42
+ DEFAULT_MAX_MESSAGES,
43
+ )
44
+ from .persistence import (
45
+ save_history,
46
+ save_summary,
47
+ save_usage,
48
+ load_history,
49
+ load_history_from_dir,
50
+ load_summary_from_dir,
51
+ load_usage_from_dir,
52
+ find_last_session_dir,
53
+ accumulate_usage,
54
+ generate_unique_id,
55
+ _restore_dir,
56
+ )
57
+ from .sandbox import is_within_allowed_dir, tool_paths_are_allowed, _FILE_TOOL_PATH_ARGS
58
+ from .permissions import (
59
+ needs_permission,
60
+ shell_forces_ask_permission,
61
+ sudo_disables_prompts,
62
+ )
63
+ from .runner import call_function_with_timeout
24
64
 
25
65
 
26
- # ANSI escape codes for colors and bold
27
- BOLD = '\033[1m'
28
- RED = '\033[91m'
29
- GREEN = '\033[92m'
30
- YELLOW = '\033[93m'
31
- BLUE = '\033[94m'
32
- MAGENTA = '\033[95m'
33
- CYAN = '\033[96m'
34
- RESET = '\033[0m'
35
-
36
- def save_history(messages, args, session_name=""):
37
- # Save messages to history.json
38
- if args.cache_dir:
39
- cache_dir = args.cache_dir
40
- else:
41
- cache_dir = os.path.expanduser("~/.cache/mini_code/sessions/")
42
- os.makedirs(cache_dir, exist_ok=True)
43
- history_path = os.path.join(cache_dir, f"history_{session_name}.json")
44
- with open(history_path, 'w') as f:
45
- json.dump(messages, f, indent=2)
66
+ __all__ = [
67
+ # re-exported helpers (kept for the test-suite / external callers)
68
+ "is_within_allowed_dir",
69
+ "tool_paths_are_allowed",
70
+ "_FILE_TOOL_PATH_ARGS",
71
+ "maybe_compact",
72
+ "accumulate_usage",
73
+ "shell_forces_ask_permission",
74
+ "sudo_disables_prompts",
75
+ "needs_permission",
76
+ "call_function_with_timeout",
77
+ "save_history",
78
+ "save_summary",
79
+ "load_history",
80
+ "generate_unique_id",
81
+ "find_last_session_dir",
82
+ # public classes / entry points
83
+ "AgentSession",
84
+ "main",
85
+ "parse_args",
86
+ ]
87
+
46
88
 
47
- def generate_unique_id():
48
- """Generate a unique ID based on current time and a random component."""
49
- timestamp = int(time.time() * 1000000) # microseconds
50
- return f"{timestamp}-{uuid.uuid4().hex[:8]}"
89
+ # ---------------------------------------------------------------------------
90
+ # argument parsing
91
+ # ---------------------------------------------------------------------------
51
92
 
52
93
  def parse_args():
53
94
  parser = argparse.ArgumentParser(description="Agent loop for interacting with a language model")
54
95
  # 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")
96
+ parser.add_argument("--url", type=str, default=f"{DEFAULT_MODEL_URL}", help=f"API host, default={DEFAULT_MODEL_URL}")
97
+ parser.add_argument("--max-tokens", type=int, help="Maximum tokens for LLM response, default=None")
98
+ parser.add_argument("--temperature", type=float, default=DEFAULT_TEMPERATURE, help=f"Temperature for LLM, default={DEFAULT_TEMPERATURE}")
99
+ parser.add_argument("--model", type=str, help="Model name, default=None")
59
100
  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
101
 
61
102
  # Other settings:
62
- parser.add_argument("--cache-dir", type=str, help="The cache dir for the session histories.")
103
+ parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
104
+ parser.add_argument("--restore-session", type=str, help="The location of a previous session to continue. (e.g. ~/.cache/mini_code/sessions/1792028090823027-a9d77653/)")
105
+ parser.add_argument("-l","--load-last-session", action="store_true", help="Load the most recent previous session from the cache dir and continue it (under a new session id), instead of starting fresh. --restore-session takes precedence if also given.")
63
106
 
64
107
  # Agent behaviour related
65
108
  parser.add_argument("--system-prompt", type=str, help="Replace system prompt, with a custom system prompt.")
66
- parser.add_argument("--auto-mode", action="store_true", help="Whether to run the agent in `auto-mode'. Or default: `manual-mode'.")
109
+ parser.add_argument("-a", "--auto-mode", action="store_true", help="Whether to run the agent in `auto-mode'. Or default: `manual-mode'.")
67
110
  parser.add_argument("--agent-md", type=str, help="If the agent should use an agent-md file... (it will added after system message.)")
68
111
  parser.add_argument("--prompt", type=str, help="An initial prompt from the user...")
69
112
 
70
113
  # Agent permissions related
71
114
  parser.add_argument("--ask-permission", action="store_true", help="Ask for permission before any tool call.")
72
- parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations")
73
- parser.add_argument("--enable-shell", action="store_true", help="Allow shell execution. Default: False") #todo
115
+ parser.add_argument("--sudo", action="store_true", help="DANGEROUS: never ask for permission. Every non-destructive tool call and shell command runs automatically without confirmation. Destructive (DENY-tier) commands are still refused.")
116
+ parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations, default='./'")
117
+ parser.add_argument("-s", "--enable-shell", action="store_true", help="Allow shell execution. default= False")
118
+ parser.add_argument("-g","--enable-subagents", action="store_true", help="Allow the agent to delegate work to sub-agents (exposed as extra tools). default= False")
119
+ 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}")
120
+ 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}")
121
+ parser.add_argument("-p","--plan", action="store_true", help="Enable plan mode: the agent keeps a CURRENT_PLAN.md (its step-by-step plan, marked finished when the goal is done) and CURRENT_GOAL.md (the user's latest instruction) in the session directory. Also changes how history compaction works.")
74
122
 
123
+ # Output verbosity
124
+ parser.add_argument("--verbose", action="store_true", help="Print the full, detailed output. Without it (default), only user/assistant turns, token usage and tool calls are shown.")
75
125
 
76
126
  args = parser.parse_args()
77
127
  return args
78
128
 
79
129
 
80
- def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir):
81
- # Calculate the length of the longest line inside the box
130
+ # ---------------------------------------------------------------------------
131
+ # banners / helpers
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def style_line_prompt(text):
135
+ """A short, styled inline prompt (used for the 'You:' line)."""
136
+ return ui.console.style(text, bold=True, fg="cyan") + " "
137
+
138
+
139
+ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
140
+ ui.console.rule("help", fg="cyan")
141
+ if not args.auto_mode:
142
+ help_message = "Manual-mode: the agent asks for your input after each LLM prediction."
143
+ else:
144
+ help_message = "Auto-mode: the agent keeps calling the LLM until no further tool calls are possible."
145
+ ui.console.emit(ui.console.style(help_message, dim=True, fg="cyan"))
146
+ ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
147
+ ui.console.emit(" /history list the conversation (turn number, what happened, where)")
148
+ ui.console.emit(" /history <n> show the full messages recorded for turn n")
149
+ ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
150
+ ui.console.emit(" /clear clear the screen (keeps the conversation history)")
151
+ ui.console.emit(" /help show this help")
152
+ ui.console.emit(" /quit, /exit save the session and exit")
153
+ if args.enable_subagents or args.verbose:
154
+ ui.console.emit(ui.console.style("Sub-agents:", bold=True, fg="cyan"))
155
+ for sa in subagents.get_registry().values():
156
+ ui.console.emit(f" {sa.name:16} {sa.description}")
157
+ ui.console.rule(char="═", fg="cyan")
158
+
159
+
160
+ def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir, model_name=None):
161
+ """Render the startup info banner through the UI."""
82
162
  version = __import__('mini_code_cli').__version__
83
-
84
- permission_mode = "All tool calls require permission." if args.ask_permission else "Tool calls will execute without asking for permission."
163
+
164
+ if getattr(args, "sudo", False):
165
+ permission_mode = "SUDO MODE: no permission prompts (DENY commands still blocked)."
166
+ elif args.ask_permission:
167
+ permission_mode = "All tool calls require permission."
168
+ else:
169
+ permission_mode = "Tool calls execute without asking."
85
170
  agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
86
171
  shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
87
172
 
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}")
119
-
120
- def call_function_with_timeout(func, tool_params, name):
121
- """calls function with params"""
122
- class TimeoutError(Exception):
123
- pass
124
-
125
- def timeout_handler(signum, frame):
126
- raise TimeoutError("Function call timed out")
127
-
128
- timeout_seconds = 30 # Set your desired timeout in seconds
129
- signal.signal(signal.SIGALRM, timeout_handler)
130
- signal.alarm(timeout_seconds)
173
+ ui.console.welcome(version, unique_id, cache_dir, allowed_dir,
174
+ server_url, model_name, permission_mode, agent_mode, shell_mode)
175
+
176
+
177
+ def _make_tool_call_note(console, count):
178
+ console.note(f"LLM is trying to call {count} tool(s).", "info")
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # history compaction
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES):
186
+ """Compact the conversation history once it grows beyond ``max_messages``.
187
+
188
+ Long (especially auto-mode) sessions can overflow the model's context window.
189
+ When the history gets too large we ask the LLM to summarize it and replace the
190
+ old turns with that summary, keeping the original system prompt as the first
191
+ message. The compaction call itself is a single, tool-free request, so it
192
+ cannot trigger further tool calls or recurse into compaction.
193
+ """
194
+ if len(messages) <= max_messages:
195
+ # No compaction: keep the same segment (history_index) and signal the
196
+ # caller with (messages, did_compact=False, unchanged_index). Returning
197
+ # the full triple here is required -- callers unpack all three, so a
198
+ # bare messages would crash them.
199
+ return messages, False, history_index
200
+
131
201
  try:
132
- result = func(**tool_params)
133
- except TimeoutError:
134
- print(f"{BOLD}{RED}Error:{RESET} Function {name} timed out after {timeout_seconds} seconds.")
135
- result = "Error:Function {name} timed out after {timeout_seconds} seconds."
136
- finally:
137
- signal.alarm(0) # Disable the alarm
138
-
139
- return result
202
+ ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
203
+ summary_prompt = prompts.compaction_prompt(messages)
204
+ summary, _, compact_usage = utils.call_openai_server(
205
+ [{"role": "user", "content": summary_prompt}],
206
+ max_tokens=1024,
207
+ temperature=0.0,
208
+ model=model,
209
+ server_url=server_url,
210
+ api_key=api_key,
211
+ timeout=llm_timeout,
212
+ )
213
+ accumulate_usage(token_stats, compact_usage)
214
+ if summary:
215
+ system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
216
+ compacted = []
217
+ if system_content:
218
+ compacted.append({"role": "system", "content": system_content})
219
+ compacted.append({
220
+ "role": "user",
221
+ "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
222
+ })
223
+ ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
224
+ # A new segment (history_index + 1) is started on compaction: the
225
+ # compacted messages are its snapshot, and every later turn appends
226
+ # to it. The caller records a single high-level 'summary' turn for
227
+ # this index so the recorder stays flat across the rollover.
228
+ return compacted, True, history_index + 1
229
+ except Exception as e:
230
+ ui.console.note(f"Failed to compact history: {e}", "error")
140
231
 
232
+ return messages, False, history_index
141
233
 
142
234
 
143
- def main():
144
- args = parse_args()
235
+ # ---------------------------------------------------------------------------
236
+ # the agent session
237
+ # ---------------------------------------------------------------------------
145
238
 
146
- # Get session unique ID:
147
- unique_id = generate_unique_id()
148
- cache_dir = args.cache_dir if args.cache_dir else "~/.cache/mini_code/sessions/"
239
+ class AgentSession:
240
+ """A single interactive agent session.
149
241
 
150
- # LLM Related
151
- server_url = f"{args.url}"
152
- api_key = None
153
- if args.api_key:
154
- api_key = args.api_key
155
- else:
156
- api_key = os.environ.get("MINI_CODE_API_KEY", "")
157
- if api_key:
158
- print(f"{YELLOW}API Key found at MINI_CODE_API_KEY{RESET}")
159
- else:
160
- print(f"{YELLOW}NO API Key found at MINI_CODE_API_KEY. Set it with export MINI_CODE_API_KEY='sk-api-key'{RESET}")
242
+ The whole loop used to live in one ~300 line ``main()``. Splitting it into
243
+ small, named methods keeps each step (read input -> call the LLM -> run tool
244
+ calls -> persist) easy to follow and gives the interesting bits somewhere to
245
+ be unit-tested.
246
+ """
161
247
 
162
-
163
- # Permissions related
164
- allowed_dir = os.path.abspath(args.allowed_dir)
248
+ EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
249
+ CLEAR_COMMANDS = {"clear", "/clear"}
165
250
 
166
- if not args.enable_shell:
167
- forbidden_tools = tools.shell_tools
168
- else:
169
- forbidden_tools = None
251
+ def __init__(self, args, console=None):
252
+ self.args = args
253
+ self.allowed_dir = os.path.abspath(args.allowed_dir)
254
+ self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
170
255
 
171
- # TOOLs
172
- tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden_tools)
173
- llm_tools_dict = tools.util_get_tools(forbidden_tools=forbidden_tools)
256
+ # The console that renders everything the agent prints. Unit tests /
257
+ # other callers can swap it out (e.g. AgentSession(args, console=...) with
258
+ # a color=False console) but it defaults to the shared ui.console.
259
+ self.console = console if console is not None else ui.console
260
+ # --verbose toggles the amount of detail printed. Defaults to False so
261
+ # the quiet, transcript-only view is the out-of-the-box experience.
262
+ self.console.verbose = getattr(args, "verbose", False)
174
263
 
264
+ # LLM / server configuration.
265
+ self.server_url = f"{args.url}"
266
+ model_name = utils.determine_vllm_model(self.server_url)
267
+ self.model = model_name if model_name else args.model
268
+ self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
175
269
 
176
- # System Message and Agent.md
177
- if args.system_prompt:
178
- system_prompt = args.system_prompt
179
- else:
180
- system_prompt = prompts.system_prompt(tools_dict=tools_dict, allowed_dir=allowed_dir)
270
+ # Which tools the LLM may call. The shell tool is only available when
271
+ # --enable-shell was passed.
272
+ forbidden = None if args.enable_shell else tools.shell_tools
273
+ self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
274
+ self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
275
+
276
+ # Sub-agents (opt-in via --enable-subagents). Each registered sub-agent
277
+ # becomes an extra tool the model can call; see subagents/. A child
278
+ # session built by a sub-agent never sets enable_subagents, so sub-agents
279
+ # cannot recursively spawn sub-agents.
280
+ self.enable_subagents = bool(getattr(args, "enable_subagents", False))
281
+ self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
282
+ for sa in self.subagents_registry.values():
283
+ self.llm_tools.append(sa.tool_schema())
284
+ # Marked so _run_one_tool_call knows to delegate rather than call a
285
+ # real function.
286
+ self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
287
+
288
+ # Token accounting for the whole session.
289
+ self.token_stats = {
290
+ "prompt_tokens": 0,
291
+ "completion_tokens": 0,
292
+ "total_tokens": 0,
293
+ "llm_calls": 0,
294
+ "tool_calls": 0,
295
+ }
296
+
297
+ self.unique_id = generate_unique_id()
181
298
 
182
- messages = [{"role": "system", "content": system_prompt}]
299
+ # Plan-mode tracking (see plan.py). Always instantiated so the loop can
300
+ # call it unconditionally; every method no-ops when --plan is disabled.
301
+ self.plan_mode = bool(getattr(self.args, "plan", False))
302
+ self.plan_manager = plan_utils.PlanManager(
303
+ os.path.join(self.cache_dir, self.unique_id), enabled=self.plan_mode)
304
+
305
+ # High-level recorder + history segments. Instead of a single ever-growing
306
+ # history.json, a session keeps:
307
+ # * ``history_{n}.json`` files -- one per conversation *segment*; a brand
308
+ # new segment is started (n -> n+1) every time the history is compacted.
309
+ # * ``summary_history.json`` -- a flat, high-level *recorder*: one record
310
+ # per turn (the running turn number, *what* happened -- an assistant
311
+ # reply, a tool call, or a compaction summary -- and which
312
+ # ``history_{n}.json`` + message index the turn lives in).
313
+ # The recorder lets the user inspect and rewind the conversation (/history,
314
+ # /reset) without reading the full detailed histories, because every turn's
315
+ # recorded index marks a faithful end-of-turn snapshot of its segment.
316
+ self.history_index = 0
317
+ self.summary = []
318
+
319
+ # report_path is set on a *sub-agent child* session (by
320
+ # SubAgent._build_child_args) so it can exempt its own report.md -- which
321
+ # lives under the parent's cache dir, usually outside --allowed-dir --
322
+ # from the sandbox. A normal session leaves this None.
323
+ self.report_path = getattr(args, "report_path", None)
324
+
325
+ # Conversation state. When --restore-session is given we continue that
326
+ # session's history instead of starting fresh -- still under the brand
327
+ # new unique id above, so the original session file is never
328
+ # overwritten. Falls back to a freshly built history if the restore
329
+ # file is missing or unreadable.
330
+ # --load-last-session auto-detects the most recent previous session in the
331
+ # cache dir and continues it -- exactly like --restore-session, but without
332
+ # needing to know the folder name. An explicit --restore-session always wins
333
+ # and is used verbatim; otherwise the newest session is resolved here.
334
+ restore_session = getattr(self.args, "restore_session", None)
335
+ self.loaded_last_session_dir = None
336
+ if getattr(self.args, "load_last_session", False) and not restore_session:
337
+ detected = find_last_session_dir(self.cache_dir)
338
+ if detected:
339
+ restore_session = detected
340
+ self.loaded_last_session_dir = detected
341
+ restore_directory = _restore_dir(restore_session)
342
+ restored = load_history_from_dir(restore_directory) if restore_directory else None
343
+ self.messages = restored if restored is not None else self._build_initial_messages()
344
+ if restored is not None:
345
+ self._restore_usage_if_available(restore_session)
346
+ self._load_summary_if_restored(restore_directory)
347
+ else:
348
+ # --load-last-session asked for a session but nothing usable was found:
349
+ # don't advertise a load. The recorder starts empty and the first
350
+ # assistant / tool-call turn seeds turn 1 -- mirroring the restore
351
+ # path, which also has no leading system turn, so /history turn
352
+ # numbers stay consistent whether or not we restored.
353
+ self.loaded_last_session_dir = None
183
354
 
184
- if args.agent_md:
185
- print(f"{YELLOW}AGENT.md '{args.agent_md}' specified.{RESET}")
355
+ # Snapshot the initial (or restored) messages as the first history segment
356
+ # and persist the recorder, so a session starts with on-disk state to
357
+ # inspect and rewind to.
358
+ save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
359
+ save_summary(self.summary, self.cache_dir, self.unique_id)
186
360
 
187
- # Check if the file exists
188
- if not os.path.isfile(args.agent_md):
189
- print(f"{RED}Error: The AGENT.md '{args.agent_md}' does not exist.{RESET}")
361
+ def _build_initial_messages(self):
362
+ """Assemble the system prompt and optional Agent.md into the first turns."""
363
+ if self.args.system_prompt:
364
+ system_prompt = self.args.system_prompt
190
365
  else:
191
- # Load the content of agent-md file and add it after system message
192
- with open(args.agent_md, 'r', encoding='utf-8') as f:
193
- agent_md_content = f.read()
194
- print(f"{YELLOW}AGENT.md '{args.agent_md}' loaded.{RESET}")
366
+ system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir, plan_mode=self.plan_mode)
195
367
 
196
- # Append the content as a user message after the system prompt
197
- messages.append({"role": "user", "content": agent_md_content})
368
+ messages = [{"role": "system", "content": system_prompt}]
198
369
 
199
- print_welcome(allowed_dir, server_url, args, unique_id, cache_dir)
370
+ if self.args.agent_md:
371
+ self.console.note(f"AGENT.md '{self.args.agent_md}' specified.", "warn")
372
+ if not os.path.isfile(self.args.agent_md):
373
+ self.console.note(f"Error: The AGENT.md '{self.args.agent_md}' does not exist.", "error")
374
+ else:
375
+ with open(self.args.agent_md, "r", encoding="utf-8") as f:
376
+ agent_md_content = f.read()
377
+ self.console.note(f"AGENT.md '{self.args.agent_md}' loaded.", "ok")
378
+ # Append Agent.md as a user turn, right after the system prompt.
379
+ messages.insert(1, {"role": "user", "content": agent_md_content})
380
+
381
+ return messages
382
+
383
+ def _restore_usage_if_available(self, restore_session):
384
+ """Seed this session's token totals from a sibling usage.json, if the
385
+ restored session had one, so the running summary stays continuous.
386
+
387
+ Best-effort only: token accounting is loaded on top of (never in place
388
+ of) the in-memory totals, and any problem here is ignored silently so a
389
+ missing or malformed usage file can never break a restore.
390
+ """
391
+ if not restore_session:
392
+ return
393
+ directory = _restore_dir(restore_session)
394
+ saved = load_usage_from_dir(directory)
395
+ for key in self.token_stats:
396
+ if key in saved:
397
+ self.token_stats[key] += int(saved[key] or 0)
398
+
399
+ def _load_summary_if_restored(self, directory):
400
+ """Restore the high-level recorder and segment index for a restore.
200
401
 
201
- # Agent Logic Flags
202
- llm_response_flag = False
203
- llm_tool_response_flag = False
204
- llm_repeat_flag = False
205
- tool_count = 0
206
-
207
- user_input = args.prompt
402
+ Prefers the saved ``summary_history.json`` so the recorder stays flat and
403
+ contiguous across the restore, seeding ``self.summary`` and the next
404
+ ``history_index`` from its last record. If no recorder was saved (e.g. a
405
+ session created by older code, or only a classic ``history.json``), we
406
+ rebuild a minimal recorder by scanning the restored messages for
407
+ assistant turns, so navigation still works. Best-effort only.
408
+ """
409
+ saved = load_summary_from_dir(directory)
410
+ saved = [r for r in saved if isinstance(r, dict)]
411
+ if saved:
412
+ self.summary = saved
413
+ self.history_index = int(saved[-1].get("history_file", 0))
414
+ return
415
+ turn = 0
416
+ for i, m in enumerate(self.messages):
417
+ if m.get("role") == "assistant":
418
+ turn += 1
419
+ self.summary.append({"turn": turn, "what": "assistant",
420
+ "history_file": 0, "index": i})
208
421
 
209
- while True:
422
+ def _record_turn(self, what, messages, history_index=None):
423
+ """Append one entry to the high-level recorder.
424
+
425
+ Each entry records the running turn number, a one-line *what* (assistant
426
+ reply, tool call(s), or a history compaction summary) and exactly where
427
+ the turn lives -- ``history_{history_index}.json`` at message ``index``.
428
+ ``index`` points at the last message appended this step, i.e. the
429
+ end-of-turn snapshot that replaying ``messages`` reconstructs, which is
430
+ what lets :meth:`_reset_history` rewind to that exact state.
431
+ """
432
+ if history_index is None:
433
+ history_index = self.history_index
434
+ self.summary.append({
435
+ "turn": len(self.summary) + 1,
436
+ "what": what,
437
+ "history_file": history_index,
438
+ "index": max(len(messages) - 1, 0),
439
+ })
440
+
441
+ def _show_history(self):
442
+ """Print the high-level recorder as a numbered list of turns."""
443
+ rows = [(str(r.get("turn", i + 1)),
444
+ r.get("what", "?"),
445
+ f"history_{r.get('history_file', 0)}.json#{r.get('index', 0)}")
446
+ for i, r in enumerate(self.summary)]
447
+ self.console.history_list(rows)
448
+
449
+ def _inspect_history(self, arg):
450
+ """Print the full messages recorded for a single turn."""
210
451
  try:
452
+ record = self.summary[-1] if arg == "last" else self.summary[int(arg) - 1]
453
+ except (ValueError, IndexError):
454
+ self.console.note(f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
455
+ return
456
+ messages = self._segment_upto(record)
457
+ if messages is None:
458
+ return
459
+ self.console.history_detail(record, messages, total=len(self.summary))
211
460
 
212
- if llm_tool_response_flag and args.auto_mode: #skip user input and try to call the model again until there no more tool calls.
213
- print(f"{BOLD}{YELLOW}System:{RESET} Skipping user input. Continuing with LLM calls until no more tool calls. [Tool count={tool_count}]")
214
- llm_repeat_flag = True
215
- else:
216
- # get user input
217
- if llm_repeat_flag:
218
- print(f"{BOLD}{YELLOW}System:{RESET} Called {tool_count} tools in total. Exiting tool loop.")
219
-
220
- tool_count = 0
221
- if not user_input:
222
- try:
223
- print("-"*30)
224
- user_input = input(f"{BOLD}{CYAN}You:{RESET} ")
225
- if user_input.lower() in ["quit", "exit", "/quit", "/exit"]:
226
- print("\nThank you. Goodbye!")
227
- print(f"Saving history to: {cache_dir}/{unique_id}")
228
- save_history(messages, args, unique_id)
229
- break
230
- except KeyboardInterrupt:
231
- print("\nThank you. Goodbye!")
232
- print(f"Saving history to: {cache_dir}/{unique_id}")
233
- save_history(messages, args, unique_id)
234
- break
461
+ def _segment_upto(self, record):
462
+ """Load a recorded turn's segment and truncate it at its index.
463
+
464
+ Returns the messages reproducing the conversation state at the end of
465
+ that turn, or None if the segment could not be loaded (never raises: a
466
+ missing or moved segment must not crash navigation).
467
+ """
468
+ directory = os.path.join(self.cache_dir, self.unique_id)
469
+ segment_path = os.path.join(directory, f"history_{record.get('history_file', 0)}.json")
470
+ try:
471
+ with open(segment_path, "r", encoding="utf-8") as f:
472
+ segment = json.load(f)
473
+ except Exception:
474
+ self.console.note("Could not load the history segment for that turn.", "error")
475
+ return None
476
+ idx = max(0, min(int(record.get("index", 0)), len(segment) - 1))
477
+ return segment[:idx + 1]
478
+
479
+ def _reset_history(self, keep, record):
480
+ """Rewind the conversation to the end of a recorded turn.
481
+
482
+ Rebuilds ``self.messages`` from the turn's recorded segment + index,
483
+ moves the session back onto that segment (``history_index``) and drops
484
+ everything after it -- both the extra messages and the later recorder
485
+ entries. New turns then continue from that state. Best-effort only: a
486
+ segment that cannot be loaded leaves the session unchanged.
487
+ """
488
+ messages = self._segment_upto(record)
489
+ if messages is None:
490
+ return
491
+ self.messages = messages
492
+ self.history_index = int(record.get("history_file", 0))
493
+ self.summary = self.summary[:keep]
494
+ save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
495
+ save_summary(self.summary, self.cache_dir, self.unique_id)
496
+ self.console.note(
497
+ f"Reset to turn {keep}: kept {len(self.messages)} messages "
498
+ f"(history_{self.history_index}.json up to index "
499
+ f"{int(record.get('index', 0))}).", "ok")
500
+ self.console.note("Continuing: new turns will append to this state.", "info")
501
+
502
+ def _warn_sudo(self):
503
+ """Print a prominent red warning about --sudo mode.
504
+
505
+ --sudo means the agent will never stop to ask before acting, so the user
506
+ is effectively handing over unattended control of their machine (within
507
+ the DENY-tier limits). We render it as a red box so it stands out on any
508
+ real terminal (it degrades to a plain ASCII box when colour is off).
509
+ """
510
+ self.console.print_box(
511
+ [
512
+ "SUDO MODE: the agent will NEVER ask for permission.",
513
+ "All tool calls and shell commands run automatically and unattended.",
514
+ "Only clearly-destructive (DENY-tier) commands are still refused.",
515
+ "You are responsible for everything it does on this machine.",
516
+ ],
517
+ title="DANGER",
518
+ fg="red",
519
+ )
520
+
521
+ def print_announcements(self):
522
+ """Print the info / warning banners that depend on the CLI flags."""
523
+ if self.api_key:
524
+ self.console.announce_api_key(present=True)
525
+ else:
526
+ self.console.announce_api_key(present=False)
527
+
528
+ if shell_forces_ask_permission(self.args):
529
+ self.console.announce_shell()
530
+
531
+ # --sudo hands the agent unattended control, so make that impossible to miss.
532
+ if sudo_disables_prompts(self.args):
533
+ self._warn_sudo()
534
+
535
+ if self.enable_subagents:
536
+ self.console.note(
537
+ f"Sub-agents enabled ({len(self.subagents_registry)} available: "
538
+ f"{', '.join(self.subagents_registry)}).", "info")
539
+
540
+ def run(self, initial_prompt=None):
541
+ """Drive the agent loop until the user quits."""
542
+ self.print_announcements()
543
+ print_welcome(self.allowed_dir, self.server_url, self.args,
544
+ self.unique_id, self.cache_dir, self.model)
545
+
546
+ # Report the outcome of --load-last-session up front, so the user sees
547
+ # whether we resumed an earlier conversation (under a brand-new id) or
548
+ # started fresh because no previous session was found.
549
+ if self.loaded_last_session_dir is not None:
550
+ self.console.note(
551
+ f"Loaded most recent session "
552
+ f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
553
+ f"it under a new session id.", "ok")
554
+ elif getattr(self.args, "load_last_session", False):
555
+ self.console.note(
556
+ "No previous session found to load; starting a fresh session.", "warn")
557
+
558
+ # Hint at startup so the high-level history feature is discoverable:
559
+ # /history browses the recorder, /history <n> inspects a turn, and
560
+ # /reset <n> rewinds the conversation to that turn's state.
561
+ self.console.emit(
562
+ self.console.style(
563
+ "Tip: /history to browse the conversation, /history <n> to inspect a "
564
+ "turn, /reset <n> to rewind. Type /help for all commands.",
565
+ dim=True, fg="cyan"))
566
+
567
+ # The --prompt value is a one-shot initial turn: it is sent verbatim and
568
+ # then cleared, so later turns come from the user (manual mode) or are
569
+ # produced automatically by the loop (auto mode).
570
+ pending = initial_prompt
571
+
572
+ while True:
573
+ try:
574
+ # 1) Get the user's turn: the one-shot preset, or a fresh line.
575
+ if pending is not None:
576
+ self.console.rule(title="Preset prompt", char="=", fg="cyan")
577
+ self.console.turn("You", pending, accent="cyan")
578
+ user_input = pending
579
+ pending = None
235
580
  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}")
581
+ user_input = self._read_user_line()
582
+ if user_input is None: # quit / exit
583
+ self._finish_goal_and_shutdown()
584
+ break
585
+
586
+ # Interactive slash commands are handled in place and never
587
+ # reach the LLM. _handle_command returns True when the input
588
+ # was such a command, so we just re-prompt (continue).
589
+ if user_input.startswith("/") and self._handle_command(user_input):
285
590
  continue
286
591
 
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
- })
592
+ # In plan mode, remember the user's latest instruction as the
593
+ # active goal so the loop (and plan-mode compaction) can track it
594
+ # even if the agent never writes it to disk itself.
595
+ self._capture_goal(user_input)
596
+
597
+ # 2) Hand the user turn to the model and act on its tool calls.
598
+ self.messages.append({"role": "user", "content": user_input})
599
+ while True:
600
+ should_continue = self._step()
601
+ self._persist()
602
+ if not should_continue:
603
+ break
604
+ self.console.note("Continuing with LLM calls until no more tool calls.", "info")
605
+ except _Abort:
606
+ # Ctrl-C during a permission prompt already shut the session
607
+ # down; just unwind the loop quietly.
608
+ break
609
+ except KeyboardInterrupt:
610
+ self.console.note("Interrupting: Mini Code Exiting...", "error")
611
+ # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
612
+ self._persist()
613
+
614
+ # -- autonomous drive (used by sub-agents) ------------------------------
615
+
616
+ def _drive_auto(self, initial_task, max_steps=None):
617
+ """Run unattended to completion and return the final assistant answer.
618
+
619
+ Appends ``initial_task`` as a user turn, then keeps stepping until the
620
+ model makes no further tool calls (auto-mode). ``max_steps`` caps the
621
+ number of model calls so a misbehaving sub-agent cannot loop forever.
622
+ """
623
+ self.messages.append({"role": "user", "content": initial_task})
624
+ steps = 0
625
+ while True:
626
+ should_continue = self._step()
627
+ self._persist()
628
+ steps += 1
629
+ if max_steps and steps >= max_steps:
630
+ self.console.note(
631
+ f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
632
+ break
633
+ if not should_continue:
634
+ break
635
+ return self._last_assistant_text()
636
+
637
+ def _last_assistant_text(self):
638
+ """Return the content of the most recent assistant (non tool-call) turn."""
639
+ for m in reversed(self.messages):
640
+ if m.get("role") == "assistant" and not m.get("tool_calls"):
641
+ return m.get("content") or ""
642
+ return ""
643
+
644
+ # -- plan mode ---------------------------------------------------------
645
+
646
+ def _capture_goal(self, user_input):
647
+ """Seed CURRENT_GOAL.md with the user's latest instruction (plan mode).
648
+
649
+ The loop owns the goal (the user's last instruction); the agent owns the
650
+ plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
651
+ Slash commands and the empty input are not real instructions, so they are
652
+ ignored. No-op when --plan is off.
653
+ """
654
+ if not self.plan_mode or user_input is None:
655
+ return
656
+ stripped = user_input.strip()
657
+ if not stripped or stripped.startswith("/"):
658
+ return
659
+ self.plan_manager.write_goal(user_input)
660
+
661
+ def _finish_goal_and_shutdown(self):
662
+ """Mark the active goal finished in CURRENT_PLAN.md, then quit/save."""
663
+ self._mark_current_goal_finished()
664
+ self.shutdown()
665
+
666
+ def _mark_current_goal_finished(self):
667
+ """Append a \"goal finished\" entry to the plan document (plan mode).
668
+
669
+ Uses the goal the loop last recorded (self.plan_manager.goal); a no-op
670
+ when --plan is off, so it is safe to call unconditionally.
671
+ """
672
+ self.plan_manager.mark_finished(self.plan_manager.goal)
673
+
674
+ def _apply_plan_to_compacted(self, messages):
675
+ """Re-inject the running plan + current goal after a history compaction.
676
+
677
+ Compaction replaces the transcript with a summary, which would otherwise
678
+ drop the agent's plan and the goal the loop seeded. We read them back off
679
+ disk and append them as fresh user turns so the model keeps working
680
+ against the goal it is still trying to finish. The goal is appended last
681
+ so it reads as the most recent instruction the model should act on.
682
+ """
683
+ plan_text = self.plan_manager.read_plan()
684
+ goal_text = self.plan_manager.read_goal()
685
+
686
+ # Recover the user's latest instruction from the pre-compaction tail, in
687
+ # case the loop had not seeded a goal file yet (e.g. the agent finished
688
+ # everything before ever writing CURRENT_GOAL.md).
689
+ current_input = None
690
+ for m in reversed(messages):
691
+ if m.get("role") == "user":
692
+ current_input = m.get("content")
693
+ break
694
+ if current_input and not goal_text:
695
+ self.plan_manager.write_goal(current_input)
696
+ goal_text = self.plan_manager.read_goal()
697
+
698
+ rebuilt = list(messages)
699
+ if plan_text and plan_text.strip():
700
+ rebuilt.append({"role": "user",
701
+ "content": f"Here is the current plan for the active goal (from CURRENT_PLAN.md):\n{plan_text}"})
702
+ if goal_text and goal_text.strip():
703
+ rebuilt.append({"role": "user",
704
+ "content": f"The current goal / latest user instruction (from CURRENT_GOAL.md):\n{goal_text}"})
705
+ elif current_input:
706
+ rebuilt.append({"role": "user", "content": current_input})
707
+ return rebuilt
708
+
709
+ def _is_plan_file(self, name, tool_params):
710
+ """Whether a read/write targets a plan-mode file in the session dir.
711
+
712
+ Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
713
+ session directory is usually outside --allowed-dir. Scoped tightly: plan
714
+ mode only, only read_file / write_file, only when the resolved path is a
715
+ plan file living directly in this session's directory.
716
+ """
717
+ if not self.plan_mode or name not in ("read_file", "write_file"):
718
+ return False
719
+ path = tool_params.get("file_path", "")
720
+ if not path:
721
+ return False
722
+ try:
723
+ resolved = os.path.realpath(path)
724
+ except (ValueError, TypeError):
725
+ return False
726
+ session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
727
+ if os.path.dirname(resolved) != session:
728
+ return False
729
+ return os.path.basename(resolved) in (
730
+ plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
731
+
732
+ def _is_report_file(self, name, tool_params):
733
+ """Whether a write targets this session's own sub-agent report.md.
734
+
735
+ A sub-agent writes its report.md under the parent's cache dir, which
736
+ normally sits outside --allowed-dir. This exempts that single, known file
737
+ so the sub-agent can actually write it (otherwise the sandbox would block
738
+ the write and the parent would never receive the report). Scoped tightly:
739
+ only when this is a child session (report_path set), only read_file /
740
+ write_file, and only when the resolved path is exactly the report file.
741
+ """
742
+ if not self.report_path or name not in ("read_file", "write_file"):
743
+ return False
744
+ path = tool_params.get("file_path", "")
745
+ if not path:
746
+ return False
747
+ try:
748
+ resolved = os.path.realpath(path)
749
+ except (ValueError, TypeError):
750
+ return False
751
+ try:
752
+ expected = os.path.realpath(self.report_path)
753
+ except (ValueError, TypeError):
754
+ return False
755
+ return resolved == expected
756
+
757
+ # -- interactive input -------------------------------------------------
758
+
759
+ def _read_user_line(self):
760
+ """Read one turn from the user. Returns the text to send, or None to quit."""
761
+ user_input = self.console.read_line(style_line_prompt("You:"))
762
+ if user_input is None: # EOF / Ctrl-C at the prompt
763
+ self.console.note("Thank you. Goodbye! Saving history...", "info")
764
+ return None
765
+
766
+ if user_input.lower() in self.EXIT_COMMANDS:
767
+ return None
768
+
769
+ if user_input == "/help":
770
+ print_help(self.allowed_dir, self.server_url, self.args,
771
+ self.unique_id, self.cache_dir, self.model)
772
+ # Nothing to send to the model; ask again.
773
+ return self._read_user_line()
774
+
775
+ return user_input
776
+
777
+ def _handle_command(self, user_input):
778
+ """Handle an interactive slash command before it reaches the LLM.
779
+
780
+ Supported:
781
+ /history list the high-level recorder (turn number, what
782
+ happened, and which history_{n}.json + index)
783
+ /history <n> show the full messages recorded for turn <n>
784
+ /reset [<n>] rewind the conversation to the end of turn <n>
785
+ (no <n> rewinds to the most recent turn)
786
+ /clear clear the screen (keeps the conversation)
787
+ /help show the command help
788
+
789
+ Returns True when the input was a recognised command and was fully
790
+ handled, so the caller re-prompts instead of sending it to the model.
791
+ Unknown slash commands print a note and are swallowed (also True).
792
+ """
793
+ stripped = user_input.strip()
794
+ if not stripped.startswith("/"):
795
+ return False
796
+ parts = stripped[1:].split(None, 1)
797
+ cmd, arg = parts[0].lower(), parts[1] if len(parts) > 1 else ""
798
+
799
+ if cmd == "history":
800
+ self._inspect_history(arg) if arg else self._show_history()
801
+ return True
802
+ if cmd in ("reset", "rewind"):
803
+ self._do_reset(arg)
804
+ return True
805
+ if cmd == "help":
806
+ print_help(self.allowed_dir, self.server_url, self.args,
807
+ self.unique_id, self.cache_dir, self.model)
808
+ return True
809
+ if cmd in self.CLEAR_COMMANDS:
810
+ self.console.clear_screen()
811
+ return True
812
+ self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
813
+ return True
814
+
815
+ def _do_reset(self, arg):
816
+ """Rewind the conversation to the end of a recorded turn.
817
+
818
+ No argument resets to the most recent turn (a no-op that just replays the
819
+ current state). With <n>, rewinds to that turn, dropping the messages and
820
+ recorder entries after it.
821
+ """
822
+ if not self.summary:
823
+ self.console.note("No history has been recorded yet.", "warn")
824
+ return
825
+ target = self.summary[-1]
826
+ if arg:
827
+ try:
828
+ idx = int(arg) - 1
829
+ if not (0 <= idx < len(self.summary)):
830
+ raise ValueError
831
+ target = self.summary[idx]
832
+ except ValueError:
833
+ self.console.note(
834
+ f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
835
+ return
836
+ self._reset_history(target["turn"], target)
837
+
838
+ # -- tool execution ----------------------------------------------------
839
+
840
+ def _run_tool_calls(self, tool_calls):
841
+ _make_tool_call_note(self.console, len(tool_calls))
842
+ for call in tool_calls:
843
+ self._run_one_tool_call(call)
844
+
845
+ def _run_one_tool_call(self, call):
846
+ call_id = call.get("id", "")
847
+ name = call.get("function", {}).get("name", "")
848
+ params_str = call.get("function", {}).get("arguments", "{}")
849
+
850
+ try:
851
+ tool_params = json.loads(params_str)
852
+ except json.JSONDecodeError:
853
+ self.console.note(f"Error parsing arguments for tool {name}", "error")
854
+ self._append_tool_result(call_id,
855
+ f"Error: Unknown tool: {name}, params {params_str}")
856
+ return
857
+
858
+ # Sub-agent: delegate the task to a sub-agent (which spins up its own
859
+ # nested AgentSession) and feed the result back as the tool result.
860
+ if name in self.subagents_registry:
861
+ result = self._run_subagent(name, tool_params)
862
+ self._append_tool_result(call_id, result)
863
+ return
864
+
865
+ if name not in self.tools_dict:
866
+ self.console.note(f"Unknown tool: {name}, params {params_str}", "error")
867
+ self._append_tool_result(call_id,
868
+ f"Error: Unknown tool: {name}, params {params_str}")
869
+ return
870
+
871
+ # 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan mode
872
+ # additionally lets the agent maintain CURRENT_PLAN.md /
873
+ # CURRENT_GOAL.md in the session directory, which normally sits
874
+ # outside --allowed-dir, so those writes are exempted).
875
+ if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
876
+ if self._is_plan_file(name, tool_params):
877
+ pass # allow the plan/goal files into the session directory
878
+ elif self._is_report_file(name, tool_params):
879
+ pass # allow a sub-agent's own report.md under the cache dir
337
880
  else:
338
- messages.append({
339
- "role": "assistant",
340
- "content": response_text,
341
- # "tool_calls": tool_calls
342
- })
343
-
344
- if not llm_response_flag and not llm_tool_response_flag:
345
- print(f"{BOLD}{RED}Warning:{RESET} No response was given by the LLM.")
881
+ self.console.note(
882
+ f"File operation not allowed outside allowed directory: {self.allowed_dir}", "error")
883
+ self._append_tool_result(call_id,
884
+ f"Error: File operation not allowed outside allowed directory: {self.allowed_dir}")
885
+ return
346
886
 
347
- save_history(messages, args, unique_id)
887
+ # 2) Shell-safety classifier (DENY / ASK / ALLOW), always enforced.
888
+ shell_cmd = None
889
+ if name == "run_shell_command":
890
+ shell_cmd = str(tool_params.get("command", ""))
891
+ tier, reason = shell_safety_utils.classify_command(shell_cmd)
892
+ if shell_safety_utils.is_deny(tier):
893
+ self.console.note(f"Refusing to run dangerous command: {shell_cmd!r} ({reason})", "error")
894
+ self._append_tool_result(call_id,
895
+ f"Command refused for safety reasons ({reason}): {shell_cmd!r}. Please choose a safer command.")
896
+ return
897
+ else:
898
+ tier = reason = None
899
+
900
+ # 3) Permission prompt. Either the user asked for prompts on every call,
901
+ # or the shell-safety classifier flagged this command as risky (ASK).
902
+ if needs_permission(self.args, shell_cmd, tier):
903
+ ok = self._ask_permission(name, params_str, shell_cmd, tier, reason)
904
+ if not ok:
905
+ self._append_tool_result(call_id,
906
+ f"User denied permission for tool {name} and params {params_str}.")
907
+ return
908
+
909
+ # 4) Execute the tool. In non-verbose mode the tool call (and the usage
910
+ # line above) lives in the transient region: it flashes while the
911
+ # agent works and is cleared in place, keeping the transcript down to
912
+ # just the turns. In verbose mode everything is printed permanently.
913
+ transient = not self.console.verbose
914
+ func = self.tools_dict[name]["function"]
915
+ result = call_function_with_timeout(func, tool_params, name, timeout_seconds=self.args.tool_timeout)
348
916
 
349
- except KeyboardInterrupt:
350
- print(f"{BOLD}{RED}INTERRUPTING:{RESET} Mini Code Exiting...")
917
+ self.console.tool_call_display(name, tool_params, result, transient=transient)
918
+ self._append_tool_result(call_id, str(result))
351
919
 
920
+ def _run_subagent(self, name, tool_params):
921
+ """Run a sub-agent for ``task`` and return the text to feed back."""
922
+ subagent = self.subagents_registry[name]
923
+ task = tool_params.get("task", "")
924
+ max_steps = tool_params.get("max_steps")
925
+ self.console.note(f"Delegating to sub-agent '{name}' (task: {_oneline(task, 80)})", "info")
926
+ try:
927
+ result = subagent.run(task, parent=self, max_steps=max_steps)
928
+ self.console.note(f"Sub-agent '{name}' finished.", "ok")
929
+ return result
352
930
  except Exception as e:
353
- print(f"{BOLD}{RED}FATAL ERROR:{RESET} Mini Code Crashed...:\n{e}")
354
-
355
- finally:
356
- print(f"Saving history to: {cache_dir}/{unique_id}")
357
- save_history(messages, args, unique_id)
931
+ self.console.note(f"Sub-agent '{name}' failed: {e}", "error")
932
+ return f"Error: sub-agent '{name}' failed: {e}"
933
+
934
+ def _ask_permission(self, name, params_str, shell_cmd, tier, reason):
935
+ """Prompt the user to confirm a tool call (ask-permission mode).
936
+
937
+ Delegates the actual prompting -- a styled box plus the answer input --
938
+ to the console so all interactive input lives in one place. Returns True
939
+ to proceed."""
940
+ try:
941
+ return self.console.ask_permission(name, params_str, shell_cmd, tier, reason)
942
+ except (EOFError, KeyboardInterrupt):
943
+ self.console.note("Thank you. Goodbye! Saving history...", "info")
944
+ self.shutdown()
945
+ raise _Abort
946
+
947
+ def _append_tool_result(self, call_id, content):
948
+ self.messages.append({"role": "tool", "tool_call_id": call_id, "content": str(content)})
949
+
950
+ def _print_assistant(self, text):
951
+ # A delimiter line before every agent turn keeps consecutive turns
952
+ # visually separated (the assistant may reply several times in a row
953
+ # in auto-mode, or right after a tool-call summary).
954
+ self.console.divider()
955
+ self.console.turn("Assistant", text, accent="green")
956
+
957
+ def _persist(self):
958
+ save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
959
+ save_summary(self.summary, self.cache_dir, self.unique_id)
960
+ usage_path = save_usage(self.token_stats, self.cache_dir, self.unique_id)
961
+ self.console.note(f"Saved usage to: {usage_path}", "info")
962
+
963
+ def shutdown(self):
964
+ """Persist and print a final summary (called on quit / exit)."""
965
+ self._persist()
966
+ self.console.note(
967
+ f"Saved session to {self.cache_dir}/{self.unique_id}/ "
968
+ f"(history_0..{self.history_index}.json + summary_history.json).", "ok")
969
+ print_usage_summary(self.token_stats)
970
+
971
+ # -- single LLM turn ---------------------------------------------------
972
+
973
+ def _step(self):
974
+ """Run a single LLM turn: call the model, render its text, run its tool
975
+ calls. Returns True if the LLM made tool calls and we're in auto-mode,
976
+ signalling the loop to continue without asking the user."""
977
+ # Keep long sessions within the model's context window. Compaction
978
+ # rolls the session over to a fresh history segment (history_index + 1)
979
+ # and returns that bump so the recorder can stay flat across it.
980
+ self.messages, did_compact, new_index = maybe_compact(
981
+ self.messages, self.server_url, self.model, self.api_key,
982
+ self.args.llm_timeout, self.token_stats, self.history_index,
983
+ )
984
+ if did_compact:
985
+ self.history_index = new_index
986
+ # In plan mode, re-inject the running plan + current goal so a
987
+ # compaction (which would otherwise drop them) keeps the model
988
+ # working against the goal it is still trying to finish.
989
+ if self.plan_mode:
990
+ self.messages = self._apply_plan_to_compacted(self.messages)
991
+ # Snapshot the compacted messages into the new segment so its turns
992
+ # are preserved, and record the summary as the first turn of it.
993
+ save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
994
+ self._record_turn(
995
+ f"summary: history compacted to {len(self.messages)} messages",
996
+ self.messages)
997
+
998
+ # Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
999
+ # a no-op on non-TTY / ASCII output). Stopped right after the call returns.
1000
+ self.console.start_thinking()
1001
+ response_text, tool_calls, usage = utils.call_openai_server(
1002
+ self.messages,
1003
+ max_tokens=self.args.max_tokens,
1004
+ temperature=self.args.temperature,
1005
+ model=self.model,
1006
+ server_url=self.server_url,
1007
+ tools=self.llm_tools,
1008
+ api_key=self.api_key,
1009
+ timeout=self.args.llm_timeout,
1010
+ )
1011
+ self.console.stop_thinking()
1012
+
1013
+ # A failed LLM call returns (None, None, None): treat it as "nothing
1014
+ # happened this step" so the loop degrades gracefully (records a note
1015
+ # and re-prompts) instead of crashing.
1016
+ if usage is None:
1017
+ response_text = response_text or ""
1018
+ usage = {
1019
+ "prompt_tokens": 0,
1020
+ "completion_tokens": 0,
1021
+ "total_tokens": 0,
1022
+ "tool_calls": self.token_stats["tool_calls"],
1023
+ }
1024
+
1025
+ accumulate_usage(self.token_stats, usage)
1026
+ made_tool_calls = bool(tool_calls)
1027
+ if made_tool_calls:
1028
+ self.token_stats["tool_calls"] += len(tool_calls)
1029
+
1030
+ if response_text:
1031
+ self._print_assistant(response_text)
1032
+
1033
+ if tool_calls:
1034
+ self.messages.append({
1035
+ "role": "assistant",
1036
+ "content": response_text,
1037
+ "tool_calls": tool_calls,
1038
+ })
1039
+ self._run_tool_calls(tool_calls)
1040
+ else:
1041
+ self.messages.append({"role": "assistant", "content": response_text})
1042
+
1043
+ if not response_text and not made_tool_calls:
1044
+ self.console.note("Warning: no response was given by the LLM.", "error")
1045
+
1046
+ # Mirror the running tool-call total into this call's usage dict so the
1047
+ # per-call usage line reports the actual tool-call count (the server
1048
+ # does not, so usage.get("tool_calls") would always be 0 otherwise).
1049
+ usage["tool_calls"] = self.token_stats["tool_calls"]
1050
+ # Print the usage line LAST, transiently, so it always sits at the very
1051
+ # bottom of the screen. That way the next step can clear it in place
1052
+ # (cursor up + wipe + reprint) instead of jumping up past the assistant
1053
+ # turn above it -- which is what made the screen keep scrolling.
1054
+ self.console.usage_line(usage, transient=False)
1055
+
1056
+ # Append this step's turn to the high-level recorder. On compaction the
1057
+ # recorder was already seeded for the new segment above, so this becomes
1058
+ # that segment's next turn (assistant / tool call), pointing at the
1059
+ # freshly appended message -- the end-of-turn snapshot /reset targets.
1060
+ if tool_calls:
1061
+ self._record_turn(
1062
+ f"tool call(s): {', '.join(tc.get('function', {}).get('name', '?') for tc in tool_calls)}",
1063
+ self.messages)
1064
+ elif response_text:
1065
+ self._record_turn("assistant", self.messages)
1066
+
1067
+ return made_tool_calls and self.args.auto_mode
1068
+
1069
+
1070
+ class _Abort(Exception):
1071
+ """Raised to unwind the permission prompt after a Ctrl-C / shutdown."""
1072
+
1073
+
1074
+ # ---------------------------------------------------------------------------
1075
+ # module-level helpers kept for readability / backward compatibility
1076
+ # ---------------------------------------------------------------------------
1077
+
1078
+ def _oneline(text, limit):
1079
+ one = " ".join(line.strip() for line in str(text).splitlines())
1080
+ one = " ".join(one.split())
1081
+ return one[:limit] + "..." if len(one) > limit else one
1082
+
1083
+
1084
+ def print_usage_summary(token_stats):
1085
+ ui.console.usage_summary(token_stats)
1086
+
1087
+
1088
+ def main():
1089
+ args = parse_args()
1090
+ session = AgentSession(args)
1091
+ session.run(initial_prompt=args.prompt)
1092
+
358
1093
 
359
1094
  if __name__ == "__main__":
360
- main()
1095
+ main()