Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions helpers/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ async def compress_attention(self, ratio: float = CURRENT_TOPIC_ATTENTION_COMPRE
return True

async def summarize_messages(self, messages: list[Message]):
msg_txt = [m.output_text() for m in messages]
msg_txt = [output_text_for_summary(m.output()) for m in messages]
summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
Expand Down Expand Up @@ -332,7 +332,8 @@ async def summarize(self):
self.summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=self.output_text()
"fw.topic_summary.msg.md",
content=output_text_for_summary(self.output()),
),
)
return self.summary
Expand Down Expand Up @@ -656,6 +657,45 @@ def _stringify_output(output: OutputMessage, ai_label="ai", human_label="human")
return f'{ai_label if output["ai"] else human_label}: {_stringify_content(output["content"])}'


def _stringify_summary_output(
output: OutputMessage, ai_label="ai", human_label="human"
) -> str:
content = output["content"]
if output["ai"] and isinstance(content, str):
content = _assistant_summary_content(content)
return f'{ai_label if output["ai"] else human_label}: {_stringify_content(content)}'


def _assistant_summary_content(content: str) -> str:
"""Render assistant tool envelopes as prose for summary-model inputs.

The stored history remains unchanged. This view intentionally omits reasoning and
tool arguments so utility models do not imitate the agent's JSON protocol or ingest
large command payloads while summarizing a conversation.
"""
from helpers import extract_tools

request = extract_tools.extract_tool_request(content)
if request is None:
return content

try:
tool_name, tool_args = extract_tools.normalize_tool_request(request)
except ValueError:
return content

if tool_name == "response":
for key in ("text", "message"):
response = tool_args.get(key)
if isinstance(response, str) and response.strip():
return response

headline = request.get("headline")
if isinstance(headline, str) and headline.strip():
return f"{headline.strip()} (tool: {tool_name})"
return f"Used tool {tool_name}."


def _stringify_content(content: MessageContent) -> str:
# already a string
if isinstance(content, str):
Expand Down Expand Up @@ -733,6 +773,15 @@ def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human
return "\n".join(_stringify_output(o, ai_label, human_label) for o in messages)


def output_text_for_summary(
messages: list[OutputMessage], ai_label="ai", human_label="human"
) -> str:
"""Return a utility-model transcript without assistant tool-protocol JSON."""
return "\n".join(
_stringify_summary_output(o, ai_label, human_label) for o in messages
)


def clear_responses_provider_state(agent) -> None:
key = getattr(agent, "DATA_NAME_RESPONSES_STATE", "responses_state")
get_data = getattr(agent, "get_data", None)
Expand Down
2 changes: 2 additions & 0 deletions helpers/history.py.dox.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
- `group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]`
- `output_langchain(messages: list[OutputMessage])`
- `output_text(messages: list[OutputMessage], ai_label=..., human_label=...)`
- `output_text_for_summary(messages: list[OutputMessage], ai_label=..., human_label=...)`
- `clear_responses_provider_state(agent) -> None`
- `_merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent`
- `_merge_properties(a: Dict[str, MessageContent], b: Dict[str, MessageContent]) -> Dict[str, MessageContent]`
Expand All @@ -82,6 +83,7 @@
- `Message.from_dict()` normalizes legacy AI Responses metadata through `LLMResult.metadata()` so loaded chats shed transient payloads while unrelated metadata and non-AI tool-result inputs remain intact.
- `output_langchain()` removes leading assistant messages after grouping so provider histories always begin with a user turn; the WebUI greeting remains persisted and displayed but is not sent as an orphaned assistant message.
- `_json_dumps()` emits compact JSON (`","`, `":"` separators) for serialized history and generated non-string user-turn content.
- `output_text_for_summary()` is a non-persistent utility-model view. It renders valid assistant tool envelopes as prose, preserves final response text, and omits reasoning plus tool arguments; `output_text()` remains the lossless text representation used by APIs and backups.
- Observed side-effect areas: filesystem writes, filesystem deletion, model calls, plugin state, settings/state persistence, secret handling.
- Imported dependency areas include: `abc`, `asyncio`, `collections`, `collections.abc`, `enum`, `helpers`, `json`, `langchain_core.messages`, `math`, `plugins._model_config.helpers.model_config`, `typing`, `uuid`.

