feat(compact): preserve task state across compaction
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -273,6 +273,7 @@ async def build_runtime(
|
||||
"read_file_state": [],
|
||||
"invoked_skills": [],
|
||||
"async_agent_state": [],
|
||||
"recent_work_log": [],
|
||||
"compact_checkpoints": [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user