Skip to content

Commit 8768159

Browse files
committed
Add a resume message after automatic compaction
The compacted history ended with the original user prompt, which reads as "start here" and invites the model to redo work the summary already records. Append an injected resume message that says the summarised work is done and asks for the unfinished parts only. The manual /compact command does not inject it (it waits for the next user message), the prompt filter drops it from the preserved prompts, and the title/render filters keep it out of session titles and the conversation panel.
1 parent 97b26ba commit 8768159

9 files changed

Lines changed: 168 additions & 28 deletions

File tree

‎python_agent_harness/core/context_manager.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ def need_compaction(self) -> bool:
6565
def compact(self) -> bool:
6666
"""Compact the conversation; return True on success.
6767
68-
On success the history is replaced by the summary frame followed
69-
by every real user prompt (nudges and other harness-injected
70-
messages excluded), so the model keeps the actual requests; the
71-
last prompt is the resume request for the next round.
68+
On success the history is replaced by the summary frame, every
69+
real user prompt (nudges and other harness-injected messages
70+
excluded), and a trailing resume message telling the model what
71+
is already done — the summary and the prompts alone end on a
72+
bare user request, which invites redoing finished work.
7273
"""
7374
loop = self.loop
7475
prompts = user_prompt_texts(loop.messages)
@@ -87,8 +88,10 @@ def compact(self) -> bool:
8788
# stays untouched): it is part of the user turn, never a
8889
# system message. Every real user prompt (nudges and other
8990
# harness-injected messages excluded) is preserved verbatim
90-
# after the frame, so the model keeps the actual requests.
91-
loop.messages = compacted_messages(summary, prompts)
91+
# after the frame, so the model keeps the actual requests,
92+
# and the trailing resume message states that the summarised
93+
# work is done (injected flag: TUI and filters skip it).
94+
loop.messages = compacted_messages(summary, prompts, resume=True)
9295
# The shared conversation now is the compacted one: mirror it
9396
# onto session.last_messages so the TUI (renders from it) and
9497
# a later manual /compact start from the summary, not the old