Expand Down
1 change: 1 addition & 0 deletions plugins/_chat_compaction/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- Store the compacted summary as non-assistant history context so provider-history normalization cannot discard it as an orphaned assistant turn.
- Preserve loaded skill names from `skill_instructions` metadata without copying full skill bodies into compacted summaries.
- Preserve only secret references such as names, aliases, purposes, or storage locations; never preserve secret values.
- Build model-facing compaction input from the summary transcript view so assistant tool-protocol JSON, reasoning, and large tool arguments are not copied into the summarizer prompt; keep the pre-compaction text backup lossless.
- Clear the cached context window after replacing history so stale transcript content is not persisted as active resumable state; the cache rebuilds on the next model turn.
- After replacing local history, clear the active Responses provider continuation while preserving stored response IDs for cleanup.
- Do not discard original context data unless the compaction flow explicitly owns that behavior.
Expand Down
24 changes: 17 additions & 7 deletions plugins/_chat_compaction/helpers/compactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
import models as models_module
from agent import Agent
from helpers import files, tokens
from helpers.history import History, clear_responses_provider_state, output_text
from helpers.history import (
History,
clear_responses_provider_state,
output_text,
output_text_for_summary,
)
from helpers.persist_chat import (
export_json_chat,
get_chat_folder_path,
Expand Down Expand Up @@ -99,12 +104,15 @@ async def run_compaction(
# Step 1: Extract full conversation text
history_output = agent.history.output()
full_text = output_text(history_output, ai_label="assistant", human_label="user")
summary_text = output_text_for_summary(
history_output, ai_label="assistant", human_label="user"
)

if not full_text.strip():
if not summary_text.strip():
raise ValueError("No conversation content to compact")

# Step 2: Estimate tokens, resolve model, and compute context budget
token_count = tokens.approximate_tokens(full_text)
token_count = tokens.approximate_tokens(summary_text)

resolved_cfg, model = _build_model(use_chat_model, preset_name, agent)
ctx_length = int(resolved_cfg.get("ctx_length", 128000)) if resolved_cfg else 128000
Expand All @@ -122,11 +130,11 @@ async def run_compaction(
# Step 4: Handle large histories by chunking if necessary
if token_count > max_input_tokens:
summary = await _compact_large_history(
agent, full_text, token_count, max_input_tokens, log_item, model
agent, summary_text, token_count, max_input_tokens, log_item, model
)
else:
summary = await _compact_single_pass(
agent, full_text, log_item, model
agent, summary_text, log_item, model
)

if not summary or not summary.strip():
Expand Down Expand Up @@ -347,8 +355,10 @@ async def get_compaction_stats(context) -> dict:

# Estimate tokens
history_output = agent.history.output()
full_text = output_text(history_output, ai_label="assistant", human_label="user")
token_count = tokens.approximate_tokens(full_text) if full_text else 0
summary_text = output_text_for_summary(
history_output, ai_label="assistant", human_label="user"
)
token_count = tokens.approximate_tokens(summary_text) if summary_text else 0

# Get model names for both chat and utility
chat_cfg = get_chat_model_config(agent)
Expand Down
62 changes: 62 additions & 0 deletions tests/test_chat_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ def output(self):
return [{"ai": False, "content": "hello"}]


class _StructuredCompactionHistory:
def output(self):
return [
{"ai": False, "content": "Inspect the service."},
{
"ai": True,
"content": (
'{"thoughts":["Read the credentials"],'
'"headline":"Checking service status",'
'"tool_name":"code_execution_tool",'
'"tool_args":{"code":"print(SECRET_VALUE)"}}'
),
},
]


class _CompactionLog:
def __init__(self):
self.logs = []
Expand Down Expand Up @@ -231,3 +247,49 @@ async def fake_single_pass(*args, **kwargs):
assert "previous_response_id" not in state
assert state["response_ids"] == ["resp_previous", "resp_current"]
assert "ctx_window" not in agent.data


@pytest.mark.asyncio
async def test_manual_compaction_summarizes_plain_view_but_backs_up_raw_history(
monkeypatch,
):
captured = {}

async def fake_single_pass(_agent, transcript, *_args, **_kwargs):
captured["summary_transcript"] = transcript
return "summary"

def fake_backup(_context, transcript):
captured["backup_transcript"] = transcript
return {"txt": "/tmp/pre.txt"}

agent = _CompactionAgent()
agent.history = _StructuredCompactionHistory()
context = SimpleNamespace(
id="compact-structured-chat",
agent0=agent,
log=_CompactionLog(),
streaming_agent=object(),
)

monkeypatch.setattr(
compactor,
"_build_model",
lambda *args: ({"ctx_length": 128000}, _RecordingModel()),
)
monkeypatch.setattr(compactor, "_compact_single_pass", fake_single_pass)
monkeypatch.setattr(compactor, "_save_pre_compaction_backup", fake_backup)
monkeypatch.setattr(compactor, "save_tmp_chat", lambda *args: None)
monkeypatch.setattr(compactor, "remove_msg_files", lambda *args: None)
monkeypatch.setattr(compactor, "mark_dirty_all", lambda *args, **kwargs: None)

await compactor.run_compaction(context)

assert captured["summary_transcript"] == (
"user: Inspect the service.\n"
"assistant: Checking service status (tool: code_execution_tool)"
)
assert "Read the credentials" not in captured["summary_transcript"]
assert "SECRET_VALUE" not in captured["summary_transcript"]
assert '"thoughts"' in captured["backup_transcript"]
assert "SECRET_VALUE" in captured["backup_transcript"]
51 changes: 50 additions & 1 deletion tests/test_history.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from helpers.history import output_langchain
from helpers.history import output_langchain, output_text, output_text_for_summary
from langchain_core.messages import AIMessage, HumanMessage


Expand All @@ -12,3 +12,52 @@ def test_output_langchain_omits_leading_assistant_messages():
)

assert messages == [HumanMessage("Hello"), AIMessage("Hi")]


def test_output_text_for_summary_flattens_assistant_tool_protocol():
messages = [
{"ai": False, "content": "Check the database connection."},
{
"ai": True,
"content": (
'{"thoughts":["Inspect credentials"],'
'"headline":"Checking database configuration",'
'"tool_name":"code_execution_tool",'
'"tool_args":{"runtime":"terminal","code":"psql -l"}}'
),
},
]

raw = output_text(messages, ai_label="assistant", human_label="user")
summary = output_text_for_summary(
messages, ai_label="assistant", human_label="user"
)

assert '"thoughts"' in raw
assert '"tool_args"' in raw
assert summary == (
"user: Check the database connection.\n"
"assistant: Checking database configuration (tool: code_execution_tool)"
)
assert "Inspect credentials" not in summary
assert "psql -l" not in summary


def test_output_text_for_summary_keeps_final_response_text():
messages = [
{
"ai": True,
"content": (
'{"thoughts":["Done"],"headline":"Responding",'
'"tool_name":"response","tool_args":{"text":"The fix is ready."}}'
),
}
]

assert output_text_for_summary(messages) == "ai: The fix is ready."


def test_output_text_for_summary_preserves_plain_assistant_content():
messages = [{"ai": True, "content": "A plain assistant response."}]

assert output_text_for_summary(messages) == "ai: A plain assistant response."