mini-code-cli 0.1.0.2__tar.gz → 0.1.0.3__tar.gz
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-0.1.0.2 → mini_code_cli-0.1.0.3}/PKG-INFO +2 -1
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/README.md +1 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/agent_loop.py +198 -54
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/runner.py +23 -6
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/utils.py +30 -3
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/PKG-INFO +2 -1
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/SOURCES.txt +2 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/pyproject.toml +1 -1
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_integration.py +9 -2
- mini_code_cli-0.1.0.3/tests/test_resume_offer.py +178 -0
- mini_code_cli-0.1.0.3/tests/test_robustness.py +179 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/LICENSE +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/__init__.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/config.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/permissions.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/persistence.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/plan.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/prompts.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/sandbox.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/shell_safety_utils.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/skills.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/__init__.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/base.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/builtin.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/tools.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli/ui.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/dependency_links.txt +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/entry_points.txt +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/requires.txt +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/top_level.txt +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/setup.cfg +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_agent_loop.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_last_session_pointer.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_plan.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_sandboxing.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_shell_safety.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_skills.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_subagents.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_tools.py +0 -0
- {mini_code_cli-0.1.0.2 → mini_code_cli-0.1.0.3}/tests/test_ui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.3
|
|
4
4
|
Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
|
|
5
5
|
Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
@@ -721,6 +721,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
721
721
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
722
722
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
723
723
|
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
724
|
+
- **Interactive session offer:** if a previous session exists in the cache dir but you did **not** explicitly ask to resume one (i.e. neither `--load-last-session` nor `--restore-session` was passed), the prompt asks what you'd like to do before the conversation starts: 1) **Resume** the previous session (splice its history in under a brand-new session id and continue it), 2) **Start a new** session (empty history), or 3) **Exit** (start nothing). Ctrl-C / EOF at the offer is treated as a graceful exit. This is distinct from `--load-last-session`, which silently resumes the most recent session without asking.
|
|
724
725
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
725
726
|
|
|
726
727
|
### Available tools
|
|
@@ -47,6 +47,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
47
47
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
48
48
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
49
49
|
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
50
|
+
- **Interactive session offer:** if a previous session exists in the cache dir but you did **not** explicitly ask to resume one (i.e. neither `--load-last-session` nor `--restore-session` was passed), the prompt asks what you'd like to do before the conversation starts: 1) **Resume** the previous session (splice its history in under a brand-new session id and continue it), 2) **Start a new** session (empty history), or 3) **Exit** (start nothing). Ctrl-C / EOF at the offer is treated as a graceful exit. This is distinct from `--load-last-session`, which silently resumes the most recent session without asking.
|
|
50
51
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
51
52
|
|
|
52
53
|
### Available tools
|
|
@@ -138,6 +138,7 @@ def parse_args():
|
|
|
138
138
|
args.enable_shell = True
|
|
139
139
|
args.sudo = True
|
|
140
140
|
args.auto_mode = True
|
|
141
|
+
args.skills = True
|
|
141
142
|
|
|
142
143
|
if args.all_last:
|
|
143
144
|
args.load_last_session = True
|
|
@@ -289,36 +290,10 @@ class AgentSession:
|
|
|
289
290
|
self.model = model_name if model_name else args.model
|
|
290
291
|
self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
|
|
291
292
|
|
|
292
|
-
# Which tools the LLM may call
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
self.
|
|
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}
|
|
309
|
-
|
|
310
|
-
# Skills (opt-in via --skills). Each registered skill tool is exposed to
|
|
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)
|
|
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().
|
|
296
|
+
self._register_tools(args)
|
|
322
297
|
|
|
323
298
|
# Token accounting for the whole session.
|
|
324
299
|
self.token_stats = {
|
|
@@ -366,33 +341,47 @@ class AgentSession:
|
|
|
366
341
|
# cache dir and continues it -- exactly like --restore-session, but without
|
|
367
342
|
# needing to know the folder name. An explicit --restore-session always wins
|
|
368
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).
|
|
369
349
|
restore_session = getattr(self.args, "restore_session", None)
|
|
350
|
+
self.last_session_candidate = None
|
|
370
351
|
self.loaded_last_session_dir = None
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
384
370
|
restore_directory = _restore_dir(restore_session)
|
|
385
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.
|
|
386
375
|
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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:
|
|
396
385
|
self.loaded_last_session_dir = None
|
|
397
386
|
|
|
398
387
|
# Snapshot the initial (or restored) messages as the first history segment
|
|
@@ -401,6 +390,56 @@ class AgentSession:
|
|
|
401
390
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
402
391
|
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
403
392
|
|
|
393
|
+
def _detect_last_session(self):
|
|
394
|
+
"""Resolve the most recent previous session dir, once.
|
|
395
|
+
|
|
396
|
+
Consults the .mini pointer recorded under --allowed-dir first (an
|
|
397
|
+
explicit record of the last session worked in), then falls back to the
|
|
398
|
+
timestamp heuristic over the cache dir. Returns the path, or None. The
|
|
399
|
+
constructor calls this a single time so the (relatively expensive)
|
|
400
|
+
pointer / directory scan does not run twice for the two independent
|
|
401
|
+
concerns -- --load-last-session vs. the interactive resume candidate.
|
|
402
|
+
"""
|
|
403
|
+
return find_last_session_dir(
|
|
404
|
+
self.cache_dir,
|
|
405
|
+
allowed_dir=self.allowed_dir,
|
|
406
|
+
use_pointer=True,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def _register_tools(self, args):
|
|
410
|
+
"""Wire up which tools the LLM may call, and register the opt-in ones.
|
|
411
|
+
|
|
412
|
+
Base tools depend on --enable-shell (the shell tool is withheld unless
|
|
413
|
+
shell is explicitly on). Then two opt-in features each add their own
|
|
414
|
+
sandbox-safe tools:
|
|
415
|
+
|
|
416
|
+
* Sub-agents (--enable-subagents): every registered sub-agent becomes
|
|
417
|
+
an extra tool. Its tools_dict entry has ``function=None`` so
|
|
418
|
+
_run_one_tool_call knows to *delegate* to a nested session rather
|
|
419
|
+
than call a real function. A child session never sets
|
|
420
|
+
enable_subagents, so sub-agents cannot recursively spawn sub-agents.
|
|
421
|
+
* Skills (--skills): the .mini directory (inside --allowed-dir) exposes
|
|
422
|
+
list/read/create/run_skill tools. They are sandbox-safe -- names are
|
|
423
|
+
validated as single path segments and resolved under .mini, so they
|
|
424
|
+
never accept an arbitrary path from the model and are usable even
|
|
425
|
+
without --enable-shell.
|
|
426
|
+
"""
|
|
427
|
+
forbidden = None if args.enable_shell else tools.shell_tools
|
|
428
|
+
self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
|
|
429
|
+
self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
|
|
430
|
+
|
|
431
|
+
self.enable_subagents = bool(getattr(args, "enable_subagents", False))
|
|
432
|
+
self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
|
|
433
|
+
for sa in self.subagents_registry.values():
|
|
434
|
+
self.llm_tools.append(sa.tool_schema())
|
|
435
|
+
self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
|
|
436
|
+
|
|
437
|
+
self.enable_skills = bool(getattr(args, "enable_skills", False))
|
|
438
|
+
if self.enable_skills:
|
|
439
|
+
self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
|
|
440
|
+
self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
|
|
441
|
+
self.tools_dict.update(self.skills_tools_dict)
|
|
442
|
+
|
|
404
443
|
def _build_initial_messages(self):
|
|
405
444
|
"""Assemble the system prompt and optional Agent.md into the first turns."""
|
|
406
445
|
if self.args.system_prompt:
|
|
@@ -466,6 +505,28 @@ class AgentSession:
|
|
|
466
505
|
self.summary.append({"turn": turn, "what": "assistant",
|
|
467
506
|
"history_file": 0, "index": i})
|
|
468
507
|
|
|
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
|
+
|
|
469
530
|
def _record_turn(self, what, messages, history_index=None):
|
|
470
531
|
"""Append one entry to the high-level recorder.
|
|
471
532
|
|
|
@@ -584,12 +645,84 @@ class AgentSession:
|
|
|
584
645
|
f"Sub-agents enabled ({len(self.subagents_registry)} available: "
|
|
585
646
|
f"{', '.join(self.subagents_registry)}).", "info")
|
|
586
647
|
|
|
648
|
+
def _offer_resume_last_session(self):
|
|
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):
|
|
679
|
+
"""Prompt for the resume choice and return its label.
|
|
680
|
+
|
|
681
|
+
Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
|
|
682
|
+
degrades cleanly on a non-TTY / piped console: an EOF / Ctrl-C (``read_line``
|
|
683
|
+
returns None) is treated as ``"exit"`` so the loop never spins on unread input.
|
|
684
|
+
"""
|
|
685
|
+
self.console.emit(
|
|
686
|
+
self.console.style(
|
|
687
|
+
"A previous session was found. What would you like to do?", dim=True,
|
|
688
|
+
fg="cyan"))
|
|
689
|
+
self.console.emit(" 1) Resume the previous session (continue where you left off)")
|
|
690
|
+
self.console.emit(" 2) Start a new session (empty history)")
|
|
691
|
+
self.console.emit(" 3) Exit (start nothing)")
|
|
692
|
+
while True:
|
|
693
|
+
answer = self.console.read_line(
|
|
694
|
+
self.console.style("Resume previous session? Type 1, 2, or 3: ", fg="cyan"))
|
|
695
|
+
if answer is None:
|
|
696
|
+
# EOF / Ctrl-C at the offer -> graceful exit, no session written.
|
|
697
|
+
return "exit"
|
|
698
|
+
choice = answer.strip().lower()
|
|
699
|
+
if choice in ("1", "resume"):
|
|
700
|
+
return "resume"
|
|
701
|
+
if choice in ("2", "new"):
|
|
702
|
+
return "new"
|
|
703
|
+
if choice in ("3", "exit"):
|
|
704
|
+
return "exit"
|
|
705
|
+
self.console.note("Please choose 1, 2, or 3.", "warn")
|
|
706
|
+
|
|
587
707
|
def run(self, initial_prompt=None):
|
|
588
708
|
"""Drive the agent loop until the user quits."""
|
|
589
709
|
self.print_announcements()
|
|
590
710
|
print_welcome(self.allowed_dir, self.server_url, self.args,
|
|
591
711
|
self.unique_id, self.cache_dir, self.model)
|
|
592
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
|
+
|
|
593
726
|
# Announce the session's unique id as an assistant-styled message so the
|
|
594
727
|
# user can see / copy the id this conversation is being saved under.
|
|
595
728
|
self._print_session_id()
|
|
@@ -660,7 +793,8 @@ class AgentSession:
|
|
|
660
793
|
self._persist()
|
|
661
794
|
if not should_continue:
|
|
662
795
|
break
|
|
663
|
-
self.console.note(
|
|
796
|
+
self.console.note(
|
|
797
|
+
"Continuing with LLM calls until no more tool calls.", "info")
|
|
664
798
|
except _Abort:
|
|
665
799
|
# Ctrl-C during a permission prompt already shut the session
|
|
666
800
|
# down; just unwind the loop quietly.
|
|
@@ -855,7 +989,11 @@ class AgentSession:
|
|
|
855
989
|
# -- interactive input -------------------------------------------------
|
|
856
990
|
|
|
857
991
|
def _read_user_line(self):
|
|
858
|
-
"""Read one turn from the user. Returns the text to send, or None to quit.
|
|
992
|
+
"""Read one turn from the user. Returns the text to send, or None to quit.
|
|
993
|
+
|
|
994
|
+
An empty (blank) line is not a real instruction, so it is ignored and the
|
|
995
|
+
user is asked again rather than sending an empty turn to the model.
|
|
996
|
+
"""
|
|
859
997
|
self.console.short_divider()
|
|
860
998
|
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
861
999
|
if user_input is None: # EOF / Ctrl-C at the prompt
|
|
@@ -871,6 +1009,12 @@ class AgentSession:
|
|
|
871
1009
|
# Nothing to send to the model; ask again.
|
|
872
1010
|
return self._read_user_line()
|
|
873
1011
|
|
|
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
|
+
if not user_input.strip():
|
|
1015
|
+
self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
|
|
1016
|
+
return self._read_user_line()
|
|
1017
|
+
|
|
874
1018
|
return user_input
|
|
875
1019
|
|
|
876
1020
|
def _handle_command(self, user_input):
|
|
@@ -21,10 +21,10 @@ class _TimedOut(Exception):
|
|
|
21
21
|
def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
|
|
22
22
|
"""Call ``func(**tool_params)``, aborting after ``timeout_seconds``.
|
|
23
23
|
|
|
24
|
-
On timeout
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
On timeout -- or if the tool raises *any* other exception -- a friendly
|
|
25
|
+
``Error:`` string is returned so the failure reaches the model as a tool
|
|
26
|
+
result (letting it recover or retry) instead of propagating and crashing the
|
|
27
|
+
whole agent loop. A timeout is reported distinctly from a generic failure.
|
|
28
28
|
"""
|
|
29
29
|
def _handler(signum, frame):
|
|
30
30
|
raise _TimedOut()
|
|
@@ -33,8 +33,9 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
|
|
|
33
33
|
previous = signal.signal(signal.SIGALRM, _handler)
|
|
34
34
|
except (ValueError, OSError):
|
|
35
35
|
# signal only works in the main thread; if we cannot arm it, just run
|
|
36
|
-
# the tool without the wall-clock guard rather than failing outright
|
|
37
|
-
|
|
36
|
+
# the tool without the wall-clock guard rather than failing outright --
|
|
37
|
+
# but still shield the loop from a tool that raises.
|
|
38
|
+
return _call_tool_safely(func, tool_params, name)
|
|
38
39
|
|
|
39
40
|
signal.alarm(int(timeout_seconds) if timeout_seconds else 0)
|
|
40
41
|
try:
|
|
@@ -42,6 +43,22 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
|
|
|
42
43
|
except _TimedOut:
|
|
43
44
|
ui.console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
|
|
44
45
|
return f"Error: Function {name} timed out after {timeout_seconds} seconds."
|
|
46
|
+
except Exception as e: # noqa: BLE001 -- a failing tool must not crash the loop
|
|
47
|
+
ui.console.note(f"Tool {name} raised an error: {e}", "error")
|
|
48
|
+
return f"Error: tool {name} raised an error: {e}"
|
|
45
49
|
finally:
|
|
46
50
|
signal.alarm(0)
|
|
47
51
|
signal.signal(signal.SIGALRM, previous)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _call_tool_safely(func, tool_params, name):
|
|
55
|
+
"""Run a tool with no wall-clock guard (SIGALRM unavailable) but still
|
|
56
|
+
shield the loop from a tool that raises.
|
|
57
|
+
|
|
58
|
+
Used as the fallback when we could not arm SIGALRM (e.g. a non-main thread).
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return func(**tool_params)
|
|
62
|
+
except Exception as e: # noqa: BLE001 -- never let a tool exception propagate
|
|
63
|
+
ui.console.note(f"Tool {name} raised an error: {e}", "error")
|
|
64
|
+
return f"Error: tool {name} raised an error: {e}"
|
|
@@ -2,6 +2,27 @@ import requests
|
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
def _coerce_usage_int(value):
|
|
6
|
+
"""Best-effort: coerce a raw usage field to a non-negative int.
|
|
7
|
+
|
|
8
|
+
Servers sometimes report ``null``/``None`` or oddly-shaped numbers for token
|
|
9
|
+
usage. This normalises those to 0 so a downstream
|
|
10
|
+
``accumulate_usage``/``usage["tool_calls"]`` assignment can never raise.
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
return int(value)
|
|
14
|
+
except (TypeError, ValueError):
|
|
15
|
+
return 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _usage_or_zero(value):
|
|
19
|
+
"""Return ``int(value)`` when that is safe, else 0 (for summing fields)."""
|
|
20
|
+
try:
|
|
21
|
+
return int(value)
|
|
22
|
+
except (TypeError, ValueError):
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
5
26
|
def determine_vllm_model(server_url, api_key=None, timeout=120):
|
|
6
27
|
"""In case of a vLLm local server, read the first served model id from
|
|
7
28
|
``{server_url}/v1/models`` so the caller does not have to name the model.
|
|
@@ -93,9 +114,12 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
|
|
|
93
114
|
|
|
94
115
|
# Token usage, when the server reports it (many local servers do not).
|
|
95
116
|
usage = response.json().get("usage", {}) or {}
|
|
96
|
-
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
97
|
-
completion_tokens = usage.get("completion_tokens", 0)
|
|
98
|
-
total_tokens =
|
|
117
|
+
prompt_tokens = _coerce_usage_int(usage.get("prompt_tokens", 0))
|
|
118
|
+
completion_tokens = _coerce_usage_int(usage.get("completion_tokens", 0))
|
|
119
|
+
total_tokens = _coerce_usage_int(
|
|
120
|
+
usage.get("total_tokens",
|
|
121
|
+
_usage_or_zero(usage.get("prompt_tokens"))
|
|
122
|
+
+ _usage_or_zero(usage.get("completion_tokens"))))
|
|
99
123
|
|
|
100
124
|
return content, tool_calls, {
|
|
101
125
|
"prompt_tokens": prompt_tokens,
|
|
@@ -126,3 +150,6 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
|
|
|
126
150
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
127
151
|
print(f"Error processing response: {e}")
|
|
128
152
|
return None, None, None
|
|
153
|
+
except Exception as e: # noqa: BLE001 -- a network/parse hiccup must not crash the loop
|
|
154
|
+
print(f"Unexpected error calling OpenAI server: {e}")
|
|
155
|
+
return None, None, None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.3
|
|
4
4
|
Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
|
|
5
5
|
Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
@@ -721,6 +721,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
721
721
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
722
722
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
723
723
|
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
724
|
+
- **Interactive session offer:** if a previous session exists in the cache dir but you did **not** explicitly ask to resume one (i.e. neither `--load-last-session` nor `--restore-session` was passed), the prompt asks what you'd like to do before the conversation starts: 1) **Resume** the previous session (splice its history in under a brand-new session id and continue it), 2) **Start a new** session (empty history), or 3) **Exit** (start nothing). Ctrl-C / EOF at the offer is treated as a graceful exit. This is distinct from `--load-last-session`, which silently resumes the most recent session without asking.
|
|
724
725
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
725
726
|
|
|
726
727
|
### Available tools
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mini-code-cli"
|
|
3
3
|
# version = { file = "mini_code_cli/__init__.py", attr = "__version__" }
|
|
4
|
-
version = "0.1.0.
|
|
4
|
+
version = "0.1.0.3"
|
|
5
5
|
description = "Mini Coding Agent. Allows you to easily experiment with a small coding agent."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.10"
|
|
@@ -78,8 +78,15 @@ class TestAgentLoopIntegration(unittest.TestCase):
|
|
|
78
78
|
return f.read()
|
|
79
79
|
|
|
80
80
|
def test_plan_mode_end_to_end(self):
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
# allowed_dir points at the throw-away temp dir (not "."): otherwise
|
|
82
|
+
# run() would consult the *real* .mini/last_session.json pointer in the
|
|
83
|
+
# repo, offer to resume it, and consume the scripted /quit. The plan
|
|
84
|
+
# files themselves live in the cache dir (session_dir), independent of
|
|
85
|
+
# allowed_dir, and the plan-file sandbox exemption keys off plan-mode +
|
|
86
|
+
# path-in-session-dir, so the temp allowed_dir changes nothing about the
|
|
87
|
+
# assertion.
|
|
88
|
+
args = _make_args(cache_dir=self.tmp.name, allowed_dir=self.tmp.name,
|
|
89
|
+
plan=True, prompt="Solve the puzzle")
|
|
83
90
|
sess = agent_loop.AgentSession(args, console=self.console)
|
|
84
91
|
|
|
85
92
|
# /quit ends the interactive loop after the preset prompt has run.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Tests for the interactive "resume last session" offer.
|
|
2
|
+
|
|
3
|
+
When a previous session exists (a recorded ``.mini`` pointer, or the most
|
|
4
|
+
recent session in the cache dir) but the user did *not* explicitly ask to
|
|
5
|
+
resume one, the agent offers a choice before starting the loop:
|
|
6
|
+
|
|
7
|
+
1) Resume -- splice the previous session's history into the current one.
|
|
8
|
+
2) New -- start a fresh (empty) session.
|
|
9
|
+
3) Exit -- stop before anything is written or the loop enters.
|
|
10
|
+
|
|
11
|
+
The tests drive the mocked console through a couple of turns and verify each
|
|
12
|
+
choice, plus that a session with no previous session never shows the offer.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import types
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
import unittest.mock as mock
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
24
|
+
|
|
25
|
+
import mini_code_cli.agent_loop as agent_loop
|
|
26
|
+
from mini_code_cli.agent_loop import AgentSession
|
|
27
|
+
from mini_code_cli import ui, persistence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_args(**kw):
|
|
31
|
+
a = types.SimpleNamespace(
|
|
32
|
+
url="http://x", model="m", allowed_dir=".", cache_dir=None, api_key=None,
|
|
33
|
+
system_prompt=None, agent_md=None, prompt=None, enable_shell=False,
|
|
34
|
+
ask_permission=False, auto_mode=False, restore_session=None, llm_timeout=30,
|
|
35
|
+
tool_timeout=30, load_last_session=False, verbose=False, max_tokens=None,
|
|
36
|
+
temperature=1.0, plan=False, enable_subagents=False, enable_skills=False,
|
|
37
|
+
sudo=False,
|
|
38
|
+
)
|
|
39
|
+
a.__dict__.update(kw)
|
|
40
|
+
return a
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestResumeOffer(unittest.TestCase):
|
|
44
|
+
def setUp(self):
|
|
45
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
46
|
+
self.addCleanup(self.tmp.cleanup)
|
|
47
|
+
self.allowed = self.tmp.name
|
|
48
|
+
self.cache = os.path.join(self.tmp.name, "cache")
|
|
49
|
+
os.makedirs(self.cache)
|
|
50
|
+
self.buf = io.StringIO()
|
|
51
|
+
self.console = ui.Console(stream=self.buf, color=False, unicode=False)
|
|
52
|
+
|
|
53
|
+
def _prev_session(self, content):
|
|
54
|
+
"""Create a previous session with the given messages, and mark it the
|
|
55
|
+
last session via the .mini pointer."""
|
|
56
|
+
d = os.path.join(self.cache, "prev")
|
|
57
|
+
os.makedirs(d)
|
|
58
|
+
with open(os.path.join(d, "history.json"), "w") as f:
|
|
59
|
+
json.dump(content, f)
|
|
60
|
+
persistence.save_last_session_pointer(self.allowed, d)
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
def _fake_empty_llm(self):
|
|
64
|
+
def fake(messages, **kw):
|
|
65
|
+
# No answer, no tool calls -> the manual-mode loop just re-prompts.
|
|
66
|
+
return "", [], {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
67
|
+
return fake
|
|
68
|
+
|
|
69
|
+
def _run_until_quit(self, args, answers):
|
|
70
|
+
"""Build a session and run() it, feeding `answers` to the console's
|
|
71
|
+
read_line (resume choice, then /quit), with the network calls mocked.
|
|
72
|
+
|
|
73
|
+
Extra ``/quit`` lines are appended so the mock is never exhausted: the
|
|
74
|
+
resume offer consumes the first answer, and the loop keeps asking until
|
|
75
|
+
it sees ``/quit``.
|
|
76
|
+
"""
|
|
77
|
+
session = AgentSession(args, console=self.console)
|
|
78
|
+
answers = list(answers) + ["/quit"] * 5
|
|
79
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server",
|
|
80
|
+
self._fake_empty_llm()), \
|
|
81
|
+
mock.patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
|
|
82
|
+
mock.patch.object(self.console, "read_line", side_effect=iter(answers)):
|
|
83
|
+
session.run()
|
|
84
|
+
return session
|
|
85
|
+
|
|
86
|
+
def test_no_previous_session_does_not_offer(self):
|
|
87
|
+
# No previous session at all -> the offer is a no-op and run() just
|
|
88
|
+
# enters the loop (which we end via a /quit read_line).
|
|
89
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
90
|
+
session = self._run_until_quit(args, ["/quit"])
|
|
91
|
+
self.assertNotIn("A previous session was found", self.buf.getvalue())
|
|
92
|
+
# No restore happened.
|
|
93
|
+
self.assertIsNone(session.loaded_last_session_dir)
|
|
94
|
+
|
|
95
|
+
def test_offer_prompt_appears_for_previous_session(self):
|
|
96
|
+
self._prev_session([{"role": "system", "content": "sys"},
|
|
97
|
+
{"role": "user", "content": "remember 42"}])
|
|
98
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
99
|
+
session = self._run_until_quit(args, ["1", "2"])
|
|
100
|
+
out = self.buf.getvalue()
|
|
101
|
+
self.assertIn("A previous session was found", out)
|
|
102
|
+
self.assertIn("Resume", out)
|
|
103
|
+
|
|
104
|
+
def test_resume_choice_splices_history_and_announces(self):
|
|
105
|
+
prev = self._prev_session([{"role": "system", "content": "sys"},
|
|
106
|
+
{"role": "user", "content": "remember 42"}])
|
|
107
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
108
|
+
session = self._run_until_quit(args, ["1"])
|
|
109
|
+
# The previous conversation was spliced into the restored session.
|
|
110
|
+
joined = "\n".join(m.get("content", "") for m in session.messages)
|
|
111
|
+
self.assertIn("remember 42", joined)
|
|
112
|
+
# run() advertised that it was continuing the previous session.
|
|
113
|
+
self.assertIn("continuing", self.buf.getvalue())
|
|
114
|
+
# The candidate was consumed (no leftover restore pending).
|
|
115
|
+
self.assertIsNone(session.last_session_candidate)
|
|
116
|
+
self.assertEqual(session.loaded_last_session_dir, os.path.abspath(prev))
|
|
117
|
+
|
|
118
|
+
def test_new_choice_starts_fresh(self):
|
|
119
|
+
self._prev_session([{"role": "system", "content": "sys"},
|
|
120
|
+
{"role": "user", "content": "remember 42"}])
|
|
121
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
122
|
+
session = self._run_until_quit(args, ["2"])
|
|
123
|
+
# Only the freshly-built system message remains; the old history is not
|
|
124
|
+
# spliced in.
|
|
125
|
+
contents = [m.get("content", "") for m in session.messages]
|
|
126
|
+
self.assertNotIn("remember 42", contents)
|
|
127
|
+
self.assertIsNone(session.loaded_last_session_dir)
|
|
128
|
+
|
|
129
|
+
def test_exit_choice_stops_before_loop(self):
|
|
130
|
+
self._prev_session([{"role": "system", "content": "sys"},
|
|
131
|
+
{"role": "user", "content": "remember 42"}])
|
|
132
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
133
|
+
# The LLM must never be called on the exit path. Track whether it is.
|
|
134
|
+
state = {"called": False}
|
|
135
|
+
|
|
136
|
+
def boom(*a, **k):
|
|
137
|
+
state["called"] = True
|
|
138
|
+
return "", [], {}
|
|
139
|
+
|
|
140
|
+
session = AgentSession(args, console=self.console)
|
|
141
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", boom), \
|
|
142
|
+
mock.patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
|
|
143
|
+
mock.patch.object(self.console, "read_line", side_effect=iter(["3"])):
|
|
144
|
+
session.run() # exits cleanly via the offer
|
|
145
|
+
# The LLM was never reached; the candidate was consumed.
|
|
146
|
+
self.assertFalse(state["called"])
|
|
147
|
+
self.assertIsNone(session.last_session_candidate)
|
|
148
|
+
|
|
149
|
+
def test_bad_input_reprompts_then_new(self):
|
|
150
|
+
self._prev_session([{"role": "system", "content": "sys"},
|
|
151
|
+
{"role": "user", "content": "remember 42"}])
|
|
152
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
153
|
+
session = self._run_until_quit(args, ["banana", "2"])
|
|
154
|
+
out = self.buf.getvalue()
|
|
155
|
+
self.assertIn("Please choose 1, 2, or 3", out)
|
|
156
|
+
|
|
157
|
+
def test_exit_via_eof(self):
|
|
158
|
+
# EOF / Ctrl-C at the offer (read_line returns None) is a graceful exit.
|
|
159
|
+
self._prev_session([{"role": "system", "content": "sys"},
|
|
160
|
+
{"role": "user", "content": "remember 42"}])
|
|
161
|
+
args = _make_args(cache_dir=self.cache, allowed_dir=self.allowed)
|
|
162
|
+
state = {"called": False}
|
|
163
|
+
|
|
164
|
+
def boom(*a, **k):
|
|
165
|
+
state["called"] = True
|
|
166
|
+
return "", [], {}
|
|
167
|
+
|
|
168
|
+
session = AgentSession(args, console=self.console)
|
|
169
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", boom), \
|
|
170
|
+
mock.patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
|
|
171
|
+
mock.patch.object(self.console, "read_line", return_value=None):
|
|
172
|
+
session.run()
|
|
173
|
+
self.assertFalse(state["called"])
|
|
174
|
+
self.assertIsNone(session.last_session_candidate)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
unittest.main()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Regression tests for the agent-loop hardening:
|
|
2
|
+
|
|
3
|
+
* runner.call_function_with_timeout shields the loop from a tool that raises
|
|
4
|
+
(returns a friendly Error string instead of propagating), and still returns
|
|
5
|
+
a distinct message on timeout.
|
|
6
|
+
* utils.call_openai_server degrades to (None, None, None) on ANY failure
|
|
7
|
+
(including malformed usage payloads) instead of raising.
|
|
8
|
+
* an empty / blank user response just re-prompts: it does not append a user
|
|
9
|
+
turn and does not call the LLM.
|
|
10
|
+
* an LLM that returns no response and no tool calls leaves the loop intact
|
|
11
|
+
(records a warning, re-prompts) rather than crashing.
|
|
12
|
+
"""
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import types
|
|
17
|
+
import time
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
import unittest.mock as mock
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
23
|
+
|
|
24
|
+
import mini_code_cli.agent_loop as agent_loop
|
|
25
|
+
from mini_code_cli import runner, utils, ui
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# runner: a tool that raises must not crash the loop
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
class TestToolFailureRobustness(unittest.TestCase):
|
|
33
|
+
def test_tool_that_raises_returns_error_string(self):
|
|
34
|
+
def boom(**kw):
|
|
35
|
+
raise RuntimeError("explode")
|
|
36
|
+
with mock.patch.object(ui.console, "note") as noted:
|
|
37
|
+
result = runner.call_function_with_timeout(boom, {}, "read_file", timeout_seconds=30)
|
|
38
|
+
self.assertIsInstance(result, str)
|
|
39
|
+
self.assertIn("Error", result)
|
|
40
|
+
self.assertIn("read_file", result)
|
|
41
|
+
self.assertTrue(any("raised an error" in str(c.args[0]) for c in noted.call_args_list))
|
|
42
|
+
|
|
43
|
+
def test_timeout_still_reports_distinctly(self):
|
|
44
|
+
def slow(**kw):
|
|
45
|
+
time.sleep(3)
|
|
46
|
+
return "never"
|
|
47
|
+
result = runner.call_function_with_timeout(slow, {}, "read_file", timeout_seconds=1)
|
|
48
|
+
self.assertIsInstance(result, str)
|
|
49
|
+
self.assertIn("timed out", result)
|
|
50
|
+
|
|
51
|
+
def test_normal_call_still_returns_value(self):
|
|
52
|
+
def ok(**kw):
|
|
53
|
+
return "fine"
|
|
54
|
+
self.assertEqual(runner.call_function_with_timeout(ok, {}, "read_file"), "fine")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# utils: call_openai_server never raises
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
class TestLLMFailureRobustness(unittest.TestCase):
|
|
62
|
+
def test_malformed_usage_is_coerced_not_raised(self):
|
|
63
|
+
# A server that reports null token fields must not crash the caller's
|
|
64
|
+
# accumulation logic. The success path coerces those to ints.
|
|
65
|
+
self.assertEqual(utils._coerce_usage_int(None), 0)
|
|
66
|
+
self.assertEqual(utils._coerce_usage_int("not-a-number"), 0)
|
|
67
|
+
self.assertEqual(utils._coerce_usage_int(5), 5)
|
|
68
|
+
|
|
69
|
+
def test_connection_error_degrades_to_none(self):
|
|
70
|
+
with mock.patch("mini_code_cli.utils.requests.post",
|
|
71
|
+
side_effect=Exception("boom")):
|
|
72
|
+
content, tool_calls, usage = utils.call_openai_server(
|
|
73
|
+
[{"role": "user", "content": "hi"}], server_url="http://x")
|
|
74
|
+
self.assertIsNone(content)
|
|
75
|
+
self.assertIsNone(tool_calls)
|
|
76
|
+
self.assertIsNone(usage)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# agent loop: empty response + no-response handling
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _make_args(**kw):
|
|
84
|
+
a = types.SimpleNamespace(
|
|
85
|
+
url="http://x", model="m", allowed_dir=".", cache_dir=None, api_key=None,
|
|
86
|
+
system_prompt=None, agent_md=None, prompt=None, enable_shell=False,
|
|
87
|
+
ask_permission=False, auto_mode=False, restore_session=None, llm_timeout=30,
|
|
88
|
+
tool_timeout=30, load_last_session=False, verbose=False, max_tokens=None,
|
|
89
|
+
temperature=1.0, plan=False, enable_subagents=False, enable_skills=False, sudo=False,
|
|
90
|
+
)
|
|
91
|
+
a.__dict__.update(kw)
|
|
92
|
+
return a
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _EmptyUserTurnBase(unittest.TestCase):
|
|
96
|
+
"""Run a session with a scripted console and a mocked LLM."""
|
|
97
|
+
|
|
98
|
+
def setUp(self):
|
|
99
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
100
|
+
self.addCleanup(self.tmp.cleanup)
|
|
101
|
+
self.buf = io.StringIO()
|
|
102
|
+
self.console = ui.Console(stream=self.buf, color=False, unicode=False)
|
|
103
|
+
self.llm_calls = 0
|
|
104
|
+
|
|
105
|
+
def _counting_llm(self, answer="hi", tool_calls=None):
|
|
106
|
+
def fake(messages, **kw):
|
|
107
|
+
self.llm_calls += 1
|
|
108
|
+
return answer, tool_calls or [], {
|
|
109
|
+
"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
|
110
|
+
return fake
|
|
111
|
+
|
|
112
|
+
def _session(self, **kw):
|
|
113
|
+
# Use a fresh temp dir as --allowed-dir so there is no .mini last-session
|
|
114
|
+
# pointer (which would otherwise trigger run()'s interactive resume
|
|
115
|
+
# offer and consume the first scripted read_line).
|
|
116
|
+
with mock.patch.object(agent_loop.utils, "determine_vllm_model", return_value=None):
|
|
117
|
+
return agent_loop.AgentSession(
|
|
118
|
+
_make_args(cache_dir=self.tmp.name, allowed_dir=self.tmp.name),
|
|
119
|
+
console=self.console)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestEmptyUserResponse(_EmptyUserTurnBase):
|
|
123
|
+
def test_blank_response_reprompts_without_calling_llm(self):
|
|
124
|
+
# A blank line (and one with only spaces) must be ignored and re-prompted,
|
|
125
|
+
# never sent to the model. /quit then ends the loop.
|
|
126
|
+
sess = self._session()
|
|
127
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", self._counting_llm()), \
|
|
128
|
+
mock.patch.object(self.console, "read_line",
|
|
129
|
+
side_effect=["", " ", "/quit"]):
|
|
130
|
+
sess.run()
|
|
131
|
+
# No user turn was ever worth an LLM call.
|
|
132
|
+
self.assertEqual(self.llm_calls, 0)
|
|
133
|
+
# And no empty user message was appended.
|
|
134
|
+
self.assertFalse(any(m.get("role") == "user" and not m.get("content")
|
|
135
|
+
for m in sess.messages))
|
|
136
|
+
|
|
137
|
+
def test_real_input_still_sent_to_llm(self):
|
|
138
|
+
# Sanity check: a non-empty line does reach the model.
|
|
139
|
+
sess = self._session()
|
|
140
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", self._counting_llm()), \
|
|
141
|
+
mock.patch.object(self.console, "read_line", side_effect=["hello", "/quit"]):
|
|
142
|
+
sess.run()
|
|
143
|
+
self.assertEqual(self.llm_calls, 1)
|
|
144
|
+
self.assertTrue(any(m.get("role") == "user" and m.get("content") == "hello"
|
|
145
|
+
for m in sess.messages))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestNoResponseFromLLM(_EmptyUserTurnBase):
|
|
149
|
+
def test_empty_response_and_no_tool_calls_keeps_loop_alive(self):
|
|
150
|
+
# The LLM returns nothing (no text, no tool calls): the loop must record
|
|
151
|
+
# a warning and keep going (re-prompt), not crash.
|
|
152
|
+
sess = self._session()
|
|
153
|
+
|
|
154
|
+
def empty(messages, **kw):
|
|
155
|
+
self.llm_calls += 1
|
|
156
|
+
return "", [], {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
157
|
+
|
|
158
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", empty), \
|
|
159
|
+
mock.patch.object(self.console, "read_line", side_effect=["go", "/quit"]):
|
|
160
|
+
sess.run() # must not raise
|
|
161
|
+
# The session persisted and survived.
|
|
162
|
+
self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, sess.unique_id)))
|
|
163
|
+
|
|
164
|
+
def test_llm_returns_none_everything_keeps_loop_alive(self):
|
|
165
|
+
# A hard failure returns (None, None, None); the loop survives and can
|
|
166
|
+
# still be driven to a clean /quit.
|
|
167
|
+
sess = self._session()
|
|
168
|
+
|
|
169
|
+
def boom(messages, **kw):
|
|
170
|
+
return None, None, None
|
|
171
|
+
|
|
172
|
+
with mock.patch.object(agent_loop.utils, "call_openai_server", boom), \
|
|
173
|
+
mock.patch.object(self.console, "read_line", side_effect=["go", "/quit"]):
|
|
174
|
+
sess.run() # must not raise
|
|
175
|
+
self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, sess.unique_id)))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|