Skip to content

Commit 8d8b7ab

Browse files
stdrcCopilot
andauthored
fix(compaction): remove think part from constructed compact message (#447)
Signed-off-by: Richard Chien <[email protected]> Signed-off-by: stdrc <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 31fdb90 commit 8d8b7ab

File tree

4 files changed

+128
-45
lines changed

4 files changed

+128
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Only write entries that are worth mentioning to users.
1717
- Core: Fix startup crash when there is broken symbolic link in the working directory
1818
- Core: Add builtin `okabe` agent file with `SendDMail` tool enabled
1919
- CLI: Add `--agent` option to specify builtin agents like `default` and `okabe`
20+
- Core: Improve compaction logic to better preserve relevant information
2021

2122
## [0.61] - 2025-12-04
2223

src/kimi_cli/prompts/compact.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
You are tasked with compacting a coding conversation context. This is critical for maintaining an effective working memory for the coding agent.
1+
2+
---
3+
4+
The above is a list of messages in an agent conversation. You are now given a task to compact this conversation context according to specific priorities and rules.
25

36
**Compression Priorities (in order):**
47
1. **Current Task State**: What is being worked on RIGHT NOW
@@ -19,10 +22,6 @@ You are tasked with compacting a coding conversation context. This is critical f
1922
- For errors: Keep full error message + final solution
2023
- For discussions: Extract decisions and action items only
2124

22-
**Input Context to Compress:**
23-
24-
${CONTEXT}
25-
2625
**Required Output Structure:**
2726

2827
<current_focus>

src/kimi_cli/soul/compaction.py

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from __future__ import annotations
22

33
from collections.abc import Sequence
4-
from string import Template
5-
from typing import TYPE_CHECKING, Protocol, runtime_checkable
4+
from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
65

76
import kosong
8-
from kosong.message import ContentPart, Message, ThinkPart
7+
from kosong.message import ContentPart, Message, TextPart, ThinkPart
98
from kosong.tooling.empty import EmptyToolset
109

1110
import kimi_cli.prompts as prompts
@@ -33,46 +32,21 @@ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Messa
3332
...
3433

3534

36-
class SimpleCompaction(Compaction):
37-
MAX_PRESERVED_MESSAGES = 2
38-
39-
async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
40-
history = list(messages)
41-
if not history:
42-
return history
35+
if TYPE_CHECKING:
4336

44-
preserve_start_index = len(history)
45-
n_preserved = 0
46-
for index in range(len(history) - 1, -1, -1):
47-
if history[index].role in {"user", "assistant"}:
48-
n_preserved += 1
49-
if n_preserved == self.MAX_PRESERVED_MESSAGES:
50-
preserve_start_index = index
51-
break
37+
def type_check(simple: SimpleCompaction):
38+
_: Compaction = simple
5239

53-
if n_preserved < self.MAX_PRESERVED_MESSAGES:
54-
return history
5540

56-
to_compact = history[:preserve_start_index]
57-
to_preserve = history[preserve_start_index:]
41+
class SimpleCompaction:
42+
def __init__(self, max_preserved_messages: int = 2) -> None:
43+
self.max_preserved_messages = max_preserved_messages
5844

59-
if not to_compact:
60-
# Let's hope this won't exceed the context size limit
45+
async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
46+
compact_message, to_preserve = self.prepare(messages)
47+
if compact_message is None:
6148
return to_preserve
6249

63-
# Convert history to string for the compact prompt
64-
history_text = "\n\n".join(
65-
f"## Message {i + 1}\nRole: {msg.role}\nContent: {msg.content}"
66-
for i, msg in enumerate(to_compact)
67-
)
68-
69-
# Build the compact prompt using string template
70-
compact_template = Template(prompts.COMPACT)
71-
compact_prompt = compact_template.substitute(CONTEXT=history_text)
72-
73-
# Create input message for compaction
74-
compact_message = Message(role="user", content=compact_prompt)
75-
7650
# Call kosong.step to get the compacted context
7751
# TODO: set max completion tokens
7852
logger.debug("Compacting context...")
@@ -100,8 +74,42 @@ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Messa
10074
compacted_messages.extend(to_preserve)
10175
return compacted_messages
10276

77+
class PrepareResult(NamedTuple):
78+
compact_message: Message | None
79+
to_preserve: Sequence[Message]
10380

104-
if TYPE_CHECKING:
81+
def prepare(self, messages: Sequence[Message]) -> PrepareResult:
82+
if not messages or self.max_preserved_messages <= 0:
83+
return self.PrepareResult(compact_message=None, to_preserve=messages)
10584

106-
def type_check(simple: SimpleCompaction):
107-
_: Compaction = simple
85+
history = list(messages)
86+
preserve_start_index = len(history)
87+
n_preserved = 0
88+
for index in range(len(history) - 1, -1, -1):
89+
if history[index].role in {"user", "assistant"}:
90+
n_preserved += 1
91+
if n_preserved == self.max_preserved_messages:
92+
preserve_start_index = index
93+
break
94+
95+
if n_preserved < self.max_preserved_messages:
96+
return self.PrepareResult(compact_message=None, to_preserve=messages)
97+
98+
to_compact = history[:preserve_start_index]
99+
to_preserve = history[preserve_start_index:]
100+
101+
if not to_compact:
102+
# Let's hope this won't exceed the context size limit
103+
return self.PrepareResult(compact_message=None, to_preserve=to_preserve)
104+
105+
# Create input message for compaction
106+
compact_message = Message(role="user", content=[])
107+
for i, msg in enumerate(to_compact):
108+
compact_message.content.append(
109+
TextPart(text=f"## Message {i + 1}\nRole: {msg.role}\nContent:\n")
110+
)
111+
compact_message.content.extend(
112+
part for part in msg.content if not isinstance(part, ThinkPart)
113+
)
114+
compact_message.content.append(TextPart(text="\n" + prompts.COMPACT))
115+
return self.PrepareResult(compact_message=compact_message, to_preserve=to_preserve)

tests/test_simple_compaction.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
from inline_snapshot import snapshot
4+
from kosong.message import Message, TextPart, ThinkPart
5+
6+
import kimi_cli.prompts as prompts
7+
from kimi_cli.soul.compaction import SimpleCompaction
8+
9+
10+
def test_prepare_returns_original_when_not_enough_messages():
11+
messages = [Message(role="user", content=[TextPart(text="Only one message")])]
12+
13+
result = SimpleCompaction(max_preserved_messages=2).prepare(messages)
14+
15+
assert result == snapshot(
16+
SimpleCompaction.PrepareResult(
17+
compact_message=None,
18+
to_preserve=[Message(role="user", content=[TextPart(text="Only one message")])],
19+
)
20+
)
21+
22+
23+
def test_prepare_skips_compaction_with_only_preserved_messages():
24+
messages = [
25+
Message(role="user", content=[TextPart(text="Latest question")]),
26+
Message(role="assistant", content=[TextPart(text="Latest reply")]),
27+
]
28+
29+
result = SimpleCompaction(max_preserved_messages=2).prepare(messages)
30+
31+
assert result == snapshot(
32+
SimpleCompaction.PrepareResult(
33+
compact_message=None,
34+
to_preserve=[
35+
Message(role="user", content=[TextPart(text="Latest question")]),
36+
Message(role="assistant", content=[TextPart(text="Latest reply")]),
37+
],
38+
)
39+
)
40+
41+
42+
def test_prepare_builds_compact_message_and_preserves_tail():
43+
messages = [
44+
Message(role="system", content=[TextPart(text="System note")]),
45+
Message(
46+
role="user",
47+
content=[TextPart(text="Old question"), ThinkPart(think="Hidden thoughts")],
48+
),
49+
Message(role="assistant", content=[TextPart(text="Old answer")]),
50+
Message(role="user", content=[TextPart(text="Latest question")]),
51+
Message(role="assistant", content=[TextPart(text="Latest answer")]),
52+
]
53+
54+
result = SimpleCompaction(max_preserved_messages=2).prepare(messages)
55+
56+
assert result.compact_message == snapshot(
57+
Message(
58+
role="user",
59+
content=[
60+
TextPart(text="## Message 1\nRole: system\nContent:\n"),
61+
TextPart(text="System note"),
62+
TextPart(text="## Message 2\nRole: user\nContent:\n"),
63+
TextPart(text="Old question"),
64+
TextPart(text="## Message 3\nRole: assistant\nContent:\n"),
65+
TextPart(text="Old answer"),
66+
TextPart(text="\n" + prompts.COMPACT),
67+
],
68+
)
69+
)
70+
assert result.to_preserve == snapshot(
71+
[
72+
Message(role="user", content=[TextPart(text="Latest question")]),
73+
Message(role="assistant", content=[TextPart(text="Latest answer")]),
74+
]
75+
)

0 commit comments

Comments
 (0)