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