Doing final pass of adjustments with anythingllm
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
87
ai_client.py
87
ai_client.py
@@ -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
5
gui.py
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user