mini-code-cli 0.1.1.1__py3-none-any.whl → 0.1.1.3__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/__init__.py +29 -10
- mini_code_cli/__main__.py +12 -0
- mini_code_cli/agent_loop.py +202 -30
- mini_code_cli/runner.py +14 -6
- mini_code_cli/skills.py +4 -1
- mini_code_cli/ui.py +31 -11
- mini_code_cli/utils.py +63 -24
- mini_code_cli-0.1.1.3.dist-info/METADATA +390 -0
- {mini_code_cli-0.1.1.1.dist-info → mini_code_cli-0.1.1.3.dist-info}/RECORD +13 -12
- mini_code_cli-0.1.1.1.dist-info/METADATA +0 -1006
- {mini_code_cli-0.1.1.1.dist-info → mini_code_cli-0.1.1.3.dist-info}/WHEEL +0 -0
- {mini_code_cli-0.1.1.1.dist-info → mini_code_cli-0.1.1.3.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.1.1.1.dist-info → mini_code_cli-0.1.1.3.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.1.1.1.dist-info → mini_code_cli-0.1.1.3.dist-info}/top_level.txt +0 -0
mini_code_cli/__init__.py
CHANGED
|
@@ -11,22 +11,41 @@ to ``"unknown"`` so importing the package can never fail over a version lookup.
|
|
|
11
11
|
__all__ = ["__version__"]
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _version_from_file(path):
|
|
15
|
+
"""Return the ``[project] version`` in ``path``.
|
|
16
|
+
|
|
17
|
+
``tomllib`` only exists from Python 3.11, and the scan below is not a
|
|
18
|
+
nicety for 3.10: without it this function used to skip the file entirely on
|
|
19
|
+
that interpreter and report the *installed* metadata instead, so a checkout
|
|
20
|
+
with a stale editable install showed the wrong version in the banner (found
|
|
21
|
+
on the 3.10 CI leg). Both paths read the same key, and the section tracking
|
|
22
|
+
matters: a ``version`` key in some other table must not win.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
import tomllib # Python >= 3.11
|
|
26
|
+
except ImportError: # pragma: no cover - exercised on the 3.10 CI leg
|
|
27
|
+
tomllib = None
|
|
28
|
+
if tomllib is not None:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
return str(tomllib.load(f)["project"]["version"])
|
|
31
|
+
section = None
|
|
32
|
+
with open(path, encoding="utf-8") as f:
|
|
33
|
+
for line in f:
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
if stripped.startswith("["):
|
|
36
|
+
section = stripped
|
|
37
|
+
elif section == "[project]" and stripped.startswith("version"):
|
|
38
|
+
return stripped.split("=", 1)[1].strip().strip('"')
|
|
39
|
+
raise KeyError(f"no version in the [project] table of {path}")
|
|
40
|
+
|
|
41
|
+
|
|
14
42
|
def _read_version():
|
|
15
43
|
"""Best-effort package version: pyproject.toml, then installed metadata."""
|
|
16
44
|
try:
|
|
17
45
|
from pathlib import Path
|
|
18
46
|
|
|
19
47
|
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
20
|
-
|
|
21
|
-
import tomllib # Python >= 3.11
|
|
22
|
-
except ImportError: # pragma: no cover - older interpreters
|
|
23
|
-
tomllib = None
|
|
24
|
-
if tomllib is not None:
|
|
25
|
-
try:
|
|
26
|
-
with open(pyproject, "rb") as f:
|
|
27
|
-
return str(tomllib.load(f)["project"]["version"])
|
|
28
|
-
except Exception:
|
|
29
|
-
pass
|
|
48
|
+
return _version_from_file(pyproject)
|
|
30
49
|
except Exception:
|
|
31
50
|
pass
|
|
32
51
|
try:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Allow ``python -m mini_code_cli`` to start the agent.
|
|
2
|
+
|
|
3
|
+
The console script ``mini-code`` (see ``[project.scripts]``) is the normal entry
|
|
4
|
+
point; this module exists so the package also runs straight from a checkout or
|
|
5
|
+
an installed wheel without the script on ``PATH`` -- which is what a reader
|
|
6
|
+
tries first after ``pip install -e .``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .agent_loop import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
raise SystemExit(main())
|
mini_code_cli/agent_loop.py
CHANGED
|
@@ -31,7 +31,7 @@ from . import prompts
|
|
|
31
31
|
from . import shell_safety_utils
|
|
32
32
|
from . import tools
|
|
33
33
|
from . import utils
|
|
34
|
-
from .utils import oneline
|
|
34
|
+
from .utils import oneline, plural
|
|
35
35
|
from . import ui
|
|
36
36
|
from . import subagents
|
|
37
37
|
from . import skills
|
|
@@ -118,7 +118,12 @@ def _safe_int(value, default=0):
|
|
|
118
118
|
# ---------------------------------------------------------------------------
|
|
119
119
|
|
|
120
120
|
def parse_args():
|
|
121
|
-
|
|
121
|
+
# ``prog`` is pinned so ``--help`` reads the same whether the agent was
|
|
122
|
+
# started through the ``mini-code`` script, ``python -m mini_code_cli`` or
|
|
123
|
+
# a test harness (without it, the usage line said ``__main__.py``).
|
|
124
|
+
parser = argparse.ArgumentParser(
|
|
125
|
+
prog="mini-code",
|
|
126
|
+
description="Agent loop for interacting with a language model")
|
|
122
127
|
# MODEL / LLM related
|
|
123
128
|
parser.add_argument("--url", type=str, default=f"{DEFAULT_MODEL_URL}", help=f"API host, default={DEFAULT_MODEL_URL}")
|
|
124
129
|
parser.add_argument("--max-tokens", type=int, help="Maximum tokens for a single LLM response (the reply budget), default=None. Also used as the output headroom reserved when deriving the compaction limit from the model's context window.")
|
|
@@ -267,6 +272,37 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
|
|
|
267
272
|
return False, ""
|
|
268
273
|
|
|
269
274
|
|
|
275
|
+
def _in_flight_tail(messages):
|
|
276
|
+
"""Return the messages of the turn that is still in flight, or ``[]``.
|
|
277
|
+
|
|
278
|
+
Compaction rewrites the transcript, so whatever it drops is gone from the
|
|
279
|
+
conversation *and* from everything saved afterwards. The last turn is not
|
|
280
|
+
finished work, though: it is either the instruction being answered right now
|
|
281
|
+
(the transcript ends with a ``user`` message) or the tool exchange the model
|
|
282
|
+
is waiting on (it ends with a ``tool`` result). Either way it has to be
|
|
283
|
+
carried across verbatim, or the model answers the summary instead of the
|
|
284
|
+
user's request -- and the typed line never reaches the recorder, the new
|
|
285
|
+
segment or a later restore.
|
|
286
|
+
|
|
287
|
+
The suffix starts at the last ``user`` message, which always begins a
|
|
288
|
+
self-contained turn: every ``assistant`` tool call inside it is followed by
|
|
289
|
+
its ``tool`` results, so the slice is a valid transcript on its own.
|
|
290
|
+
|
|
291
|
+
``[]`` when the transcript already ends with an ``assistant`` reply: that
|
|
292
|
+
turn is answered, and re-sending it would ask the model to answer it twice.
|
|
293
|
+
"""
|
|
294
|
+
if not messages:
|
|
295
|
+
return []
|
|
296
|
+
last = messages[-1]
|
|
297
|
+
if not isinstance(last, dict) or last.get("role") == "assistant":
|
|
298
|
+
return []
|
|
299
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
300
|
+
message = messages[index]
|
|
301
|
+
if isinstance(message, dict) and message.get("role") == "user":
|
|
302
|
+
return list(messages[index:])
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
|
|
270
306
|
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
|
|
271
307
|
history_index=0, token_limit=None, reserved_output_tokens=0,
|
|
272
308
|
current_tokens=None, plan_hook=None, console=None):
|
|
@@ -279,7 +315,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
279
315
|
against ``token_limit``. Nothing else triggers compaction: in particular the
|
|
280
316
|
number of messages is not a criterion. Compaction is then done by asking the
|
|
281
317
|
LLM to summarize the history and replacing the old turns with that summary,
|
|
282
|
-
keeping the original system prompt as the first message
|
|
318
|
+
keeping the original system prompt as the first message *and the turn that is
|
|
319
|
+
still in flight* (see :func:`_in_flight_tail`) verbatim after it -- a summary
|
|
320
|
+
of the user's current request is not the request. The compaction call
|
|
283
321
|
itself is a single, tool-free request, so it cannot trigger further tool
|
|
284
322
|
calls or recurse into compaction.
|
|
285
323
|
|
|
@@ -289,10 +327,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
289
327
|
``plan_hook`` is an optional callable the caller supplies only when running
|
|
290
328
|
under plan mode (in practice ``AgentSession._handle_compaction``). After the
|
|
291
329
|
summary is built the hook re-injects the running goal + plan so the compacted
|
|
292
|
-
messages carry them forward. It receives the compacted list and
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
330
|
+
messages carry them forward. It receives the compacted list *and* the
|
|
331
|
+
in-flight tail (see below) and must update the compacted list in place,
|
|
332
|
+
because that list is what gets returned to the caller. Passing it in --
|
|
333
|
+
instead of referencing the owning session -- keeps ``maybe_compact`` a
|
|
334
|
+
small, module-level, network-free helper that the test-suite can import and
|
|
335
|
+
exercise directly.
|
|
296
336
|
"""
|
|
297
337
|
# ``console`` defaults to None so callers may omit it, but every branch below
|
|
298
338
|
# reports through a console. Falling back to the shared one keeps the
|
|
@@ -311,8 +351,20 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
311
351
|
# bare messages would crash them.
|
|
312
352
|
return messages, False, history_index
|
|
313
353
|
|
|
354
|
+
# The turn this step is answering -- the instruction just typed, or the tool
|
|
355
|
+
# exchange the model is waiting on -- is carried across the rollover (see
|
|
356
|
+
# :func:`_in_flight_tail`). It is computed *before* the slow summary call so
|
|
357
|
+
# the console can tell the user right away that nothing they just sent is at
|
|
358
|
+
# stake; a compaction note that stays silent about that is exactly what makes
|
|
359
|
+
# the rollover look like it might have eaten the message.
|
|
360
|
+
tail = _in_flight_tail(messages)
|
|
361
|
+
kept_note = (f" The current turn ({plural(len(tail), 'message')}) is kept "
|
|
362
|
+
f"verbatim." if tail else "")
|
|
363
|
+
|
|
314
364
|
try:
|
|
315
|
-
console.note(
|
|
365
|
+
console.note(
|
|
366
|
+
f"Compacting the conversation: {plural(len(messages), 'message')} "
|
|
367
|
+
f"-> a summary ({reason}).{kept_note}", "warn")
|
|
316
368
|
summary_prompt = prompts.compaction_prompt(messages)
|
|
317
369
|
|
|
318
370
|
# The summariser sometimes answers with nothing at all. An empty summary
|
|
@@ -324,7 +376,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
324
376
|
# the over-cap transcript was retried, and paid for, on every step.
|
|
325
377
|
summary = ""
|
|
326
378
|
for attempt in (1, 2):
|
|
327
|
-
console.start_thinking("compacting")
|
|
379
|
+
console.start_thinking("compacting history")
|
|
328
380
|
try:
|
|
329
381
|
reply, _, compact_usage, _ = utils.unpack_llm_result(
|
|
330
382
|
utils.call_openai_server(
|
|
@@ -347,9 +399,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
347
399
|
console.note("The summariser returned an empty summary; retrying once.", "warn")
|
|
348
400
|
|
|
349
401
|
if not summary:
|
|
402
|
+
# Say plainly what did *not* happen: nothing was dropped and the
|
|
403
|
+
# segment did not move -- only the shrink did, so the next request
|
|
404
|
+
# may still be too large.
|
|
350
405
|
console.note(
|
|
351
406
|
f"Could not compact history: the summariser returned an empty "
|
|
352
|
-
f"summary twice. Keeping all {len(messages)}
|
|
407
|
+
f"summary twice. Keeping all {plural(len(messages), 'message')} "
|
|
408
|
+
f"and history_{history_index}.json as they are -- nothing was "
|
|
409
|
+
f"lost, but the next request may still be too large "
|
|
353
410
|
f"({reason}).", "warn")
|
|
354
411
|
return messages, False, history_index
|
|
355
412
|
|
|
@@ -362,22 +419,56 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
362
419
|
"content": f"Here is a summary of our earlier conversation so far:\n{summary}",
|
|
363
420
|
})
|
|
364
421
|
|
|
422
|
+
# The turn in flight has to survive the rollover. Compaction runs at the
|
|
423
|
+
# *start* of a step, so ``messages`` normally ends with the user message
|
|
424
|
+
# this step is meant to answer, or with the tool result the model is
|
|
425
|
+
# waiting on. Replacing the transcript with just (system, summary)
|
|
426
|
+
# therefore dropped the user's instruction on the floor: the model
|
|
427
|
+
# answered the summary -- "Understood. What would you like me to do?" --
|
|
428
|
+
# and the typed line never reached the new segment, the recorder or a
|
|
429
|
+
# later restore (it survived only inside the *closed* segment, which no
|
|
430
|
+
# recorder record points at any more). ``tail`` was computed before the
|
|
431
|
+
# summary call so the opening note could already promise this.
|
|
432
|
+
|
|
365
433
|
# In plan mode, re-inject the running plan + current goal so a
|
|
366
434
|
# compaction (which would otherwise drop them) keeps the model
|
|
367
435
|
# working against the goal it is still trying to finish. The hook is
|
|
368
436
|
# wired in only when the caller runs under plan mode; otherwise
|
|
369
|
-
# compaction degrades to a plain summary.
|
|
437
|
+
# compaction degrades to a plain summary. It is also handed the tail so
|
|
438
|
+
# it can restate the *real* instruction rather than the summary text.
|
|
370
439
|
if plan_hook is not None:
|
|
371
|
-
plan_hook(compacted)
|
|
372
|
-
|
|
373
|
-
|
|
440
|
+
plan_hook(compacted, tail)
|
|
441
|
+
|
|
442
|
+
# ... and only then the live turn, so the user's actual instruction
|
|
443
|
+
# reads as the most recent thing the model has to act on.
|
|
444
|
+
compacted.extend(tail)
|
|
445
|
+
|
|
446
|
+
# What the user is shown has to answer the three questions a rollover
|
|
447
|
+
# raises: *what* was folded away (the summary itself), *how much* (the
|
|
448
|
+
# closing segment's message count, and what the new one starts with),
|
|
449
|
+
# and *where it went* (which segment file was closed and which was
|
|
450
|
+
# started).
|
|
451
|
+
console.note(f"Summary preview: {utils.oneline(summary, 120)}", "warn")
|
|
452
|
+
kept = (f"summary + {plural(len(tail), 'kept message')}" if tail
|
|
453
|
+
else "summary only")
|
|
454
|
+
# Phrased as "old segment -> new segment" rather than "N messages -> M
|
|
455
|
+
# messages": the replacement can be *longer* than what it replaced (a
|
|
456
|
+
# system prompt, the summary and the kept turn), and a bare pair of
|
|
457
|
+
# counts that sometimes grows reads like a bug.
|
|
458
|
+
console.note(
|
|
459
|
+
f"Compacted history_{history_index}.json "
|
|
460
|
+
f"({plural(len(messages), 'message')}) into a summary; "
|
|
461
|
+
f"history_{history_index + 1}.json now starts with "
|
|
462
|
+
f"{plural(len(compacted), 'message')} ({kept}).", "warn")
|
|
374
463
|
# A new segment (history_index + 1) is started on compaction: the
|
|
375
464
|
# compacted messages are its snapshot, and every later turn appends
|
|
376
465
|
# to it. The caller records a single high-level 'summary' turn for
|
|
377
466
|
# this index so the recorder stays flat across the rollover.
|
|
378
467
|
return compacted, True, history_index + 1
|
|
379
468
|
except Exception as e:
|
|
380
|
-
console.note(
|
|
469
|
+
console.note(
|
|
470
|
+
f"Failed to compact history: {e}. Keeping all "
|
|
471
|
+
f"{plural(len(messages), 'message')} unchanged.", "error")
|
|
381
472
|
|
|
382
473
|
return messages, False, history_index
|
|
383
474
|
|
|
@@ -427,6 +518,11 @@ class AgentSession:
|
|
|
427
518
|
|
|
428
519
|
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
429
520
|
|
|
521
|
+
# The good-bye box is printed once, from shutdown(). Several exits
|
|
522
|
+
# funnel through there (quit, exit, EOF, a /quit command) and a repeated
|
|
523
|
+
# box would read like a second session had ended.
|
|
524
|
+
self._farewell_shown = False
|
|
525
|
+
|
|
430
526
|
# Token accounting. ``token_stats`` covers the *current history
|
|
431
527
|
# segment* and is reset when compaction rolls over to a new one;
|
|
432
528
|
# ``total_usage`` is the whole-session accumulator and never decreases.
|
|
@@ -1263,8 +1359,12 @@ class AgentSession:
|
|
|
1263
1359
|
return
|
|
1264
1360
|
except KeyboardInterrupt:
|
|
1265
1361
|
self.console.note("Interrupting: Mini Code Exiting...", "error")
|
|
1362
|
+
self.shutdown(farewell=False)
|
|
1363
|
+
return
|
|
1266
1364
|
except Exception as e:
|
|
1267
1365
|
self.console.note(f"Exception {e}. Exiting...", "error")
|
|
1366
|
+
self.shutdown(farewell=False)
|
|
1367
|
+
return
|
|
1268
1368
|
self.shutdown()
|
|
1269
1369
|
|
|
1270
1370
|
def _emit_tip(self):
|
|
@@ -1508,7 +1608,7 @@ class AgentSession:
|
|
|
1508
1608
|
return True
|
|
1509
1609
|
|
|
1510
1610
|
|
|
1511
|
-
def _handle_compaction(self, compacted):
|
|
1611
|
+
def _handle_compaction(self, compacted, tail=()):
|
|
1512
1612
|
"""Re-inject goal + plan into the compacted messages (plan mode).
|
|
1513
1613
|
|
|
1514
1614
|
Plan-mode counterpart to the plan handling inside :meth:`maybe_compact`,
|
|
@@ -1516,7 +1616,8 @@ class AgentSession:
|
|
|
1516
1616
|
a network-free module-level helper. The loop wires in this method only
|
|
1517
1617
|
when plan mode is on; it re-injects the goal + plan via
|
|
1518
1618
|
:meth:`_apply_plan_to_compacted`, which also rewrites the tracking files
|
|
1519
|
-
to match.
|
|
1619
|
+
to match. ``tail`` is the turn that is still in flight, which
|
|
1620
|
+
``maybe_compact`` appends after the hook's messages.
|
|
1520
1621
|
|
|
1521
1622
|
The hook must update ``compacted`` **in place**: ``maybe_compact``
|
|
1522
1623
|
returns *that* list to the caller, which then assigns it to
|
|
@@ -1524,9 +1625,9 @@ class AgentSession:
|
|
|
1524
1625
|
discarded a moment later, silently dropping the goal and plan -- which is
|
|
1525
1626
|
exactly what happened before this was a slice assignment.
|
|
1526
1627
|
"""
|
|
1527
|
-
compacted[:] = self._apply_plan_to_compacted(compacted)
|
|
1628
|
+
compacted[:] = self._apply_plan_to_compacted(compacted, tail)
|
|
1528
1629
|
|
|
1529
|
-
def _apply_plan_to_compacted(self, messages):
|
|
1630
|
+
def _apply_plan_to_compacted(self, messages, tail=()):
|
|
1530
1631
|
"""Re-inject the current goal + plan after a history compaction.
|
|
1531
1632
|
|
|
1532
1633
|
Compaction replaces the transcript with a summary, which would otherwise
|
|
@@ -1534,16 +1635,18 @@ class AgentSession:
|
|
|
1534
1635
|
back off disk and append them as fresh user turns so the model keeps
|
|
1535
1636
|
working against the goal it is still trying to finish. The goal is
|
|
1536
1637
|
appended last so it reads as the most recent instruction the model
|
|
1537
|
-
should act on
|
|
1638
|
+
should act on -- ``maybe_compact`` then appends the in-flight turn, which
|
|
1639
|
+
is the same instruction verbatim.
|
|
1538
1640
|
"""
|
|
1539
1641
|
plan_text = self.plan_manager.read_plan()
|
|
1540
1642
|
goal_text = self.plan_manager.read_goal()
|
|
1541
1643
|
|
|
1542
|
-
# Recover the user's latest instruction from the
|
|
1543
|
-
#
|
|
1544
|
-
#
|
|
1644
|
+
# Recover the user's latest instruction from the *in-flight turn* -- not
|
|
1645
|
+
# from ``messages``, which by now holds the summary -- so a session that
|
|
1646
|
+
# compacts before its goal was recorded still restates the real
|
|
1647
|
+
# instruction instead of the summary text.
|
|
1545
1648
|
current_input = None
|
|
1546
|
-
for m in reversed(
|
|
1649
|
+
for m in reversed(list(tail or ())):
|
|
1547
1650
|
if m.get("role") == "user":
|
|
1548
1651
|
current_input = m.get("content")
|
|
1549
1652
|
break
|
|
@@ -1857,7 +1960,9 @@ class AgentSession:
|
|
|
1857
1960
|
# just the turns. In verbose mode everything is printed permanently.
|
|
1858
1961
|
transient = not self.console.verbose
|
|
1859
1962
|
func = self.tools_dict[name]["function"]
|
|
1860
|
-
result = call_function_with_timeout(func, tool_params, name,
|
|
1963
|
+
result = call_function_with_timeout(func, tool_params, name,
|
|
1964
|
+
timeout_seconds=self.args.tool_timeout,
|
|
1965
|
+
console=self.console)
|
|
1861
1966
|
|
|
1862
1967
|
self.console.tool_call_display(name, tool_params, result, transient=transient)
|
|
1863
1968
|
self._append_tool_result(call_id, str(result))
|
|
@@ -1931,8 +2036,15 @@ class AgentSession:
|
|
|
1931
2036
|
self.console.note(
|
|
1932
2037
|
f"Saved usage to: {usage_path} (session total: {total_path})", "info")
|
|
1933
2038
|
|
|
1934
|
-
def shutdown(self):
|
|
1935
|
-
"""Persist and print a final summary (called on quit / exit).
|
|
2039
|
+
def shutdown(self, farewell=True):
|
|
2040
|
+
"""Persist and print a final summary (called on quit / exit).
|
|
2041
|
+
|
|
2042
|
+
``farewell=False`` is used by the interrupt / fatal paths in :meth:`run`,
|
|
2043
|
+
which have already told the user (in red) that the session is being torn
|
|
2044
|
+
down: a cheerful "thanks and here is your state" box on top of a crash is
|
|
2045
|
+
the wrong note, and the state it would recap is the state that just
|
|
2046
|
+
failed.
|
|
2047
|
+
"""
|
|
1936
2048
|
self._persist()
|
|
1937
2049
|
segments = history_segment_indices(self.session_dir)
|
|
1938
2050
|
# List what is really on disk. The old message claimed
|
|
@@ -1948,7 +2060,62 @@ class AgentSession:
|
|
|
1948
2060
|
self.console.note(
|
|
1949
2061
|
f"Saved session to {self.cache_dir}/{self.unique_id}/ "
|
|
1950
2062
|
f"({present} + summary_history.json).", "ok")
|
|
1951
|
-
|
|
2063
|
+
# The detailed per-field usage block is verbose-only (the console
|
|
2064
|
+
# documents it that way); in the quiet default view the good-bye box
|
|
2065
|
+
# below carries the same totals, so nothing is lost by leaving it out.
|
|
2066
|
+
if self.console.verbose:
|
|
2067
|
+
self.console.usage_summary(self.total_usage)
|
|
2068
|
+
if farewell:
|
|
2069
|
+
self._farewell(segments)
|
|
2070
|
+
|
|
2071
|
+
def _farewell(self, segments=None):
|
|
2072
|
+
"""Print the good-bye box: version, sign-off and what the session left.
|
|
2073
|
+
|
|
2074
|
+
Printed once (see ``_farewell_shown``) and only on a normal exit. It
|
|
2075
|
+
restates the state in one glanceable place -- the same facts the user
|
|
2076
|
+
would otherwise have to piece together from the save note, ``/history``
|
|
2077
|
+
and the (verbose-only) usage readout, which in the quiet default view
|
|
2078
|
+
means they saw none of it.
|
|
2079
|
+
"""
|
|
2080
|
+
if self._farewell_shown:
|
|
2081
|
+
return
|
|
2082
|
+
self._farewell_shown = True
|
|
2083
|
+
from . import __version__ as version
|
|
2084
|
+
|
|
2085
|
+
if segments is None:
|
|
2086
|
+
segments = history_segment_indices(self.session_dir)
|
|
2087
|
+
if not segments:
|
|
2088
|
+
where = f"history_{self.history_index}.json"
|
|
2089
|
+
elif len(segments) == 1:
|
|
2090
|
+
where = f"history_{segments[0]}.json"
|
|
2091
|
+
else:
|
|
2092
|
+
where = f"history_{segments[0]}..{segments[-1]}.json"
|
|
2093
|
+
|
|
2094
|
+
turns = len(self.summary)
|
|
2095
|
+
usage = self.total_usage or {}
|
|
2096
|
+
|
|
2097
|
+
def count(key):
|
|
2098
|
+
# A display detail on the exit path must never raise: a server that
|
|
2099
|
+
# reported ``null`` usage leaves a None here, and crashing while
|
|
2100
|
+
# saying goodbye would be the worst possible time to fail.
|
|
2101
|
+
try:
|
|
2102
|
+
return int(usage.get(key) or 0)
|
|
2103
|
+
except (TypeError, ValueError):
|
|
2104
|
+
return 0
|
|
2105
|
+
|
|
2106
|
+
rows = [
|
|
2107
|
+
("session id", self.unique_id),
|
|
2108
|
+
("saved under", f"{self.cache_dir}/{self.unique_id}/"),
|
|
2109
|
+
("history", f"{where} (segment {self.history_index}), "
|
|
2110
|
+
f"{plural(len(self.messages), 'message')}"),
|
|
2111
|
+
("conversation", plural(turns, "recorded turn")),
|
|
2112
|
+
("tokens", f"{count('prompt_tokens'):,} in / "
|
|
2113
|
+
f"{count('completion_tokens'):,} out "
|
|
2114
|
+
f"({count('total_tokens'):,} total)"),
|
|
2115
|
+
("calls", f"{count('llm_calls'):,} LLM, "
|
|
2116
|
+
f"{count('tool_calls'):,} tool"),
|
|
2117
|
+
]
|
|
2118
|
+
self.console.farewell(version, rows)
|
|
1952
2119
|
|
|
1953
2120
|
# -- single LLM turn ---------------------------------------------------
|
|
1954
2121
|
|
|
@@ -2000,14 +2167,19 @@ class AgentSession:
|
|
|
2000
2167
|
self.history_index)
|
|
2001
2168
|
except Exception as e:
|
|
2002
2169
|
self.console.note(
|
|
2003
|
-
f"Could not save the closed history segment
|
|
2170
|
+
f"Could not save the closed history segment "
|
|
2171
|
+
f"history_{self.history_index}.json: {e}. The compacted "
|
|
2172
|
+
f"conversation is unaffected, but /reset back into that "
|
|
2173
|
+
f"segment may be incomplete.", "warn")
|
|
2004
2174
|
self.history_index = new_index
|
|
2005
2175
|
self.token_stats = empty_usage()
|
|
2006
2176
|
# Snapshot the compacted messages into the new segment so its turns
|
|
2007
2177
|
# are preserved, and record the summary as the first turn of it.
|
|
2008
2178
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
2009
2179
|
self._record_turn(
|
|
2010
|
-
f"summary:
|
|
2180
|
+
f"summary: history_{new_index - 1}.json "
|
|
2181
|
+
f"({plural(len(closing_messages), 'message')}) compacted into a "
|
|
2182
|
+
f"summary; history_{self.history_index}.json started",
|
|
2011
2183
|
self.messages)
|
|
2012
2184
|
|
|
2013
2185
|
# Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
|
mini_code_cli/runner.py
CHANGED
|
@@ -18,13 +18,19 @@ class _TimedOut(Exception):
|
|
|
18
18
|
pass
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT
|
|
21
|
+
def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT,
|
|
22
|
+
console=None):
|
|
22
23
|
"""Call ``func(**tool_params)``, aborting after ``timeout_seconds``.
|
|
23
24
|
|
|
24
25
|
On timeout -- or if the tool raises *any* other exception -- a friendly
|
|
25
26
|
``Error:`` string is returned so the failure reaches the model as a tool
|
|
26
27
|
result (letting it recover or retry) instead of propagating and crashing the
|
|
27
28
|
whole agent loop. A timeout is reported distinctly from a generic failure.
|
|
29
|
+
|
|
30
|
+
``console`` is the session's console. It defaults to the shared one so a
|
|
31
|
+
bare call still works, but the agent loop passes its own: a sub-agent's
|
|
32
|
+
console redirects its output, and reporting to the module-level console
|
|
33
|
+
would print a background child's tool errors onto the user's terminal.
|
|
28
34
|
"""
|
|
29
35
|
def _handler(signum, frame):
|
|
30
36
|
raise _TimedOut()
|
|
@@ -35,30 +41,32 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
|
|
|
35
41
|
# signal only works in the main thread; if we cannot arm it, just run
|
|
36
42
|
# the tool without the wall-clock guard rather than failing outright --
|
|
37
43
|
# but still shield the loop from a tool that raises.
|
|
38
|
-
return _call_tool_safely(func, tool_params, name)
|
|
44
|
+
return _call_tool_safely(func, tool_params, name, console)
|
|
39
45
|
|
|
46
|
+
console = console if console is not None else ui.console
|
|
40
47
|
signal.alarm(int(timeout_seconds) if timeout_seconds else 0)
|
|
41
48
|
try:
|
|
42
49
|
return func(**tool_params)
|
|
43
50
|
except _TimedOut:
|
|
44
|
-
|
|
51
|
+
console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
|
|
45
52
|
return f"Error: Function {name} timed out after {timeout_seconds} seconds."
|
|
46
53
|
except Exception as e: # noqa: BLE001 -- a failing tool must not crash the loop
|
|
47
|
-
|
|
54
|
+
console.note(f"Tool {name} raised an error: {e}", "error")
|
|
48
55
|
return f"Error: tool {name} raised an error: {e}"
|
|
49
56
|
finally:
|
|
50
57
|
signal.alarm(0)
|
|
51
58
|
signal.signal(signal.SIGALRM, previous)
|
|
52
59
|
|
|
53
60
|
|
|
54
|
-
def _call_tool_safely(func, tool_params, name):
|
|
61
|
+
def _call_tool_safely(func, tool_params, name, console=None):
|
|
55
62
|
"""Run a tool with no wall-clock guard (SIGALRM unavailable) but still
|
|
56
63
|
shield the loop from a tool that raises.
|
|
57
64
|
|
|
58
65
|
Used as the fallback when we could not arm SIGALRM (e.g. a non-main thread).
|
|
59
66
|
"""
|
|
67
|
+
console = console if console is not None else ui.console
|
|
60
68
|
try:
|
|
61
69
|
return func(**tool_params)
|
|
62
70
|
except Exception as e: # noqa: BLE001 -- never let a tool exception propagate
|
|
63
|
-
|
|
71
|
+
console.note(f"Tool {name} raised an error: {e}", "error")
|
|
64
72
|
return f"Error: tool {name} raised an error: {e}"
|
mini_code_cli/skills.py
CHANGED
|
@@ -294,7 +294,10 @@ def run_skill(allowed_dir, skill_name, script_name, args=None):
|
|
|
294
294
|
argv = shlex.split(args) if args else []
|
|
295
295
|
cmd = [sys.executable, script_path] + argv
|
|
296
296
|
try:
|
|
297
|
-
|
|
297
|
+
# Run *inside* the skill's own directory: a script that writes or reads
|
|
298
|
+
# a relative path then stays in the skill folder instead of landing in
|
|
299
|
+
# whatever directory the agent process happens to have inherited.
|
|
300
|
+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=base,
|
|
298
301
|
timeout=DEFAULT_TOOL_TIMEOUT, check=False)
|
|
299
302
|
except Exception as e:
|
|
300
303
|
return f"Error: {e}"
|
mini_code_cli/ui.py
CHANGED
|
@@ -29,7 +29,7 @@ import shutil
|
|
|
29
29
|
import sys
|
|
30
30
|
import threading
|
|
31
31
|
|
|
32
|
-
from .utils import oneline
|
|
32
|
+
from .utils import oneline, plural
|
|
33
33
|
|
|
34
34
|
# --- ANSI SGR codes ---------------------------------------------------------
|
|
35
35
|
_RESET = "\033[0m"
|
|
@@ -348,12 +348,6 @@ class Console:
|
|
|
348
348
|
styled_lines.append(text)
|
|
349
349
|
self.print_box(styled_lines, title=title, fg=fg)
|
|
350
350
|
|
|
351
|
-
def truncate(self, text, limit):
|
|
352
|
-
text = str(text)
|
|
353
|
-
if len(text) > limit:
|
|
354
|
-
return text[:limit] + "... (truncated)"
|
|
355
|
-
return text
|
|
356
|
-
|
|
357
351
|
@staticmethod
|
|
358
352
|
def _oneline(text, limit=None):
|
|
359
353
|
"""Collapse ``text`` to one line (delegates to :func:`utils.oneline`)."""
|
|
@@ -570,7 +564,7 @@ class Console:
|
|
|
570
564
|
"""Render one attempted tool call: its name, parameters and result."""
|
|
571
565
|
self.emit(self.style(f"{self.glyph('gear')} {name}", fg="magenta", bold=True))
|
|
572
566
|
for key, value in (params or {}).items():
|
|
573
|
-
snippet = self.
|
|
567
|
+
snippet = self._oneline(str(value), 100)
|
|
574
568
|
key_str = self.style(f" {key}:", fg="gray", dim=True) if self.color_enabled \
|
|
575
569
|
else f" {key}:"
|
|
576
570
|
self.emit(f"{key_str} {snippet}")
|
|
@@ -586,7 +580,7 @@ class Console:
|
|
|
586
580
|
first = text.splitlines()[0] if text.strip() else "(no output)"
|
|
587
581
|
arrow = self.style(self.glyph("arrow"), fg=color) if self.color_enabled \
|
|
588
582
|
else self.glyph("arrow")
|
|
589
|
-
self.emit(f" {arrow} {self.
|
|
583
|
+
self.emit(f" {arrow} {self._oneline(first, 300)}")
|
|
590
584
|
|
|
591
585
|
def tool_call_display(self, name, params=None, result=None, transient=True):
|
|
592
586
|
"""Render one attempted tool call.
|
|
@@ -624,7 +618,7 @@ class Console:
|
|
|
624
618
|
f"total={usage.get('total_tokens', 0)}")
|
|
625
619
|
if llm_calls is not None:
|
|
626
620
|
calls = int(llm_calls)
|
|
627
|
-
text += f" | {calls
|
|
621
|
+
text += f" | {plural(calls, 'LLM call')}"
|
|
628
622
|
label = self.style("tokens", fg="cyan", dim=True) if self.color_enabled else "tokens"
|
|
629
623
|
return f"{label}: {text}"
|
|
630
624
|
|
|
@@ -683,7 +677,7 @@ class Console:
|
|
|
683
677
|
self.emit(self.style(header, bold=True, fg="cyan"))
|
|
684
678
|
for turn, what, where in rows:
|
|
685
679
|
turn_cell = self.style(turn.rjust(turn_w), fg="magenta", bold=True) if self.color_enabled else turn.rjust(turn_w)
|
|
686
|
-
what_cell = self.style(self.
|
|
680
|
+
what_cell = self.style(self._oneline(what, what_w), dim=True) if self.color_enabled else what.ljust(what_w)
|
|
687
681
|
where_cell = self.style(where, fg="gray", dim=True) if self.color_enabled else where
|
|
688
682
|
self.emit(f"{turn_cell} {what_cell} {where_cell}")
|
|
689
683
|
self.emit(self.style("(commands: /history <n> to inspect, /reset <n> to rewind)", dim=True, fg="cyan"))
|
|
@@ -739,6 +733,32 @@ class Console:
|
|
|
739
733
|
]
|
|
740
734
|
self.banner(f"MiniCodeCLI v{version}", lines, fg="cyan")
|
|
741
735
|
|
|
736
|
+
def farewell(self, version, rows, sign_off=None):
|
|
737
|
+
"""Render the exit banner: a sign-off plus a summary of the session state.
|
|
738
|
+
|
|
739
|
+
Deliberately narrower than :meth:`welcome`. Everything the welcome banner
|
|
740
|
+
shows is about how the session *will* run -- server, model, permissions,
|
|
741
|
+
allowed dir -- and all of it is irrelevant once the session is over. What
|
|
742
|
+
the user can still act on is what the session *left behind*: which
|
|
743
|
+
folder it was saved in, which history segments exist, how much was said
|
|
744
|
+
and what it cost. So the box carries only the version, a good-bye line
|
|
745
|
+
and those ``rows``.
|
|
746
|
+
|
|
747
|
+
``rows`` is a list of ``(label, value)`` pairs, laid out in a single
|
|
748
|
+
colon-aligned column like the welcome banner; the caller supplies the
|
|
749
|
+
pairs because it owns the state (the console stays free of session
|
|
750
|
+
policy).
|
|
751
|
+
"""
|
|
752
|
+
label_w = max((len(str(label)) for label, _ in rows), default=0)
|
|
753
|
+
lines = [sign_off or "Goodbye, and thanks for using MiniCodeCLI!", ""]
|
|
754
|
+
lines += [f"{str(label).ljust(label_w)} : {value}" for label, value in rows]
|
|
755
|
+
# Yellow, not the welcome banner's cyan: this is the one box that
|
|
756
|
+
# *closes* the session, so it reads as a distinct, valedictory note
|
|
757
|
+
# rather than the startup banner reappearing. Colours only apply on a
|
|
758
|
+
# colour-capable TTY (see :meth:`box`), so piped/ASCII output is
|
|
759
|
+
# unchanged.
|
|
760
|
+
self.banner(f"MiniCodeCLI v{version} - goodbye", lines, fg="yellow")
|
|
761
|
+
|
|
742
762
|
def announce_api_key(self, present):
|
|
743
763
|
if present:
|
|
744
764
|
self.note("API key found (MINI_CODE_API_KEY).", "ok")
|