From f3d8ff11d9f365bfa73f2f80433892cab3f180b7 Mon Sep 17 00:00:00 2001 From: Concedo <39025047+LostRuins@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:04:18 +0800 Subject: [PATCH] add session compaction --- kcpp_agent.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/kcpp_agent.py b/kcpp_agent.py index d68606bfd..26e27901a 100644 --- a/kcpp_agent.py +++ b/kcpp_agent.py @@ -185,7 +185,7 @@ Rules: - Never claim a tool succeeded unless you received a successful tool result. - If a tool result ends with a truncation marker, do not treat it as complete; make narrower follow-up calls to retrieve what you still need. - Keep tool calls simple and make only the calls necessary for the user's request. -- Paths may be relative or absolute. Relative paths are relative to the directory where this program was started. +- Paths may be relative or absolute. Relative paths are relative to the current working directory. - The current working directory is {Path.cwd()}. - The shell tool uses {SHELL_DESCRIPTION}; write commands using that shell's syntax. - For edit, replace an exact old_text string with new_text. If the old text is not unique, the edit will fail unless replace_all is true. @@ -844,10 +844,11 @@ def chat_completion( payload = { "model": model, "messages": messages, - "tools": tools, - "tool_choice": "auto", "temperature": temperature, } + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" if max_tokens is not None: payload["max_tokens"] = max_tokens @@ -1076,6 +1077,9 @@ def print_runtime_help( "\n" + color("Runtime commands:", ANSI_BOLD_CYAN) + "\n" " /help Show this help\n" " /clear Clear history and refresh MCP tools\n" + " /compact Summarize history to save context space\n" + " /workdir Show the current working directory\n" + " /workdir PATH Change directory and clear the session\n" " /confirm Show confirmation status\n" " /confirm on Require approval for every tool call\n" " /confirm off Auto-approve tool calls\n" @@ -1091,6 +1095,7 @@ def print_runtime_help( f"\nConfirmation is currently {confirmation}.\n" f"Reasoning display is currently {reasoning}.\n" f"Verbose tool display is currently {verbosity}.\n" + f"Working directory: {Path.cwd()}\n" ) @@ -1105,6 +1110,51 @@ def reasoning_text(message: dict[str, Any]) -> str: return "" +def compact_session( + messages: list[dict[str, Any]], + base_url: str, + api_key: str, + model: str, + temperature: float, + max_tokens: int | None, + request_timeout: int, +) -> str: + """Summarize the complete conversation without changing it on failure.""" + summary_request = [ + *messages, + { + "role": "user", + "content": ( + "Summarize this session for your future self so you can continue the work " + "with the earlier messages removed. Be concise and accurate. Include the " + "overall and current goals, decisions, completed work, important findings " + "and file paths, and remaining steps or blockers. Preserve details needed " + "to act; do not invent progress. Return only the summary." + ), + }, + ] + response = chat_completion( + base_url=base_url, + api_key=api_key, + model=model, + messages=summary_request, + tools=[], + temperature=temperature, + max_tokens=max_tokens, + request_timeout=request_timeout, + ) + try: + choice = response["choices"][0] + summary = choice["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise APIResponseError("summary response was malformed") from exc + if choice.get("finish_reason") == "length": + raise APIResponseError("summary was cut off by the output token limit") + if not isinstance(summary, str) or not summary.strip(): + raise APIResponseError("model returned an empty summary") + return summary.strip() + + def run_agent( base_url: str, api_key: str, @@ -1157,6 +1207,7 @@ def run_agent( print(color("Model:", ANSI_CYAN) + f" {model}") print(color("Endpoint:", ANSI_CYAN) + f" {base_url}") + print(color("Working directory:", ANSI_CYAN) + f" {Path.cwd()}") if max_tokens is not None: print(color("Max output tokens:", ANSI_CYAN) + f" {max_tokens}") confirmation = "OFF (--yes)" if auto_approve else "ON" @@ -1188,6 +1239,48 @@ def run_agent( refresh_mcp_tools() print("Conversation cleared.\n") continue + if command == "/compact": + if command_arg: + print("Usage: /compact\n") + continue + if len(messages) == 1: + print("Nothing to compact.\n") + continue + try: + with Throbber("Summarizing session"): + summary = compact_session( + messages, base_url, api_key, model, temperature, + max_tokens, request_timeout, + ) + except (EndpointUnavailableError, APIResponseError) as exc: + print(f"Compaction failed: {exc}. Conversation unchanged.\n") + continue + messages[:] = [ + {"role": "system", "content": system_prompt()}, + {"role": "assistant", "content": f"Summary of the earlier session:\n{summary}"}, + ] + print(f"Session compacted:\n{summary}\n") + continue + if command == "/workdir": + if not command_arg: + print(f"Working directory: {Path.cwd()}\n") + continue + requested = command_arg + if len(requested) >= 2 and requested[0] == requested[-1] and requested[0] in "\"'": + requested = requested[1:-1] + target = Path(requested).expanduser() + try: + if not target.is_dir(): + raise NotADirectoryError(f"Not a directory: {target}") + os.chdir(target) + except OSError as exc: + print(f"Cannot change working directory: {exc}\n") + continue + messages[:] = [{"role": "system", "content": system_prompt()}] + refresh_mcp_tools() + print(f"Working directory: {Path.cwd()}") + print("Conversation cleared (/clear fresh session).\n") + continue if command == "/confirm": setting = command_arg.lower() if not setting: @@ -1412,8 +1505,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--temperature", type=temperature_value, - default=0.3, - help="Sampling temperature (default: 0.3)", + default=0.4, + help="Sampling temperature (default: 0.4)", ) parser.add_argument( "--max-tool-result-chars",