mini-code-cli 0.1.1.1__tar.gz → 0.1.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/PKG-INFO +23 -1
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/README.md +22 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/agent_loop.py +197 -27
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/ui.py +26 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/PKG-INFO +23 -1
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/pyproject.toml +1 -1
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_e2e_agent_loop.py +45 -1
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_interactive.py +33 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_tokens.py +55 -4
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/LICENSE +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/__init__.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/config.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/permissions.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/persistence.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/plan.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/prompts.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/runner.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/sandbox.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/shell_safety_utils.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/skills.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/__init__.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/base.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/builtin.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/tokens.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/tools.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/utils.py +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/SOURCES.txt +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/dependency_links.txt +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/entry_points.txt +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/requires.txt +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/top_level.txt +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/setup.cfg +0 -0
- {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_subagents.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.1.
|
|
3
|
+
Version: 0.1.1.2
|
|
4
4
|
Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
|
|
5
5
|
Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
@@ -970,6 +970,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
|
|
|
970
970
|
output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
|
|
971
971
|
is no message-count cap to configure.
|
|
972
972
|
|
|
973
|
+
Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
|
|
974
|
+
across the rollover: the message you just typed (or, mid-turn, the tool call the model is
|
|
975
|
+
waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
|
|
976
|
+
instruction cannot swallow it — the model still answers what you asked, and the turn stays in
|
|
977
|
+
the session file, in `/history` and in a later restore.
|
|
978
|
+
|
|
979
|
+
Because a rollover leaves no trace in the transcript (the old turns really are gone), the
|
|
980
|
+
console says in plain terms what happened — all of it without `--verbose`:
|
|
981
|
+
|
|
982
|
+
```
|
|
983
|
+
[!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
|
|
984
|
+
[!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
|
|
985
|
+
[!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
So you can see *what* was folded away (a preview of the summary itself), *how much* (the
|
|
989
|
+
message count of the segment that was closed), *where it went* (`history_0.json` closed,
|
|
990
|
+
`history_1.json` started) and that nothing you just typed was lost. If the summariser answers
|
|
991
|
+
with an empty summary twice, or the call fails, compaction is abandoned and the note says so
|
|
992
|
+
explicitly — "nothing was lost, but the next request may still be too large" — with the
|
|
993
|
+
transcript and the segment left untouched.
|
|
994
|
+
|
|
973
995
|
|
|
974
996
|
## Serving a model locally
|
|
975
997
|
|
|
@@ -290,6 +290,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
|
|
|
290
290
|
output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
|
|
291
291
|
is no message-count cap to configure.
|
|
292
292
|
|
|
293
|
+
Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
|
|
294
|
+
across the rollover: the message you just typed (or, mid-turn, the tool call the model is
|
|
295
|
+
waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
|
|
296
|
+
instruction cannot swallow it — the model still answers what you asked, and the turn stays in
|
|
297
|
+
the session file, in `/history` and in a later restore.
|
|
298
|
+
|
|
299
|
+
Because a rollover leaves no trace in the transcript (the old turns really are gone), the
|
|
300
|
+
console says in plain terms what happened — all of it without `--verbose`:
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
[!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
|
|
304
|
+
[!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
|
|
305
|
+
[!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
So you can see *what* was folded away (a preview of the summary itself), *how much* (the
|
|
309
|
+
message count of the segment that was closed), *where it went* (`history_0.json` closed,
|
|
310
|
+
`history_1.json` started) and that nothing you just typed was lost. If the summariser answers
|
|
311
|
+
with an empty summary twice, or the call fails, compaction is abandoned and the note says so
|
|
312
|
+
explicitly — "nothing was lost, but the next request may still be too large" — with the
|
|
313
|
+
transcript and the segment left untouched.
|
|
314
|
+
|
|
293
315
|
|
|
294
316
|
## Serving a model locally
|
|
295
317
|
|
|
@@ -267,6 +267,42 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
|
|
|
267
267
|
return False, ""
|
|
268
268
|
|
|
269
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
|
+
|
|
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
|
|
@@ -1931,8 +2034,15 @@ class AgentSession:
|
|
|
1931
2034
|
self.console.note(
|
|
1932
2035
|
f"Saved usage to: {usage_path} (session total: {total_path})", "info")
|
|
1933
2036
|
|
|
1934
|
-
def shutdown(self):
|
|
1935
|
-
"""Persist and print a final summary (called on quit / exit).
|
|
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
|
+
"""
|
|
1936
2046
|
self._persist()
|
|
1937
2047
|
segments = history_segment_indices(self.session_dir)
|
|
1938
2048
|
# List what is really on disk. The old message claimed
|
|
@@ -1948,7 +2058,62 @@ class AgentSession:
|
|
|
1948
2058
|
self.console.note(
|
|
1949
2059
|
f"Saved session to {self.cache_dir}/{self.unique_id}/ "
|
|
1950
2060
|
f"({present} + summary_history.json).", "ok")
|
|
1951
|
-
|
|
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)
|
|
1952
2117
|
|
|
1953
2118
|
# -- single LLM turn ---------------------------------------------------
|
|
1954
2119
|
|
|
@@ -2000,14 +2165,19 @@ class AgentSession:
|
|
|
2000
2165
|
self.history_index)
|
|
2001
2166
|
except Exception as e:
|
|
2002
2167
|
self.console.note(
|
|
2003
|
-
f"Could not save the closed history segment
|
|
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")
|
|
2004
2172
|
self.history_index = new_index
|
|
2005
2173
|
self.token_stats = empty_usage()
|
|
2006
2174
|
# Snapshot the compacted messages into the new segment so its turns
|
|
2007
2175
|
# are preserved, and record the summary as the first turn of it.
|
|
2008
2176
|
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
2009
2177
|
self._record_turn(
|
|
2010
|
-
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",
|
|
2011
2181
|
self.messages)
|
|
2012
2182
|
|
|
2013
2183
|
# Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
|
|
@@ -739,6 +739,32 @@ class Console:
|
|
|
739
739
|
]
|
|
740
740
|
self.banner(f"MiniCodeCLI v{version}", lines, fg="cyan")
|
|
741
741
|
|
|
742
|
+
def farewell(self, version, rows, sign_off=None):
|
|
743
|
+
"""Render the exit banner: a sign-off plus a summary of the session state.
|
|
744
|
+
|
|
745
|
+
Deliberately narrower than :meth:`welcome`. Everything the welcome banner
|
|
746
|
+
shows is about how the session *will* run -- server, model, permissions,
|
|
747
|
+
allowed dir -- and all of it is irrelevant once the session is over. What
|
|
748
|
+
the user can still act on is what the session *left behind*: which
|
|
749
|
+
folder it was saved in, which history segments exist, how much was said
|
|
750
|
+
and what it cost. So the box carries only the version, a good-bye line
|
|
751
|
+
and those ``rows``.
|
|
752
|
+
|
|
753
|
+
``rows`` is a list of ``(label, value)`` pairs, laid out in a single
|
|
754
|
+
colon-aligned column like the welcome banner; the caller supplies the
|
|
755
|
+
pairs because it owns the state (the console stays free of session
|
|
756
|
+
policy).
|
|
757
|
+
"""
|
|
758
|
+
label_w = max((len(str(label)) for label, _ in rows), default=0)
|
|
759
|
+
lines = [sign_off or "Goodbye, and thanks for using MiniCodeCLI!", ""]
|
|
760
|
+
lines += [f"{str(label).ljust(label_w)} : {value}" for label, value in rows]
|
|
761
|
+
# Yellow, not the welcome banner's cyan: this is the one box that
|
|
762
|
+
# *closes* the session, so it reads as a distinct, valedictory note
|
|
763
|
+
# rather than the startup banner reappearing. Colours only apply on a
|
|
764
|
+
# colour-capable TTY (see :meth:`box`), so piped/ASCII output is
|
|
765
|
+
# unchanged.
|
|
766
|
+
self.banner(f"MiniCodeCLI v{version} - goodbye", lines, fg="yellow")
|
|
767
|
+
|
|
742
768
|
def announce_api_key(self, present):
|
|
743
769
|
if present:
|
|
744
770
|
self.note("API key found (MINI_CODE_API_KEY).", "ok")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.1.
|
|
3
|
+
Version: 0.1.1.2
|
|
4
4
|
Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
|
|
5
5
|
Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
@@ -970,6 +970,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
|
|
|
970
970
|
output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
|
|
971
971
|
is no message-count cap to configure.
|
|
972
972
|
|
|
973
|
+
Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
|
|
974
|
+
across the rollover: the message you just typed (or, mid-turn, the tool call the model is
|
|
975
|
+
waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
|
|
976
|
+
instruction cannot swallow it — the model still answers what you asked, and the turn stays in
|
|
977
|
+
the session file, in `/history` and in a later restore.
|
|
978
|
+
|
|
979
|
+
Because a rollover leaves no trace in the transcript (the old turns really are gone), the
|
|
980
|
+
console says in plain terms what happened — all of it without `--verbose`:
|
|
981
|
+
|
|
982
|
+
```
|
|
983
|
+
[!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
|
|
984
|
+
[!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
|
|
985
|
+
[!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
So you can see *what* was folded away (a preview of the summary itself), *how much* (the
|
|
989
|
+
message count of the segment that was closed), *where it went* (`history_0.json` closed,
|
|
990
|
+
`history_1.json` started) and that nothing you just typed was lost. If the summariser answers
|
|
991
|
+
with an empty summary twice, or the call fails, compaction is abandoned and the note says so
|
|
992
|
+
explicitly — "nothing was lost, but the next request may still be too large" — with the
|
|
993
|
+
transcript and the segment left untouched.
|
|
994
|
+
|
|
973
995
|
|
|
974
996
|
## Serving a model locally
|
|
975
997
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mini-code-cli"
|
|
3
3
|
# version = { file = "mini_code_cli/__init__.py", attr = "__version__" }
|
|
4
|
-
version = "0.1.1.
|
|
4
|
+
version = "0.1.1.2"
|
|
5
5
|
description = "Mini Coding Agent. Allows you to easily experiment with a small coding agent."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.10"
|
|
@@ -522,7 +522,7 @@ class CompactionTest(unittest.TestCase):
|
|
|
522
522
|
messages = self._messages(4)
|
|
523
523
|
out, did_compact, _ = self._compact(
|
|
524
524
|
messages, token_limit=10, current_tokens=50,
|
|
525
|
-
plan_hook=lambda msgs: msgs.append({"role": "user", "content": "GOAL + PLAN"}))
|
|
525
|
+
plan_hook=lambda msgs, tail: msgs.append({"role": "user", "content": "GOAL + PLAN"}))
|
|
526
526
|
|
|
527
527
|
self.assertTrue(did_compact)
|
|
528
528
|
self.assertEqual(out[-1]["content"], "GOAL + PLAN")
|
|
@@ -714,6 +714,50 @@ class TokenBudgetCompactionTest(unittest.TestCase):
|
|
|
714
714
|
# Atomic writes: nothing half-written is left lying around.
|
|
715
715
|
self.assertEqual([n for n in os.listdir(session_dir) if ".tmp." in n], [])
|
|
716
716
|
|
|
717
|
+
def test_the_typed_turn_survives_the_rollover(self):
|
|
718
|
+
"""The line typed just before a compaction is still the turn answered.
|
|
719
|
+
|
|
720
|
+
The bug: the rollover replaced the transcript with (system, summary), so
|
|
721
|
+
the user message this step existed to answer was summarised away. The
|
|
722
|
+
model answered the summary ("Understood. What would you like me to
|
|
723
|
+
do?"), and the typed line was missing from the live transcript, from the
|
|
724
|
+
new segment, from the recorder's target and therefore from a restore --
|
|
725
|
+
from the user's side, they typed an instruction and nothing happened.
|
|
726
|
+
"""
|
|
727
|
+
session = self._session(max_context_tokens=1)
|
|
728
|
+
typed = "please fix the parser bug in main.py"
|
|
729
|
+
session.messages.append({"role": "user", "content": typed})
|
|
730
|
+
|
|
731
|
+
self._run_step(session, [
|
|
732
|
+
("A summary of the work so far.", [], USAGE), # the compaction call
|
|
733
|
+
("Understood. Fixing the parser.", [], USAGE), # the step's own call
|
|
734
|
+
])
|
|
735
|
+
session.shutdown()
|
|
736
|
+
|
|
737
|
+
# 1) the model was asked to answer the typed line, not the summary.
|
|
738
|
+
self.assertEqual(session.messages[-2], {"role": "user", "content": typed})
|
|
739
|
+
self.assertIn("A summary of the work so far.", session.messages[-3]["content"])
|
|
740
|
+
|
|
741
|
+
# 2) the recorder's last record reaches it, so /history and /reset can.
|
|
742
|
+
last = session.summary[-1]
|
|
743
|
+
self.assertEqual(last["history_file"], session.history_index)
|
|
744
|
+
self.assertIn(typed, [m.get("content") for m in session._segment_upto(last)])
|
|
745
|
+
|
|
746
|
+
# 3) it is in the segment the recorder points at, so a restore has it.
|
|
747
|
+
session_dir = os.path.join(self.cache_dir, session.unique_id)
|
|
748
|
+
with open(os.path.join(session_dir, f"history_{last['history_file']}.json"),
|
|
749
|
+
"r", encoding="utf-8") as f:
|
|
750
|
+
segment = json.load(f)
|
|
751
|
+
self.assertIn(typed, [m.get("content") for m in segment])
|
|
752
|
+
|
|
753
|
+
args = make_args(self.workdir, self.cache_dir)
|
|
754
|
+
args.restore_session = session_dir
|
|
755
|
+
restored = AgentSession(args, console=silent_console())
|
|
756
|
+
restored.start_session()
|
|
757
|
+
self.assertEqual([m.get("content") for m in restored.messages],
|
|
758
|
+
[m.get("content") for m in session.messages])
|
|
759
|
+
self.assertIn(typed, [m.get("content") for m in restored.messages])
|
|
760
|
+
|
|
717
761
|
def test_the_stale_prompt_report_is_dropped_after_a_rollover(self):
|
|
718
762
|
session = self._session(max_context_tokens=1)
|
|
719
763
|
session.last_prompt_tokens = 12_345
|
|
@@ -941,6 +941,35 @@ class CompactionEdgeCaseTest(unittest.TestCase):
|
|
|
941
941
|
console=self.console())
|
|
942
942
|
return result, stub
|
|
943
943
|
|
|
944
|
+
def test_success_reports_what_was_compacted_where_and_what_was_kept(self):
|
|
945
|
+
"""The default (non-verbose) console has to answer the user's questions.
|
|
946
|
+
|
|
947
|
+
A rollover leaves no trace in the transcript, so the notes are the only
|
|
948
|
+
thing the user has: they must say *what* was folded away (a summary
|
|
949
|
+
preview), *how much* (before -> after), *where it went* (which segment
|
|
950
|
+
file was closed and which was started) and that the instruction being
|
|
951
|
+
answered was not lost. All of it is ``warn``, i.e. visible without
|
|
952
|
+
``--verbose``.
|
|
953
|
+
"""
|
|
954
|
+
messages = [{"role": "system", "content": "S"},
|
|
955
|
+
{"role": "user", "content": "q"},
|
|
956
|
+
{"role": "assistant", "content": "a"},
|
|
957
|
+
{"role": "user", "content": "the new instruction"}]
|
|
958
|
+
(out, did_compact, index), _ = self._compact(
|
|
959
|
+
messages, ["The user asked a question and got an answer."])
|
|
960
|
+
text = self.output()
|
|
961
|
+
|
|
962
|
+
self.assertTrue(did_compact)
|
|
963
|
+
self.assertEqual(len(out), 3) # system + summary + turn in flight
|
|
964
|
+
# the summary content itself, not just a message count
|
|
965
|
+
self.assertIn("Summary preview: The user asked a question", text)
|
|
966
|
+
# how much was folded away, and which segment file it went into
|
|
967
|
+
self.assertIn("Compacted history_0.json (4 messages) into a summary", text)
|
|
968
|
+
self.assertIn("history_1.json now starts with 3 messages", text)
|
|
969
|
+
self.assertIn("summary + 1 kept message", text)
|
|
970
|
+
# and that the instruction this step is answering survived the rollover
|
|
971
|
+
self.assertIn("The current turn (1 message) is kept verbatim", text)
|
|
972
|
+
|
|
944
973
|
def test_compaction_prompt_tolerates_missing_content(self):
|
|
945
974
|
"""A tool-call-only turn (content None or absent) must not raise KeyError."""
|
|
946
975
|
messages = [
|
|
@@ -972,6 +1001,9 @@ class CompactionEdgeCaseTest(unittest.TestCase):
|
|
|
972
1001
|
self.assertEqual(len(stub.calls), 2) # exactly one retry
|
|
973
1002
|
self.assertEqual(self.stats["llm_calls"], 2) # both attempts were billed
|
|
974
1003
|
self.assertIn("Could not compact history", self.output())
|
|
1004
|
+
# ... and it must say that nothing was lost, not just that it failed.
|
|
1005
|
+
self.assertIn("nothing was lost", self.output())
|
|
1006
|
+
self.assertIn("Keeping all 3 messages and history_0.json", self.output())
|
|
975
1007
|
|
|
976
1008
|
def test_empty_summary_then_a_real_one_still_compacts(self):
|
|
977
1009
|
"""A single empty reply is recovered from by the retry."""
|
|
@@ -1002,6 +1034,7 @@ class CompactionEdgeCaseTest(unittest.TestCase):
|
|
|
1002
1034
|
self.assertEqual(index, 0)
|
|
1003
1035
|
self.assertIs(out, messages)
|
|
1004
1036
|
self.assertIn("Failed to compact history", self.output())
|
|
1037
|
+
self.assertIn("Keeping all 3 messages unchanged", self.output())
|
|
1005
1038
|
|
|
1006
1039
|
|
|
1007
1040
|
if __name__ == "__main__":
|
|
@@ -289,9 +289,11 @@ class CompactionTriggerTest(unittest.TestCase):
|
|
|
289
289
|
self.assertTrue(did_compact)
|
|
290
290
|
self.assertEqual(index, 1)
|
|
291
291
|
# No system prompt to preserve, so the old turns collapse to one summary
|
|
292
|
-
# turn --
|
|
293
|
-
|
|
292
|
+
# turn -- plus the second (unanswered) user message, which is the turn in
|
|
293
|
+
# flight and must reach the model, not just the summariser.
|
|
294
|
+
self.assertEqual(len(out), 2)
|
|
294
295
|
self.assertIn("A short summary.", out[0]["content"])
|
|
296
|
+
self.assertEqual(out[1], messages[-1])
|
|
295
297
|
self.assertEqual(self.stats["llm_calls"], 1)
|
|
296
298
|
|
|
297
299
|
def test_a_large_transcript_under_the_budget_does_not_compact(self):
|
|
@@ -365,6 +367,52 @@ class CompactionTriggerTest(unittest.TestCase):
|
|
|
365
367
|
self.assertEqual(index, 0)
|
|
366
368
|
self.assertEqual(self.stats["llm_calls"], 0) # no summariser call
|
|
367
369
|
|
|
370
|
+
# -- the turn in flight survives the rollover --------------------------
|
|
371
|
+
|
|
372
|
+
def test_a_fresh_user_message_is_not_summarised_away(self):
|
|
373
|
+
"""The line just typed must reach the model, not only the summariser.
|
|
374
|
+
|
|
375
|
+
Compaction used to return (system, summary) only, so the message the
|
|
376
|
+
next request was supposed to answer was gone: the model answered the
|
|
377
|
+
summary instead and the typed line never appeared again.
|
|
378
|
+
"""
|
|
379
|
+
messages = [{"role": "system", "content": "SYSTEM"},
|
|
380
|
+
{"role": "user", "content": "earlier question"},
|
|
381
|
+
{"role": "assistant", "content": "earlier answer"},
|
|
382
|
+
{"role": "user", "content": "the new instruction"}]
|
|
383
|
+
out, did_compact, _ = self._compact(
|
|
384
|
+
messages, token_limit=1_000, current_tokens=4_000)
|
|
385
|
+
|
|
386
|
+
self.assertTrue(did_compact)
|
|
387
|
+
self.assertEqual([m["role"] for m in out], ["system", "user", "user"])
|
|
388
|
+
self.assertIn("A short summary.", out[1]["content"])
|
|
389
|
+
self.assertEqual(out[2], {"role": "user", "content": "the new instruction"})
|
|
390
|
+
|
|
391
|
+
def test_a_tool_exchange_in_flight_is_carried_over(self):
|
|
392
|
+
"""Compacting mid tool loop keeps the call *and* the result it awaits."""
|
|
393
|
+
messages = [{"role": "system", "content": "SYSTEM"},
|
|
394
|
+
{"role": "user", "content": "read the file"},
|
|
395
|
+
{"role": "assistant", "content": "", "tool_calls": [{"id": "c1"}]},
|
|
396
|
+
{"role": "tool", "tool_call_id": "c1", "content": "file contents"}]
|
|
397
|
+
out, did_compact, _ = self._compact(
|
|
398
|
+
messages, token_limit=1_000, current_tokens=4_000)
|
|
399
|
+
|
|
400
|
+
self.assertTrue(did_compact)
|
|
401
|
+
self.assertEqual([m["role"] for m in out],
|
|
402
|
+
["system", "user", "user", "assistant", "tool"])
|
|
403
|
+
self.assertEqual(out[-1]["content"], "file contents")
|
|
404
|
+
|
|
405
|
+
def test_an_answered_turn_adds_nothing(self):
|
|
406
|
+
"""A transcript ending in an assistant reply has no turn in flight."""
|
|
407
|
+
messages = [{"role": "system", "content": "SYSTEM"},
|
|
408
|
+
{"role": "user", "content": "question"},
|
|
409
|
+
{"role": "assistant", "content": "answer"}]
|
|
410
|
+
out, did_compact, _ = self._compact(
|
|
411
|
+
messages, token_limit=1_000, current_tokens=4_000)
|
|
412
|
+
|
|
413
|
+
self.assertTrue(did_compact)
|
|
414
|
+
self.assertEqual([m["role"] for m in out], ["system", "user"])
|
|
415
|
+
|
|
368
416
|
# -- unchanged behaviour ----------------------------------------------
|
|
369
417
|
|
|
370
418
|
def test_an_explicit_limit_is_honoured(self):
|
|
@@ -389,11 +437,14 @@ class CompactionTriggerTest(unittest.TestCase):
|
|
|
389
437
|
def test_the_plan_hook_still_reinjects_the_goal_and_plan(self):
|
|
390
438
|
out, did_compact, _ = self._compact(
|
|
391
439
|
long_messages(2, 8_000), token_limit=1_000, current_tokens=4_000,
|
|
392
|
-
plan_hook=lambda msgs: msgs.append(
|
|
440
|
+
plan_hook=lambda msgs, tail: msgs.append(
|
|
393
441
|
{"role": "user", "content": "GOAL + PLAN"}))
|
|
394
442
|
|
|
395
443
|
self.assertTrue(did_compact)
|
|
396
|
-
|
|
444
|
+
# The goal/plan context sits between the summary and the live turn, so
|
|
445
|
+
# the user's actual instruction stays the last thing the model reads.
|
|
446
|
+
self.assertEqual(out[1]["content"], "GOAL + PLAN")
|
|
447
|
+
self.assertEqual(out[2]["role"], "user") # the in-flight turn
|
|
397
448
|
|
|
398
449
|
def test_an_empty_summary_keeps_the_transcript_and_the_segment(self):
|
|
399
450
|
messages = long_messages(2, 8_000)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|