From 55d8814f94b1b798b86cdc2f9508f42a2b544eec Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 8 Nov 2025 00:35:41 +0100 Subject: [PATCH] feat: add autonomous mode command-line argument feat: improve error handling in autonomous mode feat: enhance assistant output for better user experience feat: track usage and cost in autonomous mode refactor: update api call function to accept database connection refactor: update list models function to accept database connection refactor: update assistant class to track api requests refactor: update http client to log requests maintenance: update pyproject.toml version to 1.25.0 docs: update changelog with version 1.24.0 changes --- CHANGELOG.md | 8 ++ pyproject.toml | 2 +- rp/autonomous/mode.py | 8 ++ rp/core/api.py | 13 ++- rp/core/assistant.py | 11 ++- rp/core/http_client.py | 22 ++++- rp/tools/__init__.py | 22 +++-- rp/tools/agents.py | 1 + rp/tools/database.py | 53 +++++++++++ rp/tools/filesystem.py | 202 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 323 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2766c47..93a7176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ + + +## Version 1.24.0 - 2025-11-07 + +Users can now run the tool in autonomous mode using a command-line argument. We've also improved error handling and assistant output for a better experience. + +**Changes:** 5 files, 62 lines +**Languages:** Markdown (8 lines), Python (52 lines), TOML (2 lines) ## Version 1.23.0 - 2025-11-07 diff --git a/pyproject.toml b/pyproject.toml index 275bbd0..434ffef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rp" -version = "1.23.0" +version = "1.24.0" description = "R python edition. The ultimate autonomous AI CLI." readme = "README.md" requires-python = ">=3.10" diff --git a/rp/autonomous/mode.py b/rp/autonomous/mode.py index c512a7b..24e1c8d 100644 --- a/rp/autonomous/mode.py +++ b/rp/autonomous/mode.py @@ -97,6 +97,14 @@ def process_response_autonomous(assistant, response): get_tools_definition(), verbose=assistant.verbose, ) + if "usage" in follow_up: + usage = follow_up["usage"] + input_tokens = usage.get("prompt_tokens", 0) + output_tokens = usage.get("completion_tokens", 0) + assistant.usage_tracker.track_request(assistant.model, input_tokens, output_tokens) + cost = assistant.usage_tracker._calculate_cost(assistant.model, input_tokens, output_tokens) + total_cost = assistant.usage_tracker.session_usage["estimated_cost"] + print(f"{Colors.YELLOW}💰 Cost: ${cost:.4f} | Total: ${total_cost:.4f}{Colors.RESET}") return process_response_autonomous(assistant, follow_up) content = message.get("content", "") from rp.ui import render_markdown diff --git a/rp/core/api.py b/rp/core/api.py index ea7f76e..9367fb5 100644 --- a/rp/core/api.py +++ b/rp/core/api.py @@ -7,7 +7,7 @@ from rp.core.http_client import http_client logger = logging.getLogger("rp") -def call_api(messages, model, api_url, api_key, use_tools, tools_definition, verbose=False): +def call_api(messages, model, api_url, api_key, use_tools, tools_definition, verbose=False, db_conn=None): try: messages = auto_slim_messages(messages, verbose=verbose) logger.debug(f"=== API CALL START ===") @@ -34,8 +34,15 @@ def call_api(messages, model, api_url, api_key, use_tools, tools_definition, ver logger.debug(f"Tool calling enabled with {len(tools_definition)} tools") request_json = data logger.debug(f"Request payload size: {len(request_json)} bytes") + # Log the API request to database if db_conn is provided + if db_conn: + + from rp.tools.database import log_api_request + log_result = log_api_request(model, api_url, request_json, db_conn) + if log_result.get("status") != "success": + logger.warning(f"Failed to log API request: {log_result.get('error')}") logger.debug("Sending HTTP request...") - response = http_client.post(api_url, headers=headers, json_data=request_json) + response = http_client.post(api_url, headers=headers, json_data=request_json, db_conn=db_conn) if response.get("error"): if "status" in response: logger.error(f"API HTTP Error: {response['status']} - {response.get('text', '')}") @@ -82,7 +89,7 @@ def list_models(model_list_url, api_key): headers = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" - response = http_client.get(model_list_url, headers=headers) + response = http_client.get(model_list_url, headers=headers, db_conn=None) if response.get("error"): return {"error": response.get("text", "HTTP error")} data = json.loads(response["text"]) diff --git a/rp/core/assistant.py b/rp/core/assistant.py index 79f4ed2..142b85d 100644 --- a/rp/core/assistant.py +++ b/rp/core/assistant.py @@ -32,7 +32,7 @@ from rp.tools.agents import ( remove_agent, ) from rp.tools.command import kill_process, run_command, tail_process -from rp.tools.database import db_get, db_query, db_set +from rp.tools.database import db_get, db_query, db_set, log_api_request from rp.tools.filesystem import ( chdir, clear_edit_tracker, @@ -138,6 +138,9 @@ class Assistant: cursor.execute( "CREATE TABLE IF NOT EXISTS file_versions\n (id INTEGER PRIMARY KEY AUTOINCREMENT,\n filepath TEXT, content TEXT, hash TEXT,\n timestamp REAL, version INTEGER)" ) + cursor.execute( + "CREATE TABLE IF NOT EXISTS api_request_logs\n (id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp REAL, model TEXT, api_url TEXT,\n request_payload TEXT)" + ) self.db_conn.commit() logger.debug("Database initialized successfully") except Exception as e: @@ -310,6 +313,7 @@ class Assistant: self.use_tools, get_tools_definition(), verbose=self.verbose, + db_conn=self.db_conn, ) return self.process_response(follow_up) content = message.get("content", "") @@ -462,9 +466,9 @@ class Assistant: def run(self): try: - if self.args.autonomous or (not self.args.interactive and not self.args.message and sys.stdin.isatty()): + if self.args.autonomous: self.run_autonomous() - elif self.args.interactive: + elif self.args.interactive or (not self.args.message and sys.stdin.isatty()): self.run_repl() else: self.run_single() @@ -489,6 +493,7 @@ def process_message(assistant, message): assistant.use_tools, get_tools_definition(), verbose=assistant.verbose, + db_conn=assistant.db_conn, ) spinner.stop() if "usage" in response: diff --git a/rp/core/http_client.py b/rp/core/http_client.py index 8a6a9af..435563a 100644 --- a/rp/core/http_client.py +++ b/rp/core/http_client.py @@ -1,3 +1,4 @@ +import json import logging import time import requests @@ -19,6 +20,7 @@ class SyncHTTPClient: data: Optional[bytes] = None, json_data: Optional[Dict[str, Any]] = None, timeout: float = 30.0, + db_conn=None, ) -> Dict[str, Any]: """Make a sync HTTP request using requests with retry logic.""" attempt = 0 @@ -35,6 +37,19 @@ class SyncHTTPClient: timeout=timeout, ) response.raise_for_status() # Raise an exception for bad status codes + # Prepare request body for logging + if json_data is not None: + request_body = json.dumps(json_data) + elif data is not None: + request_body = data.decode('utf-8') if isinstance(data, bytes) else str(data) + else: + request_body = "" + # Log the request + if db_conn: + from rp.tools.database import log_http_request + log_result = log_http_request(method, url, request_body, response.text, response.status_code, db_conn) + if log_result.get("status") != "success": + logger.warning(f"Failed to log HTTP request: {log_result.get('error')}") return { "status": response.status_code, "headers": dict(response.headers), @@ -58,9 +73,9 @@ class SyncHTTPClient: return {"error": True, "exception": str(e)} def get( - self, url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0 + self, url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, db_conn=None ) -> Dict[str, Any]: - return self.request("GET", url, headers=headers, timeout=timeout) + return self.request("GET", url, headers=headers, timeout=timeout, db_conn=db_conn) def post( self, @@ -69,9 +84,10 @@ class SyncHTTPClient: data: Optional[bytes] = None, json_data: Optional[Dict[str, Any]] = None, timeout: float = 30.0, + db_conn=None, ) -> Dict[str, Any]: return self.request( - "POST", url, headers=headers, data=data, json_data=json_data, timeout=timeout + "POST", url, headers=headers, data=data, json_data=json_data, timeout=timeout, db_conn=db_conn ) def set_default_headers(self, headers: Dict[str, str]): diff --git a/rp/tools/__init__.py b/rp/tools/__init__.py index 3c4f423..f6c8534 100644 --- a/rp/tools/__init__.py +++ b/rp/tools/__init__.py @@ -17,14 +17,7 @@ from rp.tools.editor import ( open_editor, ) from rp.tools.filesystem import ( - chdir, - getpwd, - index_source_directory, - list_directory, - mkdir, - read_file, - search_replace, - write_file, + get_uid, read_specific_lines, replace_specific_line, insert_line_at_position, delete_specific_line, read_file, write_file, list_directory, mkdir, chdir, getpwd, index_source_directory, search_replace, get_editor, close_editor, open_editor, editor_insert_text, editor_replace_text, display_edit_summary, display_edit_timeline, clear_edit_tracker ) from rp.tools.lsp import get_diagnostics from rp.tools.memory import ( @@ -54,9 +47,11 @@ agent = execute_agent_task __all__ = [ "add_knowledge_entry", + "agent", "apply_patch", "bash", "chdir", + "clear_edit_tracker", "close_editor", "collaborate_agents", "create_agent", @@ -65,23 +60,28 @@ __all__ = [ "db_query", "db_set", "delete_knowledge_entry", + "delete_specific_line", "diagnostics", - "post_image", + "display_edit_summary", + "display_edit_timeline", "edit", "editor_insert_text", "editor_replace_text", "editor_search", "execute_agent_task", + "get_editor", "get_knowledge_by_category", "get_knowledge_entry", "get_knowledge_statistics", "get_tools_definition", + "get_uid", "getpwd", "glob", "glob_files", "grep", "http_fetch", "index_source_directory", + "insert_line_at_position", "kill_process", "list_agents", "list_directory", @@ -89,9 +89,12 @@ __all__ = [ "mkdir", "open_editor", "patch", + "post_image", "python_exec", "read_file", + "read_specific_lines", "remove_agent", + "replace_specific_line", "run_command", "run_command_interactive", "search_knowledge", @@ -104,3 +107,4 @@ __all__ = [ "write", "write_file", ] + diff --git a/rp/tools/agents.py b/rp/tools/agents.py index 13353ef..217d7f4 100644 --- a/rp/tools/agents.py +++ b/rp/tools/agents.py @@ -23,6 +23,7 @@ def _create_api_wrapper(): use_tools=use_tools, tools_definition=tools_definition, verbose=False, + db_conn=None, ) return api_wrapper diff --git a/rp/tools/database.py b/rp/tools/database.py index aa3ce74..c8ab43a 100644 --- a/rp/tools/database.py +++ b/rp/tools/database.py @@ -1,3 +1,4 @@ +import json import time @@ -74,3 +75,55 @@ def db_query(query, db_conn): return {"status": "success", "rows_affected": cursor.rowcount} except Exception as e: return {"status": "error", "error": str(e)} + +def log_api_request(model, api_url, request_payload, db_conn): + """Log an API request to the database. + + Args: + model: The model used. + api_url: The API URL. + request_payload: The JSON payload sent. + db_conn: Database connection. + + Returns: + Dict with status. + """ + if not db_conn: + return {"status": "error", "error": "Database not initialized"} + try: + cursor = db_conn.cursor() + cursor.execute( + "INSERT INTO api_request_logs (timestamp, model, api_url, request_payload) VALUES (?, ?, ?, ?)", + (time.time(), model, api_url, json.dumps(request_payload)), + ) + db_conn.commit() + return {"status": "success"} + except Exception as e: + return {"status": "error", "error": str(e)} + +def log_http_request(method, url, request_body, response_body, status_code, db_conn): + """Log an HTTP request to the database. + + Args: + method: The HTTP method. + url: The URL. + request_body: The request body. + response_body: The response body. + status_code: The status code. + db_conn: Database connection. + + Returns: + Dict with status. + """ + if not db_conn: + return {"status": "error", "error": "Database not initialized"} + try: + cursor = db_conn.cursor() + cursor.execute( + "INSERT INTO request_log (timestamp, method, url, request_body, response_body, status_code) VALUES (?, ?, ?, ?, ?, ?)", + (time.time(), method, url, request_body, response_body, status_code), + ) + db_conn.commit() + return {"status": "success"} + except Exception as e: + return {"status": "error", "error": str(e)} diff --git a/rp/tools/filesystem.py b/rp/tools/filesystem.py index f71b108..d26832c 100644 --- a/rp/tools/filesystem.py +++ b/rp/tools/filesystem.py @@ -16,6 +16,207 @@ def get_uid(): return _id +def read_specific_lines(filepath: str, start_line: int, end_line: Optional[int] = None, db_conn: Optional[Any] = None) -> dict: + """ + Read specific lines or a range of lines from a file. + + This function allows reading a single line or a contiguous range of lines from the specified file. + It supports optional database connection for tracking read operations. + + Args: + filepath (str): The path to the file to read from. Supports user home directory expansion (e.g., ~). + start_line (int): The 1-based line number to start reading from. + end_line (Optional[int]): The 1-based line number to end reading at (inclusive). If None, reads only the start_line. + db_conn (Optional[Any]): An optional database connection object for tracking read operations. If provided, marks the file as read in the database. + + Returns: + dict: A dictionary containing the status and either the content or an error message. + - On success: {"status": "success", "content": str} where content is the lines joined by newlines. + - On error: {"status": "error", "error": str} with the exception message. + + Raises: + None: Exceptions are caught and returned in the response dictionary. + + Examples: + # Read line 5 only + result = read_specific_lines("example.txt", 5) + + # Read lines 10 to 20 + result = read_specific_lines("example.txt", 10, 20) + """ + try: + path = os.path.expanduser(filepath) + with open(path, 'r') as file: + lines = file.readlines() + total_lines = len(lines) + if start_line < 1 or start_line > total_lines: + return {"status": "error", "error": f"Start line {start_line} is out of range. File has {total_lines} lines."} + if end_line is None: + end_line = start_line + if end_line < start_line or end_line > total_lines: + return {"status": "error", "error": f"End line {end_line} is out of range. File has {total_lines} lines."} + selected_lines = lines[start_line - 1:end_line] + content = ''.join(selected_lines) + if db_conn: + from rp.tools.database import db_set + db_set("read:" + path, "true", db_conn) + return {"status": "success", "content": content} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def replace_specific_line(filepath: str, line_number: int, new_content: str, db_conn: Optional[Any] = None, show_diff: bool = True) -> dict: + """ + Replace the content of a specific line in a file. + + This function replaces the entire content of a single line with new text. It supports optional database tracking + and diff display. The file must be read first if a database connection is provided. + + Args: + filepath (str): The path to the file to modify. Supports user home directory expansion (e.g., ~). + line_number (int): The 1-based line number to replace. + new_content (str): The new content to place on the specified line (should not include trailing newline). + db_conn (Optional[Any]): An optional database connection for tracking. Requires the file to be read first. + show_diff (bool): If True, displays a diff of the changes after replacement. + + Returns: + dict: A dictionary with status and message or error. + - On success: {"status": "success", "message": str} describing the operation. + - On error: {"status": "error", "error": str} with the exception or validation message. + + Raises: + None: Exceptions are handled internally. + + Examples: + # Replace line 3 with new text + result = replace_specific_line("file.txt", 3, "New line content") + """ + try: + path = os.path.expanduser(filepath) + if not os.path.exists(path): + return {"status": "error", "error": "File does not exist"} + if db_conn: + from rp.tools.database import db_get + read_status = db_get("read:" + path, db_conn) + if read_status.get("status") != "success" or read_status.get("value") != "true": + return {"status": "error", "error": "File must be read before writing. Please read the file first."} + with open(path, 'r') as file: + lines = file.readlines() + total_lines = len(lines) + if line_number < 1 or line_number > total_lines: + return {"status": "error", "error": f"Line number {line_number} is out of range. File has {total_lines} lines."} + old_content = ''.join(lines) + lines[line_number - 1] = new_content + '\n' if not new_content.endswith('\n') else new_content + new_full_content = ''.join(lines) + with open(path, 'w') as file: + file.writelines(lines) + if show_diff: + diff_result = display_content_diff(old_content, new_full_content, filepath) + if diff_result["status"] == "success": + print(diff_result["visual_diff"]) + return {"status": "success", "message": f"Replaced line {line_number} in {path}"} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def insert_line_at_position(filepath: str, line_number: int, new_content: str, db_conn: Optional[Any] = None, show_diff: bool = True) -> dict: + """ + Insert a new line at a specific position in a file. + + This function inserts new content as a new line before the specified line number. If line_number is beyond the file's length, + it appends to the end. Supports database tracking and diff display. + + Args: + filepath (str): The path to the file to modify. Supports user home directory expansion (e.g., ~). + line_number (int): The 1-based line number before which to insert the new line. If greater than total lines, appends. + new_content (str): The content for the new line (trailing newline is added if missing). + db_conn (Optional[Any]): Optional database connection for tracking. File must be read first if provided. + show_diff (bool): If True, displays a diff of the changes after insertion. + + Returns: + dict: Status and message or error. + - Success: {"status": "success", "message": str} + - Error: {"status": "error", "error": str} + + Examples: + # Insert before line 5 + result = insert_line_at_position("file.txt", 5, "Inserted line") + """ + try: + path = os.path.expanduser(filepath) + if not os.path.exists(path): + return {"status": "error", "error": "File does not exist"} + if db_conn: + from rp.tools.database import db_get + read_status = db_get("read:" + path, db_conn) + if read_status.get("status") != "success" or read_status.get("value") != "true": + return {"status": "error", "error": "File must be read before writing. Please read the file first."} + with open(path, 'r') as file: + lines = file.readlines() + old_content = ''.join(lines) + insert_index = min(line_number - 1, len(lines)) + lines.insert(insert_index, new_content + '\n' if not new_content.endswith('\n') else new_content) + new_full_content = ''.join(lines) + with open(path, 'w') as file: + file.writelines(lines) + if show_diff: + diff_result = display_content_diff(old_content, new_full_content, filepath) + if diff_result["status"] == "success": + print(diff_result["visual_diff"]) + return {"status": "success", "message": f"Inserted line at position {line_number} in {path}"} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def delete_specific_line(filepath: str, line_number: int, db_conn: Optional[Any] = None, show_diff: bool = True) -> dict: + """ + Delete a specific line from a file. + + This function removes the specified line from the file. Supports database tracking and diff display. + + Args: + filepath (str): The path to the file to modify. Supports user home directory expansion (e.g., ~). + line_number (int): The 1-based line number to delete. + db_conn (Optional[Any]): Optional database connection for tracking. File must be read first if provided. + show_diff (bool): If True, displays a diff of the changes after deletion. + + Returns: + dict: Status and message or error. + - Success: {"status": "success", "message": str} + - Error: {"status": "error", "error": str} + + Examples: + # Delete line 10 + result = delete_specific_line("file.txt", 10) + """ + try: + path = os.path.expanduser(filepath) + if not os.path.exists(path): + return {"status": "error", "error": "File does not exist"} + if db_conn: + from rp.tools.database import db_get + read_status = db_get("read:" + path, db_conn) + if read_status.get("status") != "success" or read_status.get("value") != "true": + return {"status": "error", "error": "File must be read before writing. Please read the file first."} + with open(path, 'r') as file: + lines = file.readlines() + total_lines = len(lines) + if line_number < 1 or line_number > total_lines: + return {"status": "error", "error": f"Line number {line_number} is out of range. File has {total_lines} lines."} + old_content = ''.join(lines) + del lines[line_number - 1] + new_full_content = ''.join(lines) + with open(path, 'w') as file: + file.writelines(lines) + if show_diff: + diff_result = display_content_diff(old_content, new_full_content, filepath) + if diff_result["status"] == "success": + print(diff_result["visual_diff"]) + return {"status": "success", "message": f"Deleted line {line_number} from {path}"} + except Exception as e: + return {"status": "error", "error": str(e)} + + def read_file(filepath: str, db_conn: Optional[Any] = None) -> dict: """ Read the contents of a file. @@ -381,3 +582,4 @@ def clear_edit_tracker(): clear_tracker() return {"status": "success", "message": "Edit tracker cleared"} +