‎python_agent_harness/prompts/core.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,11 @@ def user_prompt_texts(messages: list) -> list[str]:
500500
(``remember_user_text`` excludes reminders for title generation —
501501
a different purpose, so the filters differ.)
502502
"""
503-
nudge = config.NUDGE_MESSAGE
503+
# Harness-injected one-liners replayed verbatim on every compaction
504+
# would accumulate: the completion nudge, and the resume message the
505+
# automatic compaction appends (a bare "keep going" carries no task
506+
# content — the summary already covers the progress it refers to).
507+
injected = {config.NUDGE_MESSAGE, config.RESUME_MESSAGE}
504508
is_reminder: list[bool] = []
505509
last_reminder = -1
506510
for msg in messages:
@@ -523,7 +527,7 @@ def user_prompt_texts(messages: list) -> list[str]:
523527
if _message_role(msg) != "user":
524528
continue
525529
text = _message_text(msg)
526-
if not text or text == nudge:
530+
if not text or text in injected:
527531
continue
528532
if text.startswith(config.COMPACT_HEADER):
529533
continue
@@ -587,19 +591,29 @@ def compact_summary(
587591
return summary or None
588592

589593

590-
def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
594+
def compacted_messages(summary: str, prompts: list[str], resume: bool = False) -> list[Message]:
591595
"""The post-compaction history: the summary frame as a user message
592596
followed by every preserved user prompt, oldest first.
593597
594598
The summary lives in the user turn (the system prompt is passed
595599
separately and stays untouched); *prompts* are the real user
596600
requests (see ``user_prompt_texts``) that must survive compaction.
601+
602+
*resume* appends ``config.RESUME_MESSAGE`` (flagged ``injected``, so
603+
the TUI and the filters skip it) after the prompts: without it the
604+
rebuilt history ends with a bare user request and the model may redo
605+
work the summary already records. Only the automatic in-loop
606+
compaction passes it — the manual /compact command waits for the
607+
next user message instead.
597608
"""
598609
frame = (config.COMPACT_HEADER + summary + config.COMPACT_SEPARATOR).strip()
599-
return [
610+
messages = [
600611
Message(role="user", content=frame),
601612
*[Message(role="user", content=p) for p in prompts],
602613
]
614+
if resume:
615+
messages.append(Message(role="user", content=config.RESUME_MESSAGE, injected=True))
616+
return messages
603617

604618

605619
# ---- custom agent discovery -----------------------------------------------

‎python_agent_harness/session/config.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@
4444
# ---- compaction -----------------------------------------------------------
4545
COMPACT_HEADER = "**[Compacted Summary]**\n\n"
4646
COMPACT_SEPARATOR = "\n\n---\n\n**[Context compacted]**\n\n---\n\n"
47+
# Appended after the summary frame + preserved prompts on the AUTOMATIC
48+
# path only (AgentLoop -> ContextManager.compact). The rebuilt history
49+
# ends with the original user request, and a bare request reads as "start
50+
# here", so the model tends to redo work the summary already records. The
51+
# manual /compact command does NOT inject it: it replaces the history and
52+
# waits for the next user message.
53+
RESUME_MESSAGE = (
54+
"Compaction just finished; the summary above records the work already done. "
55+
"Do not repeat completed steps or re-run finished tool calls. "
56+
"Review which parts of the request above are already done "
57+
"and continue with the unfinished ones."
58+
)
4759

4860
# ---- token calibration ----------------------------------------------------
4961
CALIBRATION_MIN = 0.5

‎python_agent_harness/session/session.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -670,14 +670,16 @@ def _mode_prompts(self) -> dict[str, str]:
670670
def remember_user_text(self, messages: list) -> None:
671671
"""Remember the last real user message for session-title generation.
672672
673-
Skips harness-injected messages (nudges, plan/build-switch
674-
reminders, queued mode prompts — flagged ``injected``) so a
675-
title is never generated from "Review the original user request
676-
and the Task Completion Rules…" or a mode-switch reminder.
673+
Skips harness-injected messages (nudges, the post-compaction
674+
resume message, plan/build-switch reminders, queued mode prompts
675+
— flagged ``injected``; the content check also covers restored
676+
sessions, where the flag is lost) so a title is never generated
677+
from "Review the original user request and the Task Completion
678+
Rules…" or a mode-switch reminder.
677679
"""
678-
nudge = config.NUDGE_MESSAGE
680+
injected = {config.NUDGE_MESSAGE, config.RESUME_MESSAGE}
679681
for m in reversed(messages):
680-
if m.role == "user" and not m.injected and m.text() != nudge:
682+
if m.role == "user" and not m.injected and m.text() not in injected:
681683
self.store.remember_first_user_message(m.text())
682684
break
683685

‎python_agent_harness/tui/render.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,12 @@ def _is_injected_user_text(text: str) -> bool:
144144
145145
Live sessions flag injected messages directly; these content checks
146146
cover restored sessions, where the flag is lost in the markdown
147-
round-trip: the completion nudge, the <system-reminder>-wrapped
148-
plan/build-switch prompts (plan.md, plan-mode.md, build-switch.md
149-
all start with the reminder tag), and the plan-exit approval notice.
147+
round-trip: the completion nudge, the post-compaction resume message,
148+
the <system-reminder>-wrapped plan/build-switch prompts (plan.md,
149+
plan-mode.md, build-switch.md all start with the reminder tag), and
150+
the plan-exit approval notice.
150151
"""
151-
if text == config.NUDGE_MESSAGE:
152+
if text == config.NUDGE_MESSAGE or text == config.RESUME_MESSAGE:
152153
return True
153154
return _is_mode_reminder_text(text)
154155

