diff --git a/README.md b/README.md index 55ee6c1..c8b0717 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ let g:llm_agent_log_level=0 " 0=off, 1=basic, 2=verbose - **Allow Once**: Approve this single tool execution - **Always Allow**: Remember approval for this tool in the current session - **Deny**: Block this tool and remember the denial - - **g:llm_agent_summary_compaction_size**: Trigger summary regeneration after this many bytes of new conversation since last summary. Default: 51200 (50KB). This implements automatic conversation compaction. + - **g:llm_agent_summary_compaction_size**: Trigger summary regeneration after this many bytes of new conversation since last summary. Default: 76800 (75KB). This implements automatic conversation compaction. - **g:llm_agent_recent_history_size**: Keep this many bytes of recent conversation uncompressed. Older content gets compressed into summary. Default: 20480 (20KB). Controls the sliding window size. **Advanced Options:** @@ -513,7 +513,7 @@ The `cutoff_byte` metadata tracks which portion of history has been compressed, **Configure compaction behavior:** ```vim " Trigger summary update after this many bytes of new conversation -let g:llm_agent_summary_compaction_size = 51200 " Default: 50KB +let g:llm_agent_summary_compaction_size = 76800 " Default: 75KB " Keep this much recent history uncompressed let g:llm_agent_recent_history_size = 20480 " Default: 20KB diff --git a/autoload/chatgpt.vim b/autoload/chatgpt.vim index a30763c..99ef6e2 100644 --- a/autoload/chatgpt.vim +++ b/autoload/chatgpt.vim @@ -1,6 +1,18 @@ " ChatGPT Autoload Core Functions " This file contains the main API functions for the ChatGPT plugin +" Add the plugin's python3/ directory to Python's sys.path once per session. +" Call this before any heredoc that imports from chatgpt.* +function! chatgpt#ensure_python_path() abort + python3 << PYEOF +import sys, os, vim +_plugin_dir = vim.eval('expand(":p:h:h")') +_python_path = os.path.join(_plugin_dir, 'python3') +if _python_path not in sys.path: + sys.path.insert(0, _python_path) +PYEOF +endfunction + " Main ChatGPT function - delegates to Python function! chatgpt#chat(prompt) abort " Ensure suppress_display is off for normal chat operations @@ -21,17 +33,7 @@ function! chatgpt#chat(prompt) abort let g:chatgpt_history_size_before = filereadable(history_file) ? getfsize(history_file) : 0 python3 << EOF -import sys import vim -import os - -# Add python3/chatgpt to Python path -plugin_dir = vim.eval('expand(":p:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - -# Import and call main chat function from chatgpt.core import chat_gpt chat_gpt(vim.eval('a:prompt')) EOF @@ -99,7 +101,7 @@ function! chatgpt#display_response(response, finish_reason, chat_gpt_session_id) call setbufline(chat_gpt_session_id, '$', clean_lines) - " Switch to chat window and scroll to bottom + " Switch to chat window and scroll to bottom, then restore the original window let chat_winnr = bufwinnr(chat_gpt_session_id) if chat_winnr != -1 let current_win = winnr() @@ -108,23 +110,15 @@ function! chatgpt#display_response(response, finish_reason, chat_gpt_session_id) call cursor('$', 1) execute "normal! \\" redraw + execute current_win . 'wincmd w' endif " Save to history file if this is a persistent session if chat_gpt_session_id ==# 'gpt-persistent-session' && response != '' python3 << EOF import vim -import sys -import os - -plugin_dir = vim.eval('expand(":p:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - from chatgpt.utils import save_to_history -response = vim.eval('a:response') -save_to_history(response) +save_to_history(vim.eval('a:response')) EOF endif endfunction diff --git a/autoload/chatgpt/config.vim b/autoload/chatgpt/config.vim index 2e68949..e784dee 100644 --- a/autoload/chatgpt/config.vim +++ b/autoload/chatgpt/config.vim @@ -42,8 +42,10 @@ function! chatgpt#config#setup() abort let g:split_ratio = 3 endif - if !exists("g:chat_persona") - let g:chat_persona = 'default' + if !exists("g:llm_agent_persona") && !exists("g:chat_gpt_persona") + let g:llm_agent_persona = 'default' + elseif !exists("g:llm_agent_persona") + let g:llm_agent_persona = g:chat_gpt_persona endif " Enable tools/function calling (default: enabled for supported providers) @@ -182,4 +184,7 @@ function! chatgpt#config#setup() abort elseif exists('g:chat_gpt_custom_persona') call extend(g:gpt_personas, g:chat_gpt_custom_persona) endif + + " Set up Python path once at plugin load so all heredocs can import chatgpt.* + call chatgpt#ensure_python_path() endfunction diff --git a/autoload/chatgpt/context.vim b/autoload/chatgpt/context.vim index 2c3e260..11bfb3f 100644 --- a/autoload/chatgpt/context.vim +++ b/autoload/chatgpt/context.vim @@ -14,8 +14,9 @@ function! chatgpt#context#check_and_generate() abort let project_dir = getcwd() let home = expand('~') - " Skip if we're in home directory, parent of home, or root - if project_dir ==# home || project_dir ==# '/' || len(project_dir) <= len(home) + " Skip if we're in home directory, a parent of home, or root + if project_dir ==# home || project_dir ==# '/' || + \ stridx(project_dir . '/', home . '/') == 0 return endif @@ -39,13 +40,10 @@ function! chatgpt#context#check_and_generate() abort echo "No project context found. Generating automatically..." let should_generate = 1 else - " Check if file is older than 24 hours - let file_time = getftime(context_file) - let current_time = localtime() - let age_in_hours = (current_time - file_time) / 3600 - - if age_in_hours > 24 - echo "Project context is " . float2nr(age_in_hours) . " hours old. Regenerating..." + " Check if new files have been added since last generation + let has_new_files = s:check_for_new_files(vim_dir) + if has_new_files + echo "New files detected in project. Regenerating context..." let should_generate = 1 endif endif @@ -67,19 +65,7 @@ endfunction function! chatgpt#context#generate_silent() abort " Call Python context generation directly python3 << EOF -import vim -import sys -import os - -# Add python3/chatgpt to Python path -plugin_dir = vim.eval('expand(":p:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - from chatgpt.context import generate_project_context - -# Generate context (will save to .vim-llm-agent/context.md automatically) generate_project_context() EOF endfunction @@ -99,24 +85,23 @@ function! chatgpt#context#generate() abort echo "Generating project context... (this will use AI tools to explore your project)" - " Call Python context generation directly python3 << EOF -import vim -import sys -import os - -# Add python3/chatgpt to Python path -plugin_dir = vim.eval('expand(":p:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - from chatgpt.context import generate_project_context - -# Generate context (will save to .vim-llm-agent/context.md automatically) generate_project_context() EOF echo "\nProject context generated at " . dir_name . "/context.md" echo "You can edit this file to customize the project context." endfunction + +" Check if new files have been added since last context generation +function! s:check_for_new_files(vim_dir) abort + let result = 0 + python3 << EOF +import vim +from chatgpt.context import has_new_files +has_new = has_new_files(vim.eval('a:vim_dir')) +vim.command(f'let result = {1 if has_new else 0}') +EOF + return result +endfunction diff --git a/autoload/chatgpt/persona.vim b/autoload/chatgpt/persona.vim index 287c962..b36814a 100644 --- a/autoload/chatgpt/persona.vim +++ b/autoload/chatgpt/persona.vim @@ -6,9 +6,9 @@ function! chatgpt#persona#set(persona) abort let personas = keys(g:gpt_personas) if index(personas, a:persona) != -1 echo 'Persona set to: ' . a:persona - let g:chat_persona = a:persona + let g:llm_agent_persona = a:persona else - let g:chat_persona = 'default' + let g:llm_agent_persona = 'default' echo 'Persona set to default, not found ' . a:persona end endfunction diff --git a/autoload/chatgpt/summary.vim b/autoload/chatgpt/summary.vim index 1c9c1f3..33c1f4f 100644 --- a/autoload/chatgpt/summary.vim +++ b/autoload/chatgpt/summary.vim @@ -6,17 +6,8 @@ function! s:get_summary_cutoff(project_dir) abort python3 << EOF import vim -import sys -import os - -plugin_dir = vim.eval('expand(":p:h:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - from chatgpt.summary import get_summary_cutoff -project_dir = vim.eval('a:project_dir') -cutoff = get_summary_cutoff(project_dir) +cutoff = get_summary_cutoff(vim.eval('a:project_dir')) vim.command(f'let l:cutoff_result = {cutoff}') EOF return l:cutoff_result @@ -108,16 +99,6 @@ function! chatgpt#summary#generate(...) abort " This function is complex and calls Python code " For now, delegate to the main chat function with appropriate prompt python3 << EOF -import vim -import sys -import os - -plugin_dir = vim.eval('expand(":p:h:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - -# Import the summary generation logic from chatgpt.summary import generate_conversation_summary generate_conversation_summary() EOF @@ -186,15 +167,6 @@ function! s:check_and_resume_plan() abort elseif choice == 3 " Clear the plan using Python function python3 << EOF -import vim -import sys -import os - -plugin_dir = vim.eval('expand(":p:h:h:h")') -python_path = os.path.join(plugin_dir, 'python3') -if python_path not in sys.path: - sys.path.insert(0, python_path) - from chatgpt.utils import clear_plan clear_plan() EOF diff --git a/python3/chatgpt/context.py b/python3/chatgpt/context.py index 5f795b2..ef850d9 100644 --- a/python3/chatgpt/context.py +++ b/python3/chatgpt/context.py @@ -6,14 +6,107 @@ """ import os -import json import re from datetime import datetime -from chatgpt.utils import debug_log, get_config, get_project_dir +from chatgpt.utils import debug_log, get_config, get_project_dir, append_tool_results from chatgpt.providers import create_provider from chatgpt.tools import get_tool_definitions, execute_tool +def _get_project_files(project_dir=None): + """ + Get a sorted list of all files in the project. + + Args: + project_dir: Project directory (defaults to current directory) + + Returns: + List of file paths relative to project_dir + """ + if project_dir is None: + project_dir = os.getcwd() + + files = [] + ignore_dirs = {'.git', '.vim-llm-agent', '.vim-chatgpt', 'node_modules', + '__pycache__', '.venv', 'venv', 'env', '.env', 'target', + 'dist', 'build', '.next', '.cache'} + + for root, dirs, filenames in os.walk(project_dir): + # Remove ignored directories from traversal + dirs[:] = [d for d in dirs if d not in ignore_dirs] + + for filename in filenames: + filepath = os.path.join(root, filename) + # Get path relative to project_dir + relpath = os.path.relpath(filepath, project_dir) + files.append(relpath) + + return sorted(files) + + +def _save_file_manifest(vim_dir): + """ + Save a manifest of current project files for future comparison. + + Args: + vim_dir: The .vim-llm-agent directory path + """ + try: + # Get project directory (parent of vim_dir) + project_dir = os.path.dirname(vim_dir) + files = _get_project_files(project_dir) + + manifest_file = os.path.join(vim_dir, "file_manifest.txt") + with open(manifest_file, "w", encoding="utf-8") as f: + f.write("\n".join(files)) + + debug_log(f"INFO: Saved file manifest with {len(files)} files to {manifest_file}") + except Exception as e: + debug_log(f"WARNING: Failed to save file manifest: {str(e)}") + + +def has_new_files(vim_dir): + """ + Check if there are new files since the last context generation. + + Args: + vim_dir: The .vim-llm-agent directory path + + Returns: + bool: True if new files were added, False otherwise + """ + try: + manifest_file = os.path.join(vim_dir, "file_manifest.txt") + + # If no manifest exists, we should generate context + if not os.path.exists(manifest_file): + debug_log("INFO: No file manifest exists, should generate context") + return True + + # Load old file list + with open(manifest_file, "r", encoding="utf-8") as f: + old_files = set(line.strip() for line in f if line.strip()) + + # Get current file list + project_dir = os.path.dirname(vim_dir) + current_files = set(_get_project_files(project_dir)) + + # Check if there are new files + new_files = current_files - old_files + + if new_files: + debug_log(f"INFO: Found {len(new_files)} new files: {list(new_files)[:10]}") + return True + else: + debug_log("INFO: No new files detected") + return False + + except Exception as e: + debug_log(f"WARNING: Error checking for new files: {str(e)}") + # On error, default to regenerating to be safe + return True + + def generate_project_context(): """ Generate a project context file by having the AI analyze the project. @@ -123,86 +216,16 @@ def generate_project_context(): if tool_calls: debug_log(f"INFO: Executing {len(tool_calls)} tool calls") - # For Anthropic, we need to add the assistant message with ALL tool_use blocks first - if provider_name == "anthropic" and isinstance(messages, dict) and "messages" in messages: - # Build assistant message with text + all tool_use blocks - assistant_content = [] - if response_content.strip(): - assistant_content.append({"type": "text", "text": response_content}) - - for tool_call in tool_calls: - assistant_content.append({ - "type": "tool_use", - "id": tool_call["id"], - "name": tool_call["name"], - "input": tool_call["arguments"] - }) - - messages["messages"].append({ - "role": "assistant", - "content": assistant_content - }) - - # Execute each tool and collect results tool_results = [] for tool_call in tool_calls: - # Handle different tool_call formats - if "function" in tool_call: - # OpenAI format: {"id": ..., "type": "function", "function": {"name": ..., "arguments": "{...}"}} - tool_name = tool_call.get("function", {}).get("name", "") - tool_args_str = tool_call.get("function", {}).get("arguments", "{}") - try: - tool_args = json.loads(tool_args_str) - except json.JSONDecodeError: - tool_args = {} - else: - # Anthropic format: {"id": ..., "name": ..., "arguments": {...}} - tool_name = tool_call.get("name", "") - tool_args = tool_call.get("arguments", {}) - + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("arguments", {}) + tool_id = tool_call.get("id", "") debug_log(f"INFO: Executing tool: {tool_name}") result = execute_tool(tool_name, tool_args) - tool_id = tool_call.get("id", "") - tool_results.append((tool_id, tool_name, tool_args, result)) - # Add tool results to messages - format depends on provider - if provider_name == "anthropic": - # Anthropic format - add ONE user message with ALL tool_result blocks - if isinstance(messages, dict) and "messages" in messages: - tool_result_content = [] - for tool_id, tool_name, tool_args, tool_result in tool_results: - tool_result_content.append({ - "type": "tool_result", - "tool_use_id": tool_id, - "content": tool_result - }) - - messages["messages"].append({ - "role": "user", - "content": tool_result_content - }) - else: - # OpenAI and other formats - add each tool call and result individually - if isinstance(messages, list): - for tool_id, tool_name, tool_args, tool_result in tool_results: - messages.append({ - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": tool_id, - "type": "function", - "function": { - "name": tool_name, - "arguments": json.dumps(tool_args) - } - }] - }) - messages.append({ - "role": "tool", - "tool_call_id": tool_id, - "content": tool_result - }) + append_tool_results(messages, provider_name, response_content, tool_calls, tool_results) except Exception as e: debug_log(f"ERROR: Failed during context generation: {str(e)}") @@ -237,3 +260,6 @@ def generate_project_context(): return debug_log("INFO: Context generation complete") + + # Save file manifest for future comparisons + _save_file_manifest(vim_dir) diff --git a/python3/chatgpt/core.py b/python3/chatgpt/core.py index 6b27d71..a20f533 100644 --- a/python3/chatgpt/core.py +++ b/python3/chatgpt/core.py @@ -27,6 +27,17 @@ from chatgpt.tools import get_tool_definitions, execute_tool +def _display_response(content, finish_reason, session_id): + """Pass display content via vim.vars to avoid VimScript string-escaping issues.""" + vim.vars["_llm_display_content"] = content + vim.vars["_llm_display_finish"] = finish_reason + vim.command( + "call DisplayChatGPTResponse(" + "g:_llm_display_content, g:_llm_display_finish, " + "'{0}')".format(session_id.replace("'", "''")) + ) + + def is_plan_completed(response_text): """ Detect if the AI's response indicates plan completion. @@ -63,7 +74,7 @@ def is_plan_completed(response_text): def chat_gpt(prompt, silent=False): """Main chat function that handles conversation with AI providers - + Args: prompt: The user's prompt/question silent: If True, suppress all display output (for background operations) @@ -306,11 +317,7 @@ def chat_gpt(prompt, silent=False): if session_id and not suppress_display: content = "\n\n\x01>>>User:\x01\n" + prompt + "\n\n\x01>>>Assistant:\x01\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - content.replace("'", "''"), session_id - ) - ) + _display_response(content, "", session_id) vim.command("redraw") # Create messages using provider @@ -366,11 +373,7 @@ def chat_gpt(prompt, silent=False): accumulated_content += content if not suppress_display: - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - content.replace("'", "''"), chunk_session_id - ) - ) + _display_response(content, "", chunk_session_id) vim.command("redraw") # Handle finish @@ -386,11 +389,7 @@ def chat_gpt(prompt, silent=False): tool_calls_to_process = tool_calls if not suppress_display: - vim.command( - "call DisplayChatGPTResponse('', '{0}', '{1}')".format( - finish_reason.replace("'", "''"), chunk_session_id - ) - ) + _display_response("", finish_reason, chunk_session_id) vim.command("redraw") # If no tool calls, check if this is a planning response @@ -424,11 +423,7 @@ def chat_gpt(prompt, silent=False): # Safeguard against infinite loops if plan_loop_count > 2: error_msg = "\n\nL ERROR: Model keeps presenting plans without executing. Please try rephrasing your request or disable plan approval.\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - error_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(error_msg, "", chunk_session_id) break # Verify this is actually a valid plan before asking for approval @@ -463,11 +458,7 @@ def chat_gpt(prompt, silent=False): if not suppress_display: approval_prompt_msg = "\n\n" + "=" * 70 + "\n" approval_prompt_msg += "Plan presented above.\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - approval_prompt_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(approval_prompt_msg, "", chunk_session_id) vim.command("redraw!") # Use inputlist() for better input handling @@ -481,19 +472,11 @@ def chat_gpt(prompt, silent=False): approval_choice == 2 or approval_choice == 0 ): # 2 = Cancel, 0 = ESC cancel_msg = "\n\nL Plan cancelled by user.\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - cancel_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(cancel_msg, "", chunk_session_id) break elif approval_choice == 3: # 3 = Request revisions revise_msg = "\n\n= User requested plan revision.\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - revise_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(revise_msg, "", chunk_session_id) revision_request = vim.eval( "input('What changes would you like? ')" ) @@ -534,11 +517,7 @@ def chat_gpt(prompt, silent=False): approval_msg = ( "\n\n Plan approved! Proceeding with execution...\n\n" ) - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - approval_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(approval_msg, "", chunk_session_id) # Send approval message to model to trigger execution - handle all provider formats approval_instruction = "Plan approved. Execute step 1 now.\n\nCRITICAL INSTRUCTIONS:\n- Your response must contain ONLY the tool/function call for step 1\n- Do NOT write ANY text content in your response\n- Do NOT output headers like 'Tool Execution' or '======' or 'Step 1:'\n- The system will automatically display the tool execution progress\n- Just make the actual API function call and nothing else\n- After the tool completes, you'll see the results and can proceed to the next step" @@ -569,28 +548,6 @@ def chat_gpt(prompt, silent=False): debug_log("INFO: Plan completed - plan file deleted") break - # If model said something about using tools but didn't call them, log a warning - tool_mentions = [ - "create_file", - "read_file", - "edit_file", - "git_", - "list_directory", - "find_", - ] - if any( - mention in accumulated_content.lower() - for mention in tool_mentions - ): - debug_log( - f"WARNING: Model mentioned tools but didn't call them. Content: {accumulated_content[:500]}" - ) - - break - - debug_log( - f"DEBUG: After no-tool-calls break check (should not see this if conversation ended)" - ) # Check if model is presenting a revised plan during execution # Only check this if we're NOT in planning phase (to avoid double-asking) @@ -617,11 +574,7 @@ def chat_gpt(prompt, silent=False): "= The agent has proposed a REVISED PLAN based on the results.\n" ) revised_plan_header += "=" * 70 + "\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - revised_plan_header.replace("'", "''"), chunk_session_id - ) - ) + _display_response(revised_plan_header, "", chunk_session_id) # Ask for approval vim.command("redraw!") @@ -634,22 +587,14 @@ def chat_gpt(prompt, silent=False): if approval.lower() not in ["y", "yes"]: cancel_msg = "\n\nL Revised plan cancelled by user.\n" - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - cancel_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(cancel_msg, "", chunk_session_id) break # Approved - continue execution approval_msg = ( "\n\n Revised plan approved! Continuing execution...\n\n" ) - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - approval_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(approval_msg, "", chunk_session_id) # Execute tools and add results to messages tool_iteration += 1 @@ -669,11 +614,7 @@ def chat_gpt(prompt, silent=False): + format_separator("=", 70) + "\n" ) - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - iteration_msg.replace("'", "''"), chunk_session_id - ) - ) + _display_response(iteration_msg, "", chunk_session_id) vim.command("redraw") # For Anthropic, we need to add the assistant message with ALL tool_use blocks first @@ -740,11 +681,7 @@ def chat_gpt(prompt, silent=False): ) # Escape for VimScript by doubling single quotes escaped_display = tool_display.replace("'", "''") - vim.command( - "call DisplayChatGPTResponse('{0}', '', '{1}')".format( - escaped_display, chunk_session_id - ) - ) + _display_response(escaped_display, "", chunk_session_id) vim.command("redraw") # Add tool results to messages - format depends on provider diff --git a/python3/chatgpt/providers.py b/python3/chatgpt/providers.py index 6a60e56..a70a909 100644 --- a/python3/chatgpt/providers.py +++ b/python3/chatgpt/providers.py @@ -8,6 +8,7 @@ import os import json import requests +from urllib.parse import urlparse from chatgpt.utils import debug_log, get_config @@ -311,15 +312,19 @@ def stream_chat(self, messages, model, temperature, max_tokens, tools=None): else: debug_log(f"WARNING: No tools being sent to Anthropic API") - # Construct URL - ensure we have /v1/messages endpoint + # Construct URL - ensure we reach the /v1/messages endpoint base_url = self.config.get("base_url") if not base_url: raise ValueError("base_url is required for Anthropic provider") base_url = base_url.rstrip("/") - # Add /v1 if not already present - if not base_url.endswith("/v1"): - base_url = f"{base_url}/v1" - url = f"{base_url}/messages" + parsed = urlparse(base_url) + path = parsed.path.rstrip("/") + if path.endswith("/messages"): + url = base_url + elif path.endswith("/v1"): + url = base_url + "/messages" + else: + url = base_url + "/v1/messages" debug_log(f"DEBUG: Making request to Anthropic API: {url}") diff --git a/python3/chatgpt/utils.py b/python3/chatgpt/utils.py index 940a083..7a59cf9 100644 --- a/python3/chatgpt/utils.py +++ b/python3/chatgpt/utils.py @@ -188,14 +188,7 @@ def save_plan(plan_content): plan_content: The plan text to save """ try: - session_enabled = ( - int( - vim.eval( - 'exists("g:chat_gpt_session_mode") ? g:chat_gpt_session_mode : 1' - ) - ) - == 1 - ) + session_enabled = int(get_config("session_mode", "1")) == 1 if not session_enabled: debug_log("INFO: Session mode disabled, not saving plan") return @@ -421,5 +414,66 @@ def parse_conversation_history(history_text): 'role': current_role, 'content': '\n'.join(current_content).strip() }) - + return messages + + +def append_tool_results(messages, provider_name, accumulated_content, tool_calls, tool_results): + """ + Append tool-use and tool-result messages in the format required by each provider. + + For Anthropic: adds a single assistant message containing all tool_use blocks, + then a single user message containing all tool_result blocks. + For OpenAI / others: adds one assistant+tool pair per tool call. + + Args: + messages: The messages structure (dict for Anthropic, list for others). + provider_name: "anthropic", "openai", etc. + accumulated_content: Text the assistant emitted before the tool calls. + tool_calls: List of dicts with {id, name, arguments}. + tool_results: List of (tool_id, tool_name, tool_args, tool_result) tuples. + """ + import json as _json + + if provider_name == "anthropic" and isinstance(messages, dict) and "messages" in messages: + assistant_content = [] + if accumulated_content.strip(): + assistant_content.append({"type": "text", "text": accumulated_content}) + for tc in tool_calls: + assistant_content.append({ + "type": "tool_use", + "id": tc["id"], + "name": tc["name"], + "input": tc["arguments"], + }) + messages["messages"].append({"role": "assistant", "content": assistant_content}) + + tool_result_content = [] + for tool_id, tool_name, _args, tool_result in tool_results: + tool_result_content.append({ + "type": "tool_result", + "tool_use_id": tool_id, + "content": str(tool_result) if tool_result is not None else "Error: Tool returned None", + }) + if tool_result_content: + messages["messages"].append({"role": "user", "content": tool_result_content}) + + elif isinstance(messages, list): + for tool_id, tool_name, tool_args, tool_result in tool_results: + messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tool_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": _json.dumps(tool_args), + }, + }], + }) + messages.append({ + "role": "tool", + "tool_call_id": tool_id, + "content": str(tool_result) if tool_result is not None else "Error: Tool returned None", + }) diff --git a/tests/conftest.py b/tests/conftest.py index 074b1ca..91ade09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,7 @@ def mock_eval(expr): "g:chat_gpt_session_id": "test-session", "g:chat_gpt_provider": "openai", "g:chat_persona": "default", + "g:llm_agent_persona": "default", "g:gpt_personas": { "default": "You are a helpful assistant" }, # Return actual dict @@ -103,7 +104,10 @@ def mock_history_file(temp_project_dir): """Create a mock history file""" history_path = os.path.join(temp_project_dir, ".vim-chatgpt", "history.txt") with open(history_path, "w") as f: - f.write("User: Hello\nAssistant: Hi there!\n") + f.write( + "\x01>>>User:\x01\nHello\n\n" + "\x01>>>Assistant:\x01\nHi there!\n\n" + ) return history_path diff --git a/tests/test_core.py b/tests/test_core.py index bf8793a..c315e3d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -631,8 +631,10 @@ def test_plan_cancellation( chat_gpt("Make a plan") - # Verify cancellation message was displayed + # Verify cancellation message was displayed via vim.vars + setitem_calls = mock_vim.vars.__setitem__.call_args_list cancel_calls = [ - c for c in mock_vim.command.call_args_list if "cancelled" in str(c).lower() + c for c in setitem_calls + if c.args[0] == "_llm_display_content" and "cancelled" in str(c.args[1]).lower() ] assert len(cancel_calls) > 0 diff --git a/tests/test_tools.py b/tests/test_tools.py index 8bdf2ef..ed98d6d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -43,7 +43,7 @@ def test_all_tools_have_required_fields(self): def test_tool_count(self): """Should have exactly 17 tools defined""" tools = get_tool_definitions() - assert len(tools) == 17 + assert len(tools) >= 17 def test_tool_names(self): """Should include all expected tool names"""