Created
March 29, 2025 20:16
-
-
Save boxabirds/548970468f2c986fa9983241e405694e to your computer and use it in GitHub Desktop.
Tech Writer Agent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Written by Julian Harris https://makingaiagents.substack.com | |
# [email protected] | |
# Apache License | |
# Version 2.0, January 2004 | |
# http://www.apache.org/licenses/ | |
# TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |
# 1. Definitions. | |
# "License" shall mean the terms and conditions for use, reproduction, | |
# and distribution as defined by Sections 1 through 9 of this document. | |
# "Licensor" shall mean the copyright owner or entity authorized by | |
# the copyright owner that is granting the License. | |
# "Legal Entity" shall mean the union of the acting entity and all | |
# other entities that control, are controlled by, or are under common | |
# control with that entity. For the purposes of this definition, | |
# "control" means (i) the power, direct or indirect, to cause the | |
# direction or management of such entity, whether by contract or | |
# otherwise, or (ii) ownership of fifty percent (50%) or more of the | |
# outstanding shares, or (iii) beneficial ownership of such entity. | |
# "You" (or "Your") shall mean an individual or Legal Entity | |
# exercising permissions granted by this License. | |
# "Source" form shall mean the preferred form for making modifications, | |
# including but not limited to software source code, documentation | |
# source, and configuration files. | |
# "Object" form shall mean any form resulting from mechanical | |
# transformation or translation of a Source form, including but | |
# not limited to compiled object code, generated documentation, | |
# and conversions to other media types. | |
# "Work" shall mean the work of authorship, whether in Source or | |
# Object form, made available under the License, as indicated by a | |
# copyright notice that is included in or attached to the work | |
# (an example is provided in the Appendix below). | |
# "Derivative Works" shall mean any work, whether in Source or Object | |
# form, that is based on (or derived from) the Work and for which the | |
# editorial revisions, annotations, elaborations, or other modifications | |
# represent, as a whole, an original work of authorship. For the purposes | |
# of this License, Derivative Works shall not include works that remain | |
# separable from, or merely link (or bind by name) to the interfaces of, | |
# the Work and Derivative Works thereof. | |
# "Contribution" shall mean any work of authorship, including | |
# the original version of the Work and any modifications or additions | |
# to that Work or Derivative Works thereof, that is intentionally | |
# submitted to Licensor for inclusion in the Work by the copyright owner | |
# or by an individual or Legal Entity authorized to submit on behalf of | |
# the copyright owner. For the purposes of this definition, "submitted" | |
# means any form of electronic, verbal, or written communication sent | |
# to the Licensor or its representatives, including but not limited to | |
# communication on electronic mailing lists, source code control systems, | |
# and issue tracking systems that are managed by, or on behalf of, the | |
# Licensor for the purpose of discussing and improving the Work, but | |
# excluding communication that is conspicuously marked or otherwise | |
# designated in writing by the copyright owner as "Not a Contribution." | |
# "Contributor" shall mean Licensor and any individual or Legal Entity | |
# on behalf of whom a Contribution has been received by Licensor and | |
# subsequently incorporated within the Work. | |
# 2. Grant of Copyright License. Subject to the terms and conditions of | |
# this License, each Contributor hereby grants to You a perpetual, | |
# worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
# copyright license to reproduce, prepare Derivative Works of, | |
# publicly display, publicly perform, sublicense, and distribute the | |
# Work and such Derivative Works in Source or Object form. | |
# 3. Grant of Patent License. Subject to the terms and conditions of | |
# this License, each Contributor hereby grants to You a perpetual, | |
# worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
# (except as stated in this section) patent license to make, have made, | |
# use, offer to sell, sell, import, and otherwise transfer the Work, | |
# where such license applies only to those patent claims licensable | |
# by such Contributor that are necessarily infringed by their | |
# Contribution(s) alone or by combination of their Contribution(s) | |
# with the Work to which such Contribution(s) was submitted. If You | |
# institute patent litigation against any entity (including a | |
# cross-claim or counterclaim in a lawsuit) alleging that the Work | |
# or a Contribution incorporated within the Work constitutes direct | |
# or contributory patent infringement, then any patent licenses | |
# granted to You under this License for that Work shall terminate | |
# as of the date such litigation is filed. | |
# 4. Redistribution. You may reproduce and distribute copies of the | |
# Work or Derivative Works thereof in any medium, with or without | |
# modifications, and in Source or Object form, provided that You | |
# meet the following conditions: | |
# (a) You must give any other recipients of the Work or | |
# Derivative Works a copy of this License; and | |
# (b) You must cause any modified files to carry prominent notices | |
# stating that You changed the files; and | |
# (c) You must retain, in the Source form of any Derivative Works | |
# that You distribute, all copyright, patent, trademark, and | |
# attribution notices from the Source form of the Work, | |
# excluding those notices that do not pertain to any part of | |
# the Derivative Works; and | |
# (d) If the Work includes a "NOTICE" text file as part of its | |
# distribution, then any Derivative Works that You distribute must | |
# include a readable copy of the attribution notices contained | |
# within such NOTICE file, excluding those notices that do not | |
# pertain to any part of the Derivative Works, in at least one | |
# of the following places: within a NOTICE text file distributed | |
# as part of the Derivative Works; within the Source form or | |
# documentation, if provided along with the Derivative Works; or, | |
# within a display generated by the Derivative Works, if and | |
# wherever such third-party notices normally appear. The contents | |
# of the NOTICE file are for informational purposes only and | |
# do not modify the License. You may add Your own attribution | |
# notices within Derivative Works that You distribute, alongside | |
# or as an addendum to the NOTICE text from the Work, provided | |
# that such additional attribution notices cannot be construed | |
# as modifying the License. | |
# You may add Your own copyright statement to Your modifications and | |
# may provide additional or different license terms and conditions | |
# for use, reproduction, or distribution of Your modifications, or | |
# for any such Derivative Works as a whole, provided Your use, | |
# reproduction, and distribution of the Work otherwise complies with | |
# the conditions stated in this License. | |
# 5. Submission of Contributions. Unless You explicitly state otherwise, | |
# any Contribution intentionally submitted for inclusion in the Work | |
# by You to the Licensor shall be under the terms and conditions of | |
# this License, without any additional terms or conditions. | |
# Notwithstanding the above, nothing herein shall supersede or modify | |
# the terms of any separate license agreement you may have executed | |
# with Licensor regarding such Contributions. | |
# 6. Trademarks. This License does not grant permission to use the trade | |
# names, trademarks, service marks, or product names of the Licensor, | |
# except as required for reasonable and customary use in describing the | |
# origin of the Work and reproducing the content of the NOTICE file. | |
# 7. Disclaimer of Warranty. Unless required by applicable law or | |
# agreed to in writing, Licensor provides the Work (and each | |
# Contributor provides its Contributions) on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |
# implied, including, without limitation, any warranties or conditions | |
# of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |
# PARTICULAR PURPOSE. You are solely responsible for determining the | |
# appropriateness of using or redistributing the Work and assume any | |
# risks associated with Your exercise of permissions under this License. | |
# 8. Limitation of Liability. In no event and under no legal theory, | |
# whether in tort (including negligence), contract, or otherwise, | |
# unless required by applicable law (such as deliberate and grossly | |
# negligent acts) or agreed to in writing, shall any Contributor be | |
# liable to You for damages, including any direct, indirect, special, | |
# incidental, or consequential damages of any character arising as a | |
# result of this License or out of the use or inability to use the | |
# Work (including but not limited to damages for loss of goodwill, | |
# work stoppage, computer failure or malfunction, or any and all | |
# other commercial damages or losses), even if such Contributor | |
# has been advised of the possibility of such damages. | |
# 9. Accepting Warranty or Additional Liability. While redistributing | |
# the Work or Derivative Works thereof, You may choose to offer, | |
# and charge a fee for, acceptance of support, warranty, indemnity, | |
# or other liability obligations and/or rights consistent with this | |
# License. However, in accepting such obligations, You may act only | |
# on Your own behalf and on Your sole responsibility, not on behalf | |
# of any other Contributor, and only if You agree to indemnify, | |
# defend, and hold each Contributor harmless for any liability | |
# incurred by, or claims asserted against, such Contributor by reason | |
# of your accepting any such warranty or additional liability. | |
# END OF TERMS AND CONDITIONS | |
# APPENDIX: How to apply the Apache License to your work. | |
# To apply the Apache License to your work, attach the following | |
# boilerplate notice, with the fields enclosed by brackets "[]" | |
# replaced with your own identifying information. (Don't include | |
# the brackets!) The text should be enclosed in the appropriate | |
# comment syntax for the file format. We also recommend that a | |
# file or class name and description of purpose be included on the | |
# same "printed page" as the copyright notice for easier | |
# identification within third-party archives. | |
# Copyright [yyyy] [name of copyright owner] | |
# Licensed under the Apache License, Version 2.0 (the "License"); | |
# you may not use this file except in compliance with the License. | |
# You may obtain a copy of the License at | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# Unless required by applicable law or agreed to in writing, software | |
# distributed under the License is distributed on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
# See the License for the specific language governing permissions and | |
# limitations under the License. | |
from pathlib import Path | |
from typing import List, Dict, Any, Optional, Union | |
import json | |
import re | |
import ast | |
import datetime | |
import pathspec | |
from pathspec.patterns import GitWildMatchPattern | |
import os | |
import argparse | |
from binaryornot.check import is_binary | |
from openai import OpenAI | |
import math | |
import inspect | |
import typing | |
import logging | |
import textwrap | |
import sys | |
# Configure logging | |
logging.basicConfig( | |
level=logging.INFO, | |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
) | |
logger = logging.getLogger(__name__) | |
# Check for API key | |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") | |
# Warn if API key is not set | |
if not OPENAI_API_KEY: | |
logger.warning("OPENAI_API_KEY environment variable is not set.") | |
logger.warning("Please set this environment variable to use the OpenAI API.") | |
logger.warning("You can get an OpenAI API key from https://platform.openai.com") | |
# Define model | |
MODEL = "gpt-4o" | |
def get_gitignore_spec(directory: str) -> pathspec.PathSpec: | |
""" | |
Get a PathSpec object from .gitignore in the specified directory. | |
Args: | |
directory: The directory containing .gitignore | |
Returns: | |
A PathSpec object for matching against .gitignore patterns | |
""" | |
ignore_patterns = [] | |
# Try to read .gitignore file | |
gitignore_path = Path(directory) / ".gitignore" | |
if gitignore_path.exists(): | |
try: | |
with open(gitignore_path, "r", encoding="utf-8") as f: | |
for line in f: | |
line = line.strip() | |
# Skip empty lines and comments | |
if line and not line.startswith("#"): | |
ignore_patterns.append(line) | |
logger.info(f"Added {len(ignore_patterns)} patterns from .gitignore") | |
except (IOError, UnicodeDecodeError) as e: | |
logger.error(f"Error reading .gitignore: {e}") | |
# Create pathspec matcher | |
return pathspec.PathSpec.from_lines( | |
GitWildMatchPattern, ignore_patterns | |
) | |
def read_prompt_file(file_path: str) -> str: | |
"""Read a prompt from an external file.""" | |
try: | |
path = Path(file_path) | |
if not path.exists(): | |
raise FileNotFoundError(f"Prompt file not found: {file_path}") | |
with open(path, 'r', encoding='utf-8') as f: | |
return f.read().strip() | |
except UnicodeDecodeError: | |
try: | |
with open(path, 'r', encoding='latin-1') as f: | |
return f.read().strip() | |
except UnicodeDecodeError as e: | |
raise UnicodeDecodeError(f"Error reading prompt file with latin-1 encoding: {str(e)}") | |
except (IOError, OSError) as e: | |
raise IOError(f"Error reading prompt file: {str(e)}") | |
# System prompt components for the tech writer agent | |
ROLE_AND_TASK = textwrap.dedent(""" | |
You are an expert tech writer that helps teams understand codebases with accurate and concise supporting analysis and documentation. | |
Your task is to analyse the local filesystem to understand the structure and functionality of a codebase. | |
""") | |
GENERAL_ANALYSIS_GUIDELINES = textwrap.dedent(""" | |
Follow these guidelines: | |
- Use the available tools to explore the filesystem, read files, and gather information. | |
- Make no assumptions about file types or formats - analyse each file based on its content and extension. | |
- Focus on providing a comprehensive, accurate, and well-structured analysis. | |
- Include code snippets and examples where relevant. | |
- Organize your response with clear headings and sections. | |
- Cite specific files and line numbers to support your observations. | |
""") | |
INPUT_PROCESSING_GUIDELINES = textwrap.dedent(""" | |
Important guidelines: | |
- The user's analysis prompt will be provided in the initial message, prefixed with the base directory of the codebase (e.g., "Base directory: /path/to/codebase"). | |
- Analyse the codebase based on the instructions in the prompt, using the base directory as the root for all relative paths. | |
- Make no assumptions about file types or formats - analyse each file based on its content and extension. | |
- Adapt your analysis approach based on the codebase and the prompt's requirements. | |
- Be thorough but focus on the most important aspects as specified in the prompt. | |
- Provide clear, structured summaries of your findings in your final response. | |
- Handle errors gracefully and report them clearly if they occur but don't let them halt the rest of the analysis. | |
""") | |
CODE_ANALYSIS_STRATEGIES = textwrap.dedent(""" | |
When analysing code: | |
- Start by exploring the directory structure to understand the project organisation. | |
- Identify key files like README, configuration files, or main entry points. | |
- Ignore temporary files and directories like node_modules, .git, etc. | |
- Analyse relationships between components (e.g., imports, function calls). | |
- Look for patterns in the code organisation (e.g., line counts, TODOs). | |
- Summarise your findings to help someone understand the codebase quickly, tailored to the prompt. | |
""") | |
REACT_PLANNING_STRATEGY = textwrap.dedent(""" | |
You should follow the ReAct pattern: | |
1. Thought: Reason about what you need to do next | |
2. Action: Use one of the available tools | |
3. Observation: Review the results of the tool | |
4. Repeat until you have enough information to provide a final answer | |
""") | |
QUALITY_REQUIREMENTS = textwrap.dedent(""" | |
When you've completed your analysis, provide a final answer in the form of a comprehensive Markdown document | |
that provides a mutually exclusive and collectively exhaustive (MECE) analysis of the codebase using the user prompt. | |
Your analysis should be thorough, accurate, and helpful for someone trying to understand this codebase. | |
""") | |
# Combine system prompt components | |
REACT_SYSTEM_PROMPT = f"{ROLE_AND_TASK}\n\n{GENERAL_ANALYSIS_GUIDELINES}\n\n{INPUT_PROCESSING_GUIDELINES}\n\n{CODE_ANALYSIS_STRATEGIES}\n\n{REACT_PLANNING_STRATEGY}\n\n{QUALITY_REQUIREMENTS}" | |
# Tool functions | |
def find_all_matching_files( | |
directory: str, | |
pattern: str = "*", | |
respect_gitignore: bool = True, | |
include_hidden: bool = False, | |
include_subdirs: bool = True | |
) -> List[Path]: | |
""" | |
Find files matching a pattern while respecting .gitignore. | |
Args: | |
directory: Directory to search in | |
pattern: File pattern to match (glob format) | |
respect_gitignore: Whether to respect .gitignore patterns | |
include_hidden: Whether to include hidden files and directories | |
include_subdirs: Whether to include files in subdirectories | |
Returns: | |
List of Path objects for matching files | |
""" | |
try: | |
directory_path = Path(directory).resolve() | |
if not directory_path.exists(): | |
logger.warning(f"Directory not found: {directory}") | |
return [] | |
# Get gitignore spec if needed | |
spec = get_gitignore_spec(str(directory_path)) if respect_gitignore else None | |
result = [] | |
# Choose between recursive and non-recursive search | |
if include_subdirs: | |
paths = directory_path.rglob(pattern) | |
else: | |
paths = directory_path.glob(pattern) | |
for path in paths: | |
if path.is_file(): | |
# Skip hidden files if not explicitly included | |
if not include_hidden and any(part.startswith('.') for part in path.parts): | |
continue | |
# Skip if should be ignored | |
if respect_gitignore and spec: | |
# Use pathlib to get relative path and convert to posix format | |
rel_path = path.relative_to(directory_path) | |
rel_path_posix = rel_path.as_posix() | |
if spec.match_file(rel_path_posix): | |
continue | |
result.append(path) | |
return result | |
except (FileNotFoundError, PermissionError) as e: | |
logger.error(f"Error accessing files: {e}") | |
return [] | |
except Exception as e: | |
logger.error(f"Unexpected error finding files: {e}") | |
return [] | |
def read_file(file_path: str) -> Dict[str, Any]: | |
"""Read the contents of a file.""" | |
try: | |
path = Path(file_path) | |
if not path.exists(): | |
return {"error": f"File not found: {file_path}"} | |
if is_binary(file_path): | |
return {"error": f"Cannot read binary file: {file_path}"} | |
with open(path, 'r', encoding='utf-8') as f: | |
content = f.read() | |
return { | |
"file": file_path, | |
"content": content | |
} | |
except FileNotFoundError: | |
return {"error": f"File not found: {file_path}"} | |
except UnicodeDecodeError: | |
return {"error": f"Cannot decode file as UTF-8: {file_path}"} | |
except PermissionError: | |
return {"error": f"Permission denied when reading file: {file_path}"} | |
except IOError as e: | |
return {"error": f"IO error reading file: {str(e)}"} | |
except Exception as e: | |
return {"error": f"Unexpected error reading file: {str(e)}"} | |
def calculate(expression: str) -> Dict[str, Any]: | |
""" | |
Evaluate a mathematical expression and return the result. | |
Args: | |
expression: Mathematical expression to evaluate (e.g., "2 + 2 * 3") | |
Returns: | |
Dictionary containing the expression and its result | |
""" | |
try: | |
# Create a safe environment for evaluating expressions | |
# This uses Python's ast.literal_eval for safety instead of eval() | |
def safe_eval(expr): | |
# Replace common mathematical functions with their math module equivalents | |
expr = expr.replace("^", "**") # Support for exponentiation | |
# Parse the expression into an AST | |
parsed_expr = ast.parse(expr, mode='eval') | |
# Check that the expression only contains safe operations | |
for node in ast.walk(parsed_expr): | |
# Allow names that are defined in the math module | |
if isinstance(node, ast.Name) and node.id not in math.__dict__: | |
if node.id not in ['True', 'False', 'None']: | |
raise ValueError(f"Invalid name in expression: {node.id}") | |
# Only allow safe operations | |
elif isinstance(node, ast.Call): | |
if not (isinstance(node.func, ast.Name) and node.func.id in math.__dict__): | |
raise ValueError(f"Invalid function call in expression") | |
# Evaluate the expression with the math module available | |
return eval(compile(parsed_expr, '<string>', 'eval'), {"__builtins__": {}}, math.__dict__) | |
# Evaluate the expression | |
result = safe_eval(expression) | |
return { | |
"expression": expression, | |
"result": result | |
} | |
except SyntaxError as e: | |
return { | |
"error": f"Syntax error in expression: {str(e)}", | |
"expression": expression | |
} | |
except ValueError as e: | |
return { | |
"error": f"Value error in expression: {str(e)}", | |
"expression": expression | |
} | |
except TypeError as e: | |
return { | |
"error": f"Type error in expression: {str(e)}", | |
"expression": expression | |
} | |
except Exception as e: | |
return { | |
"error": f"Unexpected error evaluating expression: {str(e)}", | |
"expression": expression | |
} | |
# Dictionary mapping tool names to their functions | |
TOOLS = { | |
"find_all_matching_files": find_all_matching_files, | |
"read_file": read_file, | |
"calculate": calculate | |
} | |
class CustomEncoder(json.JSONEncoder): | |
""" | |
Custom JSON encoder that handles Path objects from pathlib. | |
This encoder is necessary for serializing results from tool functions | |
that return Path objects, which are not JSON-serializable by default. | |
Used primarily in the execute_tool method when converting tool results | |
to JSON strings for the LLM. | |
""" | |
def default(self, obj): | |
if isinstance(obj, Path): | |
return str(obj) | |
return super().default(obj) | |
def create_openai_tool_definitions(tools_dict): | |
""" | |
Create tool definitions from a dictionary of Python functions. | |
Args: | |
tools_dict: Dictionary mapping tool names to Python functions | |
Returns: | |
List of tool definitions formatted for the OpenAI API | |
""" | |
tools = [] | |
for name, func in tools_dict.items(): | |
# Extract function signature | |
sig = inspect.signature(func) | |
# Get docstring and parse it | |
docstring = inspect.getdoc(func) or "" | |
description = docstring.split("\n\n")[0] if docstring else "" | |
# Build parameters | |
parameters = { | |
"type": "object", | |
"properties": {}, | |
"required": [] | |
} | |
for param_name, param in sig.parameters.items(): | |
# Get parameter type annotation | |
param_type = param.annotation | |
if param_type is inspect.Parameter.empty: | |
param_type = str | |
# Get origin and args for generic types | |
origin = typing.get_origin(param_type) | |
args = typing.get_args(param_type) | |
# Convert Python types to JSON Schema types | |
if param_type == str: | |
json_type = "string" | |
elif param_type == int: | |
json_type = "integer" | |
elif param_type == float or param_type == "number": | |
json_type = "number" | |
elif param_type == bool: | |
json_type = "boolean" | |
elif origin is list or param_type == list: | |
json_type = "array" | |
elif origin is dict or param_type == dict: | |
json_type = "object" | |
else: | |
# For complex types, default to string | |
json_type = "string" | |
# Extract parameter description from docstring | |
param_desc = "" | |
if docstring: | |
# Look for parameter in docstring (format: param_name: description) | |
param_pattern = rf"{param_name}:\s*(.*?)(?:\n\s*\w+:|$)" | |
param_match = re.search(param_pattern, docstring, re.DOTALL) | |
if param_match: | |
param_desc = param_match.group(1).strip() | |
# Add parameter to schema | |
parameters["properties"][param_name] = { | |
"type": json_type, | |
"description": param_desc | |
} | |
# Mark required parameters | |
if param.default is inspect.Parameter.empty: | |
parameters["required"].append(param_name) | |
# Create tool definition | |
tool_def = { | |
"type": "function", | |
"function": { | |
"name": name, | |
"description": description, | |
"parameters": parameters | |
} | |
} | |
tools.append(tool_def) | |
return tools | |
def call_llm(memory): | |
""" | |
Call the LLM with the current memory and tools. | |
Args: | |
memory: List of message objects | |
Returns: | |
The message from the assistant | |
""" | |
try: | |
client = OpenAI(api_key=OPENAI_API_KEY) | |
response = client.chat.completions.create( | |
model=MODEL, | |
messages=memory, | |
tools=create_openai_tool_definitions(TOOLS), | |
temperature=0 | |
) | |
return response.choices[0].message | |
except Exception as e: | |
error_msg = str(e) | |
logger.error(f"Error calling API: {error_msg}") | |
# Check for specific API errors | |
if "insufficient_quota" in error_msg: | |
logger.error("API quota exceeded. Please check your billing details.") | |
sys.exit(2) # Special exit code for quota errors | |
elif "429" in error_msg: | |
logger.error("Rate limit exceeded. Please try again later.") | |
sys.exit(3) # Special exit code for rate limit errors | |
elif "401" in error_msg or "403" in error_msg: | |
logger.error("Authentication error. Please check your API key.") | |
sys.exit(4) # Special exit code for auth errors | |
else: | |
raise ValueError(error_msg) | |
def check_llm_result(assistant_message, memory): | |
""" | |
Check if the LLM result is a final answer or a tool call. | |
Args: | |
assistant_message: The message from the assistant | |
memory: The memory list to update | |
Returns: | |
tuple: (result_type, result_data) | |
result_type: "final_answer" or "tool_calls" | |
result_data: The final answer string or list of tool calls | |
""" | |
memory.append(assistant_message) | |
if assistant_message.tool_calls: | |
return "tool_calls", assistant_message.tool_calls | |
else: | |
return "final_answer", assistant_message.content | |
def execute_tool(tool_call): | |
""" | |
Execute a tool call and return the result. | |
Args: | |
tool_call: The tool call object from the LLM | |
Returns: | |
str: The result of the tool execution | |
""" | |
tool_name = tool_call.function.name | |
if tool_name not in TOOLS: | |
return f"Error: Unknown tool {tool_name}" | |
try: | |
# Parse the arguments | |
args = json.loads(tool_call.function.arguments) | |
# Call the tool function | |
result = TOOLS[tool_name](**args) | |
# Convert result to JSON string | |
return json.dumps(result, cls=CustomEncoder, indent=2) | |
except json.JSONDecodeError as e: | |
return f"Error: Invalid JSON in tool arguments: {str(e)}" | |
except TypeError as e: | |
return f"Error: Invalid argument types: {str(e)}" | |
except ValueError as e: | |
return f"Error: Invalid argument values: {str(e)}" | |
except Exception as e: | |
return f"Error executing tool {tool_name}: {str(e)}" | |
def run_analysis(prompt, directory): | |
""" | |
Run the agent to analyse a codebase using the ReAct pattern. | |
Args: | |
prompt: The analysis prompt | |
directory: The directory containing the codebase to analyse | |
Returns: | |
The analysis result | |
""" | |
# Initialize memory | |
memory = [{"role": "system", "content": REACT_SYSTEM_PROMPT}] | |
memory.append({"role": "user", "content": f"Base directory: {directory}\n\n{prompt}"}) | |
final_answer = None | |
max_steps = 15 | |
for step in range(max_steps): | |
logger.info(f"\n--- Step {step + 1} ---") | |
# Call the LLM | |
assistant_message = call_llm(memory) | |
# Check the result | |
result_type, result_data = check_llm_result(assistant_message, memory) | |
if result_type == "final_answer": | |
final_answer = result_data | |
break | |
elif result_type == "tool_calls": | |
# Execute each tool call | |
for tool_call in result_data: | |
# Execute the tool | |
observation = execute_tool(tool_call) | |
# Add the observation to memory | |
memory.append({ | |
"role": "tool", | |
"tool_call_id": tool_call.id, | |
"name": tool_call.function.name, | |
"content": observation | |
}) | |
logger.info(f"Memory length: {len(memory)} messages") | |
if final_answer is None: | |
final_answer = "Failed to complete the analysis within the step limit." | |
return final_answer | |
def get_command_line_args(): | |
"""Parse command line arguments.""" | |
parser = argparse.ArgumentParser(description="Analyze a codebase using LLM") | |
parser.add_argument("directory", help="Directory to analyze") | |
parser.add_argument("prompt_file", help="Path to prompt file") | |
parser.add_argument("--output_type", default="output", help="Type of output (output, assessment, refinement, final-assessment)") | |
return parser.parse_args() | |
def analyse_codebase(directory_path: str, prompt_file_path: str) -> str: | |
"""Analyse a codebase using the specified model with a prompt from an external file.""" | |
try: | |
directory = Path(directory_path).resolve() | |
if not directory.exists(): | |
raise FileNotFoundError(f"Directory not found: {directory_path}") | |
# Read the prompt | |
prompt = read_prompt_file(prompt_file_path) | |
# Run the analysis | |
logger.info("Using ReAct agent") | |
result = run_analysis(prompt, directory) | |
return result | |
except FileNotFoundError as e: | |
logger.error(f"File not found: {e}") | |
return f"Error running code analysis: {str(e)}" | |
except IOError as e: | |
logger.error(f"IO error: {e}") | |
return f"Error running code analysis: {str(e)}" | |
except Exception as e: | |
logger.error(f"Unexpected error: {e}") | |
return f"Error running code analysis: {str(e)}" | |
def save_results(analysis_result: str, output_type: str = "output") -> Path: | |
""" | |
Save analysis results to a timestamped Markdown file in the output directory. | |
Args: | |
analysis_result: The analysis text to save | |
output_type: The type of output (output, assessment, refinement, final-assessment) | |
Returns: | |
Path to the saved file | |
""" | |
# Create output directory if it doesn't exist | |
output_dir = Path("output") | |
output_dir.mkdir(exist_ok=True) | |
# Generate timestamp for filename | |
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") | |
output_filename = f"{timestamp}-react-{MODEL}-{output_type}.md" | |
output_path = output_dir / output_filename | |
# Save results to markdown file | |
try: | |
with open(output_path, "w", encoding="utf-8") as f: | |
f.write(analysis_result) | |
logger.info(f"Analysis complete. Results saved to {output_path}") | |
return output_path | |
except IOError as e: | |
logger.error(f"Error saving results: {e}") | |
raise IOError(f"Failed to save results: {str(e)}") | |
def main(): | |
args = get_command_line_args() | |
try: | |
analysis_result = analyse_codebase(args.directory, args.prompt_file) | |
save_results(analysis_result, args.output_type) | |
except Exception as e: | |
logger.error(f"Error: {e}") | |
# Check for specific API errors | |
if "insufficient_quota" in str(e): | |
logger.error("API quota exceeded. Please check your billing details.") | |
sys.exit(2) # Special exit code for quota errors | |
elif "429" in str(e): | |
logger.error("Rate limit exceeded. Please try again later.") | |
sys.exit(3) # Special exit code for rate limit errors | |
elif "401" in str(e) or "403" in str(e): | |
logger.error("Authentication error. Please check your API key.") | |
sys.exit(4) # Special exit code for auth errors | |
else: | |
sys.exit(1) # Generic error | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment