From d5458d86ab1c2cae04624505e58641131dabc874 Mon Sep 17 00:00:00 2001 From: Vidit Kharecha Date: Mon, 30 Jun 2025 18:47:14 +0530 Subject: [PATCH 1/2] Agent Update, sequential creation, etc --- .../cli/interactive/chat_handler.py | 71 ++++++++++++++++++- src/ai_assistant/logic/agent/agent_main.py | 24 ++++--- src/ai_assistant/logic/agent/executor.py | 57 ++++++++------- 3 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/ai_assistant/cli/interactive/chat_handler.py b/src/ai_assistant/cli/interactive/chat_handler.py index 47de42b..8de6d75 100644 --- a/src/ai_assistant/cli/interactive/chat_handler.py +++ b/src/ai_assistant/cli/interactive/chat_handler.py @@ -1,5 +1,8 @@ +# src/ai_assistant/cli/interactive/chat_handler.py + import asyncio import re +import json from pathlib import Path from rich.console import Console from rich.panel import Panel @@ -14,6 +17,8 @@ from ...models.request import CodeRequest from ...utils.parsing_utils import extract_code_blocks from ...utils.file_utils import build_repo_context, FileUtils +from ...logic.agent.planner import Planner +from ...logic.agent import agent_main console = Console() @@ -48,6 +53,58 @@ async def _find_file_in_project(self, filename: str) -> Optional[Path]: return None # User cancelled return None + async def _classify_intent(self, message: str) -> dict: + """Uses an AI call to classify the user's intent.""" + + planner = Planner(self.session) + formatted_tools = planner._format_tools_for_prompt() + + intent_prompt = ( + "You are an intent detection expert for a command-line AI assistant. " + "Your task is to determine if a user's request can be handled by a simple chat response or if it requires a complex, multi-step execution plan using a set of available tools. " + "A complex task involves actions like creating/modifying files, running shell commands, or interacting with git.\n\n" + "**Available Tools:**\n" + f"{formatted_tools}\n\n" + "**User Request:**\n" + f'"{message}"\n\n' + "**Analysis:**\n" + "1. **Simple Chat:** The request is a question, a request for explanation, a simple code generation for a single file without saving it, or a general conversation. It can be answered in one go.\n" + "2. **Complex Task:** The request implies a sequence of actions. It mentions creating directories, running installers (like npm or pip), generating multiple files and saving them, committing to git, pushing to GitHub, or a combination of these. If the user asks to 'create a project', 'refactor this into a new structure', 'set up a new service', or 'modularize this code', it's a complex task.\n\n" + "Based on your analysis, respond with ONLY a JSON object with one of two formats:\n" + '1. For a simple chat: `{"intent": "simple_chat"}`\n' + '2. For a complex task: `{"intent": "complex_task"}`\n' + "Your JSON response:" + ) + + request = CodeRequest(prompt=intent_prompt) + response_json_str = "" + try: + async with AIService(self.config) as ai_service: + async for chunk in ai_service.stream_generate(request): + response_json_str += chunk + + # The AI might add markdown fences or other text. Find the JSON object. + match = re.search(r'```json\s*(\{.*?\})\s*```', response_json_str, re.DOTALL) + json_text = "" + if match: + json_text = match.group(1) + else: + # If no markdown fence, find the first '{' and last '}' + start = response_json_str.find('{') + end = response_json_str.rfind('}') + if start != -1 and end != -1: + json_text = response_json_str[start:end+1] + + if json_text: + return json.loads(json_text) + else: + raise json.JSONDecodeError("No JSON object found", response_json_str, 0) + + except (json.JSONDecodeError, TypeError): + # Default to simple_chat if parsing fails, which is a safe fallback. + console.print("[dim]Intent classification failed. Defaulting to chat mode.[/dim]") + return {"intent": "simple_chat"} + async def _handle_code_response(self, response_content: str): """Interactively handles code blocks in the AI's response.""" code_blocks = [b for b in extract_code_blocks(response_content) if b.get('filename')] @@ -130,10 +187,22 @@ async def _stream_and_process_response(self, request: CodeRequest): async def handle(self, message: str, session): - """Main message handler with corrected @mention and AIService usage.""" + """Main message handler with intent detection.""" try: self._stop_generation = False + # --- INTENT DETECTION --- + with console.status("[dim]Analyzing request...[/dim]"): + intent_result = await self._classify_intent(message) + intent = intent_result.get("intent") + + if intent == "complex_task": + console.print("\n[bold cyan]Complex task detected. Engaging agent...[/bold cyan]\n") + await agent_main.run_agentic_workflow(session, message, interactive=False) + return + + # --- REGULAR CHAT FLOW (if intent is simple_chat or fallback) --- + # --- Robust @mention Logic with File Finder --- mentioned_context = {} mentions = re.findall(r'@([^\s]+)', message) diff --git a/src/ai_assistant/logic/agent/agent_main.py b/src/ai_assistant/logic/agent/agent_main.py index f6354d4..ab35bbf 100644 --- a/src/ai_assistant/logic/agent/agent_main.py +++ b/src/ai_assistant/logic/agent/agent_main.py @@ -1,5 +1,3 @@ -# src/ai_assistant/logic/agent/agent_main.py - from rich.console import Console from rich.panel import Panel @@ -9,13 +7,13 @@ console = Console() -async def run_knight_mode(session, goal: str): +async def run_agentic_workflow(session, goal: str, interactive: bool): """ - The main entry point for the agentic mode. - Orchestrates the planning and execution phases. + Orchestrates the planning and execution phases for any agentic task. """ if not goal: - return console.print("[red]Usage: /knight [/red]") + console.print("[red]Agentic goal cannot be empty.[/red]") + return planner = Planner(session) executor = Executor(session) @@ -26,5 +24,15 @@ async def run_knight_mode(session, goal: str): console.print(Panel("Could not formulate a valid plan. Aborting.", border_style=Theme.ERROR, title=f"[{Theme.ERROR}]Planning Failed[/{Theme.ERROR}]")) return - # Pass the goal to the executor for a better summary - await executor.execute_plan(plan, goal) \ No newline at end of file + # Pass the goal and interactive flag to the executor + await executor.execute_plan(plan, goal, interactive=interactive) + +async def run_knight_mode(session, goal: str): + """ + The main entry point for the explicit '/knight' agentic mode. + Invokes the agentic workflow in full interactive mode. + """ + if not goal: + return console.print("[red]Usage: /knight [/red]") + + await run_agentic_workflow(session, goal, interactive=True) \ No newline at end of file diff --git a/src/ai_assistant/logic/agent/executor.py b/src/ai_assistant/logic/agent/executor.py index 3243db2..67ec4c5 100644 --- a/src/ai_assistant/logic/agent/executor.py +++ b/src/ai_assistant/logic/agent/executor.py @@ -76,17 +76,18 @@ def _render_step_for_display(self, step: dict[str, Any]) -> Tuple[str, str]: return action_text, reasoning - async def execute_plan(self, plan: List[Any], goal: str) -> None: - summary = await self._summarize_plan_with_ai(plan, goal) - - summary_title = Text("Execution Summary", style=Theme.SUMMARY_TITLE) - console.print(Panel(Markdown(summary), title=summary_title, border_style=Theme.SUMMARY_BORDER, title_align="left")) + async def execute_plan(self, plan: List[Any], goal: str, interactive: bool = True) -> None: + if interactive: + summary = await self._summarize_plan_with_ai(plan, goal) + + summary_title = Text("Execution Summary", style=Theme.SUMMARY_TITLE) + console.print(Panel(Markdown(summary), title=summary_title, border_style=Theme.SUMMARY_BORDER, title_align="left")) - if not await questionary.confirm("Proceed with this plan?", default=True, auto_enter=False).ask_async(): - console.print("[yellow]Plan execution aborted by user.[/yellow]") - return + if not await questionary.confirm("Proceed with this plan?", default=True, auto_enter=False).ask_async(): + console.print("[yellow]Plan execution aborted by user.[/yellow]") + return - console.print() + console.print() editable_plan = copy.deepcopy(plan) @@ -104,24 +105,28 @@ async def execute_plan(self, plan: List[Any], goal: str) -> None: break current_step += 1 - step_title_text = Text(f"Step {current_step}/{total_steps}", style="") - - # --- RENDER THE ABSTRACTED VIEW --- action_str, reasoning_str = self._render_step_for_display(step) - display_content = f"{action_str}\n\n[bold]Reasoning:[/bold] [dim]{reasoning_str}[/dim]" - console.print(Panel(display_content, title=step_title_text, border_style=Theme.STEP_PANEL_BORDER, expand=False)) - - action = await questionary.select("Action:", choices=["Execute", "Skip", "Edit", "Abort"]).ask_async() - - if action == "Abort": return - if action == "Skip": continue - if action == "Edit": - step_json = json.dumps(step, indent=2) - edited_json_str = await questionary.text("Edit step JSON:", multiline=True, default=step_json).ask_async() - try: - step = json.loads(edited_json_str or "{}") - command_name = step.get("command") # Re-read command name after edit - except json.JSONDecodeError: continue + + if interactive: + step_title_text = Text(f"Step {current_step}/{total_steps}", style="") + display_content = f"{action_str}\n\n[bold]Reasoning:[/bold] [dim]{reasoning_str}[/dim]" + console.print(Panel(display_content, title=step_title_text, border_style=Theme.STEP_PANEL_BORDER, expand=False)) + + action = await questionary.select("Action:", choices=["Execute", "Skip", "Edit", "Abort"]).ask_async() + + if action == "Abort": return + if action == "Skip": continue + if action == "Edit": + step_json = json.dumps(step, indent=2) + edited_json_str = await questionary.text("Edit step JSON:", multiline=True, default=step_json).ask_async() + try: + step = json.loads(edited_json_str or "{}") + command_name = step.get("command") # Re-read command name after edit + except json.JSONDecodeError: continue + else: + # Non-interactive mode: just announce the step being executed. + panel_title = Text(f"Step {current_step}/{total_steps}", style=Theme.PROMPT) + console.print(Panel(action_str, title=panel_title, border_style=Theme.STEP_PANEL_BORDER, expand=False)) args = step.get("arguments", {}) if command_name in self.tools: From 885ce01dcbe034bae880cb0f1b4cc519cd794511 Mon Sep 17 00:00:00 2001 From: Vidit Kharecha Date: Tue, 8 Jul 2025 17:54:08 +0530 Subject: [PATCH 2/2] conflict res --- .../cli/interactive/chat_handler.py | 335 +++++++----------- 1 file changed, 119 insertions(+), 216 deletions(-) diff --git a/src/ai_assistant/cli/interactive/chat_handler.py b/src/ai_assistant/cli/interactive/chat_handler.py index a0296d5..2a3f80a 100644 --- a/src/ai_assistant/cli/interactive/chat_handler.py +++ b/src/ai_assistant/cli/interactive/chat_handler.py @@ -1,24 +1,14 @@ -# src/ai_assistant/cli/interactive/chat_handler.py - import asyncio import re -import json from pathlib import Path from rich.console import Console from rich.panel import Panel -from rich.live import Live -from rich.spinner import Spinner from rich.markdown import Markdown from rich.syntax import Syntax -import questionary -from typing import Optional - +from ...utils.file_utils import FileUtils from ...services.ai_service import AIService from ...models.request import CodeRequest -from ...utils.parsing_utils import extract_code_blocks -from ...utils.file_utils import build_repo_context, FileUtils -from ...logic.agent.planner import Planner -from ...logic.agent import agent_main +from ...utils.parsing_utils import extract_file_content_from_response console = Console() @@ -35,211 +25,145 @@ def stop_generation(self): self._generation_task.cancel() console.print("\n[yellow]Stopping generation...[/yellow]") - async def _find_file_in_project(self, filename: str) -> Optional[Path]: - """Searches for a file in the project directory.""" - # Use Path.glob to find the file recursively - matches = list(self.config.work_dir.glob(f"**/{filename}")) - if len(matches) == 1: - return matches[0] - elif len(matches) > 1: - # If multiple matches, ask the user to clarify - try: - chosen_path_str = await questionary.select( - f"Found multiple files named '{filename}'. Please choose one:", - choices=[str(p.relative_to(self.config.work_dir)) for p in matches] - ).ask_async() - return self.config.work_dir / chosen_path_str if chosen_path_str else None - except Exception: - return None # User cancelled - return None - - async def _classify_intent(self, message: str) -> dict: - """Uses an AI call to classify the user's intent.""" - - planner = Planner(self.session) - formatted_tools = planner._format_tools_for_prompt() - - intent_prompt = ( - "You are an intent detection expert for a command-line AI assistant. " - "Your task is to determine if a user's request can be handled by a simple chat response or if it requires a complex, multi-step execution plan using a set of available tools. " - "A complex task involves actions like creating/modifying files, running shell commands, or interacting with git.\n\n" - "**Available Tools:**\n" - f"{formatted_tools}\n\n" - "**User Request:**\n" - f'"{message}"\n\n' - "**Analysis:**\n" - "1. **Simple Chat:** The request is a question, a request for explanation, a simple code generation for a single file without saving it, or a general conversation. It can be answered in one go.\n" - "2. **Complex Task:** The request implies a sequence of actions. It mentions creating directories, running installers (like npm or pip), generating multiple files and saving them, committing to git, pushing to GitHub, or a combination of these. If the user asks to 'create a project', 'refactor this into a new structure', 'set up a new service', or 'modularize this code', it's a complex task.\n\n" - "Based on your analysis, respond with ONLY a JSON object with one of two formats:\n" - '1. For a simple chat: `{"intent": "simple_chat"}`\n' - '2. For a complex task: `{"intent": "complex_task"}`\n' - "Your JSON response:" - ) - - request = CodeRequest(prompt=intent_prompt) - response_json_str = "" + async def _handle_file_apply_logic(self, response_content: str): + """Interactively handles the diff and apply logic for file blocks.""" + file_blocks = extract_file_content_from_response(response_content) + if not file_blocks: + return + console.print("\n[bold cyan]AI has suggested file changes. Review now or use /apply later.[/bold cyan]") + + async def _stream_and_render_response(self, request: CodeRequest): + """ + Streams the AI response to a buffer while showing a spinner, + then renders the complete, final response beautifully. + """ + response_content = "" + status_task = None try: + status_task = asyncio.create_task(self._show_status("[cyan]Helios is thinking[/cyan]")) + async with AIService(self.config) as ai_service: async for chunk in ai_service.stream_generate(request): - response_json_str += chunk + if self._stop_generation: + raise asyncio.CancelledError + response_content += str(chunk) - # The AI might add markdown fences or other text. Find the JSON object. - match = re.search(r'```json\s*(\{.*?\})\s*```', response_json_str, re.DOTALL) - json_text = "" - if match: - json_text = match.group(1) - else: - # If no markdown fence, find the first '{' and last '}' - start = response_json_str.find('{') - end = response_json_str.rfind('}') - if start != -1 and end != -1: - json_text = response_json_str[start:end+1] + if status_task and not status_task.done(): + status_task.cancel() + try: + await status_task + except asyncio.CancelledError: + pass - if json_text: - return json.loads(json_text) - else: - raise json.JSONDecodeError("No JSON object found", response_json_str, 0) - - except (json.JSONDecodeError, TypeError): - # Default to simple_chat if parsing fails, which is a safe fallback. - console.print("[dim]Intent classification failed. Defaulting to chat mode.[/dim]") - return {"intent": "simple_chat"} - - async def _handle_code_response(self, response_content: str): - """Interactively handles code blocks in the AI's response.""" - code_blocks = [b for b in extract_code_blocks(response_content) if b.get('filename')] - if not code_blocks: return - - console.print("\n[bold cyan]AI has suggested code changes. Reviewing now...[/bold cyan]") - - files_to_apply = {} - apply_all, skip_all = False, False + if not response_content: + return - for block in code_blocks: - if skip_all: break - filename, new_code = block['filename'], block['code'] - file_path = Path.cwd() / filename + self.session.last_ai_response_content = response_content + self.session.conversation_history.append({"role": "assistant", "content": response_content}) - diff_text = FileUtils.generate_diff( - await self.session.file_service.read_file(file_path) if file_path.exists() else "", - new_code, - filename - ) - console.print(Panel(Syntax(diff_text, "diff", theme="vim"), title=f"Changes for {filename}", - border_style="#3776A1")) + file_blocks = extract_file_content_from_response(response_content) - if apply_all: - files_to_apply[filename] = new_code - continue + console.print() - choice = await questionary.select( - f"Apply changes to {filename}?", - choices=["Yes", "No", "Apply All Remaining", "Skip All Remaining"], - use_indicator=True, - style=questionary.Style([ - ('selected', 'bg:#003A6B #89CFF1'), - ('pointer', '#6EB1D6 bold'), - ('instruction', '#5293BB'), - ('answer', '#89CFF1 bold'), - ('question', '#6EB1D6 bold') - ]) - ).ask_async() - - if choice == "Yes": files_to_apply[filename] = new_code - elif choice == "Apply All Remaining": apply_all = True; files_to_apply[filename] = new_code - elif choice == "Skip All Remaining": skip_all = True - - if not files_to_apply: return console.print("[yellow]No changes were applied.[/yellow]") + if not file_blocks: + console.print(Markdown(response_content, code_theme="vim")) + else: + for block in file_blocks: + syntax_lang = FileUtils.get_language_from_extension(Path(block['filename']).suffix) + + syntax_content = Syntax( + block['code'], + lexer=syntax_lang or "python", + theme="vim", + line_numbers=True, + word_wrap=True, + background_color="default" + ) + + console.print(Panel( + syntax_content, + title=f"[bold cyan]File: {block['filename']}[/bold cyan]", + border_style="blue", + expand=False, + padding=(1, 2) + )) + + if file_blocks: + console.print("\n[yellow]AI has suggested file changes. Use `/apply` to review and apply them.[/yellow]") - for filename, code in files_to_apply.items(): - try: - await self.session.file_service.write_file(Path.cwd() / filename, code) - console.print(f"[green]✓ Applied changes to {filename}[/green]") - except Exception as e: - console.print(f"[red]Error applying changes to {filename}: {e}[/red]") + except asyncio.CancelledError: + if status_task and not status_task.done(): + status_task.cancel() + try: + await status_task + except asyncio.CancelledError: + pass + console.print() + except Exception as e: + if status_task and not status_task.done(): + status_task.cancel() + try: + await status_task + except asyncio.CancelledError: + pass + console.print(f"[bold red]Error during response generation: {e}[/bold red]") + import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") - async def _stream_and_process_response(self, request: CodeRequest): - response_content = "" + async def _show_status(self, message): + """Show status spinner that can be cancelled""" try: - # Use Live but with proper height constraint - with Live(Spinner("point", text="Thinking"), console=console, refresh_per_second=4) as live: - async with AIService(self.config) as ai_service: - async for chunk in ai_service.stream_generate(request): - if self._stop_generation: - raise asyncio.CancelledError - response_content += str(chunk) - live.update(Markdown(response_content, code_theme="vim")) - - self.session.last_ai_response_content = response_content - self.session.conversation_history.append({"role": "assistant", "content": response_content}) - await self._handle_code_response(response_content) + with console.status(message, spinner="point", spinner_style="cyan"): + while True: + await asyncio.sleep(0.1) except asyncio.CancelledError: pass - except Exception as e: - console.print(f"[bold red]Error during response generation: {e}[/bold red]") async def handle(self, message: str, session): - """Main message handler with intent detection.""" + """Main message handler with robust path detection and multimodal support.""" try: self._stop_generation = False - # --- INTENT DETECTION --- - with console.status("[dim]Analyzing request...[/dim]"): - intent_result = await self._classify_intent(message) - intent = intent_result.get("intent") - - if intent == "complex_task": - console.print("\n[bold cyan]Complex task detected. Engaging agent...[/bold cyan]\n") - await agent_main.run_agentic_workflow(session, message, interactive=False) - return - - # --- REGULAR CHAT FLOW (if intent is simple_chat or fallback) --- - - # --- Robust @mention Logic with File Finder --- mentioned_context = {} - mentions = re.findall(r'@([^\s]+)', message) - if mentions: - console.print("[dim]Processing @mentions...[/dim]") - for mention in mentions: - # First check if it's a directory path - dir_path = self.config.work_dir / mention - if dir_path.is_dir(): + path_pattern = re.compile(r""" + @([^\s]+) | # @-mentions (Group 1) + (['"]) (.*?) \2 | # Quoted paths (Group 2, 3) + (? to save manually.[/dim]") - # DO NOT automatically apply - let user decide - else: - console.print("\n[dim]💡 Code generated. Use /save to save if needed.[/dim]") \ No newline at end of file + console.print(f"[dim]{traceback.format_exc()}[/dim]") \ No newline at end of file