mini-code-cli 0.1.1.0__py3-none-any.whl → 0.1.1.2__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 +648 -156
- mini_code_cli/persistence.py +194 -80
- mini_code_cli/tools.py +2 -3
- mini_code_cli/ui.py +26 -0
- mini_code_cli/utils.py +6 -13
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/METADATA +74 -14
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/RECORD +11 -11
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/WHEEL +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.2.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -18,9 +18,9 @@ smaller, focused modules so each concern is easy to read and test:
|
|
|
18
18
|
* :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
|
|
19
19
|
* :mod:`mini_code_cli.skills` -- the ``.mini`` skills feature.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
The helpers the test-suite imports from here are re-exported through
|
|
22
|
+
``__all__``, so ``from mini_code_cli.agent_loop import save_history, ...`` works
|
|
23
|
+
without the tests reaching into every module.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import os
|
|
@@ -50,11 +50,12 @@ from .persistence import (
|
|
|
50
50
|
save_summary,
|
|
51
51
|
save_usage_segment,
|
|
52
52
|
save_total_usage,
|
|
53
|
-
|
|
54
|
-
load_history_from_dir,
|
|
53
|
+
load_history_with_index,
|
|
55
54
|
load_summary_from_dir,
|
|
56
55
|
load_usage_segment,
|
|
57
56
|
load_total_usage_from_dir,
|
|
57
|
+
delete_segments_above,
|
|
58
|
+
history_segment_indices,
|
|
58
59
|
empty_usage,
|
|
59
60
|
find_last_session_dir,
|
|
60
61
|
save_last_session_pointer,
|
|
@@ -72,7 +73,7 @@ from .runner import call_function_with_timeout
|
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
__all__ = [
|
|
75
|
-
# re-exported helpers (
|
|
76
|
+
# re-exported helpers (imported from here by the test-suite)
|
|
76
77
|
"is_within_allowed_dir",
|
|
77
78
|
"tool_paths_are_allowed",
|
|
78
79
|
"_FILE_TOOL_PATH_ARGS",
|
|
@@ -84,7 +85,6 @@ __all__ = [
|
|
|
84
85
|
"call_function_with_timeout",
|
|
85
86
|
"save_history",
|
|
86
87
|
"save_summary",
|
|
87
|
-
"load_history",
|
|
88
88
|
"generate_unique_id",
|
|
89
89
|
"find_last_session_dir",
|
|
90
90
|
# public classes / entry points
|
|
@@ -94,6 +94,25 @@ __all__ = [
|
|
|
94
94
|
]
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# recorder helpers (shared by the class and the tests)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _safe_int(value, default=0):
|
|
102
|
+
"""Coerce ``value`` to ``int``, returning ``default`` when that is impossible.
|
|
103
|
+
|
|
104
|
+
Used by the *display / navigation* paths, which read a recorder that was
|
|
105
|
+
already validated at restore time but may have been hand-edited since: a
|
|
106
|
+
``/history`` listing must render a junk field rather than crash the CLI.
|
|
107
|
+
A restore itself never comes through here -- it validates strictly instead
|
|
108
|
+
(see ``AgentSession._validate_records``).
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
return int(value)
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
return default
|
|
114
|
+
|
|
115
|
+
|
|
97
116
|
# ---------------------------------------------------------------------------
|
|
98
117
|
# argument parsing
|
|
99
118
|
# ---------------------------------------------------------------------------
|
|
@@ -109,7 +128,7 @@ def parse_args():
|
|
|
109
128
|
|
|
110
129
|
# Other settings:
|
|
111
130
|
parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
|
|
112
|
-
parser.add_argument("--restore-session", type=str, help="
|
|
131
|
+
parser.add_argument("--restore-session", type=str, help="A previous session to continue: its directory (e.g. ~/.cache/mini/sessions/1792028090823027-a9d77653/) or a bare session id under --cache-dir. A session folder must hold a summary_history.json recorder plus the history_{n}.json segment it names; anything else starts a fresh session.")
|
|
113
132
|
parser.add_argument("-l","--load-last-session", action="store_true", help="Continue the most recent previous session recorded in the --allowed-dir .mini pointer, in place and without the resume prompt, instead of starting fresh. --restore-session takes precedence if also given.")
|
|
114
133
|
|
|
115
134
|
# Agent behaviour related
|
|
@@ -169,8 +188,10 @@ def print_help(args):
|
|
|
169
188
|
for command, description in (
|
|
170
189
|
("/history", "list the conversation (turn number, what happened, where)"),
|
|
171
190
|
("/history <n>", "show the full messages recorded for turn n"),
|
|
191
|
+
("/history last", "...for the most recent turn"),
|
|
172
192
|
("/plan", "show the current goal + plan (plan mode only)"),
|
|
173
|
-
("/reset [<n
|
|
193
|
+
("/reset [<n>|last]", "rewind to the end of turn n (drops everything after it);"),
|
|
194
|
+
("", "a bare /reset only re-saves the current state (no data loss)"),
|
|
174
195
|
("/clear", "clear the screen (keeps the conversation history)"),
|
|
175
196
|
("/help", "show this help"),
|
|
176
197
|
("/quit, /exit", "save the session and exit"),
|
|
@@ -246,6 +267,42 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
|
|
|
246
267
|
return False, ""
|
|
247
268
|
|
|
248
269
|
|
|
270
|
+
def _in_flight_tail(messages):
|
|
271
|
+
"""Return the messages of the turn that is still in flight, or ``[]``.
|
|
272
|
+
|
|
273
|
+
Compaction rewrites the transcript, so whatever it drops is gone from the
|
|
274
|
+
conversation *and* from everything saved afterwards. The last turn is not
|
|
275
|
+
finished work, though: it is either the instruction being answered right now
|
|
276
|
+
(the transcript ends with a ``user`` message) or the tool exchange the model
|
|
277
|
+
is waiting on (it ends with a ``tool`` result). Either way it has to be
|
|
278
|
+
carried across verbatim, or the model answers the summary instead of the
|
|
279
|
+
user's request -- and the typed line never reaches the recorder, the new
|
|
280
|
+
segment or a later restore.
|
|
281
|
+
|
|
282
|
+
The suffix starts at the last ``user`` message, which always begins a
|
|
283
|
+
self-contained turn: every ``assistant`` tool call inside it is followed by
|
|
284
|
+
its ``tool`` results, so the slice is a valid transcript on its own.
|
|
285
|
+
|
|
286
|
+
``[]`` when the transcript already ends with an ``assistant`` reply: that
|
|
287
|
+
turn is answered, and re-sending it would ask the model to answer it twice.
|
|
288
|
+
"""
|
|
289
|
+
if not messages:
|
|
290
|
+
return []
|
|
291
|
+
last = messages[-1]
|
|
292
|
+
if not isinstance(last, dict) or last.get("role") == "assistant":
|
|
293
|
+
return []
|
|
294
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
295
|
+
message = messages[index]
|
|
296
|
+
if isinstance(message, dict) and message.get("role") == "user":
|
|
297
|
+
return list(messages[index:])
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _plural(count, noun, suffix="s"):
|
|
302
|
+
"""``"1 message"`` / ``"4 messages"`` -- for messages the user reads."""
|
|
303
|
+
return f"{count} {noun}" + ("" if count == 1 else suffix)
|
|
304
|
+
|
|
305
|
+
|
|
249
306
|
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
|
|
250
307
|
history_index=0, token_limit=None, reserved_output_tokens=0,
|
|
251
308
|
current_tokens=None, plan_hook=None, console=None):
|
|
@@ -258,7 +315,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
258
315
|
against ``token_limit``. Nothing else triggers compaction: in particular the
|
|
259
316
|
number of messages is not a criterion. Compaction is then done by asking the
|
|
260
317
|
LLM to summarize the history and replacing the old turns with that summary,
|
|
261
|
-
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
|
|
262
321
|
itself is a single, tool-free request, so it cannot trigger further tool
|
|
263
322
|
calls or recurse into compaction.
|
|
264
323
|
|
|
@@ -268,10 +327,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
268
327
|
``plan_hook`` is an optional callable the caller supplies only when running
|
|
269
328
|
under plan mode (in practice ``AgentSession._handle_compaction``). After the
|
|
270
329
|
summary is built the hook re-injects the running goal + plan so the compacted
|
|
271
|
-
messages carry them forward. It receives the compacted list and
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
275
336
|
"""
|
|
276
337
|
# ``console`` defaults to None so callers may omit it, but every branch below
|
|
277
338
|
# reports through a console. Falling back to the shared one keeps the
|
|
@@ -290,8 +351,20 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
290
351
|
# bare messages would crash them.
|
|
291
352
|
return messages, False, history_index
|
|
292
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
|
+
|
|
293
364
|
try:
|
|
294
|
-
console.note(
|
|
365
|
+
console.note(
|
|
366
|
+
f"Compacting the conversation: {_plural(len(messages), 'message')} "
|
|
367
|
+
f"-> a summary ({reason}).{kept_note}", "warn")
|
|
295
368
|
summary_prompt = prompts.compaction_prompt(messages)
|
|
296
369
|
|
|
297
370
|
# The summariser sometimes answers with nothing at all. An empty summary
|
|
@@ -303,7 +376,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
303
376
|
# the over-cap transcript was retried, and paid for, on every step.
|
|
304
377
|
summary = ""
|
|
305
378
|
for attempt in (1, 2):
|
|
306
|
-
console.start_thinking("compacting")
|
|
379
|
+
console.start_thinking("compacting history")
|
|
307
380
|
try:
|
|
308
381
|
reply, _, compact_usage, _ = utils.unpack_llm_result(
|
|
309
382
|
utils.call_openai_server(
|
|
@@ -326,9 +399,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
326
399
|
console.note("The summariser returned an empty summary; retrying once.", "warn")
|
|
327
400
|
|
|
328
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.
|
|
329
405
|
console.note(
|
|
330
406
|
f"Could not compact history: the summariser returned an empty "
|
|
331
|
-
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 "
|
|
332
410
|
f"({reason}).", "warn")
|
|
333
411
|
return messages, False, history_index
|
|
334
412
|
|
|
@@ -341,22 +419,56 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
341
419
|
"content": f"Here is a summary of our earlier conversation so far:\n{summary}",
|
|
342
420
|
})
|
|
343
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
|
+
|
|
344
433
|
# In plan mode, re-inject the running plan + current goal so a
|
|
345
434
|
# compaction (which would otherwise drop them) keeps the model
|
|
346
435
|
# working against the goal it is still trying to finish. The hook is
|
|
347
436
|
# wired in only when the caller runs under plan mode; otherwise
|
|
348
|
-
# 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.
|
|
349
439
|
if plan_hook is not None:
|
|
350
|
-
plan_hook(compacted)
|
|
351
|
-
|
|
352
|
-
|
|
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")
|
|
353
463
|
# A new segment (history_index + 1) is started on compaction: the
|
|
354
464
|
# compacted messages are its snapshot, and every later turn appends
|
|
355
465
|
# to it. The caller records a single high-level 'summary' turn for
|
|
356
466
|
# this index so the recorder stays flat across the rollover.
|
|
357
467
|
return compacted, True, history_index + 1
|
|
358
468
|
except Exception as e:
|
|
359
|
-
console.note(
|
|
469
|
+
console.note(
|
|
470
|
+
f"Failed to compact history: {e}. Keeping all "
|
|
471
|
+
f"{_plural(len(messages), 'message')} unchanged.", "error")
|
|
360
472
|
|
|
361
473
|
return messages, False, history_index
|
|
362
474
|
|
|
@@ -406,6 +518,11 @@ class AgentSession:
|
|
|
406
518
|
|
|
407
519
|
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
408
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
|
+
|
|
409
526
|
# Token accounting. ``token_stats`` covers the *current history
|
|
410
527
|
# segment* and is reset when compaction rolls over to a new one;
|
|
411
528
|
# ``total_usage`` is the whole-session accumulator and never decreases.
|
|
@@ -447,6 +564,12 @@ class AgentSession:
|
|
|
447
564
|
pointer nor offers the interactive resume prompt, so it always begins a
|
|
448
565
|
fresh isolated session. That is what makes delegation safe to run
|
|
449
566
|
unattended -- there is a single place that decides this.
|
|
567
|
+
|
|
568
|
+
All three ways in (``--restore-session``, ``--load-last-session`` and the
|
|
569
|
+
interactive offer) go through :meth:`_try_restore`, which reports *why* a
|
|
570
|
+
restore was refused and then leaves the session clean for
|
|
571
|
+
:meth:`start_new_session`. A refused restore therefore never silently
|
|
572
|
+
reuses the old folder.
|
|
450
573
|
"""
|
|
451
574
|
if self.background_mode and not self.restore_session:
|
|
452
575
|
self.start_new_session()
|
|
@@ -469,13 +592,24 @@ class AgentSession:
|
|
|
469
592
|
def _try_restore(self, restore_directory):
|
|
470
593
|
"""Restore a session in place, returning whether it worked.
|
|
471
594
|
|
|
472
|
-
A restore
|
|
473
|
-
so a
|
|
595
|
+
A failed restore is not fatal: the caller falls back to a fresh session,
|
|
596
|
+
so a folder this version cannot read (or a typo'd path) never blocks the
|
|
597
|
+
CLI from starting. It is not silent either -- the reason is noted by
|
|
598
|
+
:meth:`restore_previous_session` and the fallback is announced here,
|
|
599
|
+
because a user who asked to continue a conversation needs to know that
|
|
600
|
+
they are not looking at it.
|
|
474
601
|
"""
|
|
475
602
|
try:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
603
|
+
if self.restore_previous_session(restore_directory):
|
|
604
|
+
return True
|
|
605
|
+
except Exception as e:
|
|
606
|
+
self.console.note(
|
|
607
|
+
f"Could not restore {restore_directory}: {e}", "error")
|
|
608
|
+
self._fail_restore()
|
|
609
|
+
self.console.note(
|
|
610
|
+
f"Starting a fresh session instead of restoring {restore_directory}.",
|
|
611
|
+
"warn")
|
|
612
|
+
return False
|
|
479
613
|
|
|
480
614
|
def _resume_previous(self, detected):
|
|
481
615
|
"""Continue the detected session if the user wants to; True if restored.
|
|
@@ -505,6 +639,13 @@ class AgentSession:
|
|
|
505
639
|
The id is normally minted in ``__init__`` (see there) and simply reused
|
|
506
640
|
here; it is re-minted only when it is missing, which is what happens
|
|
507
641
|
after a failed restore has cleared it.
|
|
642
|
+
|
|
643
|
+
The complete on-disk set is written straight away -- the segment, the
|
|
644
|
+
recorder and the (all-zero) usage files -- so a session this version
|
|
645
|
+
creates always satisfies the restore contract even if the process dies
|
|
646
|
+
before the first quit. A restorable session needs the recorder and the
|
|
647
|
+
segment it names; the usage files are optional, and writing them here
|
|
648
|
+
keeps the folder self-describing rather than half-populated.
|
|
508
649
|
"""
|
|
509
650
|
self.unique_id = self.unique_id or generate_unique_id()
|
|
510
651
|
self.session_dir = os.path.join(self.cache_dir, self.unique_id)
|
|
@@ -519,6 +660,9 @@ class AgentSession:
|
|
|
519
660
|
|
|
520
661
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
521
662
|
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
663
|
+
save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
|
|
664
|
+
self.history_index)
|
|
665
|
+
save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
|
|
522
666
|
|
|
523
667
|
# Record this session in --allowed-dir/.mini so a later
|
|
524
668
|
# --load-last-session (or the interactive resume offer) can find it.
|
|
@@ -526,51 +670,106 @@ class AgentSession:
|
|
|
526
670
|
|
|
527
671
|
|
|
528
672
|
def restore_previous_session(self, restore_directory):
|
|
529
|
-
"""
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
recorder
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
673
|
+
"""Restore a session from ``restore_directory``, strictly, or refuse it.
|
|
674
|
+
|
|
675
|
+
A restorable folder is exactly what this version writes: a
|
|
676
|
+
``summary_history.json`` recorder whose records name where each turn
|
|
677
|
+
lives, plus a readable ``history_{n}.json`` for the segment the *last*
|
|
678
|
+
record points at. Nothing else is accepted, and each refusal says which
|
|
679
|
+
part was wrong:
|
|
680
|
+
|
|
681
|
+
* the directory does not exist -> refuse;
|
|
682
|
+
* no recorder (or not a JSON list) -> refuse;
|
|
683
|
+
* a recorder that does not validate -> refuse (``_validate_records``);
|
|
684
|
+
* the named segment missing/empty/garbled -> refuse.
|
|
685
|
+
|
|
686
|
+
There is deliberately no "rebuild the recorder from the transcript" path
|
|
687
|
+
and no single-file ``history.json`` fallback: both had to *invent* turn
|
|
688
|
+
boundaries, which made a rebuilt session behave differently under
|
|
689
|
+
``/reset`` than a recorded one -- and only sessions this version wrote
|
|
690
|
+
are being restored.
|
|
691
|
+
|
|
692
|
+
Order matters, and it is recorder-first. The recorder says which segment
|
|
693
|
+
the user was really looking at, and the messages are loaded from *that*
|
|
694
|
+
one, so a ``/reset`` that rewound onto an older segment is not undone by
|
|
695
|
+
a stale higher segment left on disk. The loaded index becomes
|
|
696
|
+
``history_index`` -- the segment later saves append to -- and only then
|
|
697
|
+
are the usage totals restored (they are looked up by that index).
|
|
698
|
+
|
|
699
|
+
Failure is atomic: :meth:`_fail_restore` clears everything a restore
|
|
700
|
+
touches, so the caller's fallback is a clean fresh session that never
|
|
701
|
+
half-adopts this folder or reuses its id.
|
|
540
702
|
"""
|
|
703
|
+
prefix = f"Cannot restore {restore_directory}"
|
|
704
|
+
if not restore_directory or not os.path.isdir(restore_directory):
|
|
705
|
+
self.console.note(f"{prefix}: no such session directory.", "error")
|
|
706
|
+
return self._fail_restore()
|
|
707
|
+
|
|
708
|
+
saved_summary = load_summary_from_dir(restore_directory)
|
|
709
|
+
if saved_summary is None:
|
|
710
|
+
self.console.note(
|
|
711
|
+
f"{prefix}: it has no readable summary_history.json "
|
|
712
|
+
"(the recorder every restorable session has).", "error")
|
|
713
|
+
return self._fail_restore()
|
|
714
|
+
try:
|
|
715
|
+
records = self._validate_records(saved_summary)
|
|
716
|
+
except ValueError as e:
|
|
717
|
+
self.console.note(f"{prefix}: {e}.", "error")
|
|
718
|
+
return self._fail_restore()
|
|
719
|
+
|
|
720
|
+
# A recorder with no turns is a session that quit before finishing one:
|
|
721
|
+
# it is restorable, on the segment it started on.
|
|
722
|
+
preferred = records[-1]["history_file"] if records else 0
|
|
723
|
+
restored, loaded_index = load_history_with_index(restore_directory, preferred)
|
|
724
|
+
if restored is None:
|
|
725
|
+
self.console.note(
|
|
726
|
+
f"{prefix}: history_{preferred}.json is missing, empty or "
|
|
727
|
+
"unreadable.", "error")
|
|
728
|
+
return self._fail_restore()
|
|
729
|
+
|
|
541
730
|
try:
|
|
542
731
|
self.unique_id = os.path.basename(os.path.normpath(restore_directory))
|
|
543
732
|
self.session_dir = restore_directory
|
|
544
733
|
self.plan_manager = plan_utils.PlanManager(
|
|
545
734
|
self.session_dir, enabled=self.plan_mode)
|
|
546
|
-
self.
|
|
547
|
-
self.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
735
|
+
self.messages = restored
|
|
736
|
+
self.summary = records
|
|
737
|
+
self.history_index = int(loaded_index)
|
|
551
738
|
# No prompt has been sent in *this* process yet, so any remembered
|
|
552
739
|
# size would be stale: start from a plain estimate of the restored
|
|
553
740
|
# transcript (which is what project_input_tokens falls back to).
|
|
554
741
|
self.last_prompt_tokens = 0
|
|
555
742
|
self.last_prompt_message_count = 0
|
|
556
|
-
# The recorder restore decides which segment we are on, so usage --
|
|
557
|
-
# which is stored per segment -- has to be restored after it.
|
|
558
|
-
self._load_summary_if_restored(restore_directory)
|
|
559
743
|
self._restore_usage_if_available(restore_directory)
|
|
560
744
|
|
|
561
745
|
# Continuing a session in place makes it the latest one again: keep
|
|
562
746
|
# the pointer aimed at it so a later --load-last-session finds the
|
|
563
747
|
# conversation the user is actually working in.
|
|
564
748
|
self._save_last_session_pointer()
|
|
565
|
-
|
|
566
749
|
return True
|
|
567
|
-
|
|
568
750
|
except Exception as e:
|
|
569
|
-
self.
|
|
570
|
-
self.
|
|
571
|
-
self.plan_manager = None
|
|
572
|
-
return False
|
|
751
|
+
self.console.note(f"{prefix}: {e}", "error")
|
|
752
|
+
return self._fail_restore()
|
|
573
753
|
|
|
754
|
+
def _fail_restore(self):
|
|
755
|
+
"""Clear every field a restore may have touched; always returns ``False``.
|
|
756
|
+
|
|
757
|
+
A restore is all-or-nothing. Dropping ``unique_id`` is what guarantees
|
|
758
|
+
the fallback really is a *fresh* session: ``start_new_session`` re-mints
|
|
759
|
+
the id when it is missing, so a refused restore can never append to (or
|
|
760
|
+
overwrite) the folder it just declined to adopt.
|
|
761
|
+
"""
|
|
762
|
+
self.unique_id = None
|
|
763
|
+
self.session_dir = None
|
|
764
|
+
self.plan_manager = None
|
|
765
|
+
self.messages = []
|
|
766
|
+
self.summary = []
|
|
767
|
+
self.history_index = 0
|
|
768
|
+
self.token_stats = empty_usage()
|
|
769
|
+
self.total_usage = empty_usage()
|
|
770
|
+
self.last_prompt_tokens = 0
|
|
771
|
+
self.last_prompt_message_count = 0
|
|
772
|
+
return False
|
|
574
773
|
|
|
575
774
|
def _resolve_model(self, args):
|
|
576
775
|
"""Pick the model id: the one the user named, else the server's default.
|
|
@@ -667,10 +866,12 @@ class AgentSession:
|
|
|
667
866
|
|
|
668
867
|
``token_stats`` comes from the segment we restored onto
|
|
669
868
|
(``usage_{history_index}.json``) and ``total_usage`` from
|
|
670
|
-
``total_usage.json`` (
|
|
671
|
-
running
|
|
672
|
-
compaction that may have happened before it.
|
|
673
|
-
|
|
869
|
+
``total_usage.json`` (reconstructed from the segment files when it is
|
|
870
|
+
missing), so the running totals stay continuous across a restore *and*
|
|
871
|
+
across the compaction that may have happened before it. Usage is
|
|
872
|
+
optional -- a folder that has no usage files at all still restores, with
|
|
873
|
+
zeros -- and any problem there leaves the totals at zero rather than
|
|
874
|
+
failing the restore.
|
|
674
875
|
"""
|
|
675
876
|
segment = load_usage_segment(restore_directory, self.history_index)
|
|
676
877
|
total = load_total_usage_from_dir(restore_directory)
|
|
@@ -770,29 +971,52 @@ class AgentSession:
|
|
|
770
971
|
if delta > 0:
|
|
771
972
|
self.total_usage[key] = self.total_usage.get(key, 0) + delta
|
|
772
973
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
974
|
+
@staticmethod
|
|
975
|
+
def _validate_records(saved):
|
|
976
|
+
"""Validate a loaded recorder strictly; return a clean copy of its records.
|
|
977
|
+
|
|
978
|
+
A ``summary_history.json`` is the *contract* the navigation layer runs
|
|
979
|
+
on: ``/history <n>`` and ``/reset <n>`` index into the list positionally
|
|
980
|
+
and every record says exactly where its turn lives on disk
|
|
981
|
+
(``history_{n}.json`` at message ``index``). A malformed record
|
|
982
|
+
therefore cannot be repaired without inventing a location, so this
|
|
983
|
+
raises ``ValueError`` naming the offending record instead of normalising
|
|
984
|
+
it -- the restore is then refused and the user starts a fresh session,
|
|
985
|
+
which is honest. (The old lenient pass silently dropped junk entries,
|
|
986
|
+
clamped negatives at 0 and renumbered turns ``1..N``; one dropped record
|
|
987
|
+
shifted every later turn number and made a rewind land in the wrong
|
|
988
|
+
place while still reporting success.)
|
|
989
|
+
|
|
990
|
+
Requirements: a JSON list of objects, each with integer ``turn`` /
|
|
991
|
+
``history_file`` / ``index``, no negative ``history_file`` or ``index``,
|
|
992
|
+
and ``turn == position + 1`` (turns run contiguously from 1). An empty
|
|
993
|
+
list is valid -- a session that quit before finishing a turn.
|
|
782
994
|
"""
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
995
|
+
if not isinstance(saved, list):
|
|
996
|
+
raise ValueError("summary_history.json does not hold a list of turns")
|
|
997
|
+
records = []
|
|
998
|
+
for position, entry in enumerate(saved, start=1):
|
|
999
|
+
if not isinstance(entry, dict):
|
|
1000
|
+
raise ValueError(f"record {position} is not a JSON object")
|
|
1001
|
+
for key in ("turn", "history_file", "index"):
|
|
1002
|
+
value = entry.get(key)
|
|
1003
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
1004
|
+
raise ValueError(
|
|
1005
|
+
f"record {position} has a non-integer {key} ({value!r})")
|
|
1006
|
+
if entry["turn"] != position:
|
|
1007
|
+
raise ValueError(
|
|
1008
|
+
f"record {position} is numbered turn {entry['turn']} "
|
|
1009
|
+
f"(turns must run 1..{len(saved)})")
|
|
1010
|
+
if entry["history_file"] < 0 or entry["index"] < 0:
|
|
1011
|
+
raise ValueError(
|
|
1012
|
+
f"record {position} has a negative history_file/index")
|
|
1013
|
+
records.append({
|
|
1014
|
+
"turn": entry["turn"],
|
|
1015
|
+
"what": str(entry.get("what") or "?"),
|
|
1016
|
+
"history_file": entry["history_file"],
|
|
1017
|
+
"index": entry["index"],
|
|
1018
|
+
})
|
|
1019
|
+
return records
|
|
796
1020
|
|
|
797
1021
|
def _record_turn(self, what, messages, history_index=None):
|
|
798
1022
|
"""Append one entry to the high-level recorder.
|
|
@@ -815,64 +1039,193 @@ class AgentSession:
|
|
|
815
1039
|
|
|
816
1040
|
def _show_history(self):
|
|
817
1041
|
"""Print the high-level recorder as a numbered list of turns."""
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1042
|
+
if not self.summary:
|
|
1043
|
+
self.console.note("No history has been recorded yet.", "warn")
|
|
1044
|
+
return
|
|
1045
|
+
rows = [(str(_safe_int(r.get("turn", i + 1), i + 1)),
|
|
1046
|
+
str(r.get("what") or "?"),
|
|
1047
|
+
f"history_{_safe_int(r.get('history_file'), 0)}.json"
|
|
1048
|
+
f"#{_safe_int(r.get('index'), 0)}")
|
|
1049
|
+
for i, r in enumerate(self.summary) if isinstance(r, dict)]
|
|
822
1050
|
self.console.history_list(rows)
|
|
823
1051
|
|
|
1052
|
+
def _resolve_turn_record(self, arg):
|
|
1053
|
+
"""Resolve a turn selector to ``(position, record)``, or ``None``.
|
|
1054
|
+
|
|
1055
|
+
One rule for both ``/history <n>`` and ``/reset <n>``: accept ``last``
|
|
1056
|
+
(case-insensitive) or a **positive** 1-based integer, and reject
|
|
1057
|
+
everything else with the same message -- ``0``, negatives, non-integers
|
|
1058
|
+
and out-of-range values. Previously ``/history`` computed
|
|
1059
|
+
``summary[int(arg) - 1]`` without a lower bound, so ``/history 0``
|
|
1060
|
+
silently showed the *last* turn and ``/history -2`` the third-to-last,
|
|
1061
|
+
while ``/reset`` rejected exactly those inputs: the same user mistake got
|
|
1062
|
+
two different (and one silently wrong) answers.
|
|
1063
|
+
"""
|
|
1064
|
+
count = len(self.summary)
|
|
1065
|
+
if not count:
|
|
1066
|
+
self.console.note("No history has been recorded yet.", "warn")
|
|
1067
|
+
return None
|
|
1068
|
+
text = (arg or "").strip().lower()
|
|
1069
|
+
if text in ("last", ""):
|
|
1070
|
+
return count - 1, self.summary[-1]
|
|
1071
|
+
try:
|
|
1072
|
+
position = int(text) - 1
|
|
1073
|
+
except (TypeError, ValueError):
|
|
1074
|
+
position = -1
|
|
1075
|
+
if not 0 <= position < count:
|
|
1076
|
+
self.console.note(
|
|
1077
|
+
f"No such turn {arg!r} (have 1..{count}, or 'last').", "error")
|
|
1078
|
+
return None
|
|
1079
|
+
return position, self.summary[position]
|
|
1080
|
+
|
|
824
1081
|
def _inspect_history(self, arg):
|
|
825
1082
|
"""Print the full messages recorded for a single turn."""
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
except (ValueError, IndexError):
|
|
829
|
-
self.console.note(f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
|
|
1083
|
+
resolved = self._resolve_turn_record(arg)
|
|
1084
|
+
if resolved is None:
|
|
830
1085
|
return
|
|
1086
|
+
_, record = resolved
|
|
831
1087
|
messages = self._segment_upto(record)
|
|
832
1088
|
if messages is None:
|
|
833
1089
|
return
|
|
834
1090
|
self.console.history_detail(record, messages, total=len(self.summary))
|
|
835
1091
|
|
|
1092
|
+
def _load_segment(self, history_index):
|
|
1093
|
+
"""Load ``history_{history_index}.json`` as a message list, or ``None``.
|
|
1094
|
+
|
|
1095
|
+
Never raises and never trusts the argument: an out-of-range, negative or
|
|
1096
|
+
non-integer index, a missing file, unreadable JSON or a payload that is
|
|
1097
|
+
not a non-empty list all yield ``None``. Navigation and the rewind only
|
|
1098
|
+
ever *read* through here, so a damaged segment can never crash the CLI.
|
|
1099
|
+
"""
|
|
1100
|
+
index = _safe_int(history_index, -1)
|
|
1101
|
+
if index < 0 or not self.session_dir:
|
|
1102
|
+
return None
|
|
1103
|
+
path = os.path.join(self.session_dir, f"history_{index}.json")
|
|
1104
|
+
try:
|
|
1105
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
1106
|
+
segment = json.load(f)
|
|
1107
|
+
except Exception:
|
|
1108
|
+
return None
|
|
1109
|
+
if not isinstance(segment, list) or not segment:
|
|
1110
|
+
return None
|
|
1111
|
+
return [m for m in segment if isinstance(m, dict)]
|
|
1112
|
+
|
|
1113
|
+
def _segment_for(self, history_index):
|
|
1114
|
+
"""Return the messages of a segment, preferring the live transcript.
|
|
1115
|
+
|
|
1116
|
+
``_persist()`` only runs on quit (and at a compaction rollover), so the
|
|
1117
|
+
``history_{n}.json`` file on disk can lag arbitrarily far behind the
|
|
1118
|
+
running conversation. For the *current* segment the in-memory transcript
|
|
1119
|
+
is therefore the authoritative copy -- reading the file instead used to
|
|
1120
|
+
silently drop everything said since the last save (a ``/reset`` could
|
|
1121
|
+
truncate to a state older than the turn it was asked to keep). Older
|
|
1122
|
+
segments are immutable within their lifetime, so they are read from disk.
|
|
1123
|
+
"""
|
|
1124
|
+
index = _safe_int(history_index, -1)
|
|
1125
|
+
if index < 0:
|
|
1126
|
+
return None
|
|
1127
|
+
if index == self.history_index:
|
|
1128
|
+
live = [m for m in self.messages if isinstance(m, dict)]
|
|
1129
|
+
if live:
|
|
1130
|
+
return live
|
|
1131
|
+
return self._load_segment(index)
|
|
1132
|
+
|
|
836
1133
|
def _segment_upto(self, record):
|
|
837
1134
|
"""Load a recorded turn's segment and truncate it at its index.
|
|
838
1135
|
|
|
839
1136
|
Returns the messages reproducing the conversation state at the end of
|
|
840
1137
|
that turn, or None if the segment could not be loaded (never raises: a
|
|
841
|
-
missing or moved segment must not crash navigation).
|
|
1138
|
+
missing or moved segment must not crash navigation). Both recorded
|
|
1139
|
+
numbers are coerced and the index is clamped into the segment, so a
|
|
1140
|
+
stale or hand-edited recorder entry cannot slice outside it.
|
|
842
1141
|
"""
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
try:
|
|
846
|
-
with open(segment_path, "r", encoding="utf-8") as f:
|
|
847
|
-
segment = json.load(f)
|
|
848
|
-
except Exception:
|
|
1142
|
+
segment = self._segment_for(record.get("history_file"))
|
|
1143
|
+
if not segment:
|
|
849
1144
|
self.console.note("Could not load the history segment for that turn.", "error")
|
|
850
1145
|
return None
|
|
851
|
-
|
|
852
|
-
return segment[:
|
|
1146
|
+
index = max(0, min(_safe_int(record.get("index"), 0), len(segment) - 1))
|
|
1147
|
+
return segment[:index + 1]
|
|
853
1148
|
|
|
854
|
-
def _reset_history(self,
|
|
1149
|
+
def _reset_history(self, keep_count, record, force=True):
|
|
855
1150
|
"""Rewind the conversation to the end of a recorded turn.
|
|
856
1151
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1152
|
+
``keep_count`` is how many recorder entries to keep (the resolved
|
|
1153
|
+
position + 1 -- *not* ``record["turn"]``, which is display data and used
|
|
1154
|
+
to be trusted for slicing). The work is ordered so a failure cannot leave
|
|
1155
|
+
the live session remembering a state the disk did not accept:
|
|
1156
|
+
|
|
1157
|
+
1. load the target segment and compute the truncated transcript;
|
|
1158
|
+
2. refuse (with a warning) when ``force`` is off and messages after the
|
|
1159
|
+
recorded turn exist -- an unrecorded tail must not be silently
|
|
1160
|
+
destroyed;
|
|
1161
|
+
3. persist the truncated ``history_{n}.json``, ``summary_history.json``
|
|
1162
|
+
and ``total_usage.json`` *first*, rolling the segment/summary back
|
|
1163
|
+
if either write fails;
|
|
1164
|
+
4. delete the now-orphaned higher segments (best-effort: a failure is
|
|
1165
|
+
a warning, not a rollback -- the rewind is already on disk);
|
|
1166
|
+
5. only then mutate the live session.
|
|
1167
|
+
|
|
1168
|
+
Afterwards the session is really on segment ``n``: ``token_stats`` is
|
|
1169
|
+
reloaded from ``usage_{n}.json`` (so the next ``_persist()`` cannot write
|
|
1170
|
+
the later segment's counters over it), the remembered prompt size is
|
|
1171
|
+
dropped (it describes a transcript that no longer exists) and
|
|
1172
|
+
``total_usage`` is left alone -- a rewind does not un-spend tokens.
|
|
862
1173
|
"""
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1174
|
+
target_index = _safe_int(record.get("history_file"), -1)
|
|
1175
|
+
full_segment = self._segment_for(target_index)
|
|
1176
|
+
if full_segment is None:
|
|
1177
|
+
self.console.note(
|
|
1178
|
+
"Could not load the history segment for that turn; "
|
|
1179
|
+
"the session is unchanged.", "error")
|
|
1180
|
+
return False
|
|
1181
|
+
|
|
1182
|
+
index = max(0, min(_safe_int(record.get("index"), 0), len(full_segment) - 1))
|
|
1183
|
+
messages = full_segment[:index + 1]
|
|
1184
|
+
trailing = max(len(full_segment) - len(messages), 0)
|
|
1185
|
+
if not force and trailing:
|
|
1186
|
+
self.console.note(
|
|
1187
|
+
f"{trailing} message(s) after turn {keep_count} were never recorded; "
|
|
1188
|
+
f"refusing to discard them. Use /reset {keep_count} to rewind anyway.",
|
|
1189
|
+
"warn")
|
|
1190
|
+
return False
|
|
1191
|
+
|
|
1192
|
+
new_summary = [r for r in self.summary[:keep_count] if isinstance(r, dict)]
|
|
1193
|
+
try:
|
|
1194
|
+
save_history(messages, self.cache_dir, self.unique_id, target_index)
|
|
1195
|
+
save_summary(new_summary, self.cache_dir, self.unique_id)
|
|
1196
|
+
save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
|
|
1197
|
+
except Exception as e:
|
|
1198
|
+
# Put the untouched segment and recorder back, so the on-disk state
|
|
1199
|
+
# still matches the (unchanged) in-memory session.
|
|
1200
|
+
try:
|
|
1201
|
+
save_history(full_segment, self.cache_dir, self.unique_id, target_index)
|
|
1202
|
+
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
1203
|
+
except Exception:
|
|
1204
|
+
pass
|
|
1205
|
+
self.console.note(
|
|
1206
|
+
f"Could not persist the rewind ({e}); the session is unchanged.", "error")
|
|
1207
|
+
return False
|
|
1208
|
+
|
|
1209
|
+
removed = delete_segments_above(self.session_dir, target_index)
|
|
1210
|
+
|
|
866
1211
|
self.messages = messages
|
|
867
|
-
self.history_index =
|
|
868
|
-
self.summary =
|
|
869
|
-
|
|
870
|
-
|
|
1212
|
+
self.history_index = target_index
|
|
1213
|
+
self.summary = new_summary
|
|
1214
|
+
self.token_stats = load_usage_segment(self.session_dir, target_index)
|
|
1215
|
+
self.last_prompt_tokens = 0
|
|
1216
|
+
self.last_prompt_message_count = 0
|
|
1217
|
+
|
|
871
1218
|
self.console.note(
|
|
872
|
-
f"Reset to turn {
|
|
873
|
-
f"(history_{
|
|
874
|
-
|
|
1219
|
+
f"Reset to turn {keep_count}: kept {len(self.messages)} messages "
|
|
1220
|
+
f"(history_{target_index}.json up to index {index}).", "ok")
|
|
1221
|
+
if removed:
|
|
1222
|
+
# Reported at "ok" level, not "info": info notes are hidden in the
|
|
1223
|
+
# default (non-verbose) view, which is exactly where the user needs to
|
|
1224
|
+
# know that files were deleted.
|
|
1225
|
+
self.console.note(
|
|
1226
|
+
f"Removed {len(removed)} later history/usage segment file(s).", "ok")
|
|
875
1227
|
self.console.note("Continuing: new turns will append to this state.", "info")
|
|
1228
|
+
return True
|
|
876
1229
|
|
|
877
1230
|
def _warn_sudo(self):
|
|
878
1231
|
"""Print a prominent red warning about --sudo mode.
|
|
@@ -1006,8 +1359,12 @@ class AgentSession:
|
|
|
1006
1359
|
return
|
|
1007
1360
|
except KeyboardInterrupt:
|
|
1008
1361
|
self.console.note("Interrupting: Mini Code Exiting...", "error")
|
|
1362
|
+
self.shutdown(farewell=False)
|
|
1363
|
+
return
|
|
1009
1364
|
except Exception as e:
|
|
1010
1365
|
self.console.note(f"Exception {e}. Exiting...", "error")
|
|
1366
|
+
self.shutdown(farewell=False)
|
|
1367
|
+
return
|
|
1011
1368
|
self.shutdown()
|
|
1012
1369
|
|
|
1013
1370
|
def _emit_tip(self):
|
|
@@ -1251,7 +1608,7 @@ class AgentSession:
|
|
|
1251
1608
|
return True
|
|
1252
1609
|
|
|
1253
1610
|
|
|
1254
|
-
def _handle_compaction(self, compacted):
|
|
1611
|
+
def _handle_compaction(self, compacted, tail=()):
|
|
1255
1612
|
"""Re-inject goal + plan into the compacted messages (plan mode).
|
|
1256
1613
|
|
|
1257
1614
|
Plan-mode counterpart to the plan handling inside :meth:`maybe_compact`,
|
|
@@ -1259,7 +1616,8 @@ class AgentSession:
|
|
|
1259
1616
|
a network-free module-level helper. The loop wires in this method only
|
|
1260
1617
|
when plan mode is on; it re-injects the goal + plan via
|
|
1261
1618
|
:meth:`_apply_plan_to_compacted`, which also rewrites the tracking files
|
|
1262
|
-
to match.
|
|
1619
|
+
to match. ``tail`` is the turn that is still in flight, which
|
|
1620
|
+
``maybe_compact`` appends after the hook's messages.
|
|
1263
1621
|
|
|
1264
1622
|
The hook must update ``compacted`` **in place**: ``maybe_compact``
|
|
1265
1623
|
returns *that* list to the caller, which then assigns it to
|
|
@@ -1267,9 +1625,9 @@ class AgentSession:
|
|
|
1267
1625
|
discarded a moment later, silently dropping the goal and plan -- which is
|
|
1268
1626
|
exactly what happened before this was a slice assignment.
|
|
1269
1627
|
"""
|
|
1270
|
-
compacted[:] = self._apply_plan_to_compacted(compacted)
|
|
1628
|
+
compacted[:] = self._apply_plan_to_compacted(compacted, tail)
|
|
1271
1629
|
|
|
1272
|
-
def _apply_plan_to_compacted(self, messages):
|
|
1630
|
+
def _apply_plan_to_compacted(self, messages, tail=()):
|
|
1273
1631
|
"""Re-inject the current goal + plan after a history compaction.
|
|
1274
1632
|
|
|
1275
1633
|
Compaction replaces the transcript with a summary, which would otherwise
|
|
@@ -1277,16 +1635,18 @@ class AgentSession:
|
|
|
1277
1635
|
back off disk and append them as fresh user turns so the model keeps
|
|
1278
1636
|
working against the goal it is still trying to finish. The goal is
|
|
1279
1637
|
appended last so it reads as the most recent instruction the model
|
|
1280
|
-
should act on
|
|
1638
|
+
should act on -- ``maybe_compact`` then appends the in-flight turn, which
|
|
1639
|
+
is the same instruction verbatim.
|
|
1281
1640
|
"""
|
|
1282
1641
|
plan_text = self.plan_manager.read_plan()
|
|
1283
1642
|
goal_text = self.plan_manager.read_goal()
|
|
1284
1643
|
|
|
1285
|
-
# Recover the user's latest instruction from the
|
|
1286
|
-
#
|
|
1287
|
-
#
|
|
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.
|
|
1288
1648
|
current_input = None
|
|
1289
|
-
for m in reversed(
|
|
1649
|
+
for m in reversed(list(tail or ())):
|
|
1290
1650
|
if m.get("role") == "user":
|
|
1291
1651
|
current_input = m.get("content")
|
|
1292
1652
|
break
|
|
@@ -1363,8 +1723,10 @@ class AgentSession:
|
|
|
1363
1723
|
/history list the high-level recorder (turn number, what
|
|
1364
1724
|
happened, and which history_{n}.json + index)
|
|
1365
1725
|
/history <n> show the full messages recorded for turn <n>
|
|
1366
|
-
/
|
|
1367
|
-
|
|
1726
|
+
/history last ...for the most recent turn
|
|
1727
|
+
/reset [<n>|last] rewind the conversation to the end of turn <n>
|
|
1728
|
+
(a bare /reset only re-saves the current state;
|
|
1729
|
+
see _do_reset)
|
|
1368
1730
|
/plan show the current goal + plan the loop maintains
|
|
1369
1731
|
(plan mode only)
|
|
1370
1732
|
/clear clear the screen (keeps the conversation)
|
|
@@ -1404,25 +1766,38 @@ class AgentSession:
|
|
|
1404
1766
|
def _do_reset(self, arg):
|
|
1405
1767
|
"""Rewind the conversation to the end of a recorded turn.
|
|
1406
1768
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1769
|
+
``/reset <n>`` and ``/reset last`` are *explicit* rewinds: they truncate
|
|
1770
|
+
the transcript to the end of that turn and drop everything recorded after
|
|
1771
|
+
it. A bare ``/reset`` is the cautious variant -- it never truncates: when
|
|
1772
|
+
the transcript really ends at the last recorded turn it just reports
|
|
1773
|
+
"Already at turn N", and when unrecorded messages follow that turn it
|
|
1774
|
+
*warns* instead of destroying them, because the user never asked for a
|
|
1775
|
+
specific turn. Forcing the rewind is one explicit command away:
|
|
1776
|
+
``/reset <n>``.
|
|
1410
1777
|
"""
|
|
1411
|
-
|
|
1412
|
-
|
|
1778
|
+
resolved = self._resolve_turn_record(arg)
|
|
1779
|
+
if resolved is None:
|
|
1413
1780
|
return
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1781
|
+
position, record = resolved
|
|
1782
|
+
explicit = bool((arg or "").strip())
|
|
1783
|
+
|
|
1784
|
+
if not explicit and self._transcript_ends_at(record):
|
|
1785
|
+
self.console.note(
|
|
1786
|
+
f"Already at turn {position + 1} (the last recorded turn); "
|
|
1787
|
+
f"nothing to rewind. Use /reset <n> to rewind to an earlier turn.",
|
|
1788
|
+
"ok")
|
|
1789
|
+
return
|
|
1790
|
+
self._reset_history(position + 1, record, force=explicit)
|
|
1791
|
+
|
|
1792
|
+
def _transcript_ends_at(self, record):
|
|
1793
|
+
"""Whether the live transcript is exactly the state recorded for a turn.
|
|
1794
|
+
|
|
1795
|
+
That is true when the session is still on the record's segment and its
|
|
1796
|
+
last message is the one the record points at -- i.e. a bare ``/reset``
|
|
1797
|
+
has nothing left to drop.
|
|
1798
|
+
"""
|
|
1799
|
+
return (_safe_int(record.get("history_file"), -1) == self.history_index
|
|
1800
|
+
and len(self.messages) == _safe_int(record.get("index"), -1) + 1)
|
|
1426
1801
|
|
|
1427
1802
|
def _show_plan(self):
|
|
1428
1803
|
"""Show the current plan/goal the loop maintains (``/plan`` command).
|
|
@@ -1633,21 +2008,112 @@ class AgentSession:
|
|
|
1633
2008
|
badge=self._context_badge())
|
|
1634
2009
|
|
|
1635
2010
|
def _persist(self):
|
|
1636
|
-
|
|
1637
|
-
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
1638
|
-
usage_path = save_usage_segment(self.token_stats, self.cache_dir,
|
|
1639
|
-
self.unique_id, self.history_index)
|
|
1640
|
-
total_path = save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
|
|
1641
|
-
self.console.note(
|
|
1642
|
-
f"Saved usage to: {usage_path} (session total: {total_path})", "info")
|
|
2011
|
+
"""Write the current segment, recorder and usage totals to disk.
|
|
1643
2012
|
|
|
1644
|
-
|
|
1645
|
-
|
|
2013
|
+
Each write is attempted independently and none of them may raise: a full
|
|
2014
|
+
disk, a read-only cache dir or a permission hiccup has to degrade to a
|
|
2015
|
+
warning. Quitting must never crash, and a failed transcript save must not
|
|
2016
|
+
stop the usage totals (or the other way round) from being written.
|
|
2017
|
+
"""
|
|
2018
|
+
def attempt(label, write):
|
|
2019
|
+
try:
|
|
2020
|
+
return write()
|
|
2021
|
+
except Exception as e:
|
|
2022
|
+
self.console.note(f"Could not save the {label}: {e}", "error")
|
|
2023
|
+
return None
|
|
2024
|
+
|
|
2025
|
+
attempt("history segment", lambda: save_history(
|
|
2026
|
+
self.messages, self.cache_dir, self.unique_id, self.history_index))
|
|
2027
|
+
attempt("history recorder", lambda: save_summary(
|
|
2028
|
+
self.summary, self.cache_dir, self.unique_id))
|
|
2029
|
+
usage_path = attempt("segment usage", lambda: save_usage_segment(
|
|
2030
|
+
self.token_stats, self.cache_dir, self.unique_id, self.history_index))
|
|
2031
|
+
total_path = attempt("session usage", lambda: save_total_usage(
|
|
2032
|
+
self.total_usage, self.cache_dir, self.unique_id))
|
|
2033
|
+
if usage_path or total_path:
|
|
2034
|
+
self.console.note(
|
|
2035
|
+
f"Saved usage to: {usage_path} (session total: {total_path})", "info")
|
|
2036
|
+
|
|
2037
|
+
def shutdown(self, farewell=True):
|
|
2038
|
+
"""Persist and print a final summary (called on quit / exit).
|
|
2039
|
+
|
|
2040
|
+
``farewell=False`` is used by the interrupt / fatal paths in :meth:`run`,
|
|
2041
|
+
which have already told the user (in red) that the session is being torn
|
|
2042
|
+
down: a cheerful "thanks and here is your state" box on top of a crash is
|
|
2043
|
+
the wrong note, and the state it would recap is the state that just
|
|
2044
|
+
failed.
|
|
2045
|
+
"""
|
|
1646
2046
|
self._persist()
|
|
2047
|
+
segments = history_segment_indices(self.session_dir)
|
|
2048
|
+
# List what is really on disk. The old message claimed
|
|
2049
|
+
# "history_0..{history_index}.json", which was a lie after a /reset had
|
|
2050
|
+
# removed the higher segments (or moved the session back onto an earlier
|
|
2051
|
+
# one) -- it named files that no longer existed and hid the ones that did.
|
|
2052
|
+
if segments:
|
|
2053
|
+
present = (f"history_{segments[0]}.json"
|
|
2054
|
+
if len(segments) == 1 else
|
|
2055
|
+
f"history_{segments[0]}..{segments[-1]}.json")
|
|
2056
|
+
else:
|
|
2057
|
+
present = f"history_{self.history_index}.json"
|
|
1647
2058
|
self.console.note(
|
|
1648
2059
|
f"Saved session to {self.cache_dir}/{self.unique_id}/ "
|
|
1649
|
-
f"(
|
|
1650
|
-
|
|
2060
|
+
f"({present} + summary_history.json).", "ok")
|
|
2061
|
+
# The detailed per-field usage block is verbose-only (the console
|
|
2062
|
+
# documents it that way); in the quiet default view the good-bye box
|
|
2063
|
+
# below carries the same totals, so nothing is lost by leaving it out.
|
|
2064
|
+
if self.console.verbose:
|
|
2065
|
+
self.console.usage_summary(self.total_usage)
|
|
2066
|
+
if farewell:
|
|
2067
|
+
self._farewell(segments)
|
|
2068
|
+
|
|
2069
|
+
def _farewell(self, segments=None):
|
|
2070
|
+
"""Print the good-bye box: version, sign-off and what the session left.
|
|
2071
|
+
|
|
2072
|
+
Printed once (see ``_farewell_shown``) and only on a normal exit. It
|
|
2073
|
+
restates the state in one glanceable place -- the same facts the user
|
|
2074
|
+
would otherwise have to piece together from the save note, ``/history``
|
|
2075
|
+
and the (verbose-only) usage readout, which in the quiet default view
|
|
2076
|
+
means they saw none of it.
|
|
2077
|
+
"""
|
|
2078
|
+
if self._farewell_shown:
|
|
2079
|
+
return
|
|
2080
|
+
self._farewell_shown = True
|
|
2081
|
+
from . import __version__ as version
|
|
2082
|
+
|
|
2083
|
+
if segments is None:
|
|
2084
|
+
segments = history_segment_indices(self.session_dir)
|
|
2085
|
+
if not segments:
|
|
2086
|
+
where = f"history_{self.history_index}.json"
|
|
2087
|
+
elif len(segments) == 1:
|
|
2088
|
+
where = f"history_{segments[0]}.json"
|
|
2089
|
+
else:
|
|
2090
|
+
where = f"history_{segments[0]}..{segments[-1]}.json"
|
|
2091
|
+
|
|
2092
|
+
turns = len(self.summary)
|
|
2093
|
+
usage = self.total_usage or {}
|
|
2094
|
+
|
|
2095
|
+
def count(key):
|
|
2096
|
+
# A display detail on the exit path must never raise: a server that
|
|
2097
|
+
# reported ``null`` usage leaves a None here, and crashing while
|
|
2098
|
+
# saying goodbye would be the worst possible time to fail.
|
|
2099
|
+
try:
|
|
2100
|
+
return int(usage.get(key) or 0)
|
|
2101
|
+
except (TypeError, ValueError):
|
|
2102
|
+
return 0
|
|
2103
|
+
|
|
2104
|
+
rows = [
|
|
2105
|
+
("session id", self.unique_id),
|
|
2106
|
+
("saved under", f"{self.cache_dir}/{self.unique_id}/"),
|
|
2107
|
+
("history", f"{where} (segment {self.history_index}), "
|
|
2108
|
+
f"{_plural(len(self.messages), 'message')}"),
|
|
2109
|
+
("conversation", _plural(turns, "recorded turn")),
|
|
2110
|
+
("tokens", f"{count('prompt_tokens'):,} in / "
|
|
2111
|
+
f"{count('completion_tokens'):,} out "
|
|
2112
|
+
f"({count('total_tokens'):,} total)"),
|
|
2113
|
+
("calls", f"{count('llm_calls'):,} LLM, "
|
|
2114
|
+
f"{count('tool_calls'):,} tool"),
|
|
2115
|
+
]
|
|
2116
|
+
self.console.farewell(version, rows)
|
|
1651
2117
|
|
|
1652
2118
|
# -- single LLM turn ---------------------------------------------------
|
|
1653
2119
|
|
|
@@ -1659,6 +2125,9 @@ class AgentSession:
|
|
|
1659
2125
|
# rolls the session over to a fresh history segment (history_index + 1)
|
|
1660
2126
|
# and returns that bump so the recorder can stay flat across it.
|
|
1661
2127
|
segment_before = dict(self.token_stats)
|
|
2128
|
+
# The segment as it stands *before* compaction: it has to be flushed to
|
|
2129
|
+
# disk as it is being closed (see below), because nothing else writes it.
|
|
2130
|
+
closing_messages = self.messages
|
|
1662
2131
|
projected = self._projected_input_tokens()
|
|
1663
2132
|
self.messages, did_compact, new_index = maybe_compact(
|
|
1664
2133
|
self.messages, self.server_url, self.model, self.api_key,
|
|
@@ -1682,17 +2151,33 @@ class AgentSession:
|
|
|
1682
2151
|
# would trigger compaction again immediately.
|
|
1683
2152
|
self.last_prompt_tokens = 0
|
|
1684
2153
|
self.last_prompt_message_count = 0
|
|
1685
|
-
# Close the old segment's
|
|
1686
|
-
#
|
|
1687
|
-
|
|
1688
|
-
|
|
2154
|
+
# Close the old segment's files before moving on: its spend is
|
|
2155
|
+
# preserved for the totals, and its *messages* are flushed so the
|
|
2156
|
+
# recorder keeps pointing at a transcript that really exists on disk.
|
|
2157
|
+
# Nothing else writes a closing segment -- the interactive loop only
|
|
2158
|
+
# persists on quit -- so without this history_{n}.json would be short
|
|
2159
|
+
# by every turn recorded in that segment, and /reset (or a restore)
|
|
2160
|
+
# would rewind to a state the user never saw.
|
|
2161
|
+
try:
|
|
2162
|
+
save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
|
|
2163
|
+
self.history_index)
|
|
2164
|
+
save_history(closing_messages, self.cache_dir, self.unique_id,
|
|
2165
|
+
self.history_index)
|
|
2166
|
+
except Exception as e:
|
|
2167
|
+
self.console.note(
|
|
2168
|
+
f"Could not save the closed history segment "
|
|
2169
|
+
f"history_{self.history_index}.json: {e}. The compacted "
|
|
2170
|
+
f"conversation is unaffected, but /reset back into that "
|
|
2171
|
+
f"segment may be incomplete.", "warn")
|
|
1689
2172
|
self.history_index = new_index
|
|
1690
2173
|
self.token_stats = empty_usage()
|
|
1691
2174
|
# Snapshot the compacted messages into the new segment so its turns
|
|
1692
2175
|
# are preserved, and record the summary as the first turn of it.
|
|
1693
2176
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
1694
2177
|
self._record_turn(
|
|
1695
|
-
f"summary:
|
|
2178
|
+
f"summary: history_{new_index - 1}.json "
|
|
2179
|
+
f"({_plural(len(closing_messages), 'message')}) compacted into a "
|
|
2180
|
+
f"summary; history_{self.history_index}.json started",
|
|
1696
2181
|
self.messages)
|
|
1697
2182
|
|
|
1698
2183
|
# Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
|
|
@@ -1785,6 +2270,13 @@ class AgentSession:
|
|
|
1785
2270
|
self.messages)
|
|
1786
2271
|
elif response_text:
|
|
1787
2272
|
self._record_turn("assistant", self.messages)
|
|
2273
|
+
else:
|
|
2274
|
+
# A step that produced neither text nor tool calls (a failed call, or
|
|
2275
|
+
# a model that answered with nothing) still appended an assistant
|
|
2276
|
+
# message, so it is recorded as well. The recorder must cover the
|
|
2277
|
+
# *final* message: otherwise a bare /reset sees an unrecorded tail
|
|
2278
|
+
# and refuses to touch a conversation that is in fact settled.
|
|
2279
|
+
self._record_turn("assistant (no response)", self.messages)
|
|
1788
2280
|
|
|
1789
2281
|
return made_tool_calls and self.args.auto_mode
|
|
1790
2282
|
|
|
@@ -1794,7 +2286,7 @@ class _Abort(Exception):
|
|
|
1794
2286
|
|
|
1795
2287
|
|
|
1796
2288
|
# ---------------------------------------------------------------------------
|
|
1797
|
-
#
|
|
2289
|
+
# entry point
|
|
1798
2290
|
# ---------------------------------------------------------------------------
|
|
1799
2291
|
|
|
1800
2292
|
def main():
|