feat(compact): preserve task state across compaction

This commit is contained in:
tjb-tech
2026-04-10 09:53:57 +00:00
parent 76d91fdfa1
commit 3da00089d5
9 changed files with 590 additions and 125 deletions
+3 -1
View File
@@ -42,6 +42,7 @@ from openharness.plugins import load_plugins
from openharness.prompts import build_runtime_system_prompt
from openharness.plugins.installer import install_plugin_from_path, uninstall_plugin
from openharness.services import (
build_post_compact_messages,
compact_conversation,
compact_messages,
estimate_conversation_tokens,
@@ -286,7 +287,7 @@ def create_default_command_registry(
return CommandResult(message="Usage: /compact [PRESERVE_RECENT]")
before = len(context.engine.messages)
try:
compacted = await compact_conversation(
compacted_result = await compact_conversation(
context.engine.messages,
api_client=context.engine.api_client,
model=context.engine.model,
@@ -294,6 +295,7 @@ def create_default_command_registry(
preserve_recent=preserve_recent,
trigger="manual",
)
compacted = build_post_compact_messages(compacted_result)
except Exception:
compacted = compact_messages(context.engine.messages, preserve_recent=preserve_recent)
context.engine.load_messages(compacted)
+47
View File
@@ -45,6 +45,7 @@ AskUserPrompt = Callable[[str], Awaitable[str]]
MAX_TRACKED_READ_FILES = 6
MAX_TRACKED_SKILLS = 8
MAX_TRACKED_ASYNC_AGENT_EVENTS = 8
MAX_TRACKED_WORK_LOG = 10
def _is_prompt_too_long_error(exc: Exception) -> bool:
@@ -163,6 +164,20 @@ def _remember_async_agent_activity(
del bucket[:-MAX_TRACKED_ASYNC_AGENT_EVENTS]
def _remember_work_log(
tool_metadata: dict[str, object] | None,
*,
entry: str,
) -> None:
bucket = _tool_metadata_bucket(tool_metadata, "recent_work_log")
normalized = entry.strip()
if not normalized:
return
bucket.append(normalized[:320])
if len(bucket) > MAX_TRACKED_WORK_LOG:
del bucket[:-MAX_TRACKED_WORK_LOG]
def _update_plan_mode(tool_metadata: dict[str, object] | None, mode: str) -> None:
if tool_metadata is None:
return
@@ -206,6 +221,38 @@ def _record_tool_carryover(
_update_plan_mode(context.tool_metadata, "plan")
elif tool_name == "exit_plan_mode":
_update_plan_mode(context.tool_metadata, "default")
if tool_name == "read_file" and resolved_file_path is not None:
_remember_work_log(
context.tool_metadata,
entry=f"Read file {resolved_file_path}",
)
elif tool_name == "bash":
command = str(tool_input.get("command") or "").strip()
summary = tool_output.splitlines()[0].strip() if tool_output.strip() else "no output"
_remember_work_log(
context.tool_metadata,
entry=f"Ran bash: {command[:160]} [{summary[:120]}]",
)
elif tool_name == "grep":
pattern = str(tool_input.get("pattern") or "").strip()
_remember_work_log(
context.tool_metadata,
entry=f"Searched with grep pattern={pattern[:160]}",
)
elif tool_name == "skill":
_remember_work_log(
context.tool_metadata,
entry=f"Loaded skill {str(tool_input.get('name') or '').strip()}",
)
elif tool_name in {"agent", "send_message"}:
_remember_work_log(
context.tool_metadata,
entry=f"Async agent action via {tool_name}",
)
elif tool_name == "enter_plan_mode":
_remember_work_log(context.tool_metadata, entry="Entered plan mode")
elif tool_name == "exit_plan_mode":
_remember_work_log(context.tool_metadata, entry="Exited plan mode")
async def run_query(
+15 -2
View File
@@ -7,6 +7,11 @@ from typing import Iterable
from openharness.config.paths import get_project_issue_file, get_project_pr_comments_file
from openharness.config.settings import Settings
from openharness.coordinator.coordinator_mode import (
get_coordinator_system_prompt,
get_coordinator_user_context,
is_coordinator_mode,
)
from openharness.memory import find_relevant_memories, load_memory_prompt
from openharness.prompts.claudemd import load_claude_md_prompt
from openharness.prompts.system_prompt import build_system_prompt
@@ -52,7 +57,10 @@ def build_runtime_system_prompt(
extra_plugin_roots: Iterable[str | Path] | None = None,
) -> str:
"""Build the runtime system prompt with project instructions and memory."""
sections = [build_system_prompt(custom_prompt=settings.system_prompt, cwd=str(cwd))]
if is_coordinator_mode():
sections = [get_coordinator_system_prompt()]
else:
sections = [build_system_prompt(custom_prompt=settings.system_prompt, cwd=str(cwd))]
if settings.fast_mode:
sections.append(
@@ -72,9 +80,14 @@ def build_runtime_system_prompt(
extra_plugin_roots=extra_plugin_roots,
settings=settings,
)
if skills_section:
if skills_section and not is_coordinator_mode():
sections.append(skills_section)
coordinator_context = get_coordinator_user_context()
worker_tools_context = coordinator_context.get("workerToolsContext")
if worker_tools_context:
sections.append(f"# Coordinator User Context\n\n{worker_tools_context}")
claude_md = load_claude_md_prompt(cwd)
if claude_md:
sections.append(claude_md)
+2
View File
@@ -1,6 +1,7 @@
"""Service exports."""
from openharness.services.compact import (
build_post_compact_messages,
compact_conversation,
compact_messages,
estimate_conversation_tokens,
@@ -17,6 +18,7 @@ from openharness.services.token_estimation import estimate_message_tokens, estim
__all__ = [
"compact_messages",
"compact_conversation",
"build_post_compact_messages",
"estimate_conversation_tokens",
"estimate_message_tokens",
"estimate_tokens",
+374 -108
View File
@@ -12,7 +12,7 @@ import asyncio
import inspect
import logging
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Literal
from uuid import uuid4
@@ -78,6 +78,31 @@ ERROR_MESSAGE_INCOMPLETE_RESPONSE = "Compaction interrupted before a complete su
CompactTrigger = Literal["auto", "manual", "reactive"]
CompactProgressCallback = Callable[[CompactProgressEvent], Awaitable[None]]
CompactionKind = Literal["full", "session_memory"]
@dataclass
class CompactAttachment:
"""Structured compact asset carried across a compaction boundary."""
kind: str
title: str
body: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class CompactionResult:
"""Structured compaction result, inspired by Claude Code's result shape."""
trigger: CompactTrigger
compact_kind: CompactionKind
boundary_marker: ConversationMessage
summary_messages: list[ConversationMessage]
messages_to_keep: list[ConversationMessage]
attachments: list[CompactAttachment]
hook_results: list[CompactAttachment]
compact_metadata: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
@@ -315,77 +340,251 @@ def _extract_discovered_tools(messages: list[ConversationMessage]) -> list[str]:
return discovered
def build_compact_carryover_message(
def _create_attachment(kind: str, title: str, lines: list[str], *, metadata: dict[str, Any] | None = None) -> CompactAttachment | None:
filtered = [line.rstrip() for line in lines if line and line.strip()]
if not filtered:
return None
return CompactAttachment(
kind=kind,
title=title,
body="\n".join(filtered),
metadata=_sanitize_metadata(metadata or {}),
)
def render_compact_attachment(attachment: CompactAttachment) -> ConversationMessage:
"""Serialize a structured compact attachment into a conversation message."""
header = f"[Compact attachment: {attachment.kind}] {attachment.title}".strip()
text = f"{header}\n{attachment.body}".strip()
return ConversationMessage.from_user_text(text)
def create_compact_boundary_message(metadata: dict[str, Any]) -> ConversationMessage:
"""Create a boundary marker message for post-compact conversation rebuild."""
lines = [
"[Compact boundary marker]",
"Earlier conversation was compacted. Use the summary and preserved assets below as the continuity boundary.",
]
trigger = str(metadata.get("trigger") or "").strip()
compact_kind = str(metadata.get("compact_kind") or "").strip()
pre_messages = metadata.get("pre_compact_message_count")
pre_tokens = metadata.get("pre_compact_token_count")
post_messages = metadata.get("post_compact_message_count")
post_tokens = metadata.get("post_compact_token_count")
if trigger:
lines.append(f"Trigger: {trigger}")
if compact_kind:
lines.append(f"Compaction kind: {compact_kind}")
if pre_messages is not None or pre_tokens is not None:
lines.append(
"Pre-compact footprint: "
f"messages={pre_messages if pre_messages is not None else 'unknown'}, "
f"tokens={pre_tokens if pre_tokens is not None else 'unknown'}"
)
if post_messages is not None or post_tokens is not None:
lines.append(
"Post-compact footprint: "
f"messages={post_messages if post_messages is not None else 'unknown'}, "
f"tokens={post_tokens if post_tokens is not None else 'unknown'}"
)
anchor = str(metadata.get("preserved_segment_anchor") or "").strip()
if anchor:
lines.append(f"Preserved segment anchor: {anchor}")
return ConversationMessage.from_user_text("\n".join(lines))
def build_post_compact_messages(result: CompactionResult) -> list[ConversationMessage]:
"""Rebuild the post-compact message list in Claude Code's ordering."""
attachment_messages = [render_compact_attachment(attachment) for attachment in result.attachments]
hook_messages = [render_compact_attachment(attachment) for attachment in result.hook_results]
return [
result.boundary_marker,
*result.summary_messages,
*result.messages_to_keep,
*attachment_messages,
*hook_messages,
]
def _create_recent_attachments_attachment_if_needed(
attachment_paths: list[str],
) -> CompactAttachment | None:
if not attachment_paths:
return None
return _create_attachment(
"recent_attachments",
"Recent local attachments",
["Keep these local attachment paths in working memory:"] + [f"- {path}" for path in attachment_paths],
metadata={"paths": attachment_paths},
)
def create_recent_files_attachment_if_needed(
read_file_state: Any,
) -> CompactAttachment | None:
if not isinstance(read_file_state, list) or not read_file_state:
return None
lines = ["Recently read files that may still matter:"]
entries: list[dict[str, Any]] = []
for entry in read_file_state[-4:]:
if not isinstance(entry, dict):
continue
path = str(entry.get("path") or "").strip()
span = str(entry.get("span") or "").strip()
preview = str(entry.get("preview") or "").strip()
if not path:
continue
bullet = f"- {path}"
if span:
bullet += f" ({span})"
lines.append(bullet)
if preview:
lines.append(f" Preview: {preview}")
entries.append({"path": path, "span": span, "preview": preview})
return _create_attachment("recent_files", "Recently read files", lines, metadata={"entries": entries})
def create_plan_attachment_if_needed(metadata: dict[str, Any]) -> CompactAttachment | None:
permission_mode = str(metadata.get("permission_mode") or "").strip().lower()
if permission_mode != "plan":
return None
lines = [
"Plan mode is still active for this session.",
"Do not execute mutating tools until the user explicitly exits plan mode.",
]
plan_summary = str(metadata.get("plan_summary") or "").strip()
if plan_summary:
lines.append(f"Current plan summary: {plan_summary}")
return _create_attachment(
"plan",
"Plan mode context",
lines,
metadata={"permission_mode": permission_mode, "plan_summary": plan_summary},
)
def create_invoked_skills_attachment_if_needed(
invoked_skills: Any,
) -> CompactAttachment | None:
if not isinstance(invoked_skills, list) or not invoked_skills:
return None
normalized = [str(skill).strip() for skill in invoked_skills[-8:] if str(skill).strip()]
if not normalized:
return None
return _create_attachment(
"invoked_skills",
"Skills used earlier in the session",
["The following skills were invoked and may still shape the next step:", "- " + ", ".join(normalized)],
metadata={"skills": normalized},
)
def create_async_agent_attachment_if_needed(
async_agent_state: Any,
) -> CompactAttachment | None:
if not isinstance(async_agent_state, list) or not async_agent_state:
return None
entries = [str(entry).strip() for entry in async_agent_state[-6:] if str(entry).strip()]
if not entries:
return None
return _create_attachment(
"async_agents",
"Async agent and background task state",
["Recent async-agent/background-task activity:"] + [f"- {entry}" for entry in entries],
metadata={"entries": entries},
)
def create_work_log_attachment_if_needed(
recent_work_log: Any,
) -> CompactAttachment | None:
if not isinstance(recent_work_log, list) or not recent_work_log:
return None
entries = [str(entry).strip() for entry in recent_work_log[-8:] if str(entry).strip()]
if not entries:
return None
return _create_attachment(
"recent_work_log",
"Recent execution checkpoints",
["Recent work and verification steps taken in this session:"] + [f"- {entry}" for entry in entries],
metadata={"entries": entries},
)
def _create_hook_attachments(hook_note: str | None) -> list[CompactAttachment]:
if not hook_note or not hook_note.strip():
return []
attachment = _create_attachment(
"hook_results",
"Compact hook notes",
[hook_note.strip()],
metadata={"note": hook_note.strip()},
)
return [attachment] if attachment is not None else []
def _build_compact_attachments(
messages: list[ConversationMessage],
*,
metadata: dict[str, Any] | None = None,
hook_note: str | None = None,
) -> ConversationMessage | None:
"""Preserve lightweight runtime context that should survive compaction."""
metadata: dict[str, Any] | None,
) -> list[CompactAttachment]:
metadata = metadata or {}
attachments: list[CompactAttachment] = []
attachment_paths = _extract_attachment_paths(messages)
discovered_tools = _extract_discovered_tools(messages)
permission_mode = str(metadata.get("permission_mode") or "").strip().lower()
read_file_state = metadata.get("read_file_state")
invoked_skills = metadata.get("invoked_skills")
async_agent_state = metadata.get("async_agent_state")
compact_last = metadata.get("compact_last")
builders = [
_create_recent_attachments_attachment_if_needed(attachment_paths),
create_recent_files_attachment_if_needed(metadata.get("read_file_state")),
create_plan_attachment_if_needed(metadata),
create_invoked_skills_attachment_if_needed(metadata.get("invoked_skills")),
create_async_agent_attachment_if_needed(metadata.get("async_agent_state")),
create_work_log_attachment_if_needed(metadata.get("recent_work_log")),
]
attachments.extend(attachment for attachment in builders if attachment is not None)
return attachments
lines: list[str] = []
if permission_mode == "plan":
lines.extend(
[
"Plan mode is still active for this session.",
"Do not execute mutating tools until the user exits plan mode.",
]
)
if attachment_paths:
lines.append("Recent local attachments to keep in mind:")
lines.extend(f"- {path}" for path in attachment_paths)
if discovered_tools:
lines.append("Tools already discovered or used in this session:")
lines.append("- " + ", ".join(discovered_tools))
if isinstance(read_file_state, list) and read_file_state:
lines.append("Recently read files to keep in working memory:")
for entry in read_file_state[-4:]:
if not isinstance(entry, dict):
continue
path = str(entry.get("path") or "").strip()
span = str(entry.get("span") or "").strip()
preview = str(entry.get("preview") or "").strip()
if not path:
continue
bullet = f"- {path}"
if span:
bullet += f" ({span})"
lines.append(bullet)
if preview:
lines.append(f" Preview: {preview}")
if isinstance(invoked_skills, list) and invoked_skills:
lines.append("Skills invoked earlier in the session:")
lines.append("- " + ", ".join(str(skill) for skill in invoked_skills[-8:]))
if isinstance(async_agent_state, list) and async_agent_state:
lines.append("Async agent / background task state:")
lines.extend(f"- {entry}" for entry in async_agent_state[-6:])
if isinstance(compact_last, dict) and compact_last:
checkpoint = str(compact_last.get("checkpoint") or "").strip()
token_count = compact_last.get("token_count")
if checkpoint:
if token_count is not None:
lines.append(
f"Last compact checkpoint: {checkpoint} (token_count={token_count})"
)
else:
lines.append(f"Last compact checkpoint: {checkpoint}")
if hook_note:
lines.append("Compact hook note:")
lines.append(hook_note)
if not lines:
return None
return ConversationMessage.from_user_text(
"Carry-over context preserved after compaction:\n" + "\n".join(lines)
def _finalize_compaction_result(result: CompactionResult) -> CompactionResult:
messages = build_post_compact_messages(result)
result.compact_metadata.setdefault("post_compact_message_count", len(messages))
result.compact_metadata.setdefault("post_compact_token_count", estimate_message_tokens(messages))
result.boundary_marker = create_compact_boundary_message(result.compact_metadata)
return result
def _metadata_has_checkpoint(metadata: dict[str, Any] | None, checkpoint: str) -> bool:
if metadata is None:
return False
checkpoints = metadata.get("compact_checkpoints")
if not isinstance(checkpoints, list):
return False
return any(isinstance(entry, dict) and entry.get("checkpoint") == checkpoint for entry in checkpoints)
def _build_passthrough_compaction_result(
messages: list[ConversationMessage],
*,
trigger: CompactTrigger,
compact_kind: CompactionKind,
metadata: dict[str, Any] | None = None,
) -> CompactionResult:
compact_metadata = {
"trigger": trigger,
"compact_kind": compact_kind,
"pre_compact_message_count": len(messages),
"pre_compact_token_count": estimate_message_tokens(messages),
**_sanitize_metadata(metadata or {}),
}
result = CompactionResult(
trigger=trigger,
compact_kind=compact_kind,
boundary_marker=create_compact_boundary_message(compact_metadata),
summary_messages=[],
messages_to_keep=list(messages),
attachments=[],
hook_results=[],
compact_metadata=compact_metadata,
)
return _finalize_compaction_result(result)
# ---------------------------------------------------------------------------
@@ -493,7 +692,9 @@ def try_session_memory_compaction(
messages: list[ConversationMessage],
*,
preserve_recent: int = SESSION_MEMORY_KEEP_RECENT,
) -> list[ConversationMessage] | None:
trigger: CompactTrigger = "auto",
metadata: dict[str, Any] | None = None,
) -> CompactionResult | None:
"""Cheap deterministic compaction for long chats before full LLM compaction."""
if len(messages) <= preserve_recent + 4:
return None
@@ -502,13 +703,33 @@ def try_session_memory_compaction(
summary_message = _build_session_memory_message(older)
if summary_message is None:
return None
result = [summary_message, *newer]
provisional = [summary_message, *newer]
if (
estimate_message_tokens(result) >= estimate_message_tokens(messages)
and len(result) >= len(messages)
estimate_message_tokens(provisional) >= estimate_message_tokens(messages)
and len(provisional) >= len(messages)
):
return None
return result
compact_metadata = {
"trigger": trigger,
"compact_kind": "session_memory",
"pre_compact_message_count": len(messages),
"pre_compact_token_count": estimate_message_tokens(messages),
"preserve_recent": preserve_recent,
"used_session_memory": True,
"pre_compact_discovered_tools": _extract_discovered_tools(older),
"attachments": _extract_attachment_paths(older),
}
result = CompactionResult(
trigger=trigger,
compact_kind="session_memory",
boundary_marker=create_compact_boundary_message(compact_metadata),
summary_messages=[summary_message],
messages_to_keep=list(newer),
attachments=_build_compact_attachments(older, metadata=metadata),
hook_results=[],
compact_metadata=compact_metadata,
)
return _finalize_compaction_result(result)
# ---------------------------------------------------------------------------
@@ -668,7 +889,7 @@ async def compact_conversation(
emit_hooks_start: bool = True,
hook_executor: HookExecutor | None = None,
carryover_metadata: dict[str, Any] | None = None,
) -> list[ConversationMessage]:
) -> CompactionResult:
"""Compact messages by calling the LLM to produce a summary.
1. Microcompact first (cheap token reduction).
@@ -686,12 +907,17 @@ async def compact_conversation(
suppress_follow_up: If True, instruct the model not to ask follow-ups.
Returns:
The new compacted message list.
Structured compaction result that can be rebuilt into post-compact messages.
"""
from openharness.api.client import ApiMessageRequest, ApiMessageCompleteEvent
if len(messages) <= preserve_recent:
return list(messages)
return _build_passthrough_compaction_result(
messages,
trigger=trigger,
compact_kind="full",
metadata={"reason": "conversation already within preserve_recent window"},
)
# Step 1: microcompact to reduce tokens cheaply
messages, tokens_freed = microcompact_messages(messages, keep_recent=DEFAULT_KEEP_RECENT)
@@ -761,7 +987,12 @@ async def compact_conversation(
checkpoint="compact_failed",
metadata=failed_checkpoint,
)
return messages
return _build_passthrough_compaction_result(
messages,
trigger=trigger,
compact_kind="full",
metadata={"reason": reason},
)
compact_start_checkpoint = _record_compact_checkpoint(
carryover_metadata,
checkpoint="compact_start",
@@ -891,7 +1122,12 @@ async def compact_conversation(
),
)
log.warning("Compact summary was empty — returning original messages")
return messages
return _build_passthrough_compaction_result(
messages,
trigger=trigger,
compact_kind="full",
metadata={"reason": ERROR_MESSAGE_INCOMPLETE_RESPONSE},
)
# Step 4: build the new message list
summary_content = build_compact_summary_message(
@@ -900,17 +1136,7 @@ async def compact_conversation(
recent_preserved=len(newer) > 0,
)
summary_msg = ConversationMessage.from_user_text(summary_content)
carryover_msg = build_compact_carryover_message(
older,
metadata=carryover_metadata,
)
result = [summary_msg]
if carryover_msg is not None:
result.append(carryover_msg)
result.extend(newer)
post_compact_tokens = estimate_message_tokens(result)
post_hook_result = None
initial_post_compact_tokens = estimate_message_tokens([summary_msg, *newer])
if hook_executor is not None:
post_hook_result = await hook_executor.execute(
HookEvent.POST_COMPACT,
@@ -919,9 +1145,9 @@ async def compact_conversation(
"trigger": trigger,
"model": model,
"pre_compact_message_count": len(messages),
"post_compact_message_count": len(result),
"post_compact_message_count": len(newer) + 1,
"pre_compact_tokens": pre_compact_tokens,
"post_compact_tokens": post_compact_tokens,
"post_compact_tokens": initial_post_compact_tokens,
"attachments": attachment_paths,
"discovered_tools": discovered_tools,
**(carryover_metadata or {}),
@@ -932,20 +1158,51 @@ async def compact_conversation(
for result in post_hook_result.results
if result.output.strip()
)
if hook_note:
carryover_msg = build_compact_carryover_message(
older,
metadata=carryover_metadata,
hook_note=hook_note,
)
result = [summary_msg]
if carryover_msg is not None:
result.append(carryover_msg)
result.extend(newer)
post_compact_tokens = estimate_message_tokens(result)
hook_attachments = _create_hook_attachments(hook_note)
else:
hook_attachments = []
compact_metadata = {
"trigger": trigger,
"compact_kind": "full",
"pre_compact_message_count": len(messages),
"pre_compact_token_count": pre_compact_tokens,
"preserve_recent": preserve_recent,
"tokens_freed_by_microcompact": tokens_freed,
"pre_compact_discovered_tools": discovered_tools,
"used_head_truncation_retry": ptl_retries > 0,
"used_context_collapse": _metadata_has_checkpoint(carryover_metadata, "query_context_collapse_end"),
"used_session_memory": False,
"retry_attempts": max(0, attempt - 1 if "attempt" in locals() else 0),
"attachments": attachment_paths,
}
if carryover_metadata is not None:
checkpoints = carryover_metadata.get("compact_checkpoints")
if isinstance(checkpoints, list):
compact_metadata["compact_checkpoints"] = checkpoints
compact_last = carryover_metadata.get("compact_last")
if isinstance(compact_last, dict):
compact_metadata["compact_last"] = compact_last
compaction_result = CompactionResult(
trigger=trigger,
compact_kind="full",
boundary_marker=create_compact_boundary_message(compact_metadata),
summary_messages=[summary_msg],
messages_to_keep=list(newer),
attachments=_build_compact_attachments(older, metadata=carryover_metadata),
hook_results=hook_attachments,
compact_metadata=compact_metadata,
)
compaction_result = _finalize_compaction_result(compaction_result)
post_compact_messages = build_post_compact_messages(compaction_result)
post_compact_tokens = estimate_message_tokens(post_compact_messages)
compaction_result.compact_metadata["post_compact_message_count"] = len(post_compact_messages)
compaction_result.compact_metadata["post_compact_token_count"] = post_compact_tokens
compaction_result.boundary_marker = create_compact_boundary_message(compaction_result.compact_metadata)
log.info(
"Compaction done: %d -> %d messages, ~%d -> ~%d tokens (saved ~%d)",
len(messages), len(result),
len(messages), len(post_compact_messages),
pre_compact_tokens, post_compact_tokens,
pre_compact_tokens - post_compact_tokens,
)
@@ -959,11 +1216,11 @@ async def compact_conversation(
carryover_metadata,
checkpoint="compact_end",
trigger=trigger,
message_count=len(result),
message_count=len(post_compact_messages),
token_count=post_compact_tokens,
details={
"pre_compact_message_count": len(messages),
"post_compact_message_count": len(result),
"post_compact_message_count": len(post_compact_messages),
"pre_compact_tokens": pre_compact_tokens,
"post_compact_tokens": post_compact_tokens,
"tokens_saved": pre_compact_tokens - post_compact_tokens,
@@ -972,7 +1229,7 @@ async def compact_conversation(
},
),
)
return result
return compaction_result
# ---------------------------------------------------------------------------
@@ -1061,7 +1318,12 @@ async def auto_compact_if_needed(
if not force and not should_autocompact(messages, model, state):
return messages, True
session_memory = try_session_memory_compaction(messages, preserve_recent=max(preserve_recent, SESSION_MEMORY_KEEP_RECENT))
session_memory = try_session_memory_compaction(
messages,
preserve_recent=max(preserve_recent, SESSION_MEMORY_KEEP_RECENT),
trigger=trigger,
metadata=carryover_metadata,
)
if session_memory is not None:
await _emit_progress(
progress_callback,
@@ -1087,15 +1349,15 @@ async def auto_compact_if_needed(
carryover_metadata,
checkpoint="query_session_memory_end",
trigger=trigger,
message_count=len(session_memory),
token_count=estimate_message_tokens(session_memory),
message_count=len(build_post_compact_messages(session_memory)),
token_count=estimate_message_tokens(build_post_compact_messages(session_memory)),
),
)
state.compacted = True
state.turn_counter += 1
state.turn_id = uuid4().hex
state.consecutive_failures = 0
return session_memory, True
return build_post_compact_messages(session_memory), True
# Full compact needed
try:
@@ -1115,7 +1377,7 @@ async def auto_compact_if_needed(
state.turn_counter += 1
state.turn_id = uuid4().hex
state.consecutive_failures = 0
return result, True
return build_post_compact_messages(result), True
except Exception as exc:
state.consecutive_failures += 1
_record_compact_checkpoint(
@@ -1180,12 +1442,16 @@ def compact_messages(
__all__ = [
"AUTO_COMPACT_BUFFER_TOKENS",
"AutoCompactState",
"CompactAttachment",
"CompactionResult",
"COMPACTABLE_TOOLS",
"TIME_BASED_MC_CLEARED_MESSAGE",
"auto_compact_if_needed",
"build_post_compact_messages",
"build_compact_summary_message",
"compact_conversation",
"compact_messages",
"create_compact_boundary_message",
"estimate_conversation_tokens",
"estimate_message_tokens",
"format_compact_summary",
+1
View File
@@ -273,6 +273,7 @@ async def build_runtime(
"read_file_state": [],
"invoked_skills": [],
"async_agent_state": [],
"recent_work_log": [],
"compact_checkpoints": [],
},
)
+66 -1
View File
@@ -10,9 +10,10 @@ import pytest
from openharness.api.client import ApiMessageCompleteEvent, ApiRetryEvent, ApiTextDeltaEvent
from openharness.api.errors import RequestFailure
from openharness.api.usage import UsageSnapshot
from openharness.config.settings import PermissionSettings
from openharness.config.settings import PermissionSettings, Settings
from openharness.engine.messages import ConversationMessage, TextBlock, ToolUseBlock
from openharness.engine.query_engine import QueryEngine
from openharness.prompts.context import build_runtime_system_prompt
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
@@ -102,6 +103,42 @@ class PromptTooLongThenSuccessApiClient:
)
class CoordinatorLoopApiClient:
def __init__(self) -> None:
self.requests = []
self._calls = 0
async def stream_message(self, request):
self.requests.append(request)
self._calls += 1
if self._calls == 1:
yield ApiMessageCompleteEvent(
message=ConversationMessage(
role="assistant",
content=[
TextBlock(text="Launching a worker."),
ToolUseBlock(
id="toolu_agent_1",
name="agent",
input={
"description": "inspect coordinator wiring",
"prompt": "check whether coordinator mode is active",
"subagent_type": "worker",
},
),
],
),
usage=UsageSnapshot(input_tokens=2, output_tokens=2),
stop_reason=None,
)
return
yield ApiMessageCompleteEvent(
message=ConversationMessage(role="assistant", content=[TextBlock(text="Worker launched; coordinator mode is active.")]),
usage=UsageSnapshot(input_tokens=2, output_tokens=2),
stop_reason=None,
)
@pytest.mark.asyncio
async def test_query_engine_plain_text_reply(tmp_path: Path):
engine = QueryEngine(
@@ -182,6 +219,34 @@ async def test_query_engine_executes_tool_calls(tmp_path: Path):
assert len(engine.messages) == 4
@pytest.mark.asyncio
async def test_query_engine_coordinator_mode_uses_coordinator_prompt_and_runs_agent_loop(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("CLAUDE_CODE_COORDINATOR_MODE", "1")
api_client = CoordinatorLoopApiClient()
system_prompt = build_runtime_system_prompt(Settings(), cwd=tmp_path, latest_user_prompt="investigate issue")
engine = QueryEngine(
api_client=api_client,
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(PermissionSettings()),
cwd=tmp_path,
model="claude-test",
system_prompt=system_prompt,
)
events = [event async for event in engine.submit_message("investigate issue")]
assert len(api_client.requests) == 2
assert "You are a **coordinator**." in api_client.requests[0].system_prompt
assert "Coordinator User Context" in api_client.requests[0].system_prompt
assert any(isinstance(event, ToolExecutionStarted) and event.tool_name == "agent" for event in events)
agent_results = [event for event in events if isinstance(event, ToolExecutionCompleted) and event.tool_name == "agent"]
assert len(agent_results) == 1
assert isinstance(events[-1], AssistantTurnComplete)
assert "coordinator mode is active" in events[-1].message.text
@pytest.mark.asyncio
async def test_query_engine_allows_unbounded_turns_when_max_turns_is_none(tmp_path: Path):
sample = tmp_path / "hello.txt"
+27
View File
@@ -67,3 +67,30 @@ def test_build_runtime_system_prompt_includes_project_context_and_fast_mode(tmp_
assert "Need to fix flaky test" in prompt
assert "Pull Request Comments" in prompt
assert "Please simplify this branch" in prompt
def test_build_runtime_system_prompt_uses_coordinator_prompt_when_enabled(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("CLAUDE_CODE_COORDINATOR_MODE", "1")
repo = tmp_path / "repo"
repo.mkdir()
prompt = build_runtime_system_prompt(Settings(), cwd=repo, latest_user_prompt="investigate")
assert "You are a **coordinator**." in prompt
assert "Coordinator User Context" in prompt
assert "Workers spawned via the agent tool have access to these tools" in prompt
assert "Environment" not in prompt
def test_build_runtime_system_prompt_skips_coordinator_context_when_disabled(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
monkeypatch.delenv("CLAUDE_CODE_COORDINATOR_MODE", raising=False)
repo = tmp_path / "repo"
repo.mkdir()
prompt = build_runtime_system_prompt(Settings(), cwd=repo, latest_user_prompt="investigate")
assert "Coordinator User Context" not in prompt
assert "You are a **coordinator**." not in prompt
assert "Environment" in prompt
+55 -13
View File
@@ -11,6 +11,7 @@ from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolUseBlock
from openharness.hooks import HookEvent
from openharness.services import (
build_post_compact_messages,
compact_conversation,
compact_messages,
estimate_conversation_tokens,
@@ -91,8 +92,10 @@ def test_try_session_memory_compaction_reduces_long_history():
result = try_session_memory_compaction(messages)
assert result is not None
assert len(result) < len(messages)
assert "Session memory summary" in result[0].text
rebuilt = build_post_compact_messages(result)
assert len(rebuilt) < len(messages)
assert rebuilt[0].text.startswith("[Compact boundary marker]")
assert any("Session memory summary" in message.text for message in rebuilt)
def test_try_context_collapse_trims_oversized_messages():
@@ -131,7 +134,9 @@ async def test_compact_conversation_retries_after_incomplete_response():
model="claude-test",
)
assert compacted[0].text.startswith("This session is being continued")
rebuilt = build_post_compact_messages(compacted)
assert rebuilt[0].text.startswith("[Compact boundary marker]")
assert any(message.text.startswith("This session is being continued") for message in rebuilt)
@pytest.mark.asyncio
@@ -175,20 +180,57 @@ async def test_compact_conversation_runs_hooks_and_preserves_carryover_state(tmp
],
"invoked_skills": ["pikastream-video-meeting"],
"async_agent_state": ["Spawned async agent [task_id=task_123]"],
"recent_work_log": ["Ran pytest -q tests/test_compact.py [41 passed]"],
"compact_last": {"checkpoint": "query_auto_triggered", "token_count": 12345},
},
)
assert [event for event, _payload in hook_executor.events] == [HookEvent.PRE_COMPACT, HookEvent.POST_COMPACT]
assert compacted[0].text.startswith("This session is being continued")
assert "Carry-over context preserved after compaction" in compacted[1].text
assert "Plan mode is still active" in compacted[1].text
assert str(image_path) in compacted[1].text
assert "read_file" in compacted[1].text
assert "Recently read files" in compacted[1].text
assert "Skills invoked earlier" in compacted[1].text
assert "Async agent / background task state" in compacted[1].text
assert "Last compact checkpoint" in compacted[1].text
rebuilt = build_post_compact_messages(compacted)
joined = "\n\n".join(message.text for message in rebuilt)
assert rebuilt[0].text.startswith("[Compact boundary marker]")
assert any(message.text.startswith("This session is being continued") for message in rebuilt)
assert "[Compact attachment: plan]" in joined
assert "Plan mode is still active" in joined
assert str(image_path) in joined
assert "[Compact attachment: recent_files]" in joined
assert "Recently read files" in joined
assert "[Compact attachment: invoked_skills]" in joined
assert "[Compact attachment: async_agents]" in joined
assert "[Compact attachment: recent_work_log]" in joined
assert "41 passed" in joined
@pytest.mark.asyncio
async def test_compact_post_messages_keep_boundary_summary_recent_then_attachments():
messages = [
ConversationMessage(role="user", content=[TextBlock(text="first")]),
ConversationMessage(role="assistant", content=[TextBlock(text="second")]),
ConversationMessage(role="user", content=[TextBlock(text="third")]),
ConversationMessage(role="assistant", content=[TextBlock(text="fourth")]),
ConversationMessage(role="user", content=[TextBlock(text="fifth")]),
ConversationMessage(role="assistant", content=[TextBlock(text="sixth")]),
ConversationMessage(role="user", content=[TextBlock(text="seventh")]),
]
compacted = await compact_conversation(
messages,
api_client=_CompactApiClient(["<summary>condensed</summary>"]),
model="claude-test",
preserve_recent=2,
carryover_metadata={
"read_file_state": [{"path": "/tmp/demo.py", "span": "lines 1-20", "preview": "print('hi')"}],
"recent_work_log": ["Ran pytest -q tests/test_services/test_compact.py [ok]"],
},
)
rebuilt = build_post_compact_messages(compacted)
assert rebuilt[0].text.startswith("[Compact boundary marker]")
assert rebuilt[1].text.startswith("This session is being continued")
assert rebuilt[2].text == "sixth"
assert rebuilt[3].text == "seventh"
assert rebuilt[4].text.startswith("[Compact attachment:")
@pytest.mark.asyncio
@@ -216,7 +258,7 @@ async def test_auto_compact_records_richer_checkpoint_metadata(monkeypatch):
)
assert was_compacted is True
assert result[0].text.startswith("This session is being continued")
assert result[0].text.startswith("[Compact boundary marker]")
checkpoints = metadata.get("compact_checkpoints")
assert isinstance(checkpoints, list)
checkpoint_names = [entry["checkpoint"] for entry in checkpoints]