mini-code-cli 0.1.0.2__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 +416 -234
- mini_code_cli/persistence.py +34 -80
- mini_code_cli/plan.py +147 -83
- mini_code_cli/prompts.py +170 -29
- mini_code_cli/runner.py +23 -6
- 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/utils.py +30 -3
- {mini_code_cli-0.1.0.2.dist-info → mini_code_cli-0.1.0.4.dist-info}/METADATA +4 -2
- mini_code_cli-0.1.0.4.dist-info/RECORD +23 -0
- {mini_code_cli-0.1.0.2.dist-info → mini_code_cli-0.1.0.4.dist-info}/WHEEL +1 -1
- mini_code_cli-0.1.0.2.dist-info/RECORD +0 -23
- {mini_code_cli-0.1.0.2.dist-info → mini_code_cli-0.1.0.4.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.1.0.2.dist-info → mini_code_cli-0.1.0.4.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.1.0.2.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")
|
|
@@ -138,6 +139,7 @@ def parse_args():
|
|
|
138
139
|
args.enable_shell = True
|
|
139
140
|
args.sudo = True
|
|
140
141
|
args.auto_mode = True
|
|
142
|
+
args.skills = True
|
|
141
143
|
|
|
142
144
|
if args.all_last:
|
|
143
145
|
args.load_last_session = True
|
|
@@ -164,6 +166,7 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
|
|
|
164
166
|
ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
|
|
165
167
|
ui.console.emit(" /history list the conversation (turn number, what happened, where)")
|
|
166
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)")
|
|
167
170
|
ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
|
|
168
171
|
ui.console.emit(" /clear clear the screen (keeps the conversation history)")
|
|
169
172
|
ui.console.emit(" /help show this help")
|
|
@@ -204,7 +207,7 @@ def _make_tool_call_note(console, count):
|
|
|
204
207
|
# history compaction
|
|
205
208
|
# ---------------------------------------------------------------------------
|
|
206
209
|
|
|
207
|
-
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):
|
|
208
211
|
"""Compact the conversation history once it grows beyond ``max_messages``.
|
|
209
212
|
|
|
210
213
|
Long (especially auto-mode) sessions can overflow the model's context window.
|
|
@@ -212,6 +215,13 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
212
215
|
old turns with that summary, keeping the original system prompt as the first
|
|
213
216
|
message. The compaction call itself is a single, tool-free request, so it
|
|
214
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.
|
|
215
225
|
"""
|
|
216
226
|
if len(messages) <= max_messages:
|
|
217
227
|
# No compaction: keep the same segment (history_index) and signal the
|
|
@@ -223,6 +233,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
223
233
|
try:
|
|
224
234
|
ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
|
|
225
235
|
summary_prompt = prompts.compaction_prompt(messages)
|
|
236
|
+
console.start_thinking("compacting")
|
|
226
237
|
summary, _, compact_usage = utils.call_openai_server(
|
|
227
238
|
[{"role": "user", "content": summary_prompt}],
|
|
228
239
|
max_tokens=1024,
|
|
@@ -232,6 +243,8 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
232
243
|
api_key=api_key,
|
|
233
244
|
timeout=llm_timeout,
|
|
234
245
|
)
|
|
246
|
+
console.stop_thinking()
|
|
247
|
+
|
|
235
248
|
accumulate_usage(token_stats, compact_usage)
|
|
236
249
|
if summary:
|
|
237
250
|
system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
|
|
@@ -242,6 +255,15 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
242
255
|
"role": "user",
|
|
243
256
|
"content": f"Here is a summary of our earlier conversation so far:\n{summary}",
|
|
244
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
|
+
|
|
245
267
|
ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
|
|
246
268
|
# A new segment (history_index + 1) is started on compaction: the
|
|
247
269
|
# compacted messages are its snapshot, and every later turn appends
|
|
@@ -267,58 +289,34 @@ class AgentSession:
|
|
|
267
289
|
be unit-tested.
|
|
268
290
|
"""
|
|
269
291
|
|
|
270
|
-
EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
|
|
292
|
+
EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit", "exit()", "quit()"}
|
|
271
293
|
CLEAR_COMMANDS = {"clear", "/clear"}
|
|
272
294
|
|
|
273
|
-
def __init__(self, args, console=None):
|
|
295
|
+
def __init__(self, args, console=None, background_mode = False):
|
|
274
296
|
self.args = args
|
|
275
297
|
self.allowed_dir = os.path.abspath(args.allowed_dir)
|
|
276
298
|
self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
|
|
277
299
|
|
|
278
|
-
# The console that renders everything the agent prints. Unit tests /
|
|
279
|
-
# other callers can swap it out (e.g. AgentSession(args, console=...) with
|
|
280
|
-
# a color=False console) but it defaults to the shared ui.console.
|
|
281
300
|
self.console = console if console is not None else ui.console
|
|
282
|
-
# --verbose toggles the amount of detail printed. Defaults to False so
|
|
283
|
-
# the quiet, transcript-only view is the out-of-the-box experience.
|
|
284
301
|
self.console.verbose = getattr(args, "verbose", False)
|
|
285
302
|
|
|
286
303
|
# LLM / server configuration.
|
|
287
304
|
self.server_url = f"{args.url}"
|
|
288
|
-
|
|
305
|
+
try:
|
|
306
|
+
model_name = utils.determine_vllm_model(self.server_url)
|
|
307
|
+
except:
|
|
308
|
+
model_name = None
|
|
309
|
+
pass
|
|
289
310
|
self.model = model_name if model_name else args.model
|
|
290
311
|
self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
|
|
312
|
+
|
|
313
|
+
self._register_tools(args)
|
|
291
314
|
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
|
|
296
|
-
self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
|
|
297
|
-
|
|
298
|
-
# Sub-agents (opt-in via --enable-subagents). Each registered sub-agent
|
|
299
|
-
# becomes an extra tool the model can call; see subagents/. A child
|
|
300
|
-
# session built by a sub-agent never sets enable_subagents, so sub-agents
|
|
301
|
-
# cannot recursively spawn sub-agents.
|
|
302
|
-
self.enable_subagents = bool(getattr(args, "enable_subagents", False))
|
|
303
|
-
self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
|
|
304
|
-
for sa in self.subagents_registry.values():
|
|
305
|
-
self.llm_tools.append(sa.tool_schema())
|
|
306
|
-
# Marked so _run_one_tool_call knows to delegate rather than call a
|
|
307
|
-
# real function.
|
|
308
|
-
self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
|
|
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)
|
|
309
318
|
|
|
310
|
-
|
|
311
|
-
# the model as a normal tool, but they are all sandbox-safe: the .mini
|
|
312
|
-
# directory lives *inside* --allowed-dir and the tools never accept a
|
|
313
|
-
# path from the model -- skill / script names are validated as single
|
|
314
|
-
# path segments and resolved under the .mini root. So skills are exposed
|
|
315
|
-
# even in sessions without --enable-shell (run_skill only executes a
|
|
316
|
-
# named Python file, never a free-form shell command).
|
|
317
|
-
self.enable_skills = bool(getattr(args, "enable_skills", False))
|
|
318
|
-
if self.enable_skills:
|
|
319
|
-
self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
|
|
320
|
-
self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
|
|
321
|
-
self.tools_dict.update(self.skills_tools_dict)
|
|
319
|
+
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
322
320
|
|
|
323
321
|
# Token accounting for the whole session.
|
|
324
322
|
self.token_stats = {
|
|
@@ -329,78 +327,140 @@ class AgentSession:
|
|
|
329
327
|
"tool_calls": 0,
|
|
330
328
|
}
|
|
331
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."""
|
|
332
367
|
self.unique_id = generate_unique_id()
|
|
368
|
+
self.session_dir = os.path.join(self.cache_dir, self.unique_id)
|
|
333
369
|
|
|
334
|
-
# Plan-mode tracking (see plan.py). Always instantiated so the loop can
|
|
335
|
-
# call it unconditionally; every method no-ops when --plan is disabled.
|
|
336
|
-
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
337
370
|
self.plan_manager = plan_utils.PlanManager(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
# High-level recorder + history segments. Instead of a single ever-growing
|
|
341
|
-
# history.json, a session keeps:
|
|
342
|
-
# * ``history_{n}.json`` files -- one per conversation *segment*; a brand
|
|
343
|
-
# new segment is started (n -> n+1) every time the history is compacted.
|
|
344
|
-
# * ``summary_history.json`` -- a flat, high-level *recorder*: one record
|
|
345
|
-
# per turn (the running turn number, *what* happened -- an assistant
|
|
346
|
-
# reply, a tool call, or a compaction summary -- and which
|
|
347
|
-
# ``history_{n}.json`` + message index the turn lives in).
|
|
348
|
-
# The recorder lets the user inspect and rewind the conversation (/history,
|
|
349
|
-
# /reset) without reading the full detailed histories, because every turn's
|
|
350
|
-
# recorded index marks a faithful end-of-turn snapshot of its segment.
|
|
371
|
+
self.session_dir, enabled=self.plan_mode)
|
|
372
|
+
|
|
351
373
|
self.history_index = 0
|
|
352
374
|
self.summary = []
|
|
353
375
|
|
|
354
|
-
|
|
355
|
-
# SubAgent._build_child_args) so it can exempt its own report.md -- which
|
|
356
|
-
# lives under the parent's cache dir, usually outside --allowed-dir --
|
|
357
|
-
# from the sandbox. A normal session leaves this None.
|
|
358
|
-
self.report_path = getattr(args, "report_path", None)
|
|
376
|
+
self.messages = self._build_initial_messages()
|
|
359
377
|
|
|
360
|
-
# Conversation state. When --restore-session is given we continue that
|
|
361
|
-
# session's history instead of starting fresh -- still under the brand
|
|
362
|
-
# new unique id above, so the original session file is never
|
|
363
|
-
# overwritten. Falls back to a freshly built history if the restore
|
|
364
|
-
# file is missing or unreadable.
|
|
365
|
-
# --load-last-session auto-detects the most recent previous session in the
|
|
366
|
-
# cache dir and continues it -- exactly like --restore-session, but without
|
|
367
|
-
# needing to know the folder name. An explicit --restore-session always wins
|
|
368
|
-
# and is used verbatim; otherwise the newest session is resolved here.
|
|
369
|
-
restore_session = getattr(self.args, "restore_session", None)
|
|
370
|
-
self.loaded_last_session_dir = None
|
|
371
|
-
if getattr(self.args, "load_last_session", False) and not restore_session:
|
|
372
|
-
# Resolve the most recent session: the .mini pointer recorded under
|
|
373
|
-
# --allowed-dir is consulted first (an explicit record of the last
|
|
374
|
-
# session worked in), falling back to the timestamp heuristic over
|
|
375
|
-
# the cache dir.
|
|
376
|
-
detected = find_last_session_dir(
|
|
377
|
-
self.cache_dir,
|
|
378
|
-
allowed_dir=self.allowed_dir,
|
|
379
|
-
use_pointer=True,
|
|
380
|
-
)
|
|
381
|
-
if detected:
|
|
382
|
-
restore_session = detected
|
|
383
|
-
self.loaded_last_session_dir = detected
|
|
384
|
-
restore_directory = _restore_dir(restore_session)
|
|
385
|
-
restored = load_history_from_dir(restore_directory) if restore_directory else None
|
|
386
|
-
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
387
|
-
if restored is not None:
|
|
388
|
-
self._restore_usage_if_available(restore_session)
|
|
389
|
-
self._load_summary_if_restored(restore_directory)
|
|
390
|
-
else:
|
|
391
|
-
# --load-last-session asked for a session but nothing usable was found:
|
|
392
|
-
# don't advertise a load. The recorder starts empty and the first
|
|
393
|
-
# assistant / tool-call turn seeds turn 1 -- mirroring the restore
|
|
394
|
-
# path, which also has no leading system turn, so /history turn
|
|
395
|
-
# numbers stay consistent whether or not we restored.
|
|
396
|
-
self.loaded_last_session_dir = None
|
|
397
|
-
|
|
398
|
-
# Snapshot the initial (or restored) messages as the first history segment
|
|
399
|
-
# and persist the recorder, so a session starts with on-disk state to
|
|
400
|
-
# inspect and rewind to.
|
|
401
378
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
402
379
|
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
403
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
|
+
|
|
417
|
+
def _detect_last_session(self):
|
|
418
|
+
"""Resolve the most recent previous session dir, once.
|
|
419
|
+
|
|
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__).
|
|
424
|
+
"""
|
|
425
|
+
return find_last_session_dir(self.allowed_dir)
|
|
426
|
+
|
|
427
|
+
def _register_tools(self, args):
|
|
428
|
+
"""Wire up which tools the LLM may call, and register the opt-in ones.
|
|
429
|
+
|
|
430
|
+
Base tools depend on --enable-shell (the shell tool is withheld unless
|
|
431
|
+
shell is explicitly on). Then two opt-in features each add their own
|
|
432
|
+
sandbox-safe tools:
|
|
433
|
+
|
|
434
|
+
* Sub-agents (--enable-subagents): every registered sub-agent becomes
|
|
435
|
+
an extra tool. Its tools_dict entry has ``function=None`` so
|
|
436
|
+
_run_one_tool_call knows to *delegate* to a nested session rather
|
|
437
|
+
than call a real function. A child session never sets
|
|
438
|
+
enable_subagents, so sub-agents cannot recursively spawn sub-agents.
|
|
439
|
+
* Skills (--skills): the .mini directory (inside --allowed-dir) exposes
|
|
440
|
+
list/read/create/run_skill tools. They are sandbox-safe -- names are
|
|
441
|
+
validated as single path segments and resolved under .mini, so they
|
|
442
|
+
never accept an arbitrary path from the model and are usable even
|
|
443
|
+
without --enable-shell.
|
|
444
|
+
"""
|
|
445
|
+
forbidden = None if args.enable_shell else tools.shell_tools
|
|
446
|
+
self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
|
|
447
|
+
self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
|
|
448
|
+
|
|
449
|
+
self.enable_subagents = bool(getattr(args, "enable_subagents", False))
|
|
450
|
+
self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
|
|
451
|
+
for sa in self.subagents_registry.values():
|
|
452
|
+
self.llm_tools.append(sa.tool_schema())
|
|
453
|
+
self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
|
|
454
|
+
|
|
455
|
+
self.enable_skills = bool(getattr(args, "enable_skills", False))
|
|
456
|
+
if self.enable_skills:
|
|
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))
|
|
459
|
+
self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
|
|
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
|
+
|
|
463
|
+
|
|
404
464
|
def _build_initial_messages(self):
|
|
405
465
|
"""Assemble the system prompt and optional Agent.md into the first turns."""
|
|
406
466
|
if self.args.system_prompt:
|
|
@@ -410,6 +470,7 @@ class AgentSession:
|
|
|
410
470
|
tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
|
|
411
471
|
plan_mode=self.plan_mode,
|
|
412
472
|
skills_dir=self.allowed_dir if self.enable_skills else None,
|
|
473
|
+
session_dir=self.session_dir,
|
|
413
474
|
)
|
|
414
475
|
|
|
415
476
|
messages = [{"role": "system", "content": system_prompt}]
|
|
@@ -427,7 +488,7 @@ class AgentSession:
|
|
|
427
488
|
|
|
428
489
|
return messages
|
|
429
490
|
|
|
430
|
-
def _restore_usage_if_available(self,
|
|
491
|
+
def _restore_usage_if_available(self, restore_directory):
|
|
431
492
|
"""Seed this session's token totals from a sibling usage.json, if the
|
|
432
493
|
restored session had one, so the running summary stays continuous.
|
|
433
494
|
|
|
@@ -435,15 +496,12 @@ class AgentSession:
|
|
|
435
496
|
of) the in-memory totals, and any problem here is ignored silently so a
|
|
436
497
|
missing or malformed usage file can never break a restore.
|
|
437
498
|
"""
|
|
438
|
-
|
|
439
|
-
return
|
|
440
|
-
directory = _restore_dir(restore_session)
|
|
441
|
-
saved = load_usage_from_dir(directory)
|
|
499
|
+
saved = load_usage_from_dir(restore_directory)
|
|
442
500
|
for key in self.token_stats:
|
|
443
501
|
if key in saved:
|
|
444
502
|
self.token_stats[key] += int(saved[key] or 0)
|
|
445
503
|
|
|
446
|
-
def _load_summary_if_restored(self,
|
|
504
|
+
def _load_summary_if_restored(self, restore_directory):
|
|
447
505
|
"""Restore the high-level recorder and segment index for a restore.
|
|
448
506
|
|
|
449
507
|
Prefers the saved ``summary_history.json`` so the recorder stays flat and
|
|
@@ -453,7 +511,8 @@ class AgentSession:
|
|
|
453
511
|
rebuild a minimal recorder by scanning the restored messages for
|
|
454
512
|
assistant turns, so navigation still works. Best-effort only.
|
|
455
513
|
"""
|
|
456
|
-
saved = load_summary_from_dir(
|
|
514
|
+
saved = load_summary_from_dir(restore_directory)
|
|
515
|
+
|
|
457
516
|
saved = [r for r in saved if isinstance(r, dict)]
|
|
458
517
|
if saved:
|
|
459
518
|
self.summary = saved
|
|
@@ -584,50 +643,59 @@ class AgentSession:
|
|
|
584
643
|
f"Sub-agents enabled ({len(self.subagents_registry)} available: "
|
|
585
644
|
f"{', '.join(self.subagents_registry)}).", "info")
|
|
586
645
|
|
|
646
|
+
def show_resume_choice(self):
|
|
647
|
+
"""Prompt for the resume choice and return its label.
|
|
648
|
+
|
|
649
|
+
Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
|
|
650
|
+
degrades cleanly on a non-TTY / piped console: an EOF / Ctrl-C (``read_line``
|
|
651
|
+
returns None) is treated as ``"exit"`` so the loop never spins on unread input.
|
|
652
|
+
"""
|
|
653
|
+
self.console.emit(
|
|
654
|
+
self.console.style(
|
|
655
|
+
"A previous session was found. What would you like to do?", dim=True,
|
|
656
|
+
fg="cyan"))
|
|
657
|
+
self.console.emit(" 1) Resume the previous session (continue where you left off)")
|
|
658
|
+
self.console.emit(" 2) Start a new session (empty history)")
|
|
659
|
+
self.console.emit(" 3) Exit (start nothing)")
|
|
660
|
+
while True:
|
|
661
|
+
answer = self.console.read_line(
|
|
662
|
+
self.console.style("Resume previous session? Type 1, 2, or 3: ", fg="cyan"))
|
|
663
|
+
if answer is None:
|
|
664
|
+
# EOF / Ctrl-C at the offer -> graceful exit, no session written.
|
|
665
|
+
return "exit"
|
|
666
|
+
choice = answer.strip().lower()
|
|
667
|
+
if choice in ("1", "resume"):
|
|
668
|
+
return "resume"
|
|
669
|
+
if choice in ("2", "new"):
|
|
670
|
+
return "new"
|
|
671
|
+
if choice in ("3", "exit"):
|
|
672
|
+
return "exit"
|
|
673
|
+
self.console.note("Please choose 1, 2, or 3.", "warn")
|
|
674
|
+
|
|
587
675
|
def run(self, initial_prompt=None):
|
|
588
676
|
"""Drive the agent loop until the user quits."""
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
# Announce the session's unique id as an assistant-styled message so the
|
|
594
|
-
# user can see / copy the id this conversation is being saved under.
|
|
595
|
-
self._print_session_id()
|
|
596
|
-
|
|
597
|
-
# Record this session as the "latest" so a future --load-last-session can
|
|
598
|
-
# resume it without scanning the cache dir. This lives in the top-level
|
|
599
|
-
# loop's own directory (cache_dir/unique_id), and only the interactive
|
|
600
|
-
# run() writes it -- a sub-agent child drives via _drive_auto and would
|
|
601
|
-
# otherwise overwrite the pointer with its own buried path.
|
|
602
|
-
self._save_last_session_pointer()
|
|
603
|
-
|
|
604
|
-
# Report the outcome of --load-last-session up front, so the user sees
|
|
605
|
-
# whether we resumed an earlier conversation (under a brand-new id) or
|
|
606
|
-
# started fresh because no previous session was found.
|
|
607
|
-
if self.loaded_last_session_dir is not None:
|
|
608
|
-
self.console.note(
|
|
609
|
-
f"Loaded most recent session "
|
|
610
|
-
f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
|
|
611
|
-
f"it under a new session id.", "ok")
|
|
612
|
-
elif getattr(self.args, "load_last_session", False):
|
|
613
|
-
self.console.note(
|
|
614
|
-
"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
|
+
|
|
615
680
|
|
|
616
681
|
# Hint at startup so the high-level history feature is discoverable:
|
|
617
682
|
# /history browses the recorder, /history <n> inspects a turn, and
|
|
618
683
|
# /reset <n> rewinds the conversation to that turn's state.
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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"))
|
|
624
691
|
|
|
625
692
|
# The --prompt value is a one-shot initial turn: it is sent verbatim and
|
|
626
693
|
# then cleared, so later turns come from the user (manual mode) or are
|
|
627
694
|
# produced automatically by the loop (auto mode).
|
|
628
695
|
pending = initial_prompt
|
|
629
696
|
|
|
630
|
-
|
|
697
|
+
running = True
|
|
698
|
+
while running:
|
|
631
699
|
try:
|
|
632
700
|
# 1) Get the user's turn: the one-shot preset, or a fresh line.
|
|
633
701
|
if pending is not None:
|
|
@@ -638,8 +706,9 @@ class AgentSession:
|
|
|
638
706
|
pending = None
|
|
639
707
|
else:
|
|
640
708
|
user_input = self._read_user_line()
|
|
641
|
-
if user_input is None: #
|
|
642
|
-
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
|
|
643
712
|
break
|
|
644
713
|
|
|
645
714
|
# Interactive slash commands are handled in place and never
|
|
@@ -647,28 +716,60 @@ class AgentSession:
|
|
|
647
716
|
# was such a command, so we just re-prompt (continue).
|
|
648
717
|
if user_input.startswith("/") and self._handle_command(user_input):
|
|
649
718
|
continue
|
|
650
|
-
|
|
651
|
-
# In plan mode, remember the user's latest instruction as the
|
|
652
|
-
# active goal so the loop (and plan-mode compaction) can track it
|
|
653
|
-
# even if the agent never writes it to disk itself.
|
|
654
|
-
self._capture_goal(user_input)
|
|
655
|
-
|
|
656
|
-
# 2) Hand the user turn to the model and act on its tool calls.
|
|
719
|
+
|
|
657
720
|
self.messages.append({"role": "user", "content": user_input})
|
|
658
|
-
|
|
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.
|
|
659
734
|
should_continue = self._step()
|
|
660
735
|
self._persist()
|
|
661
|
-
if
|
|
662
|
-
|
|
663
|
-
|
|
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
|
+
|
|
664
757
|
except _Abort:
|
|
665
758
|
# Ctrl-C during a permission prompt already shut the session
|
|
666
759
|
# down; just unwind the loop quietly.
|
|
667
|
-
|
|
760
|
+
running = False
|
|
761
|
+
|
|
668
762
|
except KeyboardInterrupt:
|
|
669
763
|
self.console.note("Interrupting: Mini Code Exiting...", "error")
|
|
764
|
+
self.shutdown()
|
|
765
|
+
running = False
|
|
670
766
|
# Ctrl-C mid-loop just saves and re-prompts rather than crashing.
|
|
671
|
-
|
|
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")
|
|
672
773
|
|
|
673
774
|
def _print_session_id(self):
|
|
674
775
|
"""Print the session's unique id as a minimal "Assistant" turn.
|
|
@@ -680,15 +781,15 @@ class AgentSession:
|
|
|
680
781
|
"""
|
|
681
782
|
self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
|
|
682
783
|
|
|
683
|
-
def _save_last_session_pointer(self):
|
|
684
|
-
|
|
784
|
+
# def _save_last_session_pointer(self):
|
|
785
|
+
# """Record this session's directory in --allowed-dir/.mini.
|
|
685
786
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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))
|
|
692
793
|
|
|
693
794
|
# -- autonomous drive (used by sub-agents) ------------------------------
|
|
694
795
|
|
|
@@ -741,49 +842,123 @@ class AgentSession:
|
|
|
741
842
|
|
|
742
843
|
# -- plan mode ---------------------------------------------------------
|
|
743
844
|
|
|
744
|
-
def
|
|
745
|
-
"""
|
|
746
|
-
|
|
747
|
-
The loop owns the goal (the user's last instruction); the agent owns the
|
|
748
|
-
plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
|
|
749
|
-
Slash commands and the empty input are not real instructions, so they are
|
|
750
|
-
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.
|
|
751
847
|
"""
|
|
752
|
-
if not self.plan_mode or user_input is None:
|
|
753
|
-
return
|
|
754
848
|
stripped = user_input.strip()
|
|
755
849
|
if not stripped or stripped.startswith("/"):
|
|
756
850
|
return
|
|
757
|
-
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
|
+
})
|
|
758
885
|
|
|
759
886
|
def _finish_goal_and_shutdown(self):
|
|
760
|
-
"""Mark the active goal finished in CURRENT_PLAN.md, then quit/save.
|
|
761
|
-
|
|
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)
|
|
762
895
|
self.shutdown()
|
|
763
896
|
|
|
764
|
-
def
|
|
765
|
-
"""
|
|
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
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _handle_compaction(self, compacted):
|
|
935
|
+
"""Re-inject goal + plan into the compacted messages (plan mode).
|
|
766
936
|
|
|
767
|
-
|
|
768
|
-
|
|
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.
|
|
769
943
|
"""
|
|
770
|
-
self.
|
|
944
|
+
self.messages = self._apply_plan_to_compacted(compacted)
|
|
771
945
|
|
|
772
946
|
def _apply_plan_to_compacted(self, messages):
|
|
773
|
-
"""Re-inject the
|
|
947
|
+
"""Re-inject the current goal + plan after a history compaction.
|
|
774
948
|
|
|
775
949
|
Compaction replaces the transcript with a summary, which would otherwise
|
|
776
|
-
drop the
|
|
777
|
-
disk and append them as fresh user turns so the model keeps
|
|
778
|
-
against the goal it is still trying to finish. The goal is
|
|
779
|
-
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.
|
|
780
955
|
"""
|
|
781
956
|
plan_text = self.plan_manager.read_plan()
|
|
782
957
|
goal_text = self.plan_manager.read_goal()
|
|
783
958
|
|
|
784
959
|
# Recover the user's latest instruction from the pre-compaction tail, in
|
|
785
|
-
# case the loop had not
|
|
786
|
-
#
|
|
960
|
+
# case the loop had not recorded a goal yet, so there is always a goal to
|
|
961
|
+
# restate after compaction.
|
|
787
962
|
current_input = None
|
|
788
963
|
for m in reversed(messages):
|
|
789
964
|
if m.get("role") == "user":
|
|
@@ -804,29 +979,6 @@ class AgentSession:
|
|
|
804
979
|
rebuilt.append({"role": "user", "content": current_input})
|
|
805
980
|
return rebuilt
|
|
806
981
|
|
|
807
|
-
def _is_plan_file(self, name, tool_params):
|
|
808
|
-
"""Whether a read/write targets a plan-mode file in the session dir.
|
|
809
|
-
|
|
810
|
-
Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
|
|
811
|
-
session directory is usually outside --allowed-dir. Scoped tightly: plan
|
|
812
|
-
mode only, only read_file / write_file, only when the resolved path is a
|
|
813
|
-
plan file living directly in this session's directory.
|
|
814
|
-
"""
|
|
815
|
-
if not self.plan_mode or name not in ("read_file", "write_file"):
|
|
816
|
-
return False
|
|
817
|
-
path = tool_params.get("file_path", "")
|
|
818
|
-
if not path:
|
|
819
|
-
return False
|
|
820
|
-
try:
|
|
821
|
-
resolved = os.path.realpath(path)
|
|
822
|
-
except (ValueError, TypeError):
|
|
823
|
-
return False
|
|
824
|
-
session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
|
|
825
|
-
if os.path.dirname(resolved) != session:
|
|
826
|
-
return False
|
|
827
|
-
return os.path.basename(resolved) in (
|
|
828
|
-
plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
|
|
829
|
-
|
|
830
982
|
def _is_report_file(self, name, tool_params):
|
|
831
983
|
"""Whether a write targets this session's own sub-agent report.md.
|
|
832
984
|
|
|
@@ -855,20 +1007,17 @@ class AgentSession:
|
|
|
855
1007
|
# -- interactive input -------------------------------------------------
|
|
856
1008
|
|
|
857
1009
|
def _read_user_line(self):
|
|
858
|
-
"""Read one turn from the user. Returns the text to send, or None to quit.
|
|
1010
|
+
"""Read one turn from the user. Returns the text to send, or None to quit.
|
|
1011
|
+
|
|
1012
|
+
An empty (blank) line is not a real instruction, so it is ignored and the
|
|
1013
|
+
user is asked again rather than sending an empty turn to the model.
|
|
1014
|
+
"""
|
|
859
1015
|
self.console.short_divider()
|
|
860
|
-
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
861
|
-
if user_input is None: # EOF / Ctrl-C at the prompt
|
|
862
|
-
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
863
|
-
return None
|
|
864
1016
|
|
|
865
|
-
|
|
866
|
-
return None
|
|
1017
|
+
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
867
1018
|
|
|
868
|
-
if user_input
|
|
869
|
-
|
|
870
|
-
self.unique_id, self.cache_dir, self.model)
|
|
871
|
-
# Nothing to send to the model; ask again.
|
|
1019
|
+
if not user_input.strip():
|
|
1020
|
+
self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
|
|
872
1021
|
return self._read_user_line()
|
|
873
1022
|
|
|
874
1023
|
return user_input
|
|
@@ -882,6 +1031,8 @@ class AgentSession:
|
|
|
882
1031
|
/history <n> show the full messages recorded for turn <n>
|
|
883
1032
|
/reset [<n>] rewind the conversation to the end of turn <n>
|
|
884
1033
|
(no <n> rewinds to the most recent turn)
|
|
1034
|
+
/plan show the current goal + plan the loop maintains
|
|
1035
|
+
(plan mode only)
|
|
885
1036
|
/clear clear the screen (keeps the conversation)
|
|
886
1037
|
/help show the command help
|
|
887
1038
|
|
|
@@ -908,6 +1059,9 @@ class AgentSession:
|
|
|
908
1059
|
if cmd in self.CLEAR_COMMANDS:
|
|
909
1060
|
self._clear_and_show_last()
|
|
910
1061
|
return True
|
|
1062
|
+
if cmd == "plan":
|
|
1063
|
+
self._show_plan()
|
|
1064
|
+
return True
|
|
911
1065
|
self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
|
|
912
1066
|
return True
|
|
913
1067
|
|
|
@@ -934,6 +1088,33 @@ class AgentSession:
|
|
|
934
1088
|
return
|
|
935
1089
|
self._reset_history(target["turn"], target)
|
|
936
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
|
+
|
|
937
1118
|
def _clear_and_show_last(self):
|
|
938
1119
|
"""Handle /clear: wipe the screen and re-print the last turn.
|
|
939
1120
|
|
|
@@ -964,7 +1145,7 @@ class AgentSession:
|
|
|
964
1145
|
# Show a "… calling tool" spinner (in-place, non-scrolling) while the
|
|
965
1146
|
# tool(s) execute, mirroring the thinking spinner that covers the LLM
|
|
966
1147
|
# call. Always stopped (finally) so it can never leak into the next turn.
|
|
967
|
-
|
|
1148
|
+
|
|
968
1149
|
try:
|
|
969
1150
|
for call in tool_calls:
|
|
970
1151
|
self._run_one_tool_call(call)
|
|
@@ -972,9 +1153,11 @@ class AgentSession:
|
|
|
972
1153
|
self.console.stop_tool_call()
|
|
973
1154
|
|
|
974
1155
|
def _run_one_tool_call(self, call):
|
|
1156
|
+
|
|
975
1157
|
call_id = call.get("id", "")
|
|
976
1158
|
name = call.get("function", {}).get("name", "")
|
|
977
1159
|
params_str = call.get("function", {}).get("arguments", "{}")
|
|
1160
|
+
self.console.start_tool_call(tool_name=name)
|
|
978
1161
|
|
|
979
1162
|
try:
|
|
980
1163
|
tool_params = json.loads(params_str)
|
|
@@ -997,14 +1180,11 @@ class AgentSession:
|
|
|
997
1180
|
f"Error: Unknown tool: {name}, params {params_str}")
|
|
998
1181
|
return
|
|
999
1182
|
|
|
1000
|
-
# 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan
|
|
1001
|
-
#
|
|
1002
|
-
#
|
|
1003
|
-
# 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.)
|
|
1004
1186
|
if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
|
|
1005
|
-
if self.
|
|
1006
|
-
pass # allow the plan/goal files into the session directory
|
|
1007
|
-
elif self._is_report_file(name, tool_params):
|
|
1187
|
+
if self._is_report_file(name, tool_params):
|
|
1008
1188
|
pass # allow a sub-agent's own report.md under the cache dir
|
|
1009
1189
|
else:
|
|
1010
1190
|
self.console.note(
|
|
@@ -1109,14 +1289,13 @@ class AgentSession:
|
|
|
1109
1289
|
self.messages, did_compact, new_index = maybe_compact(
|
|
1110
1290
|
self.messages, self.server_url, self.model, self.api_key,
|
|
1111
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
|
|
1112
1296
|
)
|
|
1113
1297
|
if did_compact:
|
|
1114
1298
|
self.history_index = new_index
|
|
1115
|
-
# In plan mode, re-inject the running plan + current goal so a
|
|
1116
|
-
# compaction (which would otherwise drop them) keeps the model
|
|
1117
|
-
# working against the goal it is still trying to finish.
|
|
1118
|
-
if self.plan_mode:
|
|
1119
|
-
self.messages = self._apply_plan_to_compacted(self.messages)
|
|
1120
1299
|
# Snapshot the compacted messages into the new segment so its turns
|
|
1121
1300
|
# are preserved, and record the summary as the first turn of it.
|
|
1122
1301
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
@@ -1155,6 +1334,9 @@ class AgentSession:
|
|
|
1155
1334
|
made_tool_calls = bool(tool_calls)
|
|
1156
1335
|
if made_tool_calls:
|
|
1157
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
|
|
1158
1340
|
|
|
1159
1341
|
if response_text:
|
|
1160
1342
|
self._print_assistant(response_text)
|