diff --git a/.env.example b/.env.example index 2d3d2a0..54b21eb 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,10 @@ OPENAI_API_KEY=... ANTHROPIC_API_KEY=... GOOGLE_GEN_AI_API_KEY=... +GROQ_API_KEY=... CEREBRAS_API_KEY=... +OPENROUTER_API_KEY=... +GITHUB_AI_API_KEY=... # Embedding providers APIs (As needed. Only add those you need.) diff --git a/.gitignore b/.gitignore index 4942e52..a9561d3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ canvas/ compose.yml bin/ + +.python-version +uv.lock +pyproject.toml diff --git a/Dockerfile b/Dockerfile index 6b36649..4e2ae4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +##### Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + ##### Install system dependencies ##### nano is included to edit the config.json file easily if needed. RUN apt-get update \ @@ -15,7 +18,7 @@ RUN apt-get update \ ##### Install Python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN uv add -n -r requirements.txt ##### Copy project COPY . . diff --git a/README.md b/README.md index 3868ac0..f541bce 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ Ally was built a fully local agentic system using **[Ollama](https://ollama.com/ - OpenAI - Anthropic - Google GenAI +- Groq - Cerebras +- OpenRouter +- GitHub Models - _(more integrations on the way!)_ This tool is best suited for scenarios where privacy is paramount and agentic capabilities are needed in the workflow. @@ -37,7 +40,7 @@ A general-purpose agent that can: - Access the internet. - Execute commands and code. - **_Note:_** Tools always ask for your permission before executing. + **_Note:_** Tools always ask for your permission before executing. Multiple tool calls are processed sequentially to ensure clear approval flow. ### RAG @@ -91,7 +94,7 @@ This workflow is still in its early stages. You have 2 options: Via Docker or locally on your machine. -### **1. Docker** +### **1. Docker (Recommended)** Create a `.env` file (or copy `.env.example`) in any location @@ -158,6 +161,7 @@ docker start -ai ally ### Prerequesites: - [Python](https://www.python.org/) +- [UV](https://github.com/astral-sh/uv) - [Git](https://git-scm.com/downloads) (or download the source code from this repo) - [Ollama](https://ollama.com/download) (optional) @@ -175,48 +179,59 @@ This file (located at `Ally/`) controls Ally's main settings and integrations. **Example configuration:** +- Options for `"provider"` are "openai", "ollama", "anthropic", "google", "groq", "cerebras", "openrouter", and "github". + +- The model name depends on your chosen provider (often found within the provider's dashboard or models tab). + +- **`provider_per_model` and `models` with `null` values**: These fields will be automatically filled with the default provider and model specified above. For example, if you set `"brainstormer": null`, it will use the main `"provider"` and `"model"` values. + +- **`system_prompts`**: Leave these as `null` to use Ally's built-in default prompts (recommended). You can override individual prompts by providing custom prompt text. + +- **`embedding_provider`**: Options include `"hf"` (Hugging Face - runs locally) or `"ollama"` (for locally running embedding models). + +- **`embedding_model`**: Examples: `"sentence-transformers/all-MiniLM-L6-v2"` (Hugging Face) or `"all-minilm"` (Ollama). + +- **`scraping_method`**: `"simple"` is the only option for now. More powerful options coming in future versions. + ```json { "provider": "openai", "provider_per_model": { "general": "ollama", "code_gen": "anthropic", - "brainstormer": null, // autofilled with 'openai' - "web_searcher": null // autofilled with 'openai' + "brainstormer": null, + "web_searcher": null }, "model": "gpt-4o", "models": { "general": "gpt-oss:20b", "code_gen": "claude-sonnet-3.5", - "brainstormer": null, // autofilled with 'gpt-4o' - "web_searcher": null // autofilled with 'gpt-4o' + "brainstormer": null, + "web_searcher": null }, "temperatures": { - "general": 0.7, - "code_gen": 0, + "general": 1, + "code_gen": 1, "brainstormer": 1, - "web_searcher": 0 + "web_searcher": 1 }, "system_prompts": { - // (recommended) leave as-is to use Ally's defaults "general": null, "code_gen": null, "brainstormer": null, "web_searcher": null }, - "embedding_provider": null, // example: "hf" or "ollama" - "embedding_model": null, // example: "sentence-transformers/all-MiniLM-L6-v2" or "all-minilm" + "embedding_provider": null, + "embedding_model": null, - "scraping_method": "simple" // or "docling" + "scraping_method": "simple" } ``` -> **Note**: Docling is _heavy_. And requires lots of dependencies. It's recommended to go with the local install if you wish to use Docling. - -> **Alternatively**, you could setup a volume (for the parsing and the embedding models) between your machine and the container so that models are persisted across sessions. See below for information where the models are stored inside the container by default. +> **Note**: You could setup a volume for the embedding models between your machine and the container so that models are persisted across sessions. See below for information where the models are stored inside the container by default. ### 3. Configure `.env` in `/Ally` @@ -228,7 +243,10 @@ This file stores your API keys. OPENAI_API_KEY=... ANTHROPIC_API_KEY=... GOOGLE_GEN_AI_API_KEY=... +GROQ_API_KEY=... CEREBRAS_API_KEY=... +OPENROUTER_API_KEY=... +GITHUB_AI_API_KEY=... # Embedding providers APIs (As needed. Only add those you need.) @@ -258,14 +276,21 @@ Use `ally -h` for more help. ## Technical notes -1. Edit the following environment variable if needed: +1. **Logging**: Ally uses a centralized logging system that records detailed error information in log files. Error messages shown to users are kept concise for better readability, while full technical details (including stack traces) are logged for debugging purposes. + + - Log files are stored in: + - Windows: `%LOCALAPPDATA%\Ally\logs\` + - Linux/MacOS: `~/.local/share/Ally/logs/` + - Logs are organized by date: `ally_YYYYMMDD.log` + - When reporting issues, please include relevant log file excerpts + +2. Edit the following environment variable if needed: | Environment Variable | Purpose | | --------------------------- | --------------------------------------------------------------- | | `ALLY_HISTORY_DIR` | Controls where Ally stores its history. | | `ALLY_DATABASE_DIR` | Controls where Ally stores its database. | | `ALLY_EMBEDDING_MODELS_DIR` | Controls where Ally stores its embedding models (Hugging Face). | -| `ALLY_PARSING_MODELS_DIR` | Controls where Ally stores its parsing models used by Docling. | Defaults are: @@ -277,11 +302,11 @@ Linux & MacOS: ~/.local/share/Ally/... ``` -2. RAG-related tools used by Ally are large in size and are therefore downloaded only after RAG settings are enabled in the config.json file. As a result, Ally will perform additional downloads the next time it is launched following these configuration changes. +3. RAG-related tools used by Ally are large in size and are therefore downloaded only after RAG settings are enabled in the config.json file. As a result, Ally will perform additional downloads the next time it is launched following these configuration changes. -3. To save a chat, use /id to view the conversation ID. The next time you open Ally, continue the conversation by using the -i flag followed by the ID. You can do the same inside the CLI, just do `/id ` +4. To save a chat, use /id to view the conversation ID. The next time you open Ally, continue the conversation by using the -i flag followed by the ID. You can do the same inside the CLI, just do `/id ` -4. Embedding and scraping files that require OCR (such as PDFs and DOCX) currently use a CPU-only PyTorch installation. You can modify the configuration to utilize a GPU if desired, though this is typically only necessary for processing very large files. +5. Embedding and scraping files that require OCR (such as PDFs and DOCX) currently use a CPU-only PyTorch installation. You can modify the configuration to utilize a GPU if desired, though this is typically only necessary for processing very large files. ## License diff --git a/app/src/agents/general/config/system_prompt.md b/app/src/agents/general/config/system_prompt.md index 85ab077..f12cafc 100644 --- a/app/src/agents/general/config/system_prompt.md +++ b/app/src/agents/general/config/system_prompt.md @@ -1,3 +1,4 @@ +Your name is Ally. You are a pragmatic, general‑purpose assistant running in a CLI tool focused on solving user problems end‑to‑end using the tools at your disposal. Your goals are to produce correct, useful results, ask for missing details when needed, and keep communication concise and friendly. Current date: {date} diff --git a/app/src/cli/cli.py b/app/src/cli/cli.py index 27248ff..4e426e3 100644 --- a/app/src/cli/cli.py +++ b/app/src/cli/cli.py @@ -1,6 +1,7 @@ from app.src.orchestration.integrate_web_search import integrate_web_search from app.src.orchestration.units.orchestrated_codegen import CodeGenUnit from app.utils.ui_messages import UI_MESSAGES +from app.utils.logger import logger from app.src.embeddings.handle_commands import ( handle_embed_request, handle_index_request, @@ -70,29 +71,25 @@ def __init__( self.embedding_function = OpenAIEmbedder(embedding_model).get_embeddings self.rag_available = True - + case "nlpcloud" | "nlp cloud" | "nlp_cloud": from app.src.embeddings.embedding_functions.nlp_cloud_embed import ( NLPCloudEmbedder, ) - self.embedding_function = NLPCloudEmbedder(embedding_model).get_embeddings + self.embedding_function = NLPCloudEmbedder( + embedding_model + ).get_embeddings self.rag_available = True case _: self.embedding_function = None self.rag_available = False - - match scraping_method.lower(): - case "docling": - from app.src.embeddings.scrapers.docling_scraper import DoclingScraper - - self.scraper = DoclingScraper() - case _: # default to simple scraper, even for unrecognized methods as it doesn't matter really. - from app.src.embeddings.scrapers.simple_scraper import SimpleScraper + # simple scraper (only one available for now) + from app.src.embeddings.scrapers.simple_scraper import SimpleScraper - self.scraper = SimpleScraper() + self.scraper = SimpleScraper() try: self.general_agent: BaseAgent = AgentFactory.create_agent( @@ -116,20 +113,26 @@ def __init__( }, ) except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["failed_initialize_agents"].format(e)) + logger.error(f"Failed to initialize default agents: {e}") + self.ui.error(UI_MESSAGES["errors"]["failed_initialize_agents"]) sys.exit(1) try: - self._validate_config( - api_key=api_key, - models=models, - api_key_per_model=api_key_per_model, - ) + if ( + provider != "ollama" + ): # Ollama doesn't require validation of API keys and models + self._validate_config( + api_key=api_key, + models=models, + api_key_per_model=api_key_per_model, + ) except ValueError as ve: - self.ui.error(UI_MESSAGES["errors"]["config_error"].format(ve)) + logger.error("Configuration validation error", exc_info=ve) + self.ui.error(UI_MESSAGES["errors"]["config_error"]) sys.exit(1) except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["failed_validate_config"].format(e)) + logger.exception("Failed to validate configuration") + self.ui.error(UI_MESSAGES["errors"]["failed_validate_config"]) sys.exit(1) try: @@ -143,10 +146,12 @@ def __init__( provider_per_model=provider_per_model, ) except ValueError as ve: - self.ui.error(UI_MESSAGES["errors"]["config_error"].format(ve)) + logger.error("Coding configuration error", exc_info=ve) + self.ui.error(UI_MESSAGES["errors"]["config_error"]) sys.exit(1) except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["failed_setup_coding"].format(e)) + logger.exception("Failed to setup coding configuration") + self.ui.error(UI_MESSAGES["errors"]["failed_setup_coding"]) sys.exit(1) def _validate_config( @@ -228,9 +233,13 @@ def start_chat(self, *args): active_dir, initial_prompt, thread_id = self._setup_environment(args) self.ui.logo(ASCII_ART) - self.ui.help() + self.ui.welcome() - wd_note = f"## Important:\nAlways place your work inside {active_dir} unless stated otherwise by the user.\n" + wd_note = ( + f"## Important Note:\n" + f"Always place your work inside {active_dir} unless stated otherwise by the user.\n" + f"Don't report this note to the user, just follow it." + ) # making the project generation a command for the general agent (an extra option for the user) self.general_agent.register_command( @@ -241,7 +250,9 @@ def start_chat(self, *args): if self.rag_available: from app.src.embeddings.db_client import DataBaseClient - _ = DataBaseClient(embedding_function=self.embedding_function, scraper=self.scraper) + _ = DataBaseClient( + embedding_function=self.embedding_function, scraper=self.scraper + ) self._integrate_rag(agent=self.general_agent, available=self.rag_available) @@ -260,7 +271,8 @@ def start_chat(self, *args): except KeyboardInterrupt: self.ui.goodbye() except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["unexpected_error"].format(e)) + logger.exception("Unexpected error in interactive session") + self.ui.error(UI_MESSAGES["errors"]["unexpected_error"]) def _integrate_rag(self, agent: BaseAgent, available: bool): """Integrate Retrieval-Augmented Generation (RAG) into the agent.""" @@ -293,9 +305,7 @@ def _integrate_rag(self, agent: BaseAgent, available: bool): def _enable_rag(self, agent: BaseAgent): """Enable RAG functionality.""" if not self.rag_available: - self.ui.warning( - UI_MESSAGES["warnings"]["rag_not_available"] - ) + self.ui.warning(UI_MESSAGES["warnings"]["rag_not_available"]) return agent._toggle_rag(enable=True) self.ui.status_message( @@ -329,14 +339,13 @@ def launch_coding_units(self, initial_prompt: str = None, active_dir: str = None ) if not codegen_unit_success: - self.ui.error( - UI_MESSAGES["errors"]["codegen_failed"] - ) + self.ui.error(UI_MESSAGES["errors"]["codegen_failed"]) except KeyboardInterrupt: self.ui.goodbye() except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["unexpected_error"].format(e)) + logger.exception("Unexpected error in project workflow") + self.ui.error(UI_MESSAGES["errors"]["unexpected_error"]) def _setup_environment(self, user_args: list[str] = None) -> tuple[str, str, str]: """Setup working environment and configuration.""" @@ -358,7 +367,8 @@ def _setup_environment(self, user_args: list[str] = None) -> tuple[str, str, str if parsed_args.d == ".": parsed_args.d = os.getcwd() elif not validate_dir_name(parsed_args.d): - self.ui.error(UI_MESSAGES["errors"]["invalid_directory"].format(parsed_args.d)) + logger.error(f"Invalid directory name: {parsed_args.d}") + self.ui.error(UI_MESSAGES["errors"]["invalid_directory"]) sys.exit(1) active_dir = parsed_args.d @@ -467,5 +477,6 @@ def _run_codegen_unit(self, working_dir: str, initial_prompt: str = None) -> boo ) except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["failed_initialize_coding"].format(e)) + logger.exception("Failed to initialize coding workflow") + self.ui.error(UI_MESSAGES["errors"]["failed_initialize_coding"]) return False diff --git a/app/src/cli/flags.py b/app/src/cli/flags.py index 997b5c4..5e5127d 100644 --- a/app/src/cli/flags.py +++ b/app/src/cli/flags.py @@ -1,5 +1,6 @@ import argparse from app.src.core.ui import AgentUI +from app.utils.logger import logger import sys @@ -27,6 +28,7 @@ def __init__(self, ui: AgentUI): ) def error(self, message): + logger.error(f"Argument parsing error: {message}") usage = self.format_help() self.ui.error(f"{message}\n{usage}") sys.exit(2) diff --git a/app/src/core/base.py b/app/src/core/base.py index e6997a7..961d1c6 100644 --- a/app/src/core/base.py +++ b/app/src/core/base.py @@ -3,6 +3,7 @@ from app.src.core.exception_handler import AgentExceptionHandler from app.src.core.permissions import PermissionDeniedException from app.src.embeddings.rag_errors import SetupFailedError, DBAccessError +from app.utils.logger import logger from langchain_core.messages import AIMessage, ToolMessage, BaseMessage, HumanMessage from app.src.embeddings.db_client import DataBaseClient from langgraph.graph.state import CompiledStateGraph @@ -13,6 +14,7 @@ from app.utils.ui_messages import UI_MESSAGES import uuid import os +import re import openai @@ -186,9 +188,10 @@ def start_chat( self.ui.goodbye() return True - except openai.NotFoundError: + except openai.NotFoundError as e: + logger.error(f"Model not found: {self.model_name}", exc_info=e) self.ui.error( - UI_MESSAGES["errors"]["model_not_found"].format(self.model_name) + UI_MESSAGES["errors"]["model_not_found"] ) if self.prev_model_name: self.ui.status_message( @@ -197,6 +200,7 @@ def start_chat( ) self.model_name = self.prev_model_name self._handle_model_change(self.model_name) + self.prev_model_name = None else: if self.ui.confirm( UI_MESSAGES["confirmations"]["change_model"], default=True @@ -213,9 +217,10 @@ def start_chat( return True except PermissionDeniedException: - continue # Do nothing. Let the user enter a new input. + continue # Do nothing. Let the user enter a new prompt. - except lg_errors.GraphRecursionError: + except lg_errors.GraphRecursionError as e: + logger.warning("Graph recursion limit reached", exc_info=e) self.ui.warning(UI_MESSAGES["warnings"]["recursion_limit_reached"]) if self.ui.confirm( UI_MESSAGES["confirmations"]["continue_from_left_off"], default=True @@ -224,7 +229,8 @@ def start_chat( else: return False - except openai.RateLimitError: + except openai.RateLimitError as e: + logger.error("OpenAI rate limit exceeded", exc_info=e) self.ui.error(UI_MESSAGES["errors"]["rate_limit_exceeded"]) if self.ui.confirm( UI_MESSAGES["confirmations"]["change_model"], default=True @@ -240,7 +246,8 @@ def start_chat( return True except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["unexpected_error"].format(e)) + logger.exception("Unexpected error in chat loop") + self.ui.error(UI_MESSAGES["errors"]["unexpected_error"]) return False finally: @@ -327,8 +334,9 @@ def _handle_command(self, user_input: str, configuration: dict) -> bool: self.rag = False except Exception as e: + logger.exception(f"Command failed: {cmd}") self.ui.error( - UI_MESSAGES["errors"]["command_failed"].format(cmd, e) + UI_MESSAGES["errors"]["command_failed"] ) finally: @@ -471,10 +479,7 @@ def _extract_response_content(self, raw_response: dict) -> str: def _remove_thinking_block(self, content: str) -> str: """Remove thinking block from response content.""" - think_end = content.find("") - if think_end != -1: - return content[think_end + len("") :].strip() - return content + return re.sub(r".*?\s*", "", content, flags=re.DOTALL) def _display_chunk(self, chunk: BaseMessage | dict): """Display chunk content in the UI.""" @@ -501,11 +506,10 @@ def _handle_dict_chunk(self, chunk: dict): def _handle_ai_message(self, message: AIMessage): """Handle AI message display.""" - if message.tool_calls: - for tool_call in message.tool_calls: - self.ui.tool_call(tool_call["name"], tool_call["args"]) if message.content and message.content.strip(): - self.ui.ai_response(message.content) + # remove thinking blocks before displaying + content = self._remove_thinking_block(message.content) + self.ui.ai_response(content) def _handle_tool_message(self, message: ToolMessage): """Handle tool message display.""" diff --git a/app/src/core/create_base_agent.py b/app/src/core/create_base_agent.py index 861263a..f21f83e 100644 --- a/app/src/core/create_base_agent.py +++ b/app/src/core/create_base_agent.py @@ -1,12 +1,13 @@ from app.utils.constants import DEFAULT_PATHS, LAST_N_TURNS from app.src.helpers.valid_dir import validate_dir_name from app.src.core.ui import default_ui +from app.utils.ui_messages import UI_MESSAGES from langgraph.graph.state import CompiledStateGraph -from langchain_core.messages import BaseMessage, HumanMessage, AIMessage +from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage from langgraph.graph.message import add_messages from langgraph.graph import StateGraph, END, START -from typing import TypedDict, Annotated -from langgraph.prebuilt import ToolNode, tools_condition +from typing import TypedDict, Annotated, Callable +from langgraph.prebuilt import tools_condition from langgraph.checkpoint.sqlite import SqliteSaver from langchain_core.prompts import ChatPromptTemplate from pathlib import Path @@ -20,6 +21,100 @@ class State(TypedDict): messages: Annotated[list[BaseMessage], add_messages] +class SequentialToolNode: + """A custom tool node that processes tool calls one at a time. + + This prevents UI collisions when multiple tools require permission + confirmation by processing them sequentially instead of in parallel. + """ + + def __init__(self, tools: list, handle_tool_errors: bool = False): + self.tools_by_name: dict[str, Callable] = {tool.name: tool for tool in tools} + self.handle_tool_errors = handle_tool_errors + + def __call__(self, state: State) -> dict: + """Process tool calls from the last AI message sequentially.""" + messages = state.get("messages", []) + if not messages: + return {"messages": []} + + # Find the last AI message with tool calls + last_ai_message = None + for msg in reversed(messages): + if isinstance(msg, AIMessage) and msg.tool_calls: + last_ai_message = msg + break + + if not last_ai_message or not last_ai_message.tool_calls: + return {"messages": []} + + tool_calls = last_ai_message.tool_calls + total_tools = len(tool_calls) + result_messages = [] + + # Show pending tools notification if more than 1 + if total_tools > 1: + default_ui.pending_tools(total_tools) + + # Process each tool call one at a time + for idx, tool_call in enumerate(tool_calls, start=1): + tool_name = tool_call["name"] + tool_args = tool_call["args"] + tool_call_id = tool_call["id"] + + # Show progress if multiple tools + if total_tools > 1: + default_ui.processing_tool(idx, total_tools, tool_name, tool_args) + + default_ui.tool_call(tool_name, tool_args) + + tool = self.tools_by_name.get(tool_name) + if tool is None: + result_messages.append( + ToolMessage( + content=f"Tool '{tool_name}' not found.", + name=tool_name, + tool_call_id=tool_call_id, + ) + ) + continue + + try: + # Execute the tool (this will trigger the permission check inside the tool) + result = tool.invoke(tool_args) + result_messages.append( + ToolMessage( + content=str(result), + name=tool_name, + tool_call_id=tool_call_id, + ) + ) + except Exception as e: + error_content = f"Error: {e}" + # Check if it's a permission denied exception + if "PermissionDeniedException" in type(e).__name__: + error_content = UI_MESSAGES["tool"]["permission_denied"].format( + tool_name + ) + + result_messages.append( + ToolMessage( + content=error_content, + name=tool_name, + tool_call_id=tool_call_id, + ) + ) + + # Re-raise if not handling errors and it's not a permission issue + if ( + not self.handle_tool_errors + and "PermissionDeniedException" not in type(e).__name__ + ): + raise + + return {"messages": result_messages} + + _PATH_ERROR_PRINTED = False @@ -48,6 +143,16 @@ def create_base_agent( llm = None match provider.lower(): + case "groq": + from langchain_groq import ChatGroq + + llm = ChatGroq( + model=model_name, + temperature=temperature, + timeout=None, + max_retries=5, + api_key=api_key, + ) case "cerebras": from langchain_cerebras import ChatCerebras @@ -70,7 +175,7 @@ def create_base_agent( case "google": os.environ["GRPC_VERBOSITY"] = "NONE" os.environ["GRPC_CPP_VERBOSITY"] = "NONE" - + from langchain_google_genai import ChatGoogleGenerativeAI llm = ChatGoogleGenerativeAI( @@ -80,15 +185,23 @@ def create_base_agent( max_retries=5, google_api_key=api_key, ) - case "openai": + case "openai" | "openrouter" | "github": from langchain_openai import ChatOpenAI + if provider.lower() == "openai": + base_url = None + elif provider.lower() == "openrouter": + base_url = "https://openrouter.ai/api/v1" + else: # github + base_url = "https://models.github.ai/inference" + llm = ChatOpenAI( model=model_name, temperature=temperature, timeout=None, max_retries=5, api_key=api_key, + base_url=base_url, ) case "anthropic": from langchain_anthropic import ChatAnthropic @@ -121,7 +234,7 @@ def llm_node(state: State): context = build_llm_context(state["messages"]) return {"messages": [llm_chain.invoke({"messages": context})]} - tool_node = ToolNode(tools=tools, handle_tool_errors=False) + tool_node = SequentialToolNode(tools=tools, handle_tool_errors=False) graph.add_node("llm", llm_node) diff --git a/app/src/core/exception_handler.py b/app/src/core/exception_handler.py index 41ddbba..702070c 100644 --- a/app/src/core/exception_handler.py +++ b/app/src/core/exception_handler.py @@ -1,5 +1,6 @@ from app.src.core.permissions import PermissionDeniedException from app.src.core.ui import AgentUI +from app.utils.logger import logger from typing import Callable, Any import langgraph.errors as lg_errors import openai @@ -24,10 +25,12 @@ def handle_agent_exceptions( try: return operation() - except PermissionDeniedException: + except PermissionDeniedException as e: if propagate: raise + logger.warning("Permission denied for operation", exc_info=e) + if reject_operation: operation = reject_operation continue @@ -35,15 +38,18 @@ def handle_agent_exceptions( ui.error("Permission denied") return None - except lg_errors.GraphRecursionError: + except lg_errors.GraphRecursionError as e: if propagate: raise + logger.warning(f"Graph recursion error, retries: {retries}", exc_info=e) + if retry_operation: ui.warning( "Agent processing took longer than expected (Max recursion limit reached)" ) if retries >= AgentExceptionHandler.MAX_RETRIES: + logger.error(f"Max retries ({AgentExceptionHandler.MAX_RETRIES}) reached") ui.status_message( title="Max Retries Reached", message="Agent has been running for a while now. Please make the necessary adjustments to your prompt.", @@ -61,10 +67,11 @@ def handle_agent_exceptions( ui.error("Max recursion limit reached. Operation cannot continue.") return None - except openai.RateLimitError: + except openai.RateLimitError as e: if propagate: raise + logger.error("OpenAI rate limit exceeded", exc_info=e) ui.error("Rate limit exceeded. Please try again later") return None @@ -72,5 +79,6 @@ def handle_agent_exceptions( if propagate: raise - ui.error(f"An unexpected error occurred: {e}") + logger.exception("Unexpected error in agent operation") + ui.error("An unexpected error occurred") return None diff --git a/app/src/core/ui.py b/app/src/core/ui.py index ce29447..9b8fa17 100644 --- a/app/src/core/ui.py +++ b/app/src/core/ui.py @@ -1,137 +1,255 @@ -import os +import sys +import time from rich.console import Console -from app.utils.constants import CONSOLE_WIDTH -from prompt_toolkit.shortcuts import prompt -from prompt_toolkit.key_binding import KeyBindings from rich.markdown import Markdown -from rich.prompt import Confirm from rich.panel import Panel from rich.text import Text +from rich import box +from prompt_toolkit.shortcuts import prompt, choice +from prompt_toolkit.key_binding import KeyBindings +from rich.prompt import Confirm from typing import Any -from app.utils.constants import THEME -from app.utils.ui_messages import UI_MESSAGES -import time -import sys - -if os.name == "nt": - import msvcrt -else: - import tty - import termios +from app.utils.constants import CONSOLE_WIDTH, THEME +from app.utils.ui_messages import UI_MESSAGES class AgentUI: + """Minimal, modern CLI interface for Ally.""" def __init__(self, console: Console): self.console = console + self._tool_start_time: float | None = None def _style(self, color_key: str) -> str: return THEME.get(color_key, THEME["text"]) + def _format_duration(self, seconds: float) -> str: + """Format duration in human-readable form.""" + if seconds < 1: + return f"{seconds * 1000:.0f}ms" + elif seconds < 60: + return f"{seconds:.1f}s" + else: + mins = int(seconds // 60) + secs = seconds % 60 + return f"{mins}m {secs:.0f}s" + + # ───────────────────────────────────────────────────────────── + # Branding + # ───────────────────────────────────────────────────────────── + def logo(self, ascii_art: str): + """Display ASCII logo with simple styling for terminal compatibility.""" lines = ascii_art.split("\n") - n = max(len(lines) - 1, 1) - for i, line in enumerate(lines): - progress = max(0.0, (i - 1) / n) - - red = int(139 + (239 - 139) * progress) - green = int(92 + (68 - 92) * progress) - blue = int(246 + (68 - 246) * progress) + # Use theme colors that fallback gracefully on limited color terminals + styles = [ + f"bold {self._style('primary')}", + f"bold {self._style('secondary')}", + ] - color = f"#{red:02x}{green:02x}{blue:02x}" - text = Text(line, style=f"bold {color}") - self.console.print(text) + for i, line in enumerate(lines): + # Alternate between two theme colors for visual interest + style = styles[i % 2] if i > 0 else f"bold {self._style('primary')}" + self.console.print(Text(line, style=style)) def help(self, model_name: str = None): + """Display help information.""" help_content = UI_MESSAGES["help"]["content"].copy() - if model_name: help_content.append("") help_content.append(UI_MESSAGES["help"]["model_suffix"].format(model_name)) - help_content.append(UI_MESSAGES["help"]["footer"]) markdown_content = Markdown("\n".join(help_content)) - panel = Panel( markdown_content, - title=f"[bold]{UI_MESSAGES['titles']['help']}[/bold]", - border_style=self._style("muted"), - padding=(1, 2), + title=f"[bold]Help[/bold]", + border_style=self._style("primary"), + padding=(0, 1), ) + self.console.print() self.console.print(panel) + self.console.print() + + def welcome(self): + """Display a brief welcome message.""" + parts = UI_MESSAGES["messages"]["welcome"].split("Ally") + welcome_text = Text() + welcome_text.append(parts[0], style=self._style("text")) + welcome_text.append("Ally", style=f"bold {self._style('primary')}") + welcome_text.append(parts[1] + "\n", style=self._style("text")) + + help_msg = UI_MESSAGES["messages"]["welcome_help_hint"].split("/help") + welcome_text.append(help_msg[0], style=self._style("muted")) + welcome_text.append("/help", style=f"bold {self._style('accent')}") + welcome_text.append(help_msg[1], style=self._style("muted")) + self.console.print(welcome_text) + self.console.print() + + # ───────────────────────────────────────────────────────────── + # Tool Execution Display + # ───────────────────────────────────────────────────────────── def tool_call(self, tool_name: str, args: dict[str, Any]): - tool_name = UI_MESSAGES["tool"]["title"].format(tool_name) - content_parts = [tool_name] + """Display tool being executed with minimal chrome.""" + self._tool_start_time = time.time() + + self.console.print() + self.console.print( + f"[{self._style('dim')}]>[/{self._style('dim')}] " + f"[bold {self._style('accent')}]{tool_name}[/bold {self._style('accent')}]" + ) + if args: - content_parts.append(UI_MESSAGES["tool"]["arguments_header"]) for k, v in args.items(): - value_str = str(v) - if len(value_str) > 100 or "\n" in value_str: - content_parts.append( - f"- **{k}:**\n```\n{value_str[:500]}{'...' if len(value_str) > 500 else ''}\n```" - ) - else: - content_parts.append(f"- **{k}:** `{value_str}`") + # Truncate long values + if len(value_str) > 80: + value_str = value_str[:77] + "..." + elif "\n" in value_str: + value_str = value_str.split("\n")[0][:60] + "..." + self.console.print( + f"[{self._style('dim')}]{k}:[/{self._style('dim')}]\n" + f"[{self._style('muted')}]{value_str}[/{self._style('muted')}]" + ) - markdown_content = "\n".join(content_parts) + def tool_output(self, tool_name: str, content: str): + """Display tool completion with elapsed time.""" + elapsed = "" + if self._tool_start_time: + duration = time.time() - self._tool_start_time + elapsed = f" [{self._style('dim')}]{self._format_duration(duration)}[/{self._style('dim')}]" + self._tool_start_time = None + + # Truncate long output + if len(content) > 800: + content = content[:800] + "\n..." + + self.blank() + self.console.print( + f" [{self._style('success')}]Done[/{self._style('success')}]{elapsed}" + ) + + # Only show output if there's meaningful content + if content.strip() and content.strip() not in ["None", "null", ""]: + # Show output in a subtle code block style + for line in content.strip().split("\n")[:10]: # Max 10 lines + self.console.print( + f"[{self._style('muted')}]{line}[/{self._style('muted')}]" + ) + if content.strip().count("\n") > 10: + self.console.print(f"[{self._style('dim')}]...[/{self._style('dim')}]") + + # ───────────────────────────────────────────────────────────── + # AI Response + # ───────────────────────────────────────────────────────────── + + def ai_response(self, content: str): + """Display AI response with minimal framing.""" + self.console.print() try: - rendered_content = Markdown(markdown_content) - except: - rendered_content = markdown_content + rendered = Markdown(content) + except Exception: + rendered = content panel = Panel( - rendered_content, - title=f"[bold]{UI_MESSAGES['titles']['tool_executing']}[/bold]", - border_style=self._style("accent"), + rendered, + box=box.ROUNDED, + border_style=self._style("border"), padding=(1, 2), + title=f"[{self._style('primary')}]Ally[/{self._style('primary')}]", + title_align="left", ) self.console.print(panel) - def tool_output(self, tool_name: str, content: str): - tool_name = f"{tool_name}" - if len(content) > 1000: - content = content[:1000] + UI_MESSAGES["tool"]["truncated"] + # ───────────────────────────────────────────────────────────── + # Status & Messages + # ───────────────────────────────────────────────────────────── - markdown_content = ( - f"{UI_MESSAGES['tool']['output_header']}\n```\n{content}\n```" + def status_message(self, title: str, message: str, style: str = "primary"): + """Display a simple inline status message.""" + prefix_map = { + "primary": ".", + "success": "+", + "warning": "!", + "error": "x", + } + prefix = prefix_map.get(style, ".") + self.console.print( + f"[{self._style(style)}]{prefix}[/{self._style(style)}] " + f"[bold]{title}[/bold] " + f"[{self._style('muted')}]{message}[/{self._style('muted')}]" ) - try: - rendered_content = Markdown(markdown_content) - except: - rendered_content = markdown_content + def info(self, message: str): + """Display informational message.""" + self.console.print(f"[{self._style('dim')}].[/{self._style('dim')}] {message}") + def thinking(self, message: str = "Thinking"): + """Display a thinking/processing indicator.""" self.console.print( - f"[{self._style('secondary')}]{UI_MESSAGES['tool']['tool_complete'].format(tool_name)}[/{self._style('secondary')}]" + f"[{self._style('dim')}]...[/{self._style('dim')}] [{self._style('muted')}]{message}[/{self._style('muted')}]" ) - self.console.print(rendered_content) - def ai_response(self, content: str): - try: - rendered_content = Markdown(content) - except: - rendered_content = content + def goodbye(self): + """Display goodbye message.""" + self.console.print() + self.console.print( + f"[{self._style('dim')}].[/{self._style('dim')}] {UI_MESSAGES['messages']['goodbye']}" + ) + self.console.print() - panel = Panel( - rendered_content, - title=f"[bold]{UI_MESSAGES['titles']['assistant']}[/bold]", - border_style=self._style("primary"), - padding=(1, 2), + def history_cleared(self): + """Display history cleared confirmation.""" + self.status_message( + "Cleared", UI_MESSAGES["messages"]["history_cleared"], "success" ) - self.console.print(panel) - def status_message(self, title: str, message: str, style: str = "primary"): - panel = Panel( - message, - title=f"[bold]{title}[/bold]", - border_style=self._style(style), - padding=(0, 1), + def session_interrupted(self): + """Display session interrupted message.""" + self.console.print() + self.status_message( + "Interrupted", UI_MESSAGES["messages"]["session_interrupted"], "warning" ) - self.console.print(panel) + + def recursion_warning(self): + """Display warning about extended processing.""" + self.console.print() + self.console.print( + f"[{self._style('warning')}]![/{self._style('warning')}] " + f"[bold]Extended Session[/bold]" + ) + self.console.print( + f" [{self._style('muted')}]{UI_MESSAGES['messages']['recursion_warning']}[/{self._style('muted')}]" + ) + self.console.print() + + def warning(self, warning_msg: str): + """Display warning message.""" + self.status_message("Warning", warning_msg, "warning") + + def error(self, error_msg: str): + """Display error message.""" + self.status_message("Error", error_msg, "error") + + def pending_tools(self, count: int): + """Display pending tool count.""" + self.blank() + self.info(f"{count} tool(s) queued") + + def processing_tool( + self, current: int, total: int, tool_name: str, tool_args: dict + ): + """Display tool processing progress.""" + self.console.print() + self.console.print( + f"[{self._style('dim')}][{current}/{total}][/{self._style('dim')}]" + ) + + # ───────────────────────────────────────────────────────────── + # User Input + # ───────────────────────────────────────────────────────────── def get_input( self, @@ -140,30 +258,31 @@ def get_input( cwd: str | None = None, model: str | None = None, ) -> str: + """Get user input with terminal-style prompt.""" try: - info_parts = [] - if cwd: - info_parts.append(f"[dim]{cwd}[/dim]") - if model: - info_parts.append(f"[dim]{model}[/dim]") + if message: + self.console.print( + f"[{self._style('muted')}]{message}[/{self._style('muted')}]" + ) - info_line = " • ".join(info_parts) if info_parts else "" + # Extract just the folder name from cwd + import os - prompt_content = message or "" - if default: - prompt_content += f" [dim](default: {default})[/dim]" + folder_name = "~" + if cwd: + folder_name = os.path.basename(cwd.rstrip(os.sep)) or "~" - if info_line: - prompt_content += ( - f"\n{info_line}" if prompt_content.strip() else info_line - ) + # Terminal-style prompt: > foldername$ + from prompt_toolkit.formatted_text import ANSI - panel = Panel( - prompt_content, border_style=self._style("border"), padding=(0, 1) + # Build the prefix with ANSI color codes + prompt_text = ( + f"\033[38;2;16;185;129m>\033[0m " + f"\033[38;2;6;182;212m {folder_name}\033[0m" + f"\033[38;2;16;185;129m$\033[0m " ) - self.console.print(panel) - # using prompt-toolkit for multiline support + # Multiline key bindings key_binds = KeyBindings() @key_binds.add("c-n") @@ -174,7 +293,7 @@ def _(event): def _(event): event.current_buffer.validate_and_handle() - result = prompt(">> ", multiline=True, key_bindings=key_binds) + result = prompt(ANSI(prompt_text), multiline=True, key_bindings=key_binds) return result.strip() if result else (default or "") except KeyboardInterrupt: @@ -185,121 +304,53 @@ def _(event): return default or "" def confirm(self, message: str, default: bool = True) -> bool: + """Get yes/no confirmation.""" try: - panel = Panel(message, border_style=self._style("warning"), padding=(0, 1)) - self.console.print(panel) + self.console.print() + self.console.print( + f"[{self._style('warning')}]?[/{self._style('warning')}] {message}" + ) return Confirm.ask( - ">>", default=default, console=self.console, show_default=False + ">", default=default, console=self.console, show_default=True ) except KeyboardInterrupt: self.session_interrupted() sys.exit(0) except Exception: self.warning( - UI_MESSAGES["warnings"]["failed_confirm"].format( - "y" if default else "n" - ) + f"Confirmation failed, using default: {'yes' if default else 'no'}" ) return default - def get_key(self): - """Read a single key press and return a string identifier.""" - if os.name == "nt": - key = msvcrt.getch() - if key == b"\xe0": # Special keys (arrows, F keys, etc.) - key = msvcrt.getch() - return { - b"H": "UP", - b"P": "DOWN", - }.get(key, None) - elif key in (b"\r", b"\n"): - return "ENTER" - else: - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch1 = sys.stdin.read(1) - if ch1 == "\x1b": # Escape sequence - ch2 = sys.stdin.read(1) - if ch2 == "[": - ch3 = sys.stdin.read(1) - return { - "A": "UP", - "B": "DOWN", - }.get(ch3, None) - elif ch1 in ("\r", "\n"): - return "ENTER" - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - return None - def select_option(self, message: str, options: list[str]) -> int: - idx = 0 - self.console.print(f"\n{message}") - for i, opt in enumerate(options): - prefix = "▶ " if i == idx else " " - print(f"{prefix}{opt}") - - while True: - key = self.get_key() - if key == "UP" and idx > 0: - idx -= 1 - elif key == "DOWN" and idx < len(options) - 1: - idx += 1 - elif key == "ENTER": - return idx - - # Move cursor up to menu start - sys.stdout.write(f"\033[{len(options)}A") - for i, opt in enumerate(options): - prefix = "▶ " if i == idx else " " - sys.stdout.write(f"{prefix}{opt}\033[K\n") - sys.stdout.flush() - - def goodbye(self): - self.status_message( - title=UI_MESSAGES["titles"]["goodbye"], - message=UI_MESSAGES["messages"]["goodbye"], - style="primary", - ) - - def history_cleared(self): - self.status_message( - title=UI_MESSAGES["titles"]["history_cleared"], - message=UI_MESSAGES["messages"]["history_cleared"], - style="success", - ) + """Display selection menu.""" + values = [(i, opt) for i, opt in enumerate(options)] + try: + self.console.print() + result = choice(message=message, options=values) + return result + except KeyboardInterrupt: + self.session_interrupted() + sys.exit(0) + except Exception: + return 0 - def session_interrupted(self): - self.status_message( - title=UI_MESSAGES["titles"]["interrupted"], - message=UI_MESSAGES["messages"]["session_interrupted"], - style="warning", - ) + # ───────────────────────────────────────────────────────────── + # Utility + # ───────────────────────────────────────────────────────────── - def recursion_warning(self): - panel = Panel( - UI_MESSAGES["messages"]["recursion_warning"], - title=f"[bold]{UI_MESSAGES['titles']['extended_session']}[/bold]", - border_style=self._style("warning"), - padding=(1, 2), - ) - self.console.print(panel) - - def warning(self, warning_msg: str): - self.status_message( - title=UI_MESSAGES["titles"]["warning"], - message=f"{warning_msg}", - style="warning", - ) + def divider(self, title: str = None): + """Print a subtle divider line.""" + if title: + self.console.rule( + f"[{self._style('dim')}]{title}[/{self._style('dim')}]", style="dim" + ) + else: + self.console.rule(style="dim") - def error(self, error_msg: str): - self.status_message( - title=UI_MESSAGES["titles"]["error"], - message=f"{error_msg}", - style="error", - ) + def blank(self): + """Print a blank line.""" + self.console.print() default_ui = AgentUI(Console(width=CONSOLE_WIDTH)) diff --git a/app/src/embeddings/db_client.py b/app/src/embeddings/db_client.py index 587cc36..0e60b2b 100644 --- a/app/src/embeddings/db_client.py +++ b/app/src/embeddings/db_client.py @@ -3,6 +3,7 @@ from app.src.helpers.valid_dir import validate_dir_name from app.src.embeddings.rag_errors import DBAccessError, ScrapingFailedError from app.src.core.ui import default_ui +from app.utils.logger import logger from app.utils.ui_messages import UI_MESSAGES from typing import Callable, Any from pathlib import Path @@ -44,26 +45,9 @@ def __init__( import chromadb from chromadb.config import Settings except ImportError: - with default_ui.console.status( - "Embedding config found. Installing additional required packages: ChromaDB" - ): - try: - import subprocess - import sys - - # in case the user didn't setup RAG from the beginning - # we lazy-install chromadb when needed - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "chromadb", "-qqq"] - ) - - except Exception as e: - default_ui.error( - UI_MESSAGES["errors"]["failed_install_packages"].format(e) - ) - raise DBAccessError() - import chromadb - from chromadb.config import Settings + raise ImportError( + "ChromaDB is not installed. Please run 'pip install chromadb' to use the database features." + ) os.makedirs(DB_PATH, exist_ok=True) @@ -94,8 +78,9 @@ def _ensure_db_directory_exists(self) -> None: try: DB_PATH.mkdir(parents=True, exist_ok=True) except Exception as e: + logger.error(f"Failed to create database directory: {DB_PATH}", exc_info=e) default_ui.error( - UI_MESSAGES["errors"]["failed_create_db_directory"].format(e) + UI_MESSAGES["errors"]["failed_create_db_directory"] ) raise @@ -115,7 +100,8 @@ def _save_indexed_collections(self) -> None: with open(self.indexed_collections_path, "w") as f: json.dump(self.indexed_collections, f, indent=2) except Exception as e: - default_ui.error(UI_MESSAGES["errors"]["failed_save_indexed"].format(e)) + logger.error(f"Failed to save indexed collections", exc_info=e) + default_ui.error(UI_MESSAGES["errors"]["failed_save_indexed"]) def index_collection(self, collection_name: str) -> None: """Mark a collection as indexed.""" @@ -236,8 +222,9 @@ def store_documents(self, directory_path: str, collection_name: str) -> None: """Store all documents from a directory into the database.""" if not validate_dir_name(directory_path): + logger.error(f"Invalid directory path: {directory_path}") default_ui.error( - UI_MESSAGES["errors"]["invalid_directory_path"].format(directory_path) + UI_MESSAGES["errors"]["invalid_directory_path"] ) return @@ -258,17 +245,19 @@ def store_documents(self, directory_path: str, collection_name: str) -> None: if self.was_modified(directory_path, collection_name): self.store_document(directory_path, collection_name) - except ScrapingFailedError: + except ScrapingFailedError as e: + logger.error(f"Failed to scrape file: {directory_path}", exc_info=e) default_ui.error( - UI_MESSAGES["errors"]["failed_scrape"].format(directory_path) + UI_MESSAGES["errors"]["failed_scrape"] ) except: raise if not os.path.exists(directory_path): + logger.error(f"Directory does not exist: {directory_path}") default_ui.error( - UI_MESSAGES["errors"]["directory_not_exist"].format(directory_path) + UI_MESSAGES["errors"]["directory_not_exist"] ) return @@ -279,9 +268,10 @@ def store_documents(self, directory_path: str, collection_name: str) -> None: if self.was_modified(file_path, collection_name): self.store_document(file_path, collection_name) - except ScrapingFailedError: + except ScrapingFailedError as e: + logger.error(f"Failed to scrape file: {file_path}", exc_info=e) default_ui.error( - UI_MESSAGES["errors"]["failed_scrape"].format(file_path) + UI_MESSAGES["errors"]["failed_scrape"] ) except: @@ -320,9 +310,10 @@ def delete_collection(self, collection_name: str) -> None: style="success", ) - except chromadb_errors.NotFoundError: + except chromadb_errors.NotFoundError as e: + logger.warning(f"Collection not found: {collection_name}", exc_info=e) default_ui.error( - UI_MESSAGES["errors"]["collection_not_exist"].format(collection_name) + UI_MESSAGES["errors"]["collection_not_exist"] ) except Exception: diff --git a/app/src/embeddings/scrapers/docling_scraper.py b/app/src/embeddings/scrapers/docling_scraper.py deleted file mode 100644 index c2da138..0000000 --- a/app/src/embeddings/scrapers/docling_scraper.py +++ /dev/null @@ -1,120 +0,0 @@ -from app.src.core.ui import default_ui -from app.src.embeddings.scrapers.docling_setup import ARTIFACTS_PATH, setup -from app.src.embeddings.scrapers.abstract_scraper import Scraper -from app.utils.constants import REGULAR_FILE_EXTENSIONS -from app.src.embeddings.rag_errors import SetupFailedError, ScrapingFailedError -from pathlib import Path -import datetime - - -_RETRIED_DOCLING_SETUP = False -_DOC_CONVERTER = None - - -class DoclingScraper(Scraper): - - def get_converter(self): - """Initialize and return a Docling DocumentConverter.""" - setup_flag_file = Path(ARTIFACTS_PATH) / ".setup_flag" - if not setup_flag_file.exists(): - try: - setup(path=ARTIFACTS_PATH) - setup_flag_file.touch(exist_ok=True) # for thread safety - except: - raise SetupFailedError() - - from docling.document_converter import DocumentConverter - from docling.datamodel.base_models import InputFormat - from docling.datamodel.pipeline_options import ( - PdfPipelineOptions, - EasyOcrOptions, - ) - from docling.datamodel.pipeline_options import smolvlm_picture_description - from docling.document_converter import ( - PdfFormatOption, - ImageFormatOption, - WordFormatOption, - ) - - pipeline_options = PdfPipelineOptions( - artifacts_path=ARTIFACTS_PATH, - do_ocr=True, - ocr_options=EasyOcrOptions(force_full_page_ocr=True), - ) - - pipeline_options.do_table_structure = True - pipeline_options.do_formula_enrichment = True - pipeline_options.do_code_enrichment = True - pipeline_options.do_picture_description = True - pipeline_options.generate_picture_images = True - pipeline_options.do_picture_classification = True - - pipeline_options.picture_description_options = smolvlm_picture_description - - doc_converter = DocumentConverter( - format_options={ - InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options), - InputFormat.IMAGE: ImageFormatOption(pipeline_options=pipeline_options), - InputFormat.DOCX: WordFormatOption(pipeline_options=pipeline_options), - } - ) - - return doc_converter - - def scrape(self, file_path: str | Path) -> dict: - """Extract text and metadata from a file using Docling.""" - global _DOC_CONVERTER - - if _DOC_CONVERTER is None: - _DOC_CONVERTER = self.get_converter() - - doc_converter = _DOC_CONVERTER - - if any(file_path.lower().endswith(x) for x in REGULAR_FILE_EXTENSIONS): - content = self.read_regular_file(file_path) - return { - "content": content, - "metadata": { - "file_path": Path(file_path).as_posix(), - "mod_date": datetime.datetime.fromtimestamp( - Path(file_path).stat().st_mtime - ).isoformat(), - "hash": self.get_hash(file_path), - }, - } - - try: - with default_ui.console.status(f"Scraping '{file_path}'..."): - doc = doc_converter.convert(file_path).document - except: - # trying to redownload models ONCE if first scraping fails - global _RETRIED_DOCLING_SETUP - if _RETRIED_DOCLING_SETUP: - raise ScrapingFailedError() - - default_ui.warning( - "Scraping failed. Attempting to redownload parsing models and retry..." - ) - - _RETRIED_DOCLING_SETUP = True - try: - setup(path=ARTIFACTS_PATH) - except: - raise SetupFailedError() - - try: - with default_ui.console.status(f"Scraping '{file_path}'..."): - doc = doc_converter.convert(file_path).document - except: - raise ScrapingFailedError() - - return { - "content": doc.export_to_markdown(), - "metadata": { - "file_path": Path(file_path).as_posix(), - "mod_date": datetime.datetime.fromtimestamp( - Path(file_path).stat().st_mtime - ).isoformat(), - "hash": self.get_hash(file_path), - }, - } diff --git a/app/src/embeddings/scrapers/docling_setup.py b/app/src/embeddings/scrapers/docling_setup.py deleted file mode 100644 index 769848c..0000000 --- a/app/src/embeddings/scrapers/docling_setup.py +++ /dev/null @@ -1,74 +0,0 @@ -from app.src.core.ui import default_ui -from app.utils.constants import DEFAULT_PATHS -from app.src.helpers.valid_dir import validate_dir_name -from pathlib import Path -import os - - -# configure parsing models path -ARTIFACTS_PATH = "" -if "ALLY_PARSING_MODELS_DIR" in os.environ: - ARTIFACTS_PATH = Path(os.getenv("ALLY_PARSING_MODELS_DIR")) - if not validate_dir_name(str(ARTIFACTS_PATH)): - ARTIFACTS_PATH = "" - default_ui.warning( - "Invalid directory path found in $ALLY_PARSING_MODELS_DIR. Reverting to default path." - ) - -if not ARTIFACTS_PATH: - ARTIFACTS_PATH = DEFAULT_PATHS["parsing_models"] - if os.name == "nt": - ARTIFACTS_PATH = Path(os.path.expandvars(ARTIFACTS_PATH)) - else: - ARTIFACTS_PATH = Path(os.path.expanduser(ARTIFACTS_PATH)) - - -def setup(path: str = ARTIFACTS_PATH) -> None: - """ - Prepare environment and download Docling parsing models. - - Ensures required packages are installed and downloads parsing artifacts - (parsing models, EasyOCR model, and smolvlm). - - Parameters - - path (str | Path): target directory for model files (defaults to ARTIFACTS_PATH). - - Notes - - OpenGL drivers may be required for some Docling components. - - Installs the `docling` package (uses CPU PyTorch wheel by default). - - Raises an exception on failure. - """ - - with default_ui.console.status( - "Docling setup found. Installing additional required packages: Docling" - ): - import subprocess - import sys - - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "--extra-index-url", - "https://download.pytorch.org/whl/cpu", # CPU-only PyTorch. Adjust if needed. - "docling==2.55.1", # TODO: fix - "-qqq", - ] - ) - - try: - os.makedirs(path, exist_ok=True) - with default_ui.console.status("Downloading parsing models..."): - from docling.utils.model_downloader import download_models - - download_models( - output_dir=path, - progress=False, - with_smolvlm=True, - ) - - except Exception as e: - default_ui.error(f"Failed to download parsing models: {e}") - raise diff --git a/app/src/orchestration/units/base_unit.py b/app/src/orchestration/units/base_unit.py index 1becd6d..70a4045 100644 --- a/app/src/orchestration/units/base_unit.py +++ b/app/src/orchestration/units/base_unit.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Any from app.src.core.ui import default_ui +from app.utils.logger import logger from app.utils.constants import RECURSION_LIMIT from app.src.core.base import BaseAgent from app.utils.ui_messages import UI_MESSAGES @@ -55,7 +56,8 @@ def _setup_working_directory(self, default_dir: str = None) -> str: ) os.makedirs(working_dir, exist_ok=True) return working_dir - except Exception: + except Exception as e: + logger.error(f"Failed to create directory: {working_dir}", exc_info=e) self.ui.error(UI_MESSAGES["errors"]["failed_create_directory"]) working_dir = None diff --git a/app/src/orchestration/units/orchestrated_codegen.py b/app/src/orchestration/units/orchestrated_codegen.py index 10c331e..7ddf7ce 100644 --- a/app/src/orchestration/units/orchestrated_codegen.py +++ b/app/src/orchestration/units/orchestrated_codegen.py @@ -1,6 +1,7 @@ from app.src.orchestration.units.base_unit import BaseUnit from app.src.core.exception_handler import AgentExceptionHandler from app.src.orchestration.integrate_web_search import integrate_web_search +from app.utils.logger import logger from app.utils.constants import PROMPTS, RECURSION_LIMIT from app.utils.ui_messages import UI_MESSAGES from app.utils.ascii_art import ASCII_ART @@ -50,6 +51,7 @@ def run( working_dir = working_dir or self._setup_working_directory() + self.ui.blank() # Add a blank line for better readability user_input = ( prompt or self.ui.get_input( @@ -67,7 +69,8 @@ def run( return True except Exception as e: - self.ui.error(UI_MESSAGES["errors"]["workflow_execution_failed"].format(e)) + logger.exception("Workflow execution failed") + self.ui.error(UI_MESSAGES["errors"]["workflow_execution_failed"]) return False def _execute_generation_workflow( @@ -316,6 +319,7 @@ def _enhance_agents(self): web_searcher=self.agents["web_searcher"], ) except Exception as e: - error_msg = UI_MESSAGES["errors"]["failed_integrate_web_search"].format(e) + logger.exception("Failed to integrate web search capabilities") + error_msg = UI_MESSAGES["errors"]["failed_integrate_web_search"] self.ui.error(error_msg) raise RuntimeError(error_msg) diff --git a/app/src/tools/file_tools.py b/app/src/tools/file_tools.py index cd820ae..7ae6c05 100644 --- a/app/src/tools/file_tools.py +++ b/app/src/tools/file_tools.py @@ -1,4 +1,5 @@ from app.src.core.permissions import permission_manager, PermissionDeniedException +from app.utils.logger import logger from langchain_core.tools import tool import os import shutil @@ -28,6 +29,7 @@ def create_wd(path: str) -> str: os.makedirs(path, exist_ok=True) return f"Working directory created at {path}" except Exception as e: + logger.error(f"Failed to create working directory: {path}", exc_info=e) return f"Error creating working directory: {str(e)}" @@ -63,6 +65,7 @@ def create_file(file_path: str, content: str) -> str: f.write(content) return f"File created at {file_path}" except Exception as e: + logger.error(f"Failed to create file: {file_path}", exc_info=e) return f"[ERROR] Failed to create file: {str(e)}" @@ -106,6 +109,7 @@ def modify_file(file_path: str, old_content: str, new_content: str) -> str: f.write(contents) return f"File modified at {file_path}" except Exception as e: + logger.error(f"Failed to modify file: {file_path}", exc_info=e) return f"Error modifying file: {str(e)}" @@ -143,6 +147,7 @@ def append_file(file_path: str, content: str) -> str: f.write(content) return f"Content appended to {file_path}" except Exception as e: + logger.error(f"Failed to append to file: {file_path}", exc_info=e) return f"Error appending file: {str(e)}" @@ -172,6 +177,7 @@ def delete_file(file_path: str) -> str: os.remove(file_path) return f"File deleted at {file_path}" except Exception as e: + logger.error(f"Failed to delete file: {file_path}", exc_info=e) return f"Error deleting file: {str(e)}" @@ -201,6 +207,7 @@ def delete_directory(path: str) -> str: else: return f"Directory does not exist: {path}" except Exception as e: + logger.error(f"Failed to delete directory: {path}", exc_info=e) return f"Error deleting directory: {str(e)}" @@ -231,6 +238,7 @@ def read_file(file_path: str) -> str: contents = f.read() return contents except Exception as e: + logger.error(f"Failed to read file: {file_path}", exc_info=e) return f"Error reading file: {str(e)}" @@ -317,12 +325,14 @@ def _list_directory_recursive( else: items.append(f"{prefix}{item_name}") - except PermissionError: + except PermissionError as e: + logger.warning(f"Permission denied accessing: {current_path}", exc_info=e) if current_depth == 0: items.append("❌ Permission denied") else: items.append(f"{parent_prefix}❌ Permission denied") except Exception as e: + logger.error(f"Error listing directory: {current_path}", exc_info=e) if current_depth == 0: items.append(f"❌ Error: {str(e)}") else: @@ -338,6 +348,7 @@ def _list_directory_recursive( return "\n".join(result) except Exception as e: + logger.error(f"Failed to list directory: {path}", exc_info=e) return f"Error listing directory: {str(e)}" diff --git a/app/src/tools/find_tools.py b/app/src/tools/find_tools.py index 529e4a8..0952b4c 100644 --- a/app/src/tools/find_tools.py +++ b/app/src/tools/find_tools.py @@ -1,3 +1,4 @@ +from app.utils.logger import logger from langchain_core.tools import tool import os import re @@ -75,6 +76,7 @@ def find_references(dir_path: str, query: str) -> str: return "\n".join(out_lines) except Exception as e: + logger.error(f"Error searching for references: {query} in {dir_path}", exc_info=e) return f"Search error: {str(e)}" @@ -112,6 +114,7 @@ def find_declaration(dir_path: str, symbol: str) -> str: return "\n".join(out_lines) except Exception as e: + logger.error(f"Error searching for declaration: {symbol} in {dir_path}", exc_info=e) return f"Search error: {str(e)}" diff --git a/app/src/tools/git_tools.py b/app/src/tools/git_tools.py index dab41f9..a64f233 100644 --- a/app/src/tools/git_tools.py +++ b/app/src/tools/git_tools.py @@ -1,3 +1,4 @@ +from app.utils.logger import logger from langchain_core.tools import tool import subprocess import os @@ -53,6 +54,7 @@ def diff(commit1: str | None, commit2: str | None, cwd: str | None) -> str: ) except Exception as e: + logger.error(f"Git diff execution error in {cwd}", exc_info=e) return f"Execution error: {str(e)}" diff --git a/app/src/tools/web_tools.py b/app/src/tools/web_tools.py index aee0f60..0d850e7 100644 --- a/app/src/tools/web_tools.py +++ b/app/src/tools/web_tools.py @@ -1,3 +1,4 @@ +from app.utils.logger import logger from langchain_core.tools import tool from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter, Retry @@ -44,8 +45,10 @@ def fetch(url: str) -> str: return text[:10000] except requests.RequestException as e: + logger.error(f"HTTP error fetching {url}", exc_info=e) return f"[ERROR] HTTP error for {url}: {e}" except Exception as e: + logger.error(f"Failed to parse {url}", exc_info=e) return f"[ERROR] Failed to parse {url}: {e}" diff --git a/app/utils/constants.py b/app/utils/constants.py index e59811b..5b54729 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -11,16 +11,18 @@ LAST_N_TURNS = 20 +# Vibrant unified theme built around purple accent THEME = { - "primary": "#4566db", - "secondary": "#9c79ee", - "accent": "#88c5d0", - "success": "#10b981", - "warning": "#ebac40", - "error": "#ef4444", - "muted": "#6b7280", - "text": "#f8fafc", - "border": "#374151", + "primary": "#a855f7", # Vibrant purple (main accent) + "secondary": "#ec4899", # Vibrant pink (complementary) + "accent": "#06b6d4", # Vibrant cyan (tertiary) + "success": "#10b981", # Vibrant emerald + "warning": "#f59e0b", # Vibrant amber + "error": "#ef4444", # Vibrant red + "muted": "#9ca3af", # Neutral gray + "dim": "#6b7280", # Dim gray + "text": "#f3f4f6", # Light gray + "border": "#6b21a8", # Deep purple border } PROMPTS = { @@ -49,6 +51,11 @@ if os.name == "nt" else "~/.local/share/Ally/parsing_models/" ), + "logs": ( + "%LOCALAPPDATA%\\Ally\\logs\\" + if os.name == "nt" + else "~/.local/share/Ally/logs/" + ), } diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..faa334f --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,78 @@ +import logging +import os +from datetime import datetime +from pathlib import Path +from app.utils.constants import DEFAULT_PATHS + + +class AllyLogger: + """Centralized logging system for Ally.""" + + _instance = None + _logger = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(AllyLogger, cls).__new__(cls) + cls._instance._initialize_logger() + return cls._instance + + def _initialize_logger(self): + """Initialize the logger with file and console handlers.""" + # Expand environment variables and user home directory + log_dir = os.path.expandvars(DEFAULT_PATHS["logs"]) + log_dir = os.path.expanduser(log_dir) + + # Create log directory if it doesn't exist + Path(log_dir).mkdir(parents=True, exist_ok=True) + + # Create log filename with timestamp + log_filename = f"ally_{datetime.now().strftime('%Y%m%d')}.log" + log_path = os.path.join(log_dir, log_filename) + + # Create logger + self._logger = logging.getLogger("ally") + self._logger.setLevel(logging.DEBUG) + # Prevent logging from propagating to root logger (which outputs to console) + self._logger.propagate = False + + # Avoid duplicate handlers if logger already configured + if not self._logger.handlers: + # File handler - logs everything + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + file_handler.setFormatter(file_formatter) + + self._logger.addHandler(file_handler) + + def debug(self, message: str, **kwargs): + """Log debug message.""" + self._logger.debug(message, extra=kwargs) + + def info(self, message: str, **kwargs): + """Log info message.""" + self._logger.info(message, extra=kwargs) + + def warning(self, message: str, **kwargs): + """Log warning message.""" + self._logger.warning(message, extra=kwargs) + + def error(self, message: str, exc_info=None, **kwargs): + """Log error message with optional exception info.""" + self._logger.error(message, exc_info=exc_info, extra=kwargs) + + def critical(self, message: str, exc_info=None, **kwargs): + """Log critical message with optional exception info.""" + self._logger.critical(message, exc_info=exc_info, extra=kwargs) + + def exception(self, message: str, **kwargs): + """Log exception with traceback.""" + self._logger.exception(message, extra=kwargs) + + +# Create singleton instance +logger = AllyLogger() diff --git a/app/utils/ui_messages.py b/app/utils/ui_messages.py index 53691c6..cd5c542 100644 --- a/app/utils/ui_messages.py +++ b/app/utils/ui_messages.py @@ -48,6 +48,8 @@ "session_interrupted": "Session interrupted by user", "recursion_warning": "Agent has been processing for a while.\nContinue or refine your prompt?", "initializing_agents": "Initializing agents...", + "welcome": "Welcome to Ally - Your private AI assistant", + "welcome_help_hint": "Type /help for commands and usage", "working_on_task": "Working on the task...", "generation_starting_detail": "The CodeGen Agent is now generating code based on the context provided.", "brainstormer_ready_detail": "Type '/exit' or press Ctrl+C to continue to code generation", @@ -65,44 +67,45 @@ # Warnings "warnings": { "failed_confirm": "Failed to confirm action. Continuing with default value ({})", - "rag_not_available": "RAG is not available. Please configure an embedding provider.", - "rag_enabled_no_client": "RAG is enabled but no database client is configured.", - "rag_features_disabled": "RAG features disabled.", - "invalid_db_path": "Invalid directory path found in $ALLY_DATABASE_DIR. Reverting to default path.", - "recursion_limit_reached": "Agent processing took longer than expected (Max recursion limit reached)", + "rag_not_available": "RAG is not available. Please configure an embedding provider in config.json.", + "rag_enabled_no_client": "RAG is enabled but no database client is configured. Check your setup.", + "rag_features_disabled": "RAG features have been disabled due to errors.", + "invalid_db_path": "Invalid directory path found in $ALLY_DATABASE_DIR environment variable. Reverting to default path.", + "recursion_limit_reached": "Agent processing took longer than expected. Recursion limit reached.", }, # Errors "errors": { - "failed_initialize_agents": "Failed to initialize default agents: {}", - "config_error": "Configuration error: {}", - "failed_validate_config": "Failed to validate configuration: {}", - "failed_setup_coding": "Failed to setup coding configuration: {}", - "unexpected_error": "An unexpected error occurred: {}", - "codegen_failed": "Code generation workflow failed to complete successfully", - "invalid_directory": "Invalid directory name: {}", - "failed_create_directory": "Failed to create directory", - "failed_initialize_coding": "Failed to initialize coding workflow: {}", - "workflow_execution_failed": "Workflow execution failed: {}", - "coding_session_failed": "The interactive coding session did not exit safely", - "failed_integrate_web_search": "Failed to integrate web search capabilities: {}", - "rate_limit_exceeded": "Rate limit exceeded. Please try again later", - "db_not_initialized": "Database client is not initialized. There might be an issue with your embeddings config.", - "invalid_directory_path": "Invalid directory path: {}", - "directory_not_exist": "Directory {} does not exist.", - "failed_scrape": "Failed to scrape file: {}. Skipping.", - "collection_not_exist": "Collection {} does not exist.", - "failed_create_db_directory": "Failed to create database directory: {}", - "failed_save_indexed": "Failed to save indexed collections: {}", - "failed_install_packages": "Failed to install required packages. Please install them manually. Error: {}", - "db_access_error": "Database access error occurred.", - "setup_failed": "Setup failed.", - "command_failed": "Command '{}' failed: {}", - "unknown_command": "Unknown command. Type /help for instructions.", - "specify_model": "Please specify a model to change to.", - "unknown_model_command": "Unknown model command. Type /help for instructions.", - "agent_execution_failed": "[ERROR] Agent execution failed.", - "no_messages_returned": "[ERROR] Agent did not return any messages.", - "model_not_found": "Model '{}' not found or not supported.", + "failed_initialize_agents": "Failed to initialize default agents. Check your API keys and configuration." + + "\nIf you're using Ollama, ensure the Ollama app is running.", + "config_error": "Configuration error occurred. Please check your config.json file.", + "failed_validate_config": "Failed to validate configuration. Verify API keys and model names.", + "failed_setup_coding": "Failed to setup coding configuration. Check your models and API settings.", + "unexpected_error": "An unexpected error occurred. Check logs for details.", + "codegen_failed": "Code generation workflow failed to complete successfully. Check logs for details.", + "invalid_directory": "Invalid directory name. Please provide a valid path.", + "failed_create_directory": "Failed to create directory. Check path permissions and syntax.", + "failed_initialize_coding": "Failed to initialize coding workflow. Check your configuration and logs.", + "workflow_execution_failed": "Workflow execution failed. Check logs for details.", + "coding_session_failed": "The interactive coding session did not exit safely. Check logs for details.", + "failed_integrate_web_search": "Failed to integrate web search capabilities. Check logs for details.", + "rate_limit_exceeded": "API rate limit exceeded. Please wait and try again later.", + "db_not_initialized": "Database client is not initialized. Check your embeddings configuration in config.json.", + "invalid_directory_path": "Invalid directory path provided. Please use a valid file system path.", + "directory_not_exist": "The specified directory does not exist. Please check the path.", + "failed_scrape": "Failed to scrape file. Skipping. Check logs for details.", + "collection_not_exist": "The specified collection does not exist in the database.", + "failed_create_db_directory": "Failed to create database directory. Check file system permissions.", + "failed_save_indexed": "Failed to save indexed collections metadata. Check logs for details.", + "failed_install_packages": "Failed to install required packages. Please install them manually or check your internet connection.", + "db_access_error": "Database access error occurred. Check logs for details.", + "setup_failed": "Setup failed. Check logs for details.", + "command_failed": "Command execution failed. Check logs for details.", + "unknown_command": "Unknown command. Type /help to see available commands.", + "specify_model": "Please specify a model name. Usage: /model change ", + "unknown_model_command": "Unknown model command. Type /help to see available model commands.", + "agent_execution_failed": "Agent execution failed. Check logs for details.", + "no_messages_returned": "Agent did not return any messages. Check logs for details.", + "model_not_found": "Model not found or not supported. Verify the model name is correct.", "collection_name_too_short": "Collection name must be at least 3 characters long.", }, # Confirmations @@ -115,10 +118,10 @@ }, # Usage Messages "usage": { - "embed": "Usage: /embed ", - "index": "Usage: /index ", - "unindex": "Usage: /unindex ", - "delete": "Usage: /delete ", + "embed": "Usage: /embed 'directory_path' 'collection_name'", + "index": "Usage: /index 'collection_name'", + "unindex": "Usage: /unindex 'collection_name'", + "delete": "Usage: /delete 'collection_name'", }, # Success Messages "success": { @@ -129,18 +132,37 @@ # Help Content "help": { "content": [ - "Use Ctrl+n to enter a new line and Enter to submit your message.", + "Use `Ctrl+n` to enter a new line and Enter to submit your message.", + "", + "**Note**: Detailed error logs are saved to the logs directory for troubleshooting.", + "", "| Command | Description |", "|---------|-------------|", - "| /quit, /exit, /q | Exit |", - "| /clear | Clear history* |", - "| /cls | Clear screen |", - "| /model (change) | Show/change AI model |", - "| /project | Start project generation workflow |", + "| /quit, /exit, /q | Exit the application |", + "| /clear | Clear conversation history |", + "| /cls, /clearterm, /clearscreen | Clear screen |", "| /help, /h | Show this help message |", + "| /model | Show current AI model |", + "| /model change `name` | Change AI model |", + "| /id | Show current session ID |", + "| /id `` | Change to specific session ID |", + "| /project | Start project generation workflow |", + "", + "**RAG** (Retrieval-Augmented Generation) Commands (requires RAG to be setup and enabled)", + "", + "| Command | Description |", + "|---------|-------------|", + "| /start_rag | Enable RAG functionality |", + "| /stop_rag | Disable RAG functionality |", + "| /embed `` `` | Embed documents into collection |", + "| /refs, /references | Show latest references |", + "| /index `` | Index a collection for RAG |", + "| /unindex `` | Unindex a collection |", + "| /list | List all collections |", + "| /delete `` | Delete a collection |", + "| /purge | Reset database (delete all collections) |", ], - "model_suffix": "Model: *{}*", - "footer": "\n> **Not recommended during long running tasks. Use at your own risk.*", + "model_suffix": "Model: **{}**", }, # Tool Messages "tool": { @@ -149,5 +171,8 @@ "output_header": "**Output:**", "truncated": "\n... *(truncated)*", "tool_complete": "Tool Complete: {}", + "pending_tools": "{} tool(s) pending approval", + "processing_tool": "Processing tool {} of {}", + "permission_denied": "Permission denied for tool '{}'", }, } diff --git a/config.json b/config.json index c23a3f8..654525f 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,5 @@ { - "provider": "google", + "provider": "openai", "provider_per_model": { "general": null, "code_gen": null, @@ -7,7 +7,7 @@ "web_searcher": null }, - "model": "gemini-2.5-flash", + "model": "gpt-5", "models": { "general": null, "code_gen": null, @@ -16,10 +16,10 @@ }, "temperatures": { - "general": 0, - "code_gen": 0, - "brainstormer": 0, - "web_searcher": 0 + "general": 1, + "code_gen": 1, + "brainstormer": 1, + "web_searcher": 1 }, "system_prompts": { "general": null, diff --git a/main.py b/main.py index d099ffb..d8d9005 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,13 @@ -from dotenv import load_dotenv +try: + from dotenv import load_dotenv + + load_dotenv() -load_dotenv() + from app import CLI, default_ui + from app.utils.logger import logger -from app import CLI, default_ui +except Exception as e: + print("Unexpected error happened...") from warnings import filterwarnings import os @@ -13,17 +18,18 @@ logging.basicConfig(level=logging.CRITICAL) filterwarnings("ignore", category=Warning, module="torch") -filterwarnings("ignore", category=Warning, module="docling") filterwarnings("ignore", category=Warning, module="huggingface_hub") filterwarnings("ignore", category=Warning, module="onnxruntime") api_keys = { - "cerebras": os.getenv("CEREBRAS_API_KEY"), "openai": os.getenv("OPENAI_API_KEY"), "anthropic": os.getenv("ANTHROPIC_API_KEY"), "google": os.getenv("GOOGLE_GEN_AI_API_KEY"), + "openrouter": os.getenv("OPENROUTER_API_KEY"), + "github": os.getenv("GITHUB_AI_API_KEY"), + "groq": os.getenv("GROQ_API_KEY"), } @@ -34,14 +40,17 @@ try: with open(config_path) as f: config = json.load(f) -except FileNotFoundError: +except FileNotFoundError as e: + logger.error("Configuration file 'config.json' not found", exc_info=e) default_ui.error("Configuration file 'config.json' not found.") sys.exit(1) -except json.JSONDecodeError: +except json.JSONDecodeError as e: + logger.error("Configuration file 'config.json' is not valid JSON", exc_info=e) default_ui.error("Configuration file 'config.json' is not a valid JSON.") sys.exit(1) except Exception as e: - default_ui.error(f"An unexpected error occurred: {e}") + logger.exception("Unexpected error loading configuration") + default_ui.error("An unexpected error occurred") sys.exit(1) @@ -74,27 +83,43 @@ scraping_method = config.get("scraping_method") or "simple" -client = CLI( - provider=provider, - provider_per_model=provider_per_model, - models=models, - api_key=api_key, - api_key_per_model=api_key_per_model, - embedding_provider=embedding_provider, - embedding_model=embedding_model, - temperatures=temperatures, - system_prompts=system_prompts, - scraping_method=scraping_method, - stream=True, -) +client = None + +try: + client = CLI( + provider=provider, + provider_per_model=provider_per_model, + models=models, + api_key=api_key, + api_key_per_model=api_key_per_model, + embedding_provider=embedding_provider, + embedding_model=embedding_model, + temperatures=temperatures, + system_prompts=system_prompts, + scraping_method=scraping_method, + stream=True, + ) +except Exception as e: + logger.error(f"Failed to initialize the CLI client: {str(e)}", exc_info=e) + default_ui.error("Failed to initialize the CLI client. Please check the logs.") + sys.exit(1) ########### run the CLI ########### def main(): - args = sys.argv[1:] - client.start_chat(*args) + try: + args = sys.argv[1:] + client.start_chat(*args) + except Exception as e: + logger.error( + f"An unexpected error occurred during the chat session: {str(e)}", + exc_info=e, + ) + default_ui.error( + "An unexpected error occurred. Please check the logs for details." + ) if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 8a8597c..2287ddb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,36 @@ -openai==2.8.1 -beautifulsoup4==4.14.2 -google-api-python-client==2.187.0 -langchain-cerebras==0.6.0 -langchain-core==0.3.80 -langchain-google-genai==2.1.12 -langchain-ollama==0.3.10 -langchain-anthropic==0.3.22 -langgraph==1.0.1 -langgraph-checkpoint-sqlite==3.0.0 -lxml==6.0.2 +# AI/LLM Providers +openai==2.14.0 ollama==0.6.1 +langchain-anthropic==1.3.0 +langchain-cerebras==0.8.2 +langchain-groq==1.1.1 +langchain-google-genai==4.1.2 +langchain-ollama==1.0.1 + +# Embedding Models +torch==2.9.1 +transformers==4.57.3 + +# Vector Databases +chromadb==1.4.0 + +# LangChain Framework +langgraph==1.0.5 +langgraph-checkpoint-sqlite==3.0.1 + +# Web Scraping & Parsing +beautifulsoup4==4.14.3 +lxml==6.0.2 + +# Document Processing +python-docx==1.2.0 +pymupdf4llm==0.2.7 +pymupdf-layout==1.26.6 + +# Google API +google-api-python-client==2.187.0 + +# Utilities & CLI prompt-toolkit==3.0.52 python-dotenv==1.2.1 PyYAML==6.0.3 @@ -17,6 +38,3 @@ rapidfuzz==3.14.3 requests==2.32.5 rich==14.2.0 hf-xet==1.2.0 -python-docx==1.2.0 -pymupdf4llm==0.2.4 -pymupdf-layout==1.26.6 diff --git a/setup.cmd b/setup.cmd deleted file mode 100644 index 5c8a796..0000000 --- a/setup.cmd +++ /dev/null @@ -1,48 +0,0 @@ -@echo off -setlocal - -echo === Setting up Ally === - -:: Step 1: Create virtual environment -echo Creating virtual environment... -python -m venv .venv || ( - echo Failed to create virtual environment - exit /b 1 -) - -:: Step 2: Install requirements -echo Installing dependencies... -call .venv\Scripts\activate.bat -pip install -r requirements.txt || ( - echo Failed to install requirements - exit /b 1 -) - -:: Step 3: Create bin\ally.bat -echo Creating launcher script... -set "CURR_DIR=%CD%" -if not exist bin mkdir bin - -( - echo @echo off - echo "%CURR_DIR%\.venv\Scripts\activate.bat" ^&^& python "%CURR_DIR%\main.py" %%* -) > bin\ally.bat - -:: Step 4: Add bin to PATH (user scope) -set "BIN_DIR=%CURR_DIR%\bin" -for /f "tokens=2*" %%A in ('reg query "HKCU\Environment" /v PATH 2^>nul') do set "USER_PATH=%%B" - -echo ;%PATH%; | find /i ";%BIN_DIR%;" >nul -if errorlevel 1 ( - if not defined USER_PATH ( - reg add "HKCU\Environment" /v PATH /t REG_EXPAND_SZ /d "%BIN_DIR%" /f >nul - ) else ( - reg add "HKCU\Environment" /v PATH /t REG_EXPAND_SZ /d "%USER_PATH%;%BIN_DIR%" /f >nul - ) - echo Added %BIN_DIR% to PATH. Restart your terminal or log off/on. -) else ( - echo %BIN_DIR% is already in PATH. -) - -echo === Setup complete! You can now run "ally" in a new terminal window === -pause diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..9bd9cc4 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = "Stop" + +Write-Host "=== Setting up Ally (Windows) ===" -ForegroundColor Cyan + +# script directory +$InstallDir = $PSScriptRoot + +# =========================== Step 1: Install requirements ================================= + +Write-Host "`nInstalling dependencies..." -ForegroundColor Yellow + +if (-not (Get-Command "uv" -ErrorAction SilentlyContinue)) { + Write-Error "uv not found. Please install it by running: irm https://astral.sh/uv/install.ps1 | iex" + exit 1 +} + +Set-Location -Path $InstallDir + +if (-not (Test-Path "pyproject.toml")) { + uv init | Out-Null +} + +cmd /c "uv add -q -r requirements.txt" + +# =========================== Step 2: Create wrapper (ally.cmd) ============================ + +Write-Host "`nCreating launcher script..." -ForegroundColor Yellow + +# wrapper script +$LauncherPath = Join-Path -Path $InstallDir -ChildPath "ally.bat" +$PythonPath = Join-Path -Path $InstallDir -ChildPath ".venv\Scripts\python.exe" +$MainScript = Join-Path -Path $InstallDir -ChildPath "main.py" + +$BatchContent = "@echo off`r`n`"$PythonPath`" `"$MainScript`" %*" + +Set-Content -Path $LauncherPath -Value $BatchContent +Write-Host "Launcher created at: $LauncherPath" + +# =========================== Step 3: Add to PATH ========================================== + +Write-Host "`nConfiguring PATH..." -ForegroundColor Yellow + +# Get current User PATH +$CurrentPath = [Environment]::GetEnvironmentVariable("Path", "User") + +# Check if the install directory is already in the PATH +if ($CurrentPath -split ';' -notcontains $InstallDir) { + # Append the directory to the User PATH + $NewPath = "$CurrentPath;$InstallDir" + [Environment]::SetEnvironmentVariable("Path", $NewPath, "User") + Write-Host "Success! Added $InstallDir to your User PATH." -ForegroundColor Green + Write-Host "NOTE: You must close and reopen your terminal for the 'ally' command to work." -ForegroundColor Magenta +} else { + Write-Host "Path already configured." -ForegroundColor Green + Write-Host "Setup complete! You can run 'ally' now. You may need to open a new terminal window." -ForegroundColor Green +} diff --git a/setup.sh b/setup.sh index d06dc75..f1d2f5c 100644 --- a/setup.sh +++ b/setup.sh @@ -1,60 +1,52 @@ -#!/bin/sh +#!/bin/bash set -e echo "=== Setting up Ally ===" -# =========================== Step 1: Create virtual environment =========================== +# script directory +INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# =========================== Step 1: Install requirements ================================= -echo "Creating virtual environment..." -python3 -m venv .venv +printf '\nInstalling dependencies...\n' +command -v uv >/dev/null 2>&1 || { + echo "Error: uv not found. Please install it from https://github.com/astral-sh/uv" + exit 1 +} -# =========================== Step 2: Install requirements ================================= +cd "$INSTALL_DIR" + +if [ ! -f "pyproject.toml" ]; then + uv init >/dev/null 2>&1 +fi -echo "Installing dependencies..." -.venv/bin/pip install -r requirements.txt +uv add -r requirements.txt >/dev/null 2>&1 -# =========================== Step 3: Create bin/ally launcher ============================= +# =========================== Step 2: Create bin/ally launcher ============================= -echo "Creating launcher script..." -CURR_DIR="$(pwd)" -mkdir -p bin -cat > bin/ally < ally </dev/null || echo "") +printf '\nInstalling ally to /usr/local/bin...\n' -if [[ "$PARENT_SHELL" == *"zsh"* ]] || [[ "$SHELL" == *"zsh"* ]]; then - SHELL_CONFIG="$HOME/.zshrc" -elif [[ "$PARENT_SHELL" == *"bash"* ]] || [[ "$SHELL" == *"bash"* ]]; then - if [ -f "$HOME/.bash_profile" ]; then - SHELL_CONFIG="$HOME/.bash_profile" - else - SHELL_CONFIG="$HOME/.bashrc" - fi -else - SHELL_CONFIG="" -fi +# check if we have permission to write to /usr/local/bin, use sudo if needed +TARGET_PATH="/usr/local/bin/ally" -# check PATH membership -if [[ ":$PATH:" == *":$BIN_DIR:"* ]]; then - echo "$BIN_DIR already in PATH" +if [ -w "/usr/local/bin" ]; then + ln -sf "$INSTALL_DIR/ally" "$TARGET_PATH" else - if [ -n "$SHELL_CONFIG" ] && ! grep -Fxq "export PATH=\"$BIN_DIR:\$PATH\"" "$SHELL_CONFIG" 2>/dev/null; then - echo "export PATH=\"$BIN_DIR:\$PATH\"" >> "$SHELL_CONFIG" - echo "Added $BIN_DIR to $SHELL_CONFIG" - echo "Run: source $SHELL_CONFIG (or restart your terminal)" - fi + echo "Sudo permissions required to install to /usr/local/bin" + sudo ln -sf "$INSTALL_DIR/ally" "$TARGET_PATH" fi -echo "\nAdd this line to your shell config if needed: export PATH=\"$BIN_DIR:\$PATH\"" echo "=== Setup complete! You can now run 'ally' in a new terminal window ==="