mini-code-cli 0.1.0.3__py3-none-any.whl → 0.1.0.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.
- mini_code_cli/agent_loop.py +344 -306
- mini_code_cli/persistence.py +34 -80
- mini_code_cli/plan.py +147 -83
- mini_code_cli/prompts.py +170 -29
- mini_code_cli/sandbox.py +26 -7
- mini_code_cli/skills.py +90 -0
- mini_code_cli/subagents/base.py +24 -3
- mini_code_cli/subagents/builtin.py +13 -7
- mini_code_cli/tools.py +18 -2
- mini_code_cli/ui.py +68 -12
- {mini_code_cli-0.1.0.3.dist-info → mini_code_cli-0.1.0.4.dist-info}/METADATA +3 -2
- mini_code_cli-0.1.0.4.dist-info/RECORD +23 -0
- {mini_code_cli-0.1.0.3.dist-info → mini_code_cli-0.1.0.4.dist-info}/WHEEL +1 -1
- mini_code_cli-0.1.0.3.dist-info/RECORD +0 -23
- {mini_code_cli-0.1.0.3.dist-info → mini_code_cli-0.1.0.4.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.1.0.3.dist-info → mini_code_cli-0.1.0.4.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.1.0.3.dist-info → mini_code_cli-0.1.0.4.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -52,7 +52,7 @@ from .persistence import (
|
|
|
52
52
|
load_summary_from_dir,
|
|
53
53
|
load_usage_from_dir,
|
|
54
54
|
find_last_session_dir,
|
|
55
|
-
save_last_session_pointer,
|
|
55
|
+
# save_last_session_pointer,
|
|
56
56
|
accumulate_usage,
|
|
57
57
|
generate_unique_id,
|
|
58
58
|
_restore_dir,
|
|
@@ -122,6 +122,7 @@ def parse_args():
|
|
|
122
122
|
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")
|
|
123
123
|
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}")
|
|
124
124
|
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}")
|
|
125
|
+
parser.add_argument("--max-messages", type=int, default=DEFAULT_MAX_MESSAGES, help=f"History length (in messages) before the conversation is compacted into a summary. Lower it to compact sooner on small context windows. default={DEFAULT_MAX_MESSAGES}")
|
|
125
126
|
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.")
|
|
126
127
|
|
|
127
128
|
parser.add_argument("--all", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo")
|
|
@@ -165,6 +166,7 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
|
|
|
165
166
|
ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
|
|
166
167
|
ui.console.emit(" /history list the conversation (turn number, what happened, where)")
|
|
167
168
|
ui.console.emit(" /history <n> show the full messages recorded for turn n")
|
|
169
|
+
ui.console.emit(" /plan show the current goal + plan (plan mode only)")
|
|
168
170
|
ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
|
|
169
171
|
ui.console.emit(" /clear clear the screen (keeps the conversation history)")
|
|
170
172
|
ui.console.emit(" /help show this help")
|
|
@@ -205,7 +207,7 @@ def _make_tool_call_note(console, count):
|
|
|
205
207
|
# history compaction
|
|
206
208
|
# ---------------------------------------------------------------------------
|
|
207
209
|
|
|
208
|
-
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES):
|
|
210
|
+
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES, plan_hook=None, console=None):
|
|
209
211
|
"""Compact the conversation history once it grows beyond ``max_messages``.
|
|
210
212
|
|
|
211
213
|
Long (especially auto-mode) sessions can overflow the model's context window.
|
|
@@ -213,6 +215,13 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
213
215
|
old turns with that summary, keeping the original system prompt as the first
|
|
214
216
|
message. The compaction call itself is a single, tool-free request, so it
|
|
215
217
|
cannot trigger further tool calls or recurse into compaction.
|
|
218
|
+
|
|
219
|
+
``plan_hook`` is an optional callable the caller supplies only when running
|
|
220
|
+
under plan mode (in practice ``AgentSession._handle_compaction``). After the
|
|
221
|
+
summary is built the hook re-injects the running goal + plan so the compacted
|
|
222
|
+
messages carry them forward. Passing it in -- instead of referencing the
|
|
223
|
+
owning session -- keeps ``maybe_compact`` a small, module-level, network-free
|
|
224
|
+
helper that the test-suite can import and exercise directly.
|
|
216
225
|
"""
|
|
217
226
|
if len(messages) <= max_messages:
|
|
218
227
|
# No compaction: keep the same segment (history_index) and signal the
|
|
@@ -224,6 +233,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
224
233
|
try:
|
|
225
234
|
ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
|
|
226
235
|
summary_prompt = prompts.compaction_prompt(messages)
|
|
236
|
+
console.start_thinking("compacting")
|
|
227
237
|
summary, _, compact_usage = utils.call_openai_server(
|
|
228
238
|
[{"role": "user", "content": summary_prompt}],
|
|
229
239
|
max_tokens=1024,
|
|
@@ -233,6 +243,8 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
233
243
|
api_key=api_key,
|
|
234
244
|
timeout=llm_timeout,
|
|
235
245
|
)
|
|
246
|
+
console.stop_thinking()
|
|
247
|
+
|
|
236
248
|
accumulate_usage(token_stats, compact_usage)
|
|
237
249
|
if summary:
|
|
238
250
|
system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
|
|
@@ -243,6 +255,15 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
243
255
|
"role": "user",
|
|
244
256
|
"content": f"Here is a summary of our earlier conversation so far:\n{summary}",
|
|
245
257
|
})
|
|
258
|
+
|
|
259
|
+
# In plan mode, re-inject the running plan + current goal so a
|
|
260
|
+
# compaction (which would otherwise drop them) keeps the model
|
|
261
|
+
# working against the goal it is still trying to finish. The hook is
|
|
262
|
+
# wired in only when the caller runs under plan mode; otherwise
|
|
263
|
+
# compaction degrades to a plain summary.
|
|
264
|
+
if plan_hook is not None:
|
|
265
|
+
plan_hook(compacted)
|
|
266
|
+
|
|
246
267
|
ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
|
|
247
268
|
# A new segment (history_index + 1) is started on compaction: the
|
|
248
269
|
# compacted messages are its snapshot, and every later turn appends
|
|
@@ -268,33 +289,35 @@ class AgentSession:
|
|
|
268
289
|
be unit-tested.
|
|
269
290
|
"""
|
|
270
291
|
|
|
271
|
-
EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
|
|
292
|
+
EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit", "exit()", "quit()"}
|
|
272
293
|
CLEAR_COMMANDS = {"clear", "/clear"}
|
|
273
294
|
|
|
274
|
-
def __init__(self, args, console=None):
|
|
295
|
+
def __init__(self, args, console=None, background_mode = False):
|
|
275
296
|
self.args = args
|
|
276
297
|
self.allowed_dir = os.path.abspath(args.allowed_dir)
|
|
277
298
|
self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
|
|
278
299
|
|
|
279
|
-
# The console that renders everything the agent prints. Unit tests /
|
|
280
|
-
# other callers can swap it out (e.g. AgentSession(args, console=...) with
|
|
281
|
-
# a color=False console) but it defaults to the shared ui.console.
|
|
282
300
|
self.console = console if console is not None else ui.console
|
|
283
|
-
# --verbose toggles the amount of detail printed. Defaults to False so
|
|
284
|
-
# the quiet, transcript-only view is the out-of-the-box experience.
|
|
285
301
|
self.console.verbose = getattr(args, "verbose", False)
|
|
286
302
|
|
|
287
303
|
# LLM / server configuration.
|
|
288
304
|
self.server_url = f"{args.url}"
|
|
289
|
-
|
|
305
|
+
try:
|
|
306
|
+
model_name = utils.determine_vllm_model(self.server_url)
|
|
307
|
+
except:
|
|
308
|
+
model_name = None
|
|
309
|
+
pass
|
|
290
310
|
self.model = model_name if model_name else args.model
|
|
291
311
|
self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
|
|
292
|
-
|
|
293
|
-
# Which tools the LLM may call (shell / sub-agents / skills), and which
|
|
294
|
-
# of them are registered. Split out of __init__ for readability -- see
|
|
295
|
-
# _register_tools().
|
|
312
|
+
|
|
296
313
|
self._register_tools(args)
|
|
297
314
|
|
|
315
|
+
# report_path is set on a *sub-agent child* session (by
|
|
316
|
+
# SubAgent._build_child_args)
|
|
317
|
+
self.report_path = getattr(args, "report_path", None)
|
|
318
|
+
|
|
319
|
+
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
320
|
+
|
|
298
321
|
# Token accounting for the whole session.
|
|
299
322
|
self.token_stats = {
|
|
300
323
|
"prompt_tokens": 0,
|
|
@@ -304,107 +327,102 @@ class AgentSession:
|
|
|
304
327
|
"tool_calls": 0,
|
|
305
328
|
}
|
|
306
329
|
|
|
330
|
+
self.background_mode = background_mode
|
|
331
|
+
|
|
332
|
+
self.restore_session = getattr(self.args, "restore_session", None) #specific session
|
|
333
|
+
|
|
334
|
+
def start_session(self):
|
|
335
|
+
"""Loads existing session, detects existing session and asks or starts a new session."""
|
|
336
|
+
loading_success = False
|
|
337
|
+
if self.restore_session:
|
|
338
|
+
try:
|
|
339
|
+
restore_directory = _restore_dir(self.restore_session)
|
|
340
|
+
loading_success = self.restore_previous_session(restore_directory)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
else:
|
|
345
|
+
try:
|
|
346
|
+
detected = self._detect_last_session()
|
|
347
|
+
if detected and not self.background_mode:
|
|
348
|
+
answer = self.show_resume_choice()
|
|
349
|
+
if answer == "resume":
|
|
350
|
+
self.console.note("Resuming the session.", "warn")
|
|
351
|
+
restore_directory = _restore_dir(detected)
|
|
352
|
+
loading_success = self.restore_previous_session(restore_directory)
|
|
353
|
+
elif answer == "exit":
|
|
354
|
+
self.console.note("User chose to exit! Exiting.", "warn")
|
|
355
|
+
raise SystemExit("") # Exit gracefully instead of crashing
|
|
356
|
+
else:
|
|
357
|
+
self.console.note("Will start with new session!", "warn")
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
if not loading_success:
|
|
363
|
+
self.start_new_session()
|
|
364
|
+
|
|
365
|
+
def start_new_session(self):
|
|
366
|
+
"""A function to start a new session."""
|
|
307
367
|
self.unique_id = generate_unique_id()
|
|
368
|
+
self.session_dir = os.path.join(self.cache_dir, self.unique_id)
|
|
308
369
|
|
|
309
|
-
# Plan-mode tracking (see plan.py). Always instantiated so the loop can
|
|
310
|
-
# call it unconditionally; every method no-ops when --plan is disabled.
|
|
311
|
-
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
312
370
|
self.plan_manager = plan_utils.PlanManager(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
# High-level recorder + history segments. Instead of a single ever-growing
|
|
316
|
-
# history.json, a session keeps:
|
|
317
|
-
# * ``history_{n}.json`` files -- one per conversation *segment*; a brand
|
|
318
|
-
# new segment is started (n -> n+1) every time the history is compacted.
|
|
319
|
-
# * ``summary_history.json`` -- a flat, high-level *recorder*: one record
|
|
320
|
-
# per turn (the running turn number, *what* happened -- an assistant
|
|
321
|
-
# reply, a tool call, or a compaction summary -- and which
|
|
322
|
-
# ``history_{n}.json`` + message index the turn lives in).
|
|
323
|
-
# The recorder lets the user inspect and rewind the conversation (/history,
|
|
324
|
-
# /reset) without reading the full detailed histories, because every turn's
|
|
325
|
-
# recorded index marks a faithful end-of-turn snapshot of its segment.
|
|
371
|
+
self.session_dir, enabled=self.plan_mode)
|
|
372
|
+
|
|
326
373
|
self.history_index = 0
|
|
327
374
|
self.summary = []
|
|
328
375
|
|
|
329
|
-
|
|
330
|
-
# SubAgent._build_child_args) so it can exempt its own report.md -- which
|
|
331
|
-
# lives under the parent's cache dir, usually outside --allowed-dir --
|
|
332
|
-
# from the sandbox. A normal session leaves this None.
|
|
333
|
-
self.report_path = getattr(args, "report_path", None)
|
|
376
|
+
self.messages = self._build_initial_messages()
|
|
334
377
|
|
|
335
|
-
# Conversation state. When --restore-session is given we continue that
|
|
336
|
-
# session's history instead of starting fresh -- still under the brand
|
|
337
|
-
# new unique id above, so the original session file is never
|
|
338
|
-
# overwritten. Falls back to a freshly built history if the restore
|
|
339
|
-
# file is missing or unreadable.
|
|
340
|
-
# --load-last-session auto-detects the most recent previous session in the
|
|
341
|
-
# cache dir and continues it -- exactly like --restore-session, but without
|
|
342
|
-
# needing to know the folder name. An explicit --restore-session always wins
|
|
343
|
-
# and is used verbatim; otherwise the newest session is resolved here.
|
|
344
|
-
# A "last session" candidate -- only when the user did NOT explicitly
|
|
345
|
-
# ask to resume one (no --load-last-session / --restore-session): the
|
|
346
|
-
# most recent previous session in the cache dir. Unlike a real restore,
|
|
347
|
-
# the conversation starts empty here and is only spliced in if the user
|
|
348
|
-
# confirms they want it (see _offer_resume_last_session).
|
|
349
|
-
restore_session = getattr(self.args, "restore_session", None)
|
|
350
|
-
self.last_session_candidate = None
|
|
351
|
-
self.loaded_last_session_dir = None
|
|
352
|
-
# Resolve the "most recent session" exactly once, and reuse the result
|
|
353
|
-
# for both concerns below (an explicit --restore-session always wins and
|
|
354
|
-
# is used verbatim):
|
|
355
|
-
# * --load-last-session turns the detected session into a real restore
|
|
356
|
-
# (continue it under this new id), OR
|
|
357
|
-
# * when the user did NOT explicitly ask to resume (no
|
|
358
|
-
# --load-last-session / --restore-session), it becomes a resume
|
|
359
|
-
# *candidate* that run() offers interactively (see
|
|
360
|
-
# _offer_resume_last_session). The conversation still starts empty
|
|
361
|
-
# here and is spliced in only if the user confirms.
|
|
362
|
-
# Skipped entirely once a real restore is already in hand, so those paths
|
|
363
|
-
# never offer anything.
|
|
364
|
-
detected = self._detect_last_session()
|
|
365
|
-
if getattr(self.args, "load_last_session", False) and not restore_session and detected:
|
|
366
|
-
restore_session = detected
|
|
367
|
-
self.loaded_last_session_dir = detected
|
|
368
|
-
if not restore_session and detected:
|
|
369
|
-
self.last_session_candidate = detected
|
|
370
|
-
restore_directory = _restore_dir(restore_session)
|
|
371
|
-
restored = load_history_from_dir(restore_directory) if restore_directory else None
|
|
372
|
-
# A session built from an explicit restore / load-last-session reuses the
|
|
373
|
-
# restored history (under this brand-new id, so the original is never
|
|
374
|
-
# overwritten); an idle start keeps the freshly built initial messages.
|
|
375
|
-
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
376
|
-
self._restore_from(restore_directory, restored)
|
|
377
|
-
# --load-last-session asked for a session but nothing usable was found:
|
|
378
|
-
# clear the flag so run() reports "no previous session to load" instead
|
|
379
|
-
# of pretending it resumed one. When the restore succeeded the flag set
|
|
380
|
-
# above (loaded_last_session_dir = detected) is kept so run() prints the
|
|
381
|
-
# "Loaded most recent session ... and continuing it" note. Best-effort:
|
|
382
|
-
# a missing/empty/corrupt history falls back to a fresh session rather
|
|
383
|
-
# than crashing.
|
|
384
|
-
if restored is None:
|
|
385
|
-
self.loaded_last_session_dir = None
|
|
386
|
-
|
|
387
|
-
# Snapshot the initial (or restored) messages as the first history segment
|
|
388
|
-
# and persist the recorder, so a session starts with on-disk state to
|
|
389
|
-
# inspect and rewind to.
|
|
390
378
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
391
379
|
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
392
380
|
|
|
381
|
+
|
|
382
|
+
def restore_previous_session(self, restore_directory):
|
|
383
|
+
"""A function to restore from a directory.
|
|
384
|
+
|
|
385
|
+
Order matters here. ``self.messages`` must be loaded *before* the
|
|
386
|
+
recorder is restored, because the legacy fallback in
|
|
387
|
+
:meth:`_load_summary_if_restored` rebuilds the recorder by scanning the
|
|
388
|
+
restored messages. ``self.summary`` / ``self.history_index`` are seeded
|
|
389
|
+
first so that fallback can append to them and so ``_persist()`` has a
|
|
390
|
+
segment index to write to. Previously all three were unset on this path,
|
|
391
|
+
so restoring a session folder that had a ``history_N.json`` but no
|
|
392
|
+
``summary_history.json`` (older code, or a hand-made folder) raised
|
|
393
|
+
AttributeError and the caller silently started a *new* session instead.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
self.unique_id = os.path.basename(os.path.normpath(restore_directory))
|
|
397
|
+
self.session_dir = restore_directory
|
|
398
|
+
self.plan_manager = plan_utils.PlanManager(
|
|
399
|
+
self.session_dir, enabled=self.plan_mode)
|
|
400
|
+
self.summary = []
|
|
401
|
+
self.history_index = 0
|
|
402
|
+
self._restore_usage_if_available(restore_directory)
|
|
403
|
+
restored = load_history_from_dir(restore_directory) if restore_directory else None
|
|
404
|
+
|
|
405
|
+
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
406
|
+
self._load_summary_if_restored(restore_directory)
|
|
407
|
+
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
except Exception as e:
|
|
411
|
+
self.unique_id = None
|
|
412
|
+
self.session_dir = None
|
|
413
|
+
self.plan_manager = None
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
|
|
393
417
|
def _detect_last_session(self):
|
|
394
418
|
"""Resolve the most recent previous session dir, once.
|
|
395
419
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
pointer / directory scan does not run twice for the two independent
|
|
401
|
-
concerns -- --load-last-session vs. the interactive resume candidate.
|
|
420
|
+
There is deliberately NO timestamp/mtime fallback over the cache dir: a
|
|
421
|
+
previous session is restored only via the ``.mini`` pointer (an explicit
|
|
422
|
+
record of the session the user last worked in). Returns the path, or None.
|
|
423
|
+
Called at most once per session (see __init__).
|
|
402
424
|
"""
|
|
403
|
-
return find_last_session_dir(
|
|
404
|
-
self.cache_dir,
|
|
405
|
-
allowed_dir=self.allowed_dir,
|
|
406
|
-
use_pointer=True,
|
|
407
|
-
)
|
|
425
|
+
return find_last_session_dir(self.allowed_dir)
|
|
408
426
|
|
|
409
427
|
def _register_tools(self, args):
|
|
410
428
|
"""Wire up which tools the LLM may call, and register the opt-in ones.
|
|
@@ -436,9 +454,12 @@ class AgentSession:
|
|
|
436
454
|
|
|
437
455
|
self.enable_skills = bool(getattr(args, "enable_skills", False))
|
|
438
456
|
if self.enable_skills:
|
|
439
|
-
|
|
457
|
+
# list/create/run_skill + the scoped read/write_skill_file tools.
|
|
458
|
+
self.tools_dict.update(skills.get_skills_tools_dict(self.allowed_dir))
|
|
440
459
|
self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
|
|
441
|
-
self.tools_dict.update(self.
|
|
460
|
+
self.tools_dict.update(skills.get_skills_file_tools_dict(self.allowed_dir))
|
|
461
|
+
self.llm_tools.extend(skills.get_skills_file_tools(self.allowed_dir))
|
|
462
|
+
|
|
442
463
|
|
|
443
464
|
def _build_initial_messages(self):
|
|
444
465
|
"""Assemble the system prompt and optional Agent.md into the first turns."""
|
|
@@ -449,6 +470,7 @@ class AgentSession:
|
|
|
449
470
|
tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
|
|
450
471
|
plan_mode=self.plan_mode,
|
|
451
472
|
skills_dir=self.allowed_dir if self.enable_skills else None,
|
|
473
|
+
session_dir=self.session_dir,
|
|
452
474
|
)
|
|
453
475
|
|
|
454
476
|
messages = [{"role": "system", "content": system_prompt}]
|
|
@@ -466,7 +488,7 @@ class AgentSession:
|
|
|
466
488
|
|
|
467
489
|
return messages
|
|
468
490
|
|
|
469
|
-
def _restore_usage_if_available(self,
|
|
491
|
+
def _restore_usage_if_available(self, restore_directory):
|
|
470
492
|
"""Seed this session's token totals from a sibling usage.json, if the
|
|
471
493
|
restored session had one, so the running summary stays continuous.
|
|
472
494
|
|
|
@@ -474,15 +496,12 @@ class AgentSession:
|
|
|
474
496
|
of) the in-memory totals, and any problem here is ignored silently so a
|
|
475
497
|
missing or malformed usage file can never break a restore.
|
|
476
498
|
"""
|
|
477
|
-
|
|
478
|
-
return
|
|
479
|
-
directory = _restore_dir(restore_session)
|
|
480
|
-
saved = load_usage_from_dir(directory)
|
|
499
|
+
saved = load_usage_from_dir(restore_directory)
|
|
481
500
|
for key in self.token_stats:
|
|
482
501
|
if key in saved:
|
|
483
502
|
self.token_stats[key] += int(saved[key] or 0)
|
|
484
503
|
|
|
485
|
-
def _load_summary_if_restored(self,
|
|
504
|
+
def _load_summary_if_restored(self, restore_directory):
|
|
486
505
|
"""Restore the high-level recorder and segment index for a restore.
|
|
487
506
|
|
|
488
507
|
Prefers the saved ``summary_history.json`` so the recorder stays flat and
|
|
@@ -492,7 +511,8 @@ class AgentSession:
|
|
|
492
511
|
rebuild a minimal recorder by scanning the restored messages for
|
|
493
512
|
assistant turns, so navigation still works. Best-effort only.
|
|
494
513
|
"""
|
|
495
|
-
saved = load_summary_from_dir(
|
|
514
|
+
saved = load_summary_from_dir(restore_directory)
|
|
515
|
+
|
|
496
516
|
saved = [r for r in saved if isinstance(r, dict)]
|
|
497
517
|
if saved:
|
|
498
518
|
self.summary = saved
|
|
@@ -505,28 +525,6 @@ class AgentSession:
|
|
|
505
525
|
self.summary.append({"turn": turn, "what": "assistant",
|
|
506
526
|
"history_file": 0, "index": i})
|
|
507
527
|
|
|
508
|
-
def _restore_from(self, directory, messages=None):
|
|
509
|
-
"""Seed usage, recorder and messages for a restore under this id.
|
|
510
|
-
|
|
511
|
-
Shared by the constructor (for an explicit ``--restore-session`` /
|
|
512
|
-
``--load-last-session``) and the interactive resume prompt. The caller
|
|
513
|
-
resolves and loads ``directory`` itself, then hands the loaded
|
|
514
|
-
``messages`` here so all seeding lives in one place.
|
|
515
|
-
|
|
516
|
-
``usage.json`` is layered *on top* of the in-memory totals (so a restore
|
|
517
|
-
under a brand-new session id accumulates cleanly), the recorder is
|
|
518
|
-
flattened across the segment boundary so ``/history`` / ``/reset`` stay
|
|
519
|
-
consistent, and ``self.messages`` becomes the restored snapshot.
|
|
520
|
-
Best-effort: a missing directory / unreadable files leave the session
|
|
521
|
-
unchanged (a fresh, empty history).
|
|
522
|
-
"""
|
|
523
|
-
restored = messages if messages is not None else (
|
|
524
|
-
load_history_from_dir(directory) if directory else None)
|
|
525
|
-
self._restore_usage_if_available(directory)
|
|
526
|
-
self._load_summary_if_restored(directory)
|
|
527
|
-
if restored:
|
|
528
|
-
self.messages = restored
|
|
529
|
-
|
|
530
528
|
def _record_turn(self, what, messages, history_index=None):
|
|
531
529
|
"""Append one entry to the high-level recorder.
|
|
532
530
|
|
|
@@ -645,37 +643,7 @@ class AgentSession:
|
|
|
645
643
|
f"Sub-agents enabled ({len(self.subagents_registry)} available: "
|
|
646
644
|
f"{', '.join(self.subagents_registry)}).", "info")
|
|
647
645
|
|
|
648
|
-
def
|
|
649
|
-
"""Offer to continue the most recent previous session, if one exists.
|
|
650
|
-
|
|
651
|
-
Only reached when the user did *not* already ask to resume a session
|
|
652
|
-
(``--load-last-session`` / ``--restore-session``), which resolve their
|
|
653
|
-
own before this and leave ``last_session_candidate`` unset. When a
|
|
654
|
-
candidate exists the user chooses between three outcomes:
|
|
655
|
-
|
|
656
|
-
* **Resume** -- splice the previous session's history into this one
|
|
657
|
-
(still under the brand-new id) and continue it, under a new id.
|
|
658
|
-
* **New** -- leave the conversation empty and start a fresh session.
|
|
659
|
-
* **Exit** -- stop here before any pointer is written or loop enters.
|
|
660
|
-
|
|
661
|
-
The choice is recorded by setting/clearing ``self.last_session_candidate``:
|
|
662
|
-
``resume`` keeps it pointing at the previous session's directory (the
|
|
663
|
-
caller in :meth:`run` performs the restore); ``new`` clears it (nothing
|
|
664
|
-
to restore). Returns True when the user chose to exit so the caller
|
|
665
|
-
returns before writing any pointer or entering the loop.
|
|
666
|
-
"""
|
|
667
|
-
if not self.last_session_candidate:
|
|
668
|
-
return False
|
|
669
|
-
choice = self._resume_choice()
|
|
670
|
-
if choice == "resume":
|
|
671
|
-
return False # keep the candidate: run() restores it below.
|
|
672
|
-
# "new" -> clear the candidate (nothing to restore); "exit" -> clear
|
|
673
|
-
# and tell the caller to stop before writing a pointer / entering the
|
|
674
|
-
# loop.
|
|
675
|
-
self.last_session_candidate = None
|
|
676
|
-
return choice == "exit"
|
|
677
|
-
|
|
678
|
-
def _resume_choice(self):
|
|
646
|
+
def show_resume_choice(self):
|
|
679
647
|
"""Prompt for the resume choice and return its label.
|
|
680
648
|
|
|
681
649
|
Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
|
|
@@ -706,61 +674,28 @@ class AgentSession:
|
|
|
706
674
|
|
|
707
675
|
def run(self, initial_prompt=None):
|
|
708
676
|
"""Drive the agent loop until the user quits."""
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
# A previous session exists but the user did not explicitly ask to
|
|
714
|
-
# resume it. Offer them a choice; on "exit" we return here before
|
|
715
|
-
# writing any pointer or entering the loop, and we splice the
|
|
716
|
-
# restored history into this session if they pick "resume".
|
|
717
|
-
if self._offer_resume_last_session():
|
|
718
|
-
return
|
|
719
|
-
if self.last_session_candidate is not None:
|
|
720
|
-
restored_dir = self.last_session_candidate
|
|
721
|
-
self.last_session_candidate = None
|
|
722
|
-
restored = load_history_from_dir(restored_dir)
|
|
723
|
-
self._restore_from(restored_dir, restored)
|
|
724
|
-
self.loaded_last_session_dir = restored_dir
|
|
725
|
-
|
|
726
|
-
# Announce the session's unique id as an assistant-styled message so the
|
|
727
|
-
# user can see / copy the id this conversation is being saved under.
|
|
728
|
-
self._print_session_id()
|
|
729
|
-
|
|
730
|
-
# Record this session as the "latest" so a future --load-last-session can
|
|
731
|
-
# resume it without scanning the cache dir. This lives in the top-level
|
|
732
|
-
# loop's own directory (cache_dir/unique_id), and only the interactive
|
|
733
|
-
# run() writes it -- a sub-agent child drives via _drive_auto and would
|
|
734
|
-
# otherwise overwrite the pointer with its own buried path.
|
|
735
|
-
self._save_last_session_pointer()
|
|
736
|
-
|
|
737
|
-
# Report the outcome of --load-last-session up front, so the user sees
|
|
738
|
-
# whether we resumed an earlier conversation (under a brand-new id) or
|
|
739
|
-
# started fresh because no previous session was found.
|
|
740
|
-
if self.loaded_last_session_dir is not None:
|
|
741
|
-
self.console.note(
|
|
742
|
-
f"Loaded most recent session "
|
|
743
|
-
f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
|
|
744
|
-
f"it under a new session id.", "ok")
|
|
745
|
-
elif getattr(self.args, "load_last_session", False):
|
|
746
|
-
self.console.note(
|
|
747
|
-
"No previous session found to load; starting a fresh session.", "warn")
|
|
677
|
+
# Called in the beginning to init the session.
|
|
678
|
+
self.start_session()
|
|
679
|
+
|
|
748
680
|
|
|
749
681
|
# Hint at startup so the high-level history feature is discoverable:
|
|
750
682
|
# /history browses the recorder, /history <n> inspects a turn, and
|
|
751
683
|
# /reset <n> rewinds the conversation to that turn's state.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
684
|
+
tip = ("Tip: /history to browse the conversation, /history <n> to inspect a "
|
|
685
|
+
"turn, /reset <n> to rewind. Type /help for all commands.")
|
|
686
|
+
if getattr(self.args, "plan", False):
|
|
687
|
+
tip = ("Tip: /plan to show the current goal + plan (plan mode), "
|
|
688
|
+
"/history to browse the conversation, /history <n> to inspect a "
|
|
689
|
+
"turn, /reset <n> to rewind. Type /help for all commands.")
|
|
690
|
+
self.console.emit(self.console.style(tip, dim=True, fg="cyan"))
|
|
757
691
|
|
|
758
692
|
# The --prompt value is a one-shot initial turn: it is sent verbatim and
|
|
759
693
|
# then cleared, so later turns come from the user (manual mode) or are
|
|
760
694
|
# produced automatically by the loop (auto mode).
|
|
761
695
|
pending = initial_prompt
|
|
762
696
|
|
|
763
|
-
|
|
697
|
+
running = True
|
|
698
|
+
while running:
|
|
764
699
|
try:
|
|
765
700
|
# 1) Get the user's turn: the one-shot preset, or a fresh line.
|
|
766
701
|
if pending is not None:
|
|
@@ -771,8 +706,9 @@ class AgentSession:
|
|
|
771
706
|
pending = None
|
|
772
707
|
else:
|
|
773
708
|
user_input = self._read_user_line()
|
|
774
|
-
if user_input is None: #
|
|
775
|
-
self.
|
|
709
|
+
if user_input is None or user_input.lower() in self.EXIT_COMMANDS: # EOF / Ctrl-C at the prompt
|
|
710
|
+
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
711
|
+
running = False
|
|
776
712
|
break
|
|
777
713
|
|
|
778
714
|
# Interactive slash commands are handled in place and never
|
|
@@ -780,29 +716,60 @@ class AgentSession:
|
|
|
780
716
|
# was such a command, so we just re-prompt (continue).
|
|
781
717
|
if user_input.startswith("/") and self._handle_command(user_input):
|
|
782
718
|
continue
|
|
783
|
-
|
|
784
|
-
# In plan mode, remember the user's latest instruction as the
|
|
785
|
-
# active goal so the loop (and plan-mode compaction) can track it
|
|
786
|
-
# even if the agent never writes it to disk itself.
|
|
787
|
-
self._capture_goal(user_input)
|
|
788
|
-
|
|
789
|
-
# 2) Hand the user turn to the model and act on its tool calls.
|
|
719
|
+
|
|
790
720
|
self.messages.append({"role": "user", "content": user_input})
|
|
791
|
-
|
|
721
|
+
|
|
722
|
+
if self.plan_mode:
|
|
723
|
+
self.console.note(
|
|
724
|
+
"Generating plan for agent.", "info")
|
|
725
|
+
self._record_goal_request(user_input)
|
|
726
|
+
self._generate_plan_and_inject(user_input)
|
|
727
|
+
self.console.note(
|
|
728
|
+
"Plan generated.", "info")
|
|
729
|
+
running_turn = True
|
|
730
|
+
while running_turn:
|
|
731
|
+
self.console.note(
|
|
732
|
+
"Entering single turn loop.", "info")
|
|
733
|
+
# actually stepping through the agentic steps.
|
|
792
734
|
should_continue = self._step()
|
|
793
735
|
self._persist()
|
|
794
|
-
if
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
736
|
+
if should_continue:
|
|
737
|
+
self.console.note(
|
|
738
|
+
"Continuing with LLM calls until no more tool calls.", "info")
|
|
739
|
+
else:
|
|
740
|
+
self.console.note(
|
|
741
|
+
"LLM seems to have to an end of the task.", "info")
|
|
742
|
+
|
|
743
|
+
if self.plan_mode:
|
|
744
|
+
self.console.note(
|
|
745
|
+
"Reviewing the plan, if everything is done.", "info")
|
|
746
|
+
self._review_plan_completion()
|
|
747
|
+
should_continue = self._step()
|
|
748
|
+
self._persist()
|
|
749
|
+
|
|
750
|
+
# A response with no tool calls means the turn is over. This must
|
|
751
|
+
# happen regardless of plan mode: previously the assignment sat inside
|
|
752
|
+
# `if self.plan_mode`, so non-plan sessions never left the turn loop and
|
|
753
|
+
# re-called the model forever.
|
|
754
|
+
if not should_continue:
|
|
755
|
+
running_turn = False # escape the turn loop
|
|
756
|
+
|
|
798
757
|
except _Abort:
|
|
799
758
|
# Ctrl-C during a permission prompt already shut the session
|
|
800
759
|
# down; just unwind the loop quietly.
|
|
801
|
-
|
|
760
|
+
running = False
|
|
761
|
+
|
|
802
762
|
except KeyboardInterrupt:
|
|
803
763
|
self.console.note("Interrupting: Mini Code Exiting...", "error")
|
|
764
|
+
self.shutdown()
|
|
765
|
+
running = False
|
|
804
766
|
# Ctrl-C mid-loop just saves and re-prompts rather than crashing.
|
|
805
|
-
|
|
767
|
+
except Exception as e:
|
|
768
|
+
self.console.note(f"Exception {e}. Exiting...", "error")
|
|
769
|
+
self.shutdown()
|
|
770
|
+
running = False
|
|
771
|
+
self.console.note(
|
|
772
|
+
"EOF.", "info")
|
|
806
773
|
|
|
807
774
|
def _print_session_id(self):
|
|
808
775
|
"""Print the session's unique id as a minimal "Assistant" turn.
|
|
@@ -814,15 +781,15 @@ class AgentSession:
|
|
|
814
781
|
"""
|
|
815
782
|
self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
|
|
816
783
|
|
|
817
|
-
def _save_last_session_pointer(self):
|
|
818
|
-
|
|
784
|
+
# def _save_last_session_pointer(self):
|
|
785
|
+
# """Record this session's directory in --allowed-dir/.mini.
|
|
819
786
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
787
|
+
# Writes the .mini pointer that a later --load-last-session consults first
|
|
788
|
+
# (see save_last_session_pointer). Best-effort: never raises, so a pointer
|
|
789
|
+
# hiccup can never break the interactive session.
|
|
790
|
+
# """
|
|
791
|
+
# save_last_session_pointer(self.allowed_dir,
|
|
792
|
+
# os.path.join(self.cache_dir, self.unique_id))
|
|
826
793
|
|
|
827
794
|
# -- autonomous drive (used by sub-agents) ------------------------------
|
|
828
795
|
|
|
@@ -875,49 +842,123 @@ class AgentSession:
|
|
|
875
842
|
|
|
876
843
|
# -- plan mode ---------------------------------------------------------
|
|
877
844
|
|
|
878
|
-
def
|
|
879
|
-
"""
|
|
880
|
-
|
|
881
|
-
The loop owns the goal (the user's last instruction); the agent owns the
|
|
882
|
-
plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
|
|
883
|
-
Slash commands and the empty input are not real instructions, so they are
|
|
884
|
-
ignored. No-op when --plan is off.
|
|
845
|
+
def _record_goal_request(self, user_input):
|
|
846
|
+
"""Record the user's latest request in the plan-mode tracking files.
|
|
885
847
|
"""
|
|
886
|
-
if not self.plan_mode or user_input is None:
|
|
887
|
-
return
|
|
888
848
|
stripped = user_input.strip()
|
|
889
849
|
if not stripped or stripped.startswith("/"):
|
|
890
850
|
return
|
|
891
|
-
self.plan_manager.write_goal(
|
|
851
|
+
self.plan_manager.write_goal(stripped)
|
|
852
|
+
|
|
853
|
+
def _generate_plan_and_inject(self, current_instruction):
|
|
854
|
+
"""Ask the model to author a plan for the current instruction.
|
|
855
|
+
"""
|
|
856
|
+
plan_prompt_text = prompts.plan_prompt(self.messages, current_instruction)
|
|
857
|
+
plan_text = ""
|
|
858
|
+
try:
|
|
859
|
+
ui.console.note("Generating a plan for the current goal...", "info")
|
|
860
|
+
self.console.start_thinking("planning")
|
|
861
|
+
response, _, usage = utils.call_openai_server(
|
|
862
|
+
[{"role": "user", "content": plan_prompt_text}],
|
|
863
|
+
# The generated plan can be long: leave it unlimited (the server
|
|
864
|
+
# decides the cap) rather than capping the step-by-step authoring.
|
|
865
|
+
max_tokens=None,
|
|
866
|
+
temperature=0.0,
|
|
867
|
+
model=self.model,
|
|
868
|
+
server_url=self.server_url,
|
|
869
|
+
api_key=self.api_key,
|
|
870
|
+
timeout=self.args.llm_timeout,
|
|
871
|
+
)
|
|
872
|
+
self.console.stop_thinking()
|
|
873
|
+
|
|
874
|
+
accumulate_usage(self.token_stats, usage)
|
|
875
|
+
plan_text = response or ""
|
|
876
|
+
except Exception as exc:
|
|
877
|
+
ui.console.note("Could not generate a plan: %s" % exc, "warn")
|
|
878
|
+
|
|
879
|
+
self.plan_manager.write_plan(plan_text)
|
|
880
|
+
if plan_text and plan_text.strip():
|
|
881
|
+
self.messages.append({
|
|
882
|
+
"role": "user",
|
|
883
|
+
"content": "Current plan for the goal (from CURRENT_PLAN.md):\n%s" % plan_text.strip(),
|
|
884
|
+
})
|
|
892
885
|
|
|
893
886
|
def _finish_goal_and_shutdown(self):
|
|
894
|
-
"""Mark the active goal finished in CURRENT_PLAN.md, then quit/save.
|
|
895
|
-
|
|
887
|
+
"""Mark the active goal finished in CURRENT_PLAN.md, then quit/save.
|
|
888
|
+
|
|
889
|
+
The goal is whatever the loop last recorded; if the loop never recorded
|
|
890
|
+
one we fall back to reading the goal file back so a one-shot --prompt
|
|
891
|
+
preset still marks its goal finished. A no-op in method behaviour when
|
|
892
|
+
--plan is off (PlanManager no-ops), so it is safe to call unconditionally.
|
|
893
|
+
"""
|
|
894
|
+
self.plan_manager.mark_finished(self.plan_manager.goal)
|
|
896
895
|
self.shutdown()
|
|
897
896
|
|
|
898
|
-
def
|
|
899
|
-
"""
|
|
897
|
+
def _review_plan_completion(self):
|
|
898
|
+
"""Once the work looks done, review the plan's completion.
|
|
899
|
+
"""
|
|
900
|
+
plan_prompt_text = prompts.plan_review_prompt(
|
|
901
|
+
self.messages, self.plan_manager.goal or "")
|
|
902
|
+
summary = ""
|
|
903
|
+
try:
|
|
904
|
+
ui.console.note("Reviewing the current plan...", "info")
|
|
905
|
+
self.console.start_thinking("reviewing plan")
|
|
906
|
+
response, _, usage = utils.call_openai_server(
|
|
907
|
+
[{"role": "user", "content": plan_prompt_text}],
|
|
908
|
+
max_tokens=1024,
|
|
909
|
+
temperature=0.0,
|
|
910
|
+
model=self.model,
|
|
911
|
+
server_url=self.server_url,
|
|
912
|
+
api_key=self.api_key,
|
|
913
|
+
timeout=self.args.llm_timeout,
|
|
914
|
+
)
|
|
915
|
+
self.console.stop_thinking()
|
|
916
|
+
accumulate_usage(self.token_stats, usage)
|
|
917
|
+
summary = response or ""
|
|
918
|
+
except Exception as exc:
|
|
919
|
+
ui.console.note("Could not review the plan: %s" % exc, "warn")
|
|
920
|
+
|
|
921
|
+
# Persist the review summary to the plan document and re-inject it so
|
|
922
|
+
# the model works from the review result.
|
|
923
|
+
self.plan_manager.write_plan_summary(summary)
|
|
924
|
+
if summary and summary.strip():
|
|
925
|
+
self.messages.append({
|
|
926
|
+
"role": "user",
|
|
927
|
+
"content": "Plan completion summary (from CURRENT_PLAN.md):\n%s"
|
|
928
|
+
% summary.strip(),
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
return True
|
|
900
932
|
|
|
901
|
-
|
|
902
|
-
|
|
933
|
+
|
|
934
|
+
def _handle_compaction(self, compacted):
|
|
935
|
+
"""Re-inject goal + plan into the compacted messages (plan mode).
|
|
936
|
+
|
|
937
|
+
Thin plan-mode counterpart to the inlined plan handling inside
|
|
938
|
+
:meth:`maybe_compact`, which takes a ``plan_hook`` rather than the
|
|
939
|
+
owning session so it can stay a network-free module-level helper. The
|
|
940
|
+
loop wires in this method only when plan mode is on; it simply
|
|
941
|
+
re-injects the goal + plan via :meth:`_apply_plan_to_compacted`, which
|
|
942
|
+
also rewrites the tracking files to match.
|
|
903
943
|
"""
|
|
904
|
-
self.
|
|
944
|
+
self.messages = self._apply_plan_to_compacted(compacted)
|
|
905
945
|
|
|
906
946
|
def _apply_plan_to_compacted(self, messages):
|
|
907
|
-
"""Re-inject the
|
|
947
|
+
"""Re-inject the current goal + plan after a history compaction.
|
|
908
948
|
|
|
909
949
|
Compaction replaces the transcript with a summary, which would otherwise
|
|
910
|
-
drop the
|
|
911
|
-
disk and append them as fresh user turns so the model keeps
|
|
912
|
-
against the goal it is still trying to finish. The goal is
|
|
913
|
-
so it reads as the most recent instruction the model
|
|
950
|
+
drop the goal the loop recorded and the plan it maintains. We read them
|
|
951
|
+
back off disk and append them as fresh user turns so the model keeps
|
|
952
|
+
working against the goal it is still trying to finish. The goal is
|
|
953
|
+
appended last so it reads as the most recent instruction the model
|
|
954
|
+
should act on.
|
|
914
955
|
"""
|
|
915
956
|
plan_text = self.plan_manager.read_plan()
|
|
916
957
|
goal_text = self.plan_manager.read_goal()
|
|
917
958
|
|
|
918
959
|
# Recover the user's latest instruction from the pre-compaction tail, in
|
|
919
|
-
# case the loop had not
|
|
920
|
-
#
|
|
960
|
+
# case the loop had not recorded a goal yet, so there is always a goal to
|
|
961
|
+
# restate after compaction.
|
|
921
962
|
current_input = None
|
|
922
963
|
for m in reversed(messages):
|
|
923
964
|
if m.get("role") == "user":
|
|
@@ -938,29 +979,6 @@ class AgentSession:
|
|
|
938
979
|
rebuilt.append({"role": "user", "content": current_input})
|
|
939
980
|
return rebuilt
|
|
940
981
|
|
|
941
|
-
def _is_plan_file(self, name, tool_params):
|
|
942
|
-
"""Whether a read/write targets a plan-mode file in the session dir.
|
|
943
|
-
|
|
944
|
-
Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
|
|
945
|
-
session directory is usually outside --allowed-dir. Scoped tightly: plan
|
|
946
|
-
mode only, only read_file / write_file, only when the resolved path is a
|
|
947
|
-
plan file living directly in this session's directory.
|
|
948
|
-
"""
|
|
949
|
-
if not self.plan_mode or name not in ("read_file", "write_file"):
|
|
950
|
-
return False
|
|
951
|
-
path = tool_params.get("file_path", "")
|
|
952
|
-
if not path:
|
|
953
|
-
return False
|
|
954
|
-
try:
|
|
955
|
-
resolved = os.path.realpath(path)
|
|
956
|
-
except (ValueError, TypeError):
|
|
957
|
-
return False
|
|
958
|
-
session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
|
|
959
|
-
if os.path.dirname(resolved) != session:
|
|
960
|
-
return False
|
|
961
|
-
return os.path.basename(resolved) in (
|
|
962
|
-
plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
|
|
963
|
-
|
|
964
982
|
def _is_report_file(self, name, tool_params):
|
|
965
983
|
"""Whether a write targets this session's own sub-agent report.md.
|
|
966
984
|
|
|
@@ -995,22 +1013,9 @@ class AgentSession:
|
|
|
995
1013
|
user is asked again rather than sending an empty turn to the model.
|
|
996
1014
|
"""
|
|
997
1015
|
self.console.short_divider()
|
|
998
|
-
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
999
|
-
if user_input is None: # EOF / Ctrl-C at the prompt
|
|
1000
|
-
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
1001
|
-
return None
|
|
1002
1016
|
|
|
1003
|
-
|
|
1004
|
-
return None
|
|
1005
|
-
|
|
1006
|
-
if user_input == "/help":
|
|
1007
|
-
print_help(self.allowed_dir, self.server_url, self.args,
|
|
1008
|
-
self.unique_id, self.cache_dir, self.model)
|
|
1009
|
-
# Nothing to send to the model; ask again.
|
|
1010
|
-
return self._read_user_line()
|
|
1017
|
+
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
1011
1018
|
|
|
1012
|
-
# A blank line carries no instruction: say so and re-prompt instead of
|
|
1013
|
-
# appending an empty user turn and spending an LLM call on it.
|
|
1014
1019
|
if not user_input.strip():
|
|
1015
1020
|
self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
|
|
1016
1021
|
return self._read_user_line()
|
|
@@ -1026,6 +1031,8 @@ class AgentSession:
|
|
|
1026
1031
|
/history <n> show the full messages recorded for turn <n>
|
|
1027
1032
|
/reset [<n>] rewind the conversation to the end of turn <n>
|
|
1028
1033
|
(no <n> rewinds to the most recent turn)
|
|
1034
|
+
/plan show the current goal + plan the loop maintains
|
|
1035
|
+
(plan mode only)
|
|
1029
1036
|
/clear clear the screen (keeps the conversation)
|
|
1030
1037
|
/help show the command help
|
|
1031
1038
|
|
|
@@ -1052,6 +1059,9 @@ class AgentSession:
|
|
|
1052
1059
|
if cmd in self.CLEAR_COMMANDS:
|
|
1053
1060
|
self._clear_and_show_last()
|
|
1054
1061
|
return True
|
|
1062
|
+
if cmd == "plan":
|
|
1063
|
+
self._show_plan()
|
|
1064
|
+
return True
|
|
1055
1065
|
self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
|
|
1056
1066
|
return True
|
|
1057
1067
|
|
|
@@ -1078,6 +1088,33 @@ class AgentSession:
|
|
|
1078
1088
|
return
|
|
1079
1089
|
self._reset_history(target["turn"], target)
|
|
1080
1090
|
|
|
1091
|
+
def _show_plan(self):
|
|
1092
|
+
"""Show the current plan/goal the loop maintains (``/plan`` command).
|
|
1093
|
+
|
|
1094
|
+
Plan mode keeps two human-readable tracking files in the session
|
|
1095
|
+
directory (CURRENT_GOAL.md / CURRENT_PLAN.md) that the loop -- not the
|
|
1096
|
+
agent -- author. Those files live outside ``--allowed-dir``, so the
|
|
1097
|
+
model has no tool to read them back; the simplest way for the user to
|
|
1098
|
+
inspect progress is this command, which renders the goal, the current
|
|
1099
|
+
plan and the last completion review as one box, matching how
|
|
1100
|
+
``/history`` shows the transcript. A no-op (just a note) when --plan is
|
|
1101
|
+
off, since the manager has nothing to read.
|
|
1102
|
+
"""
|
|
1103
|
+
if not self.plan_mode:
|
|
1104
|
+
self.console.note("Plan mode is not enabled (--plan).", "warn")
|
|
1105
|
+
return
|
|
1106
|
+
status = self.plan_manager.read_current_status()
|
|
1107
|
+
if status is None:
|
|
1108
|
+
self.console.note("No plan information available for this session.", "warn")
|
|
1109
|
+
return
|
|
1110
|
+
self.console.rule(title="Plan", char="═", fg="cyan")
|
|
1111
|
+
self.console.status_box(
|
|
1112
|
+
status.splitlines(),
|
|
1113
|
+
title="Current goal + plan",
|
|
1114
|
+
fg="green",
|
|
1115
|
+
dim=True,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1081
1118
|
def _clear_and_show_last(self):
|
|
1082
1119
|
"""Handle /clear: wipe the screen and re-print the last turn.
|
|
1083
1120
|
|
|
@@ -1108,7 +1145,7 @@ class AgentSession:
|
|
|
1108
1145
|
# Show a "… calling tool" spinner (in-place, non-scrolling) while the
|
|
1109
1146
|
# tool(s) execute, mirroring the thinking spinner that covers the LLM
|
|
1110
1147
|
# call. Always stopped (finally) so it can never leak into the next turn.
|
|
1111
|
-
|
|
1148
|
+
|
|
1112
1149
|
try:
|
|
1113
1150
|
for call in tool_calls:
|
|
1114
1151
|
self._run_one_tool_call(call)
|
|
@@ -1116,9 +1153,11 @@ class AgentSession:
|
|
|
1116
1153
|
self.console.stop_tool_call()
|
|
1117
1154
|
|
|
1118
1155
|
def _run_one_tool_call(self, call):
|
|
1156
|
+
|
|
1119
1157
|
call_id = call.get("id", "")
|
|
1120
1158
|
name = call.get("function", {}).get("name", "")
|
|
1121
1159
|
params_str = call.get("function", {}).get("arguments", "{}")
|
|
1160
|
+
self.console.start_tool_call(tool_name=name)
|
|
1122
1161
|
|
|
1123
1162
|
try:
|
|
1124
1163
|
tool_params = json.loads(params_str)
|
|
@@ -1141,14 +1180,11 @@ class AgentSession:
|
|
|
1141
1180
|
f"Error: Unknown tool: {name}, params {params_str}")
|
|
1142
1181
|
return
|
|
1143
1182
|
|
|
1144
|
-
# 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan
|
|
1145
|
-
#
|
|
1146
|
-
#
|
|
1147
|
-
# outside --allowed-dir, so those writes are exempted).
|
|
1183
|
+
# 1) Sandbox: keep every filesystem tool inside --allowed-dir. (In plan
|
|
1184
|
+
# mode the tracking files are maintained by the loop directly -- not
|
|
1185
|
+
# through agent tools -- so nothing here needs an exemption.)
|
|
1148
1186
|
if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
|
|
1149
|
-
if self.
|
|
1150
|
-
pass # allow the plan/goal files into the session directory
|
|
1151
|
-
elif self._is_report_file(name, tool_params):
|
|
1187
|
+
if self._is_report_file(name, tool_params):
|
|
1152
1188
|
pass # allow a sub-agent's own report.md under the cache dir
|
|
1153
1189
|
else:
|
|
1154
1190
|
self.console.note(
|
|
@@ -1253,14 +1289,13 @@ class AgentSession:
|
|
|
1253
1289
|
self.messages, did_compact, new_index = maybe_compact(
|
|
1254
1290
|
self.messages, self.server_url, self.model, self.api_key,
|
|
1255
1291
|
self.args.llm_timeout, self.token_stats, self.history_index,
|
|
1292
|
+
# getattr keeps argparse-like namespaces without the attribute
|
|
1293
|
+
# (older callers / tests) working, falling back to the default.
|
|
1294
|
+
max_messages=getattr(self.args, "max_messages", DEFAULT_MAX_MESSAGES),
|
|
1295
|
+
plan_hook=self._handle_compaction if self.plan_mode else None, console=self.console
|
|
1256
1296
|
)
|
|
1257
1297
|
if did_compact:
|
|
1258
1298
|
self.history_index = new_index
|
|
1259
|
-
# In plan mode, re-inject the running plan + current goal so a
|
|
1260
|
-
# compaction (which would otherwise drop them) keeps the model
|
|
1261
|
-
# working against the goal it is still trying to finish.
|
|
1262
|
-
if self.plan_mode:
|
|
1263
|
-
self.messages = self._apply_plan_to_compacted(self.messages)
|
|
1264
1299
|
# Snapshot the compacted messages into the new segment so its turns
|
|
1265
1300
|
# are preserved, and record the summary as the first turn of it.
|
|
1266
1301
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
@@ -1299,6 +1334,9 @@ class AgentSession:
|
|
|
1299
1334
|
made_tool_calls = bool(tool_calls)
|
|
1300
1335
|
if made_tool_calls:
|
|
1301
1336
|
self.token_stats["tool_calls"] += len(tool_calls)
|
|
1337
|
+
# The model predicted more tool calls: once the work settles down a
|
|
1338
|
+
# completion review is due again (finalized_plan).
|
|
1339
|
+
self.finalized_plan = True
|
|
1302
1340
|
|
|
1303
1341
|
if response_text:
|
|
1304
1342
|
self._print_assistant(response_text)
|