Doing final pass of adjustments with anythingllm

This commit is contained in:
2026-02-22 09:54:36 -05:00
parent 34ed257cd6
commit 254ca8cbda
5 changed files with 77 additions and 31 deletions

View File

@@ -78,7 +78,7 @@ Is a local GUI tool for manually curating and sending context to AI APIs. It agg
**AI Tool Use (PowerShell):** **AI Tool Use (PowerShell):**
- Both Gemini and Anthropic are configured with a `run_powershell` tool/function declaration - 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 - 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 - 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 - 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 '<base_dir>'` and runs it via `powershell -NoProfile -NonInteractive -Command` - On approval the (possibly edited) script is passed to `shell_runner.run_powershell()` which prepends `Set-Location -LiteralPath '<base_dir>'` 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` - 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()` - 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 - 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 - `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 - Additional project metadata (description, tags, created date) could be added to `[project]` in the per-project toml

View File

@@ -23,6 +23,7 @@ _model: str = "gemini-2.0-flash"
_gemini_client = None _gemini_client = None
_gemini_chat = None _gemini_chat = None
_gemini_cache = None
_anthropic_client = None _anthropic_client = None
_anthropic_history: list[dict] = [] _anthropic_history: list[dict] = []
@@ -194,10 +195,16 @@ def set_provider(provider: str, model: str):
def reset_session(): def reset_session():
global _gemini_client, _gemini_chat global _gemini_client, _gemini_chat, _gemini_cache
global _anthropic_client, _anthropic_history 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_client = None
_gemini_chat = None _gemini_chat = None
_gemini_cache = None
_anthropic_client = None _anthropic_client = None
_anthropic_history = [] _anthropic_history = []
file_cache.reset_client() file_cache.reset_client()
@@ -421,19 +428,54 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
try: try:
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir]) _ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>" sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
tools_decl = [_gemini_tool_declaration()]
global _gemini_cache
if not _gemini_chat: 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)}]"}) _append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
payload, all_text = user_message, [] payload, all_text = user_message, []
for r_idx in range(MAX_TOOL_ROUNDS + 2): 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) 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) 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)} 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" 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}) _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 # Phase 2: drop oldest turn pairs until within budget
dropped = 0 dropped = 0
while len(history) > 2 and est > _ANTHROPIC_MAX_PROMPT_TOKENS: while len(history) > 3 and est > _ANTHROPIC_MAX_PROMPT_TOKENS:
# Always drop from the front in pairs (user, assistant) to maintain alternation # Protect history[0] (original user prompt). Drop from history[1] (assistant) and history[2] (user)
# But be careful: the first message might be user, followed by assistant if history[1].get("role") == "assistant" and len(history) > 2 and history[2].get("role") == "user":
if history[0].get("role") == "user" and len(history) > 1 and history[1].get("role") == "assistant": removed_asst = history.pop(1)
removed_user = history.pop(0) removed_user = history.pop(1)
removed_asst = history.pop(0)
dropped += 2 dropped += 2
est -= _estimate_message_tokens(removed_user)
est -= _estimate_message_tokens(removed_asst) est -= _estimate_message_tokens(removed_asst)
# If the next message is a user tool_result that belonged to the dropped assistant, est -= _estimate_message_tokens(removed_user)
# we need to drop it too to avoid dangling tool_results # Also drop dangling tool_results if the next message is an assistant and the removed user was just tool results
while history and history[0].get("role") == "user": while len(history) > 2 and history[1].get("role") == "assistant" and history[2].get("role") == "user":
content = history[0].get("content", []) content = history[2].get("content", [])
if isinstance(content, list) and content and isinstance(content[0], dict) and content[0].get("type") == "tool_result": if isinstance(content, list) and content and isinstance(content[0], dict) and content[0].get("type") == "tool_result":
removed_tr = history.pop(0) r_a = history.pop(1)
dropped += 1 r_u = history.pop(1)
est -= _estimate_message_tokens(removed_tr) dropped += 2
# And the assistant reply that followed it est -= _estimate_message_tokens(r_a)
if history and history[0].get("role") == "assistant": est -= _estimate_message_tokens(r_u)
removed_a2 = history.pop(0)
dropped += 1
est -= _estimate_message_tokens(removed_a2)
else: else:
break break
else: else:
# Edge case: history starts with something unexpected. Drop one message. # Edge case fallback: drop index 1 (protecting index 0)
removed = history.pop(0) removed = history.pop(1)
dropped += 1 dropped += 1
est -= _estimate_message_tokens(removed) est -= _estimate_message_tokens(removed)

5
gui.py
View File

@@ -291,7 +291,7 @@ class ConfirmDialog:
label=f"Approve PowerShell Command #{self._uid}", label=f"Approve PowerShell Command #{self._uid}",
tag=self._tag, tag=self._tag,
modal=True, modal=True,
no_close=False, no_close=True,
pos=(px, py), pos=(px, py),
width=w, width=w,
height=h, height=h,
@@ -513,6 +513,9 @@ class App:
# Reset AI session since context changed # Reset AI session since context changed
ai_client.reset_session() 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}") self._update_status(f"switched to: {Path(path).stem}")
def _refresh_from_project(self): def _refresh_from_project(self):

View File

@@ -43,6 +43,7 @@ import re as _re
# base_dirs : set of resolved absolute Path dirs that act as roots # base_dirs : set of resolved absolute Path dirs that act as roots
_allowed_paths: set[Path] = set() _allowed_paths: set[Path] = set()
_base_dirs: 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): 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() file_items : list of dicts from aggregate.build_file_items()
extra_base_dirs : additional directory roots to allow traversal of 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() _allowed_paths = set()
_base_dirs = 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: for item in file_items:
p = item.get("path") 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. Returns (resolved_path, error_string). error_string is empty on success.
""" """
try: 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: except Exception as e:
return None, f"ERROR: invalid path '{raw_path}': {e}" return None, f"ERROR: invalid path '{raw_path}': {e}"
if not _is_allowed(p): if not _is_allowed(p):

View File

@@ -10,7 +10,8 @@ def run_powershell(script: str, base_dir: str) -> str:
Returns a string combining stdout, stderr, and exit code. Returns a string combining stdout, stderr, and exit code.
Raises nothing - all errors are captured into the return string. 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 # Try common executable names
exe = next((x for x in ["powershell.exe", "pwsh.exe", "powershell", "pwsh"] if shutil.which(x)), None) 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" if not exe: return "ERROR: Neither powershell nor pwsh found in PATH"