From 254ca8cbda3a01bc14a50c079d7c66f656998c52 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 22 Feb 2026 09:54:36 -0500 Subject: [PATCH] Doing final pass of adjustments with anythingllm --- MainContext.md | 4 +-- ai_client.py | 87 +++++++++++++++++++++++++++++++++++-------------- gui.py | 5 ++- mcp_client.py | 9 +++-- shell_runner.py | 3 +- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/MainContext.md b/MainContext.md index 9971b28..2833022 100644 --- a/MainContext.md +++ b/MainContext.md @@ -78,7 +78,7 @@ Is a local GUI tool for manually curating and sending context to AI APIs. It agg **AI Tool Use (PowerShell):** - Both Gemini and Anthropic are configured with a `run_powershell` tool/function declaration - When the AI wants to edit or create files it emits a tool call with a `script` string -- `ai_client` runs a loop (max `MAX_TOOL_ROUNDS = 5`) feeding tool results back until the AI stops calling tools +- `ai_client` runs a loop (max `MAX_TOOL_ROUNDS = 10`) feeding tool results back until the AI stops calling tools - Before any script runs, `gui.py` shows a modal `ConfirmDialog` on the main thread; the background send thread blocks on a `threading.Event` until the user clicks Approve or Reject - The dialog displays `base_dir`, shows the script in an editable text box (allowing last-second tweaks), and has Approve & Run / Reject buttons - On approval the (possibly edited) script is passed to `shell_runner.run_powershell()` which prepends `Set-Location -LiteralPath ''` and runs it via `powershell -NoProfile -NonInteractive -Command` @@ -192,7 +192,7 @@ Entry layout: index + timestamp + direction + kind + provider/model header row, - Add more providers by adding a section to `credentials.toml`, a `_list_*` and `_send_*` function in `ai_client.py`, and the provider name to the `PROVIDERS` list in `gui.py` - System prompt support could be added as a field in the project `.toml` and passed in `ai_client.send()` - Discussion history excerpts could be individually toggleable for inclusion in the generated md -- `MAX_TOOL_ROUNDS` in `ai_client.py` caps agentic loops at 5 rounds; adjustable +- `MAX_TOOL_ROUNDS` in `ai_client.py` caps agentic loops at 10 rounds; adjustable - `COMMS_CLAMP_CHARS` in `gui.py` controls the character threshold for clamping heavy payload fields in the Comms History panel - Additional project metadata (description, tags, created date) could be added to `[project]` in the per-project toml diff --git a/ai_client.py b/ai_client.py index f0c47d1..8655c79 100644 --- a/ai_client.py +++ b/ai_client.py @@ -23,6 +23,7 @@ _model: str = "gemini-2.0-flash" _gemini_client = None _gemini_chat = None +_gemini_cache = None _anthropic_client = None _anthropic_history: list[dict] = [] @@ -194,10 +195,16 @@ def set_provider(provider: str, model: str): def reset_session(): - global _gemini_client, _gemini_chat + global _gemini_client, _gemini_chat, _gemini_cache global _anthropic_client, _anthropic_history + if _gemini_client and _gemini_cache: + try: + _gemini_client.caches.delete(name=_gemini_cache.name) + except Exception: + pass _gemini_client = None _gemini_chat = None + _gemini_cache = None _anthropic_client = None _anthropic_history = [] file_cache.reset_client() @@ -421,19 +428,54 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: try: _ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir]) sys_instr = f"{_get_combined_system_prompt()}\n\n\n{md_content}\n" + tools_decl = [_gemini_tool_declaration()] + + global _gemini_cache if not _gemini_chat: - _gemini_chat = _gemini_client.chats.create(model=_model, config=types.GenerateContentConfig(system_instruction=sys_instr, tools=[_gemini_tool_declaration()])) + chat_config = types.GenerateContentConfig(system_instruction=sys_instr, tools=tools_decl) + try: + # Gemini requires >= 32,768 tokens for caching. We try to cache, and fallback if it fails. + _gemini_cache = _gemini_client.caches.create( + model=_model, + config=types.CreateCachedContentConfig( + system_instruction=sys_instr, + tools=tools_decl, + ttl="3600s", + ) + ) + chat_config = types.GenerateContentConfig(cached_content=_gemini_cache.name) + _append_comms("OUT", "request", {"message": f"[CACHE CREATED] {_gemini_cache.name}"}) + except Exception as e: + # Fallback to standard request if under 32k tokens or cache creation fails + pass + + _gemini_chat = _gemini_client.chats.create(model=_model, config=chat_config) _append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"}) payload, all_text = user_message, [] for r_idx in range(MAX_TOOL_ROUNDS + 2): + # Strip stale file refreshes from Gemini history + if _gemini_chat and _gemini_chat.history: + for msg in _gemini_chat.history: + if msg.role == "user" and hasattr(msg, "parts"): + for p in msg.parts: + if hasattr(p, "function_response") and p.function_response and hasattr(p.function_response, "response"): + r = p.function_response.response + if isinstance(r, dict) and "output" in r: + val = r["output"] + if isinstance(val, str) and "[SYSTEM: FILES UPDATED]" in val: + r["output"] = val.split("[SYSTEM: FILES UPDATED]")[0].strip() + resp = _gemini_chat.send_message(payload) - txt = "\n".join(p.text for c in resp.candidates for p in c.content.parts if hasattr(p, "text") and p.text) + txt = "\n".join(p.text for c in resp.candidates if getattr(c, "content", None) for p in c.content.parts if hasattr(p, "text") and p.text) if txt: all_text.append(txt) - calls = [p.function_call for c in resp.candidates for p in c.content.parts if hasattr(p, "function_call") and p.function_call] + calls = [p.function_call for c in resp.candidates if getattr(c, "content", None) for p in c.content.parts if hasattr(p, "function_call") and p.function_call] usage = {"input_tokens": getattr(resp.usage_metadata, "prompt_token_count", 0), "output_tokens": getattr(resp.usage_metadata, "candidates_token_count", 0)} + cached_tokens = getattr(resp.usage_metadata, "cached_content_token_count", None) + if cached_tokens: + usage["cache_read_input_tokens"] = cached_tokens reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP" _append_comms("IN", "response", {"round": r_idx, "stop_reason": reason, "text": txt, "tool_calls": [{"name": c.name, "args": dict(c.args)} for c in calls], "usage": usage}) @@ -568,33 +610,28 @@ def _trim_anthropic_history(system_blocks: list[dict], history: list[dict]): # Phase 2: drop oldest turn pairs until within budget dropped = 0 - while len(history) > 2 and est > _ANTHROPIC_MAX_PROMPT_TOKENS: - # Always drop from the front in pairs (user, assistant) to maintain alternation - # But be careful: the first message might be user, followed by assistant - if history[0].get("role") == "user" and len(history) > 1 and history[1].get("role") == "assistant": - removed_user = history.pop(0) - removed_asst = history.pop(0) + while len(history) > 3 and est > _ANTHROPIC_MAX_PROMPT_TOKENS: + # Protect history[0] (original user prompt). Drop from history[1] (assistant) and history[2] (user) + if history[1].get("role") == "assistant" and len(history) > 2 and history[2].get("role") == "user": + removed_asst = history.pop(1) + removed_user = history.pop(1) dropped += 2 - est -= _estimate_message_tokens(removed_user) est -= _estimate_message_tokens(removed_asst) - # If the next message is a user tool_result that belonged to the dropped assistant, - # we need to drop it too to avoid dangling tool_results - while history and history[0].get("role") == "user": - content = history[0].get("content", []) + est -= _estimate_message_tokens(removed_user) + # Also drop dangling tool_results if the next message is an assistant and the removed user was just tool results + while len(history) > 2 and history[1].get("role") == "assistant" and history[2].get("role") == "user": + content = history[2].get("content", []) if isinstance(content, list) and content and isinstance(content[0], dict) and content[0].get("type") == "tool_result": - removed_tr = history.pop(0) - dropped += 1 - est -= _estimate_message_tokens(removed_tr) - # And the assistant reply that followed it - if history and history[0].get("role") == "assistant": - removed_a2 = history.pop(0) - dropped += 1 - est -= _estimate_message_tokens(removed_a2) + r_a = history.pop(1) + r_u = history.pop(1) + dropped += 2 + est -= _estimate_message_tokens(r_a) + est -= _estimate_message_tokens(r_u) else: break else: - # Edge case: history starts with something unexpected. Drop one message. - removed = history.pop(0) + # Edge case fallback: drop index 1 (protecting index 0) + removed = history.pop(1) dropped += 1 est -= _estimate_message_tokens(removed) diff --git a/gui.py b/gui.py index 322e145..9c23b47 100644 --- a/gui.py +++ b/gui.py @@ -291,7 +291,7 @@ class ConfirmDialog: label=f"Approve PowerShell Command #{self._uid}", tag=self._tag, modal=True, - no_close=False, + no_close=True, pos=(px, py), width=w, height=h, @@ -513,6 +513,9 @@ class App: # Reset AI session since context changed ai_client.reset_session() + self.cb_clear_tool_log() + self.cb_clear_comms() + self._update_response("") self._update_status(f"switched to: {Path(path).stem}") def _refresh_from_project(self): diff --git a/mcp_client.py b/mcp_client.py index 299d780..6879729 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -43,6 +43,7 @@ import re as _re # base_dirs : set of resolved absolute Path dirs that act as roots _allowed_paths: set[Path] = set() _base_dirs: set[Path] = set() +_primary_base_dir: Path | None = None def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None): @@ -53,9 +54,10 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None): file_items : list of dicts from aggregate.build_file_items() extra_base_dirs : additional directory roots to allow traversal of """ - global _allowed_paths, _base_dirs + global _allowed_paths, _base_dirs, _primary_base_dir _allowed_paths = set() _base_dirs = set() + _primary_base_dir = Path(extra_base_dirs[0]).resolve() if extra_base_dirs else Path.cwd() for item in file_items: p = item.get("path") @@ -96,7 +98,10 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]: Returns (resolved_path, error_string). error_string is empty on success. """ try: - p = Path(raw_path).resolve() + p = Path(raw_path) + if not p.is_absolute() and _primary_base_dir: + p = _primary_base_dir / p + p = p.resolve() except Exception as e: return None, f"ERROR: invalid path '{raw_path}': {e}" if not _is_allowed(p): diff --git a/shell_runner.py b/shell_runner.py index d920776..a822beb 100644 --- a/shell_runner.py +++ b/shell_runner.py @@ -10,7 +10,8 @@ def run_powershell(script: str, base_dir: str) -> str: Returns a string combining stdout, stderr, and exit code. Raises nothing - all errors are captured into the return string. """ - full_script = f"Set-Location -LiteralPath '{base_dir}'\n{script}" + safe_dir = str(base_dir).replace("'", "''") + full_script = f"Set-Location -LiteralPath '{safe_dir}'\n{script}" # Try common executable names exe = next((x for x in ["powershell.exe", "pwsh.exe", "powershell", "pwsh"] if shutil.which(x)), None) if not exe: return "ERROR: Neither powershell nor pwsh found in PATH"