‎tests/core/test_agent_loop.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -943,14 +943,18 @@ def fake_estimate(*a, **k):
943943
)
944944
result = loop.run()
945945
self.assertEqual(result, "answer after compaction")
946-
# the history is [summary frame, task one, task two, final
947-
# answer]: the old assistant turn is gone, every real prompt
948-
# survives, and the second compaction (the fake ratio stays
949-
# high for two rounds) did not stack another frame
950-
self.assertEqual(len(loop.messages), 4)
946+
# the history is [summary frame, task one, task two, resume
947+
# message, final answer]: the old assistant turn is gone, every
948+
# real prompt survives, and the automatic compaction appended
949+
# the resume message that stops the model redoing the work
950+
self.assertEqual(len(loop.messages), 5)
951951
self.assertTrue(loop.messages[0].text().startswith("**[Compacted Summary]**"))
952952
self.assertEqual([m.text() for m in loop.messages[1:3]], ["task one", "task two"])
953-
self.assertEqual(loop.messages[3].text(), "answer after compaction")
953+
self.assertEqual(loop.messages[3].text(), config.RESUME_MESSAGE)
954+
self.assertTrue(loop.messages[3].injected)
955+
self.assertEqual(loop.messages[4].text(), "answer after compaction")
956+
# exactly one resume message (a second compaction would stack it)
957+
self.assertEqual(sum(1 for m in loop.messages if m.text() == config.RESUME_MESSAGE), 1)
954958
# the shared history mirrors the compacted conversation
955959
self.assertEqual(
956960
[m.text() for m in session.last_messages], [m.text() for m in loop.messages]
@@ -1593,12 +1597,14 @@ def test_compact_keeps_prompts_excluding_nudges_and_frames(self):
15931597
],
15941598
)
15951599
self.assertTrue(loop.compact())
1596-
self.assertEqual([m.role for m in loop.messages], ["user", "user", "user", "user"])
1600+
self.assertEqual([m.role for m in loop.messages], ["user"] * 5)
15971601
self.assertTrue(loop.messages[0].text().startswith("**[Compacted Summary]**"))
15981602
self.assertEqual(
1599-
[m.text() for m in loop.messages[1:]],
1603+
[m.text() for m in loop.messages[1:4]],
16001604
["first", "second", plan_notice],
16011605
)
1606+
# the automatic path closes with the resume message
1607+
self.assertEqual(loop.messages[4].text(), config.RESUME_MESSAGE)
16021608

16031609
def test_compact_empty_summary_returns_false(self):
16041610
"""A compaction response with no text must not replace the

‎tests/prompts/test_prompts.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_parse_skill_frontmatter,
1919
agent_exclude_tools,
2020
assemble_tool_instructions,
21+
compacted_messages,
2122
discover_agents,
2223
discover_skills,
2324
index_skills,
@@ -406,6 +407,17 @@ def test_plan_exit_notice_is_latest_reminder(self):
406407
]
407408
self.assertEqual(user_prompt_texts(msgs), ["approve the plan", notice])
408409

410+
def test_excludes_resume_message(self):
411+
"""The resume message appended by the automatic compaction is
412+
harness-injected: replaying it as a prompt would stack one copy
413+
per compaction and never carry task content."""
414+
msgs = [
415+
Message(role="user", content="real question"),
416+
Message(role="assistant", content="ok"),
417+
Message(role="user", content=config.RESUME_MESSAGE, injected=True),
418+
]
419+
self.assertEqual(user_prompt_texts(msgs), ["real question"])
420+
409421
def test_excludes_previous_summary_frames(self):
410422
frame = config.COMPACT_HEADER + "old summary" + config.COMPACT_SEPARATOR
411423
msgs = [
@@ -495,6 +507,34 @@ def test_no_image_part_no_note(self):
495507
self.assertEqual(user_prompt_texts(msgs), ["plain text"])
496508

497509

510+
class TestCompactedMessages(unittest.TestCase):
511+
"""The post-compaction history: summary frame, preserved prompts and
512+
(automatic path only) a trailing resume message."""
513+
514+
def test_frame_then_prompts(self):
515+
msgs = compacted_messages("sum", ["one", "two"])
516+
self.assertEqual([m.role for m in msgs], ["user", "user", "user"])
517+
self.assertTrue(msgs[0].text().startswith(config.COMPACT_HEADER))
518+
self.assertIn("sum", msgs[0].text())
519+
self.assertTrue(msgs[0].text().endswith(config.COMPACT_SEPARATOR.strip()))
520+
self.assertEqual([m.text() for m in msgs[1:]], ["one", "two"])
521+
self.assertFalse(any(m.injected for m in msgs))
522+
523+
def test_resume_message_appended_last(self):
524+
"""Without the resume message the history ends on a bare user
525+
request, which invites redoing work the summary records."""
526+
msgs = compacted_messages("sum", ["one"], resume=True)
527+
self.assertEqual([m.text() for m in msgs[1:]], ["one", config.RESUME_MESSAGE])
528+
self.assertTrue(msgs[-1].injected)
529+
self.assertTrue(msgs[-1].role == "user")
530+
531+
def test_resume_message_dropped_by_prompt_filter(self):
532+
"""A second compaction must not carry the resume message over as
533+
a user prompt."""
534+
msgs = compacted_messages("sum", ["one"], resume=True)
535+
self.assertEqual(user_prompt_texts(msgs), ["one"])
536+
537+
498538
class TestAssembleToolInstructions(unittest.TestCase):
499539
"""Tests for assemble_tool_instructions and load_agent_prompt's
500540
{{TOOL_INSTRUCTIONS}} placeholder substitution."""

‎tests/session/test_session.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,36 @@ def test_failed_save_does_not_arm_the_debounce(self):
412412
self.assertEqual(self.session._last_save_at, self.clock)
413413

414414

415+
class TestRememberUserText(unittest.TestCase):
416+
"""The first real user message (title source) must skip harness
417+
one-liners: the completion nudge and the post-compaction resume
418+
message."""
419+
420+
def test_resume_message_not_used_as_title_source(self):
421+
session = RecordingSession()
422+
session.remember_user_text(
423+
[
424+
Message(role="user", content="real request"),
425+
Message(role="assistant", content="ok"),
426+
Message(role="user", content=config.RESUME_MESSAGE, injected=True),
427+
]
428+
)
429+
self.assertEqual(session.store.first_user_message(), "real request")
430+
431+
def test_restored_resume_message_not_used_as_title_source(self):
432+
"""A restored session loses the ``injected`` flag: the content
433+
check must still keep the resume message out of the title."""
434+
session = RecordingSession()
435+
session.remember_user_text(
436+
[
437+
Message(role="user", content="real request"),
438+
Message(role="assistant", content="ok"),
439+
Message(role="user", content=config.RESUME_MESSAGE),
440+
]
441+
)
442+
self.assertEqual(session.store.first_user_message(), "real request")
443+
444+
415445
class TestTitleGeneration(unittest.TestCase):
416446
"""Session-title generation: reasoning preamble stripped (even when
417447
it is not a prefix), failures logged and non-fatal."""
@@ -814,6 +844,23 @@ def test_success_keeps_every_user_prompt(self):
814844
],
815845
)
816846

847+
def test_success_does_not_inject_resume_message(self):
848+
"""The manual command waits for the next user message: injecting
849+
the automatic path's resume message would make the model start
850+
working again on its own. A resume message left by an earlier
851+
automatic compaction is dropped, never carried over."""
852+
session = RecordingSession()
853+
session.tools_enabled = False
854+
session.last_messages = [
855+
Message(role="user", content="first"),
856+
Message(role="assistant", content="ok"),
857+
Message(role="user", content=config.RESUME_MESSAGE, injected=True),
858+
]
859+
ok, _ = session.compact_conversation()
860+
self.assertTrue(ok)
861+
self.assertNotIn(config.RESUME_MESSAGE, [m.text() for m in session.last_messages])
862+
self.assertEqual([m.text() for m in session.last_messages[1:]], ["first"])
863+
817864
def test_success_flushes_past_the_debounce(self):
818865
"""Compaction rewrites the whole conversation, so the save must
819866
bypass the auto_save debounce — otherwise the compacted history

‎tests/tui/test_tui_render.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rich.live import Live
1212

1313
from python_agent_harness.core.models import ImagePart, Message, TextPart, ToolCall
14+
from python_agent_harness.session import config
1415
from tests.support import plan_cleanup # noqa: F401,E402 (side-effect: auto-remove /tmp plan dirs)
1516
from tests.support.tui_test_utils import make_tui
1617

@@ -1081,6 +1082,20 @@ def test_compacted_summary_renders_as_user_message(self):
10811082
self.assertIn("fixed the bug", out)
10821083
self.assertNotIn("**user:**", out)
10831084

1085+
def test_resume_message_not_rendered_as_user_input(self):
1086+
"""The post-compaction resume message is harness-injected: it
1087+
must not show up as something the user typed (live sessions flag
1088+
it, restored sessions fall back to the content check)."""
1089+
tui, buf = make_tui()
1090+
tui.session.last_messages = [
1091+
Message(role="user", content=config.RESUME_MESSAGE, injected=True),
1092+
Message(role="user", content=config.RESUME_MESSAGE),
1093+
]
1094+
tui.console.print(tui._render_frame())
1095+
out = buf.getvalue()
1096+
self.assertNotIn("**user:**", out)
1097+
self.assertNotIn("Do not repeat completed steps", out)
1098+
10841099
def test_lone_surrogates_never_reach_the_terminal(self):
10851100
"""A restored session can carry lone surrogates (the tools read
10861101
non-UTF-8 files with surrogateescape). Rich hands them to the

0 commit comments

Comments
 (0)