diff --git a/README.md b/README.md index 444f00e..adc1619 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ devorch # Interactive setup on first run devorch -p openai # Use a specific provider devorch -p local # Use Ollama (local models) devorch --resume abc123 # Resume a previous session + +# Non-interactive (scripting / CI) +devorch ask "explain this project" +devorch ask --skill commit +devorch run review "focus on security" +devorch edit src/auth.py "add input validation" ``` On first run, DevOrch walks you through provider selection and API key setup. @@ -109,15 +115,23 @@ Memory types: **user** (preferences), **feedback** (corrections), **project** (c ### Skills -Reusable prompt templates for common workflows: +Reusable prompt templates for common workflows. Use them in chat or directly from the CLI: -``` +```bash +# In chat /commit # Generate a descriptive git commit /review # Review code changes for bugs /test # Run tests and analyze results /fix # Fix the last error /explain # Explain project structure /simplify # Simplify recent code changes + +# From the terminal (non-interactive) +devorch run commit +devorch run review "focus on auth module" +devorch run test + +devorch skills # List all available skills ``` Add your own in `~/.devorch/skills/`: @@ -169,6 +183,23 @@ mcp_servers: MCP tools appear alongside built-in tools automatically. +**Manage MCP servers live in chat:** + +``` +/mcp # Show connected servers and their tools +/mcp add github npx -y @modelcontextprotocol/server-github +/mcp start github # Reconnect a server from config +/mcp stop github # Disconnect and remove its tools +``` + +**Filter MCP servers per CLI run:** + +```bash +devorch ask --mcp github "review open PRs" # Use only the github server +devorch ask --mcp github --mcp filesystem "..." # Use specific servers +devorch run commit --no-mcp # Skip MCP entirely +``` + ### Modes | Mode | Behavior | @@ -190,6 +221,32 @@ devorch permissions deny shell "rm -rf *" # Block dangerous commands Or use `/auth` in-chat to set API keys without restarting. +## Non-Interactive CLI Commands + +DevOrch works as a scriptable CLI too — no REPL needed: + +```bash +# Ask a one-shot question +devorch ask "what does this codebase do?" +devorch ask --skill review "focus on security" +devorch ask --mode plan "refactor the auth module" + +# Run a skill directly (shorthand for ask --skill) +devorch run commit +devorch run test "only unit tests" +devorch run review --no-mcp + +# Edit a file with an instruction +devorch edit src/auth.py "add input validation to login" +devorch edit README.md "update the installation section" +devorch edit app/models.py "add created_at field" --mcp sqlite + +# List available skills +devorch skills +``` + +All non-interactive commands support `--provider`, `--model`, `--mode`, `--mcp`, and `--no-mcp`. + ## All Slash Commands | Command | Description | @@ -214,7 +271,10 @@ Or use `/auth` in-chat to set API keys without restarting. | `/compact` | Summarize history | | `/save` | Save to file | | `/undo` | Undo last message | -| `/mcp` | MCP server status | +| `/mcp` | Show MCP server status | +| `/mcp add [args]` | Connect a new MCP server mid-session | +| `/mcp start ` | Reconnect a server from config | +| `/mcp stop ` | Disconnect a server and remove its tools | | `/config` | Show configuration | | `/permissions` | Show permissions | | `/tasks` | Show task list | @@ -313,8 +373,15 @@ pytest ``` DevOrch/ -├── cli/ # CLI entry point and REPL -├── core/ # Agent, executor, memory, MCP, skills +├── cli/ +│ ├── main.py # App wiring, REPL, sessions/config commands +│ ├── constants.py # VERSION, banners, slash-command registry, styles +│ └── commands/ +│ ├── _shared.py # Shared helpers (agent builder, tool setup, etc.) +│ ├── ask.py # devorch ask +│ ├── run.py # devorch run +│ └── edit.py # devorch edit +├── core/ # Agent, executor, memory, MCP, skills, modes ├── config/ # Settings, permissions ├── providers/ # AI provider implementations ├── tools/ # Built-in tools (shell, edit, search, etc.) diff --git a/cli/commands/_shared.py b/cli/commands/_shared.py new file mode 100644 index 0000000..aedc260 --- /dev/null +++ b/cli/commands/_shared.py @@ -0,0 +1,270 @@ +""" +Shared utilities for DevOrch CLI commands. + +Exports: console, SYSTEM_PROMPT, SimplePlanner, create_provider, build_tools, resolve_mode +""" + +import typer + +from config.settings import Settings +from core.agent import Agent +from core.executor import ToolExecutor +from core.mcp import MCPManager +from core.memory import MemoryTool +from core.modes import AgentMode, ModeManager +from core.planner import Planner +from providers import PROVIDERS, get_provider +from schemas.message import Message +from tools.agent import AgentTool +from tools.edit import EditTool +from tools.filesystem import FilesystemTool +from tools.grep import GrepTool +from tools.search import SearchTool +from tools.shell import ShellTool +from tools.task import TaskTool +from tools.terminal_session import TerminalSessionTool +from tools.websearch import WebFetchTool, WebSearchTool +from utils.logger import get_console, print_error, print_success + +console = get_console() + +SYSTEM_PROMPT = """You are DevOrch, an AI coding assistant with access to tools for interacting with the user's computer. + +IMPORTANT: You have the following tools available and MUST use them to help the user: + +1. **shell** - Execute shell commands (bash/powershell). Use this to: + - Run commands like `npm install`, `git clone`, `git status`, etc. + - Navigate directories, create files, run scripts + - Any short-lived terminal command that returns output + +2. **terminal_session** — The PRIMARY tool for all terminal/process needs: + - `start` — launch a command in a managed session. Defaults to 'bash' if no command. + Set gui=true to also open a visible terminal window for the user. + - `read` — read recent stdout/stderr output + - `send` — send input to the process stdin + - `stop` — terminate the session + - `list` — show all active sessions + - `reconnect` — reconnect to sessions from previous DevOrch runs + Sessions persist across DevOrch restarts with unique names (e.g. 'swift-fox-a3f2'). + Use gui=true when the user wants to SEE a terminal. Use without gui for headless monitoring. + +4. **filesystem** - Read/write/list files. Use this to: + - Read file contents to understand code + - Write or create new files + - List directory contents + +5. **search** - Find files by name patterns (like glob) + +6. **grep** - Search for text patterns within files + +7. **edit** - Make targeted edits to existing files + +8. **task** - Track progress on multi-step work. Use this to: + - Create a task list when working on complex requests (3+ steps) + - Show the user what you're currently working on + - Mark tasks complete as you finish them + + Task guidelines: + - Use when working on multiple steps or user gives multiple items + - Only ONE task should be 'in_progress' at a time + - Mark tasks 'completed' immediately after finishing each one + - content: imperative form (e.g., "Fix bug", "Run tests") + - activeForm: present continuous (e.g., "Fixing bug", "Running tests") + +9. **websearch** - Search the web for current information. Use when: + - You need up-to-date information (news, docs, releases) + - Looking up programming solutions or best practices + - Finding package/library documentation + - User asks about something you're unsure about + +10. **webfetch** - Fetch content from a specific URL. Use when: + - You need to read a documentation page + - User provides a URL to check + - You found a relevant URL from search results + +11. **memory** - Persistent memory across conversations. Use to: + - Save important context: user preferences, project decisions, feedback + - Search/load memories from previous conversations + - Memory types: user (profile), feedback (corrections), project (context), reference (links) + - Proactively save memories when you learn something important about the user or project + - Check memories at the start of conversations for relevant context + +12. **agent** - Spawn a focused sub-agent to handle a self-contained sub-task. Use when: + - A step is large enough to need its own isolated context (e.g. "review this file", "run and interpret tests", "research this topic") + - You want a clean-slate agent that won't be distracted by the main conversation + - A step only needs a specific subset of tools (pass the `tools` argument to restrict) + - Example: agent(task="review src/auth.py for security issues", tools=["filesystem", "grep"]) + - The sub-agent returns its full result as a string — summarise or act on it as needed + - Do NOT use for trivial single-tool calls — use the tool directly instead + +RULES: +- When the user asks you to CREATE something (app, file, project), USE THE TOOLS to actually do it +- Do NOT just give instructions - execute the commands yourself using the shell tool +- Do NOT ask the user to run commands manually - run them for the user +- Always prefer action over explanation — do things, don't explain how to do them +- For multi-step tasks, use the task tool to track and show progress +- Use `terminal_session` for ALL terminal needs — dev servers, scaffolds, processes, interactive shells +- If the user says "open terminal", use `terminal_session start` with gui=true so they get a visible window AND you can read output +- If the user wants you to monitor a process, use `terminal_session start` (no gui needed) +- When a GUI terminal session is active and the user asks "what did I type", "see it", "check output", or anything about the terminal, IMMEDIATELY use `terminal_session read` to read the session output — do NOT say you can't see it +- For GUI sessions: the user types in the visible window, you read output with `terminal_session read` +- For headless sessions: send commands via `send` action, read output with `read` action +- When the user corrects you or gives feedback, save it to memory for future conversations +- When you learn about the user's role, preferences, or project context, save it to memory + +When executing shell commands, use the shell tool with the command to run.""" + + +class SimplePlanner(Planner): + def __init__(self, memory_context: str = "", tools: list | None = None): + self.memory_context = memory_context + self._tools = tools or [] + + def update_tools(self, tools: list) -> None: + """Refresh the tool list — call this after adding/removing MCP servers.""" + self._tools = tools + + def plan(self, history: list[Message]) -> list[Message]: + prompt = SYSTEM_PROMPT + + # Append a live tool summary so the LLM knows exactly what's loaded, + # including any MCP tools added after startup. + if self._tools: + builtin = [t.name for t in self._tools if not t.name.startswith("mcp_")] + mcp = [t.name for t in self._tools if t.name.startswith("mcp_")] + tool_section = f"\n\nLoaded tools: {', '.join(builtin)}" + if mcp: + tool_section += f"\nLoaded MCP tools: {', '.join(mcp)}" + prompt += tool_section + + if self.memory_context: + prompt += "\n" + self.memory_context + + return [Message(role="system", content=prompt)] + history + + +def create_provider(provider_name: str, model: str, settings: Settings): + """Create and validate a provider instance.""" + provider_name = provider_name.lower() + + if provider_name not in PROVIDERS: + print_error(f"Unknown provider '{provider_name}'. Available: {', '.join(PROVIDERS.keys())}") + raise typer.Exit(1) + + api_key = settings.get_api_key(provider_name) + + if provider_name != "local" and not api_key: + env_var_name = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GOOGLE_API_KEY", + }.get(provider_name, f"{provider_name.upper()}_API_KEY") + + print_error(f"No API key found for {provider_name}.") + print_error( + f"Use 'devorch set-key {provider_name}' or set {env_var_name} environment variable" + ) + raise typer.Exit(1) + + if not model: + model = settings.get_default_model(provider_name) + + kwargs = {} + if provider_name == "local": + base_url = settings.get_base_url(provider_name) + if base_url: + kwargs["base_url"] = base_url + + return get_provider(provider_name, model=model, api_key=api_key, **kwargs) + + +def build_tools( + settings: Settings, + *, + include_terminal: bool = True, + no_mcp: bool = False, + mcp_only: list[str] | None = None, +) -> list: + """Build the standard tool list and extend with any configured MCP tools. + + Args: + include_terminal: Include TerminalSessionTool (False for edit command). + no_mcp: Skip all MCP servers entirely. + mcp_only: If given, only connect servers whose names are in this list. + """ + tools = [ + ShellTool(), + FilesystemTool(), + SearchTool(), + GrepTool(), + EditTool(), + TaskTool(), + WebSearchTool(), + WebFetchTool(), + MemoryTool(), + ] + if include_terminal: + tools.insert(1, TerminalSessionTool()) + + if no_mcp: + return tools + + mcp_config = settings.mcp_servers or {} + if mcp_only: + unknown = [n for n in mcp_only if n not in mcp_config] + if unknown: + print_error( + f"Unknown MCP server(s): {', '.join(unknown)}. " + f"Configured: {', '.join(mcp_config) or 'none'}" + ) + raise typer.Exit(1) + mcp_config = {k: v for k, v in mcp_config.items() if k in mcp_only} + + if mcp_config: + mcp_manager = MCPManager() + with console.status("[bold cyan]Connecting MCP servers...", spinner="dots"): + started = mcp_manager.load_from_config(mcp_config) + if started: + print_success(f"MCP servers connected: {', '.join(started)}") + tools.extend(mcp_manager.get_all_tools()) + + return tools + + +def resolve_mode(mode_str: str | None, default: AgentMode = AgentMode.AUTO) -> AgentMode: + """Parse and validate a mode string, exit with error on bad input.""" + if not mode_str: + return default + try: + return AgentMode(mode_str.lower()) + except ValueError as err: + print_error(f"Unknown mode '{mode_str}'. Choose from: ask, auto, plan") + raise typer.Exit(1) from err + + +def build_agent(llm, tools: list, memory_ctx: str, agent_mode: AgentMode) -> "Agent": + """Construct a configured Agent ready to run. + + AgentTool is injected after construction since it needs the provider reference. + The planner is stored on agent.planner — call agent.planner.update_tools(tools) + after adding/removing MCP servers mid-session. + """ + mode_manager = ModeManager(default_mode=agent_mode) + executor = ToolExecutor(tools=tools, mode_manager=mode_manager) + planner = SimplePlanner(memory_context=memory_ctx, tools=tools) + + agent = Agent( + provider=llm, + planner=planner, + executor=executor, + tools=tools, + mode_manager=mode_manager, + ) + + # Inject AgentTool now that we have both the provider and the full tool list + agent_tool = AgentTool(provider=llm, tools=tools) + executor.tools[agent_tool.name] = agent_tool + agent.tools.append(agent_tool) + planner.update_tools(agent.tools) + + return agent diff --git a/cli/commands/ask.py b/cli/commands/ask.py index e69de29..0602c9c 100644 --- a/cli/commands/ask.py +++ b/cli/commands/ask.py @@ -0,0 +1,68 @@ +"""ask command — non-interactive single-shot query.""" + +import typer + +from cli.commands._shared import build_agent, build_tools, console, create_provider, resolve_mode +from config.settings import Settings +from core.memory import MemoryManager +from core.modes import AgentMode +from core.skills import SkillManager +from utils.logger import print_error, print_panel + + +def ask( + prompt: str = typer.Argument(None, help="The prompt or question for DevOrch"), + provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), + model: str = typer.Option(None, "--model", "-m", help="Model name"), + skill: str = typer.Option( + None, "--skill", "-s", help="Run a named skill (e.g. commit, review)" + ), + mode: str = typer.Option(None, "--mode", help="Execution mode: ask, auto, plan"), + no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), + mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), +): + """ + Ask DevOrch a single question (non-interactive). + + Examples: + devorch ask "explain this project" + devorch ask --skill commit + devorch ask --skill review "focus on security" + devorch ask --mode auto "run the tests" + devorch ask --no-mcp "quick question" + devorch ask --mcp github --mcp filesystem "..." + """ + settings = Settings.load() + + if not provider: + provider = settings.default_provider + + llm = create_provider(provider, model, settings) + tools = build_tools(settings, no_mcp=no_mcp, mcp_only=mcp or None) + agent_mode = resolve_mode(mode, default=AgentMode.AUTO) + memory_ctx = MemoryManager().get_context_prompt() + agent = build_agent(llm, tools, memory_ctx, agent_mode) + + skill_manager = SkillManager() + if skill: + skill_data = skill_manager.get(skill) + if not skill_data: + available = ", ".join(s["name"] for s in skill_manager.list_skills()) + print_error(f"Unknown skill '{skill}'. Available: {available}") + raise typer.Exit(1) + final_prompt = skill_data["prompt"] + if prompt: + final_prompt = f"{final_prompt}\n\n{prompt}" + console.print(f"[dim]Skill: {skill} | {llm.name}/{llm.model}[/dim]") + else: + if not prompt: + print_error("Provide a prompt or use --skill .") + raise typer.Exit(1) + final_prompt = prompt + console.print(f"[dim]Using {llm.name}/{llm.model}[/dim]") + + try: + result = agent.run(final_prompt, max_iterations=15) + print_panel(result, title="DevOrch", border_style="cyan") + except Exception as e: + print_error(str(e)) diff --git a/cli/commands/edit.py b/cli/commands/edit.py index e69de29..6ea35cd 100644 --- a/cli/commands/edit.py +++ b/cli/commands/edit.py @@ -0,0 +1,52 @@ +"""edit command — target a specific file for AI-driven editing.""" + +import typer + +from cli.commands._shared import build_agent, build_tools, console, create_provider, resolve_mode +from config.settings import Settings +from core.memory import MemoryManager +from utils.logger import print_error, print_panel + + +def edit( + file: str = typer.Argument(..., help="File path to edit"), + instruction: str = typer.Argument(..., help="What to change in the file"), + provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), + model: str = typer.Option(None, "--model", "-m", help="Model name"), + mode: str = typer.Option("auto", "--mode", help="Execution mode: ask, auto, plan"), + no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), + mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), +): + """ + Edit a specific file using an AI instruction. + + Examples: + devorch edit src/auth.py "add input validation to the login function" + devorch edit README.md "update the installation section" + devorch edit app/models.py "add a created_at timestamp field to User" + devorch edit src/db.py "add indexes" --mcp sqlite + """ + settings = Settings.load() + + if not provider: + provider = settings.default_provider + + llm = create_provider(provider, model, settings) + # edit only needs file-oriented tools — no terminal/task tools + tools = build_tools(settings, include_terminal=False, no_mcp=no_mcp, mcp_only=mcp or None) + agent_mode = resolve_mode(mode) + memory_ctx = MemoryManager().get_context_prompt() + agent = build_agent(llm, tools, memory_ctx, agent_mode) + + prompt = ( + f"Edit the file `{file}` with the following instruction:\n\n" + f"{instruction}\n\n" + "Read the file first, then make the minimal targeted changes needed. Show the diff when done." + ) + + console.print(f"[dim]Editing {file} | {llm.name}/{llm.model}[/dim]") + try: + result = agent.run(prompt, max_iterations=10) + print_panel(result, title=f"DevOrch — edit {file}", border_style="cyan") + except Exception as e: + print_error(str(e)) diff --git a/cli/commands/run.py b/cli/commands/run.py index e69de29..826459b 100644 --- a/cli/commands/run.py +++ b/cli/commands/run.py @@ -0,0 +1,59 @@ +"""run command — shorthand for executing a named skill directly.""" + +import typer + +from cli.commands._shared import build_agent, build_tools, console, create_provider, resolve_mode +from config.settings import Settings +from core.memory import MemoryManager +from core.skills import SkillManager +from utils.logger import print_error, print_panel + + +def run( + skill_name: str = typer.Argument(..., help="Skill name to run (e.g. commit, review, test)"), + extra: str = typer.Argument(None, help="Optional extra context appended to the skill prompt"), + provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), + model: str = typer.Option(None, "--model", "-m", help="Model name"), + mode: str = typer.Option("auto", "--mode", help="Execution mode: ask, auto, plan"), + no_mcp: bool = typer.Option(False, "--no-mcp", help="Disable all MCP servers for this run"), + mcp: list[str] = typer.Option(None, "--mcp", help="Use only these MCP servers (repeatable)"), +): + """ + Run a skill directly. + + Examples: + devorch run commit + devorch run review + devorch run test "only run unit tests" + devorch run commit --no-mcp + devorch run review --mcp github + """ + settings = Settings.load() + + if not provider: + provider = settings.default_provider + + llm = create_provider(provider, model, settings) + + skill_manager = SkillManager() + skill_data = skill_manager.get(skill_name) + if not skill_data: + available = ", ".join(s["name"] for s in skill_manager.list_skills()) + print_error(f"Unknown skill '{skill_name}'. Available: {available}") + raise typer.Exit(1) + + final_prompt = skill_data["prompt"] + if extra: + final_prompt = f"{final_prompt}\n\n{extra}" + + tools = build_tools(settings, no_mcp=no_mcp, mcp_only=mcp or None) + agent_mode = resolve_mode(mode) + memory_ctx = MemoryManager().get_context_prompt() + agent = build_agent(llm, tools, memory_ctx, agent_mode) + + console.print(f"[dim]Skill: {skill_name} | {llm.name}/{llm.model}[/dim]") + try: + result = agent.run(final_prompt, max_iterations=15) + print_panel(result, title=f"DevOrch — {skill_name}", border_style="cyan") + except Exception as e: + print_error(str(e)) diff --git a/cli/constants.py b/cli/constants.py new file mode 100644 index 0000000..cc742a0 --- /dev/null +++ b/cli/constants.py @@ -0,0 +1,161 @@ +""" +Static UI constants for DevOrch CLI. + +Centralises VERSION, banners, slash-command registry, prompt/questionary +styles, and the slash-command autocompleter so main.py stays focused on +app wiring and REPL logic. +""" + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.styles import Style +from questionary import Style as QStyle + +from utils.logger import get_console + +console = get_console() + +# ── Version ────────────────────────────────────────────────────────────────── + +VERSION = "0.3.0" + +# ── Banners ─────────────────────────────────────────────────────────────────── + +BANNER = """ +[bold cyan] ╔╦╗┌─┐┬ ┬╔═╗┬─┐┌─┐┬ ┬ + ║║├┤ └┐┌┘║ ║├┬┘│ ├─┤ + ═╩╝└─┘ └┘ ╚═╝┴└─└─┘┴ ┴[/bold cyan]""" + +BANNER_SMALL = "[bold cyan]DevOrch[/bold cyan]" + +# ── Slash commands registry ─────────────────────────────────────────────────── +# Add new slash commands here — the REPL and autocompleter pick them up +# automatically. + +SLASH_COMMANDS: dict[str, str] = { + "/help": "Show available commands", + "/mode": "Show or change mode (plan/auto/ask)", + "/plan": "Switch to plan mode", + "/auto": "Switch to auto mode", + "/ask": "Switch to ask mode (default)", + "/clear": "Clear conversation history", + "/session": "Show current session info", + "/config": "Show configuration settings", + "/permissions": "Show permission settings", + "/compact": "Summarize and compact history", + "/models": "Browse and switch models (interactive)", + "/model": "Switch model (/model or interactive)", + "/providers": "Browse and switch providers (interactive)", + "/provider": "Switch provider (/provider or interactive)", + "/history": "Show conversation history", + "/undo": "Undo last message", + "/save": "Save conversation to file", + "/status": "Show current provider, model, and mode", + "/tasks": "Show current task list", + "/memory": "Show saved memories", + "/remember": "Save something to memory", + "/forget": "Delete a memory", + "/skills": "List available skills", + "/skill": "Run a skill (e.g. /skill commit)", + "/mcp": "Show MCP servers | add/stop/start servers inline", + "/auth": "Set or update API key for current/specified provider", +} + +# ── Questionary style (provider/model selection prompts) ───────────────────── + +QUESTIONARY_STYLE = QStyle( + [ + ("qmark", "fg:#55aaff bold"), + ("question", "fg:#ffffff bold"), + ("answer", "fg:#44ddaa bold"), + ("pointer", "fg:#55ccff bold"), + ("highlighted", "fg:#55ccff bold"), + ("selected", "fg:#55ccff"), + ("text", "fg:#bbbbbb"), + ("disabled", "fg:#555555"), + ("instruction", "fg:#666666 italic"), + ("separator", "fg:#444444"), + ] +) + +# ── prompt_toolkit style (REPL input + completion menu) ────────────────────── + +PROMPT_STYLE = Style.from_dict( + { + "prompt": "#55cc55 bold", + "prompt-arrow": "#55cc55 bold", + "": "#ffffff bold", + "command": "#66ccff bold", + "description": "#888888", + "completion-menu": "bg:#252530", + "completion-menu.completion": "bg:#252530 #cccccc", + "completion-menu.completion.current": "bg:#334466 #ffffff bold", + "completion-menu.meta": "bg:#252530 #555555", + "completion-menu.meta.current": "bg:#334466 #99bbdd", + "scrollbar.background": "bg:#2a2a3a", + "scrollbar.button": "bg:#5588bb", + "bottom-toolbar": "bg:#0e0e18 #556677", + "bottom-toolbar.text": "bg:#0e0e18 #556677", + } +) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _xml_escape(text: str) -> str: + """Escape text for use in prompt_toolkit HTML.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +class SlashCommandCompleter(Completer): + """Autocomplete for slash commands and skill shortcuts in the REPL.""" + + def __init__(self, skill_manager=None): + self._skill_manager = skill_manager + + def get_completions(self, document, complete_event): + text = document.text_before_cursor + + if not text.startswith("/"): + return + + partial = text.lower() + + for cmd, desc in SLASH_COMMANDS.items(): + if cmd.startswith(partial): + padded_cmd = cmd[1:].ljust(16) + safe_desc = _xml_escape(desc) + yield Completion( + cmd, + start_position=-len(text), + display=HTML( + f"{padded_cmd}{safe_desc}" + ), + display_meta=desc, + ) + + if self._skill_manager: + for skill in self._skill_manager.list_skills(): + skill_cmd = f"/{skill['name']}" + if skill_cmd.startswith(partial) and skill_cmd not in SLASH_COMMANDS: + padded_cmd = skill["name"].ljust(16) + safe_desc = _xml_escape(skill["description"]) + yield Completion( + skill_cmd, + start_position=-len(text), + display=HTML( + f"{padded_cmd}{safe_desc}" + ), + display_meta=f"skill: {skill['description']}", + ) + + +def print_banner(small: bool = False) -> None: + """Print the DevOrch banner.""" + if small: + console.print(f"\n {BANNER_SMALL} [dim]v{VERSION}[/dim]\n") + else: + console.print(BANNER) + console.print(f" [dim]v{VERSION}[/dim]") + console.print() diff --git a/cli/main.py b/cli/main.py index f1f8f3f..2445348 100644 --- a/cli/main.py +++ b/cli/main.py @@ -4,13 +4,27 @@ import questionary import typer from prompt_toolkit import prompt as pt_prompt -from prompt_toolkit.completion import Completer, Completion -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.styles import Style -from questionary import Style as QStyle from rich.panel import Panel from rich.table import Table +from cli.commands._shared import ( # noqa: E402 + SYSTEM_PROMPT, # noqa: E402, F401 (used by start_repl via SimplePlanner) + SimplePlanner, + console, + create_provider, +) +from cli.commands.ask import ask # noqa: E402 +from cli.commands.edit import edit # noqa: E402 +from cli.commands.run import run # noqa: E402 + +# Custom style for questionary prompts +from cli.constants import ( # noqa: E402 + PROMPT_STYLE, + QUESTIONARY_STYLE, + SLASH_COMMANDS, + SlashCommandCompleter, + print_banner, +) from config.permissions import ( PERMISSIONS_FILE, PermissionLevel, @@ -30,13 +44,12 @@ from core.mcp import MCPManager from core.memory import MemoryManager, MemoryTool from core.modes import AgentMode, ModeManager -from core.planner import Planner from core.sessions import DEFAULT_MESSAGE_LIMIT, SessionManager from core.skills import SkillManager from core.tasks import get_task_manager, reset_task_manager from providers import PROVIDER_ENV_VARS, PROVIDER_INFO, PROVIDERS, get_provider from providers.base import ModelInfo -from schemas.message import Message +from tools.agent import AgentTool # noqa: E402 from tools.edit import EditTool from tools.filesystem import FilesystemTool from tools.grep import GrepTool @@ -46,7 +59,6 @@ from tools.terminal_session import TerminalSessionTool from tools.websearch import WebFetchTool, WebSearchTool from utils.logger import ( - get_console, print_error, print_info, print_panel, @@ -55,227 +67,6 @@ print_warning, ) -# Custom style for questionary prompts -QUESTIONARY_STYLE = QStyle( - [ - ("qmark", "fg:#55aaff bold"), # question mark - ("question", "fg:#ffffff bold"), # question text - ("answer", "fg:#44ddaa bold"), # confirmed answer - ("pointer", "fg:#55ccff bold"), # » arrow - ("highlighted", "fg:#55ccff bold"), # selected item text — matches pointer - ("selected", "fg:#55ccff"), # multi-select selected - ("text", "fg:#bbbbbb"), # unselected items - ("disabled", "fg:#555555"), # disabled items - ("instruction", "fg:#666666 italic"), # instruction hint - ("separator", "fg:#444444"), # separator lines - ] -) - -# ASCII Art Banner — clean, compact -BANNER = """ -[bold cyan] ╔╦╗┌─┐┬ ┬╔═╗┬─┐┌─┐┬ ┬ - ║║├┤ └┐┌┘║ ║├┬┘│ ├─┤ - ═╩╝└─┘ └┘ ╚═╝┴└─└─┘┴ ┴[/bold cyan]""" - -BANNER_SMALL = "[bold cyan]DevOrch[/bold cyan]" - -VERSION = "0.2.1" - -# Slash commands with descriptions -SLASH_COMMANDS = { - "/help": "Show available commands", - "/mode": "Show or change mode (plan/auto/ask)", - "/plan": "Switch to plan mode", - "/auto": "Switch to auto mode", - "/ask": "Switch to ask mode (default)", - "/clear": "Clear conversation history", - "/session": "Show current session info", - "/config": "Show configuration settings", - "/permissions": "Show permission settings", - "/compact": "Summarize and compact history", - "/models": "Browse and switch models (interactive)", - "/model": "Switch model (/model or interactive)", - "/providers": "Browse and switch providers (interactive)", - "/provider": "Switch provider (/provider or interactive)", - "/history": "Show conversation history", - "/undo": "Undo last message", - "/save": "Save conversation to file", - "/status": "Show current provider, model, and mode", - "/tasks": "Show current task list", - "/memory": "Show saved memories", - "/remember": "Save something to memory", - "/forget": "Delete a memory", - "/skills": "List available skills", - "/skill": "Run a skill (e.g. /skill commit)", - "/mcp": "Show MCP server status", - "/auth": "Set or update API key for current/specified provider", -} - -# Style for prompt_toolkit (including completion menu) -PROMPT_STYLE = Style.from_dict( - { - "prompt": "#55cc55 bold", - "prompt-arrow": "#55cc55 bold", - "": "#ffffff bold", # input text — bright white, bold to stand out - "command": "#66ccff bold", - "description": "#888888", - # Completion menu styling - "completion-menu": "bg:#252530", - "completion-menu.completion": "bg:#252530 #cccccc", - "completion-menu.completion.current": "bg:#334466 #ffffff bold", - "completion-menu.meta": "bg:#252530 #555555", - "completion-menu.meta.current": "bg:#334466 #99bbdd", - # Scrollbar - "scrollbar.background": "bg:#2a2a3a", - "scrollbar.button": "bg:#5588bb", - # Bottom toolbar — near-invisible bg, just text - "bottom-toolbar": "bg:#0e0e18 #556677", - "bottom-toolbar.text": "bg:#0e0e18 #556677", - } -) - - -def _xml_escape(text: str) -> str: - """Escape text for use in prompt_toolkit HTML.""" - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - -class SlashCommandCompleter(Completer): - """Autocomplete for slash commands and skill shortcuts.""" - - def __init__(self, skill_manager=None): - self._skill_manager = skill_manager - - def get_completions(self, document, complete_event): - text = document.text_before_cursor - - # Only complete if starts with / - if not text.startswith("/"): - return - - # Get the partial command - partial = text.lower() - - # Built-in slash commands - for cmd, desc in SLASH_COMMANDS.items(): - if cmd.startswith(partial): - # Pad command name for alignment (Gemini-like layout) - padded_cmd = cmd[1:].ljust(16) # strip / for display, pad - safe_desc = _xml_escape(desc) - yield Completion( - cmd, - start_position=-len(text), - display=HTML( - f"{padded_cmd}{safe_desc}" - ), - display_meta=desc, - ) - - # Skill shortcuts (e.g. /commit, /review) - if self._skill_manager: - for skill in self._skill_manager.list_skills(): - skill_cmd = f"/{skill['name']}" - if skill_cmd.startswith(partial) and skill_cmd not in SLASH_COMMANDS: - padded_cmd = skill["name"].ljust(16) - safe_desc = _xml_escape(skill["description"]) - yield Completion( - skill_cmd, - start_position=-len(text), - display=HTML( - f"{padded_cmd}{safe_desc}" - ), - display_meta=f"skill: {skill['description']}", - ) - - -def print_banner(small: bool = False): - """Print the DevOrch banner.""" - if small: - console.print(f"\n {BANNER_SMALL} [dim]v{VERSION}[/dim]\n") - else: - console.print(BANNER) - console.print(f" [dim]v{VERSION}[/dim]") - console.print() - - -SYSTEM_PROMPT = """You are DevOrch, an AI coding assistant with access to tools for interacting with the user's computer. - -IMPORTANT: You have the following tools available and MUST use them to help the user: - -1. **shell** - Execute shell commands (bash/powershell). Use this to: - - Run commands like `npm install`, `git clone`, `git status`, etc. - - Navigate directories, create files, run scripts - - Any short-lived terminal command that returns output - -2. **terminal_session** — The PRIMARY tool for all terminal/process needs: - - `start` — launch a command in a managed session. Defaults to 'bash' if no command. - Set gui=true to also open a visible terminal window for the user. - - `read` — read recent stdout/stderr output - - `send` — send input to the process stdin - - `stop` — terminate the session - - `list` — show all active sessions - - `reconnect` — reconnect to sessions from previous DevOrch runs - Sessions persist across DevOrch restarts with unique names (e.g. 'swift-fox-a3f2'). - Use gui=true when the user wants to SEE a terminal. Use without gui for headless monitoring. - -4. **filesystem** - Read/write/list files. Use this to: - - Read file contents to understand code - - Write or create new files - - List directory contents - -5. **search** - Find files by name patterns (like glob) - -6. **grep** - Search for text patterns within files - -7. **edit** - Make targeted edits to existing files - -8. **task** - Track progress on multi-step work. Use this to: - - Create a task list when working on complex requests (3+ steps) - - Show the user what you're currently working on - - Mark tasks complete as you finish them - - Task guidelines: - - Use when working on multiple steps or user gives multiple items - - Only ONE task should be 'in_progress' at a time - - Mark tasks 'completed' immediately after finishing each one - - content: imperative form (e.g., "Fix bug", "Run tests") - - activeForm: present continuous (e.g., "Fixing bug", "Running tests") - -9. **websearch** - Search the web for current information. Use when: - - You need up-to-date information (news, docs, releases) - - Looking up programming solutions or best practices - - Finding package/library documentation - - User asks about something you're unsure about - -10. **webfetch** - Fetch content from a specific URL. Use when: - - You need to read a documentation page - - User provides a URL to check - - You found a relevant URL from search results - -11. **memory** - Persistent memory across conversations. Use to: - - Save important context: user preferences, project decisions, feedback - - Search/load memories from previous conversations - - Memory types: user (profile), feedback (corrections), project (context), reference (links) - - Proactively save memories when you learn something important about the user or project - - Check memories at the start of conversations for relevant context - -RULES: -- When the user asks you to CREATE something (app, file, project), USE THE TOOLS to actually do it -- Do NOT just give instructions - execute the commands yourself using the shell tool -- Do NOT ask the user to run commands manually - run them for the user -- Always prefer action over explanation — do things, don't explain how to do them -- For multi-step tasks, use the task tool to track and show progress -- Use `terminal_session` for ALL terminal needs — dev servers, scaffolds, processes, interactive shells -- If the user says "open terminal", use `terminal_session start` with gui=true so they get a visible window AND you can read output -- If the user wants you to monitor a process, use `terminal_session start` (no gui needed) -- When a GUI terminal session is active and the user asks "what did I type", "see it", "check output", or anything about the terminal, IMMEDIATELY use `terminal_session read` to read the session output — do NOT say you can't see it -- For GUI sessions: the user types in the visible window, you read output with `terminal_session read` -- For headless sessions: send commands via `send` action, read output with `read` action -- When the user corrects you or gives feedback, save it to memory for future conversations -- When you learn about the user's role, preferences, or project context, save it to memory - -When executing shell commands, use the shell tool with the command to run.""" - def _format_model_choice( model: ModelInfo, current_model: str = "", index: int = 0, max_name_len: int = 35 @@ -433,18 +224,6 @@ def _fuzzy_match_model(query: str, models: list[ModelInfo]) -> ModelInfo | None: return None -class SimplePlanner(Planner): - def __init__(self, memory_context: str = ""): - self.memory_context = memory_context - - def plan(self, history: list[Message]) -> list[Message]: - prompt = SYSTEM_PROMPT - if self.memory_context: - prompt += "\n" + self.memory_context - system_prompt = Message(role="system", content=prompt) - return [system_prompt] + history - - # Main app with invoke_without_command=True so we can handle bare `devorch` app = typer.Typer( help="DevOrch - Your AI Coding Assistant", invoke_without_command=True, no_args_is_help=False @@ -455,7 +234,10 @@ def plan(self, history: list[Message]) -> list[Message]: permissions_app = typer.Typer(help="Manage tool permissions") app.add_typer(permissions_app, name="permissions") -console = get_console() +# Register commands defined in cli/commands/ +app.command()(ask) +app.command()(run) +app.command()(edit) def has_any_provider_configured(settings: Settings) -> bool: @@ -682,41 +464,6 @@ def create_provider_safe(provider_name: str, model: str, settings: Settings): return get_provider(provider_name, model=model, api_key=api_key, **kwargs) -def create_provider(provider_name: str, model: str, settings: Settings): - """Create and validate a provider instance.""" - provider_name = provider_name.lower() - - if provider_name not in PROVIDERS: - print_error(f"Unknown provider '{provider_name}'. Available: {', '.join(PROVIDERS.keys())}") - raise typer.Exit(1) - - api_key = settings.get_api_key(provider_name) - - if provider_name != "local" and not api_key: - env_var_name = { - "openai": "OPENAI_API_KEY", - "anthropic": "ANTHROPIC_API_KEY", - "gemini": "GOOGLE_API_KEY", - }.get(provider_name, f"{provider_name.upper()}_API_KEY") - - print_error(f"No API key found for {provider_name}.") - print_error( - f"Use 'devorch set-key {provider_name}' or set {env_var_name} environment variable" - ) - raise typer.Exit(1) - - if not model: - model = settings.get_default_model(provider_name) - - kwargs = {} - if provider_name == "local": - base_url = settings.get_base_url(provider_name) - if base_url: - kwargs["base_url"] = base_url - - return get_provider(provider_name, model=model, api_key=api_key, **kwargs) - - def start_repl( provider: str | None = None, model: str | None = None, @@ -802,7 +549,7 @@ def start_repl( mode_manager = ModeManager(default_mode=AgentMode.ASK) executor = ToolExecutor(tools=tools, require_confirmation=True, mode_manager=mode_manager) - planner = SimplePlanner(memory_context=memory_context) + planner = SimplePlanner(memory_context=memory_context, tools=tools) def on_session_continue(new_session_id: str): print_info(f"Session continued: {new_session_id}") @@ -817,6 +564,12 @@ def on_session_continue(new_session_id: str): mode_manager=mode_manager, ) + # Inject AgentTool (needs provider + full tool list, so injected after construction) + agent_tool = AgentTool(provider=llm, tools=tools) + executor.tools[agent_tool.name] = agent_tool + agent.tools.append(agent_tool) + planner.update_tools(agent.tools) + if messages: agent.set_history(messages) @@ -1499,25 +1252,126 @@ def get_bottom_toolbar(): continue elif cmd == "mcp": - servers = mcp_manager.list_servers() - if not servers: - console.print("\n[bold]MCP Servers:[/bold] None connected") - console.print("[dim]Configure MCP servers in ~/.devorch/config.yaml:[/dim]") - console.print( - "[dim] mcp_servers:\n" - " my-server:\n" - " command: npx\n" - ' args: ["-y", "@modelcontextprotocol/server-xxx"][/dim]\n' - ) + # Sub-command dispatch: /mcp [add|stop|start] [args...] + mcp_parts = cmd_arg.split() if cmd_arg else [] + mcp_sub = mcp_parts[0] if mcp_parts else None + + if mcp_sub == "add": + # /mcp add [arg1 arg2 ...] + if len(mcp_parts) < 3: + print_warning("Usage: /mcp add [args...]") + print_info( + "Example: /mcp add github npx -y @modelcontextprotocol/server-github" + ) + else: + mcp_name = mcp_parts[1] + mcp_cmd = mcp_parts[2] + mcp_cmd_args = mcp_parts[3:] + with console.status( + f"[bold cyan]Starting '{mcp_name}'...", spinner="dots" + ): + ok, new_tools = mcp_manager.add_server( + mcp_name, mcp_cmd, mcp_cmd_args + ) + if ok: + for t in new_tools: + executor.tools[t.name] = t + agent.tools.append(t) + planner.update_tools(agent.tools) + print_success( + f"'{mcp_name}' connected — {len(new_tools)} tool(s) added" + ) + if new_tools: + console.print( + f" [dim]Tools: {', '.join(t.name for t in new_tools)}[/dim]" + ) + else: + print_error(f"Failed to start MCP server '{mcp_name}'") + + elif mcp_sub == "stop": + # /mcp stop + if len(mcp_parts) < 2: + print_warning("Usage: /mcp stop ") + else: + mcp_name = mcp_parts[1] + if mcp_manager.stop_server(mcp_name): + prefix = f"mcp_{mcp_name}_" + removed = [k for k in list(executor.tools) if k.startswith(prefix)] + for k in removed: + del executor.tools[k] + agent.tools = [ + t for t in agent.tools if not t.name.startswith(prefix) + ] + planner.update_tools(agent.tools) + print_success( + f"'{mcp_name}' stopped — {len(removed)} tool(s) removed" + ) + else: + print_error(f"No server named '{mcp_name}' is connected") + + elif mcp_sub == "start": + # /mcp start — reconnect from config + if len(mcp_parts) < 2: + print_warning("Usage: /mcp start ") + else: + mcp_name = mcp_parts[1] + mcp_cfg = (settings.mcp_servers or {}).get(mcp_name) + if not mcp_cfg: + print_error( + f"'{mcp_name}' not found in config. " + "Use /mcp add [args...] to connect a new server." + ) + else: + with console.status( + f"[bold cyan]Starting '{mcp_name}'...", spinner="dots" + ): + ok, new_tools = mcp_manager.add_server( + mcp_name, + mcp_cfg.get("command", ""), + mcp_cfg.get("args", []), + mcp_cfg.get("env", {}), + mcp_cfg.get("cwd"), + ) + if ok: + for t in new_tools: + executor.tools[t.name] = t + agent.tools.append(t) + planner.update_tools(agent.tools) + print_success( + f"'{mcp_name}' started — {len(new_tools)} tool(s) added" + ) + else: + print_error(f"Failed to start MCP server '{mcp_name}'") + else: - console.print(f"\n[bold]MCP Servers ({len(servers)}):[/bold]") - for srv in servers: - status = ( - "[green]running[/green]" if srv["running"] else "[red]stopped[/red]" + # /mcp — show status + servers = mcp_manager.list_servers() + if not servers: + console.print("\n[bold]MCP Servers:[/bold] None connected") + console.print( + "[dim]Configure in ~/.devorch/config.yaml, or connect inline:[/dim]" + ) + console.print( + "[dim] /mcp add [args...] — connect a new server[/dim]\n" + "[dim] /mcp start — reconnect from config[/dim]\n" + "[dim] /mcp stop — disconnect a server[/dim]\n" + ) + else: + console.print(f"\n[bold]MCP Servers ({len(servers)}):[/bold]") + for srv in servers: + status = ( + "[green]running[/green]" + if srv["running"] + else "[red]stopped[/red]" + ) + console.print(f" [cyan]{srv['name']}[/cyan] — {status}") + if srv["tools"]: + console.print(f" Tools: {', '.join(srv['tools'])}") + console.print( + "\n[dim]/mcp add [args] " + "| /mcp stop " + "| /mcp start [/dim]" ) - console.print(f" [cyan]{srv['name']}[/cyan] - {status}") - if srv["tools"]: - console.print(f" Tools: {', '.join(srv['tools'])}") console.print() continue @@ -1630,51 +1484,6 @@ def chat( start_repl(provider=provider, model=model, resume=resume, message_limit=message_limit) -@app.command() -def ask( - prompt: str = typer.Argument(..., help="The prompt or question for DevOrch"), - provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), - model: str = typer.Option(None, "--model", "-m", help="Model name"), -): - """ - Ask DevOrch a single question (non-interactive). - """ - settings = Settings.load() - - if not provider: - provider = settings.default_provider - - llm = create_provider(provider, model, settings) - - tools = [ - ShellTool(), - TerminalSessionTool(), - FilesystemTool(), - SearchTool(), - GrepTool(), - EditTool(), - TaskTool(), - WebSearchTool(), - WebFetchTool(), - MemoryTool(), - ] - - memory_mgr = MemoryManager() - memory_ctx = memory_mgr.get_context_prompt() - - executor = ToolExecutor(tools=tools) - planner = SimplePlanner(memory_context=memory_ctx) - - agent = Agent(provider=llm, planner=planner, executor=executor, tools=tools) - - console.print(f"[dim]Using {llm.name}/{llm.model}[/dim]") - try: - result = agent.run(prompt, max_iterations=15) - print_panel(result, title="DevOrch", border_style="cyan") - except Exception as e: - print_error(str(e)) - - @app.command() def config(): """ @@ -1763,6 +1572,30 @@ def providers(): console.print(f" - {name}") +@app.command("skills") +def skills_list(): + """ + List all available skills. + """ + skill_manager = SkillManager() + all_skills = skill_manager.list_skills() + + if not all_skills: + print_info("No skills found.") + return + + table = Table(title="Available Skills") + table.add_column("Name", style="cyan") + table.add_column("Description", style="white") + table.add_column("Source", style="dim") + + for s in all_skills: + table.add_row(s["name"], s["description"], s.get("source", "built-in")) + + console.print(table) + console.print("\n[dim]Run a skill: devorch ask --skill [/dim]") + + # Session commands @sessions_app.command("list") def sessions_list( diff --git a/core/agent.py b/core/agent.py index 0d86240..0f4cc90 100644 --- a/core/agent.py +++ b/core/agent.py @@ -56,8 +56,6 @@ def __init__( self.mode_manager = mode_manager or ModeManager() self.history: list[Message] = [] self._context_summary: str | None = None # Summary from previous session - self._awaiting_plan_approval: bool = False - self._pending_plan_response: str | None = None def set_history(self, messages: list[Message]): """Set the conversation history (used when resuming a session).""" @@ -295,82 +293,80 @@ def _check_and_handle_session_limit(self) -> bool: return True + def _awaiting_plan_approval(self) -> bool: + """True when a plan has been shown but not yet approved or rejected.""" + plan = self.mode_manager.current_plan + return plan is not None and not plan.approved + def _handle_plan_approval(self, user_input: str) -> str | None: - """Handle plan approval responses. Returns response or None to continue.""" + """Handle plan approval responses. Returns a reply string or None to proceed.""" input_lower = user_input.strip().lower() if input_lower in ("yes", "y", "proceed", "go", "ok", "continue"): - self._awaiting_plan_approval = False + self.mode_manager.approve_plan() print_success("Plan approved! Executing...") - # Switch to auto mode temporarily for this execution - return None # Continue with execution + return None # Signal: continue with execution - elif input_lower in ("no", "n", "cancel", "stop", "abort"): - self._awaiting_plan_approval = False - self._pending_plan_response = None + if input_lower in ("no", "n", "cancel", "stop", "abort"): + self.mode_manager.clear_plan() return "Plan cancelled. What would you like me to do instead?" - elif input_lower.startswith("modify") or input_lower.startswith("change"): - self._awaiting_plan_approval = False - # User wants to modify, treat the rest as new instructions - modification = ( - user_input[6:].strip() - if input_lower.startswith("modify") - else user_input[6:].strip() - ) - if modification: - return self.run(f"Please modify the plan: {modification}", max_iterations=15) + # "modify ..." or "change ..." — treat the rest as new instructions + if input_lower.startswith(("modify", "change")): + self.mode_manager.clear_plan() + rest = user_input.split(None, 1)[1] if len(user_input.split(None, 1)) > 1 else "" + if rest: + return self.run(f"Please modify the plan: {rest}", max_iterations=15) return "What changes would you like me to make to the plan?" - else: - # Treat as modification request - return self.run(user_input, max_iterations=15) + # Anything else — treat as a modification request + self.mode_manager.clear_plan() + return self.run(user_input, max_iterations=15) def run(self, user_input: str, max_iterations: int = 15): - # Check if we're awaiting plan approval - if self._awaiting_plan_approval: + is_plan_mode = self.mode_manager.mode == AgentMode.PLAN + + # If a plan was shown but not yet approved, handle the user's response first + if is_plan_mode and self._awaiting_plan_approval(): result = self._handle_plan_approval(user_input) if result is not None: return result - # If None, continue with the pending execution + # None means "approved — fall through and execute" # Check session limit before processing self._check_and_handle_session_limit() - # Save user message user_message = Message(role="user", content=user_input) self._save_message(user_message) iteration = 0 - is_plan_mode = self.mode_manager.mode == AgentMode.PLAN - plan_created = False + # A plan is "pending" on the first pass when in plan mode and no plan exists yet + needs_plan = is_plan_mode and self.mode_manager.current_plan is None while iteration < max_iterations: planned_messages = self.planner.plan(self.history) - # Inject context summary if available + # Inject previous-session context after the system message (first iteration only) if self._context_summary and iteration == 0: - # Add summary context after system message for i, msg in enumerate(planned_messages): if msg.role == "system": - context_msg = Message( - role="system", - content=f"\n\n[Previous conversation context]\n{self._context_summary}", + planned_messages.insert( + i + 1, + Message( + role="system", + content=f"\n\n[Previous conversation context]\n{self._context_summary}", + ), ) - planned_messages.insert(i + 1, context_msg) break - # In plan mode, inject plan prompt on first iteration - if is_plan_mode and iteration == 0 and not plan_created: + # Planning phase: inject plan prompt and withhold tools so LLM writes the plan first + if needs_plan and iteration == 0: for i, msg in enumerate(planned_messages): if msg.role == "system": - plan_msg = Message(role="system", content=f"\n\n{PLAN_MODE_PROMPT}") - planned_messages.insert(i + 1, plan_msg) + planned_messages.insert( + i + 1, Message(role="system", content=f"\n\n{PLAN_MODE_PROMPT}") + ) break - - # Determine which tools to provide based on mode - if is_plan_mode and not plan_created: - # In planning phase, don't provide tools so LLM creates plan first tools_for_call = None status_msg = "[bold yellow]DevOrch is planning..." else: @@ -378,15 +374,12 @@ def run(self, user_input: str, max_iterations: int = 15): status_msg = "[bold blue]DevOrch is thinking..." with console.status(status_msg, spinner="dots"): - response = self.provider.generate( - planned_messages, - tools=tools_for_call, - ) + response = self.provider.generate(planned_messages, tools=tools_for_call) - # Save assistant message (include tool_calls in metadata for providers like Mistral) + # Persist tool_calls metadata for providers that need it (e.g. Mistral) if response.tool_calls: - # Store tool_calls info in metadata for conversation reconstruction - tool_calls_data = [ + response.message.metadata = response.message.metadata or {} + response.message.metadata["tool_calls"] = [ { "id": tc.id, "type": "function", @@ -394,41 +387,27 @@ def run(self, user_input: str, max_iterations: int = 15): } for tc in response.tool_calls ] - response.message.metadata = response.message.metadata or {} - response.message.metadata["tool_calls"] = tool_calls_data self._save_message(response.message) - # In plan mode, check if this is a plan response - if is_plan_mode and not plan_created and not response.tool_calls: - plan_created = True - self._awaiting_plan_approval = True - self._pending_plan_response = response.message.content - # Check session limit after response + # Plan just created — register it in ModeManager and wait for approval + if needs_plan and not response.tool_calls: + self.mode_manager.start_plan(user_input) self._check_and_handle_session_limit() return response.message.content + # Final answer — no tool calls if not response.tool_calls: - # The LLM didn't call any tools, so we have a final answer - # Check session limit after response self._check_and_handle_session_limit() return response.message.content for call in response.tool_calls: - # Display tool call in a nice panel self._display_tool_call(call) - - # Execute without spinner - the spinner blocks input for permission prompts result = self.executor.execute(call.name, call.arguments) - - # Display result in a nice panel self._display_tool_result(call.name, result) - - # Save tool result message - tool_message = Message( - role="tool", content=str(result), name=call.name, tool_call_id=call.id + self._save_message( + Message(role="tool", content=str(result), name=call.name, tool_call_id=call.id) ) - self._save_message(tool_message) iteration += 1 diff --git a/core/executor.py b/core/executor.py index d441ea1..59a7aa1 100644 --- a/core/executor.py +++ b/core/executor.py @@ -67,6 +67,13 @@ def _get_command_description(self, tool_name: str, arguments: dict[str, Any]) -> elif action == "list": return f"list {path}" return f"{action} {path}" + elif tool_name == "agent": + task = arguments.get("task", "") + tools = arguments.get("tools") + # Truncate long tasks for display + task_preview = task[:120] + "..." if len(task) > 120 else task + tools_str = f" tools: {tools}" if tools else " tools: all" + return f"{task_preview}\n{tools_str}" else: return str(arguments) diff --git a/core/mcp.py b/core/mcp.py index 402fe53..f396958 100644 --- a/core/mcp.py +++ b/core/mcp.py @@ -324,6 +324,38 @@ def get_all_tools(self) -> list["MCPToolProxy"]: tools.append(proxy) return tools + def add_server( + self, + name: str, + command: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, + ) -> tuple[bool, list["MCPToolProxy"]]: + """Start a new MCP server and return (success, new_tools). + + If a server with the same name already exists it is stopped first. + """ + if name in self.servers: + self.servers[name].stop() + del self.servers[name] + + server = MCPServer(name=name, command=command, args=args or [], env=env or {}, cwd=cwd) + if not server.start(): + return False, [] + + self.servers[name] = server + tools = [MCPToolProxy(server=server, server_name=name, tool_def=t) for t in server.tools] + return True, tools + + def stop_server(self, name: str) -> bool: + """Stop a specific server by name. Returns True if it existed.""" + server = self.servers.pop(name, None) + if server is None: + return False + server.stop() + return True + def stop_all(self): """Stop all MCP servers.""" for server in self.servers.values(): diff --git a/core/modes.py b/core/modes.py index f9595dc..3bae454 100644 --- a/core/modes.py +++ b/core/modes.py @@ -106,16 +106,14 @@ def should_ask_permission(self) -> bool: """Check if we should ask for tool permission based on mode.""" if self._mode == AgentMode.AUTO: return False - elif self._mode == AgentMode.PLAN: - # In plan mode, don't ask during planning, only during execution - if self._current_plan and self._current_plan.approved: - return False # Plan approved, execute without asking - return True - else: # ASK mode - return True + if self._mode == AgentMode.PLAN: + # Once the plan is approved, execute without asking — the plan IS the approval + return not (self._current_plan and self._current_plan.approved) + # ASK mode — always ask + return True def is_planning(self) -> bool: - """Check if we're currently in planning phase.""" + """True while a plan has been shown but the user hasn't approved it yet.""" return ( self._mode == AgentMode.PLAN and self._current_plan is not None diff --git a/pyproject.toml b/pyproject.toml index db87630..229aeb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "devorch" -version = "0.2.1" +version = "0.3.0" description = "Multi-provider AI coding assistant CLI with 13+ providers" readme = "README.md" requires-python = ">=3.10" diff --git a/tools/agent.py b/tools/agent.py new file mode 100644 index 0000000..06854d0 --- /dev/null +++ b/tools/agent.py @@ -0,0 +1,105 @@ +""" +AgentTool — lets the main agent spawn a focused sub-agent for a sub-task. + +The sub-agent: + - Shares the same provider/model as the parent + - Gets an isolated history (no parent conversation baggage) + - Runs in AUTO mode (no confirmations) + - Cannot spawn further sub-agents (no recursion) + - Is capped at 10 iterations + +Typical use-cases the orchestrator delegates: + - Code review pass on a specific file + - Running and interpreting tests + - Writing a commit message after changes are made + - Fetching + summarising a URL before the parent continues +""" + +from typing import Any + +from pydantic import BaseModel, Field + +from core.agent import Agent +from core.executor import ToolExecutor +from core.modes import AgentMode, ModeManager +from core.planner import Planner +from schemas.message import Message +from tools.base import Tool + +SUB_AGENT_SYSTEM_PROMPT = """You are a focused sub-agent inside DevOrch. +Your job is to complete ONE specific task using the tools available to you. +Be direct and efficient. Return a clear, concise result when done. +Do not ask clarifying questions — infer what you can and proceed.""" + + +class SubAgentPlanner(Planner): + """Minimal planner for sub-agents — lightweight system prompt, no memory injection.""" + + def plan(self, history: list[Message]) -> list[Message]: + return [Message(role="system", content=SUB_AGENT_SYSTEM_PROMPT)] + history + + +class AgentToolSchema(BaseModel): + task: str = Field(..., description="The task or question for the sub-agent to complete.") + tools: list[str] | None = Field( + default=None, + description=( + "Optional list of tool names to give the sub-agent. " + "If omitted, the sub-agent gets all available tools. " + "Example: ['shell', 'filesystem', 'grep']" + ), + ) + + +class AgentTool(Tool): + """ + Spawn a focused sub-agent to handle a specific sub-task. + + Use this when: + - A task is large enough to benefit from isolated context (e.g. review, test, summarise) + - You want a clean-slate agent that won't be distracted by the main conversation + - A step requires a specialised tool subset (e.g. only web tools for research) + + The sub-agent runs independently and returns its result as a string. + It cannot spawn further sub-agents. + """ + + name = "agent" + description = ( + "Spawn a focused sub-agent to complete a specific sub-task in isolated context. " + "Use for review passes, test runs, research, or any step that benefits from " + "a clean slate. The sub-agent returns its result as a string." + ) + args_schema = AgentToolSchema + + def __init__(self, provider, tools: list[Tool]): + self._provider = provider + # Store all tools except this one — sub-agents can't spawn sub-agents + self._available_tools = [t for t in tools if t.name != "agent"] + + def run(self, arguments: dict[str, Any]) -> str: + task = arguments["task"] + requested_tools: list[str] | None = arguments.get("tools") + + # Filter tool set for the sub-agent + if requested_tools: + sub_tools = [t for t in self._available_tools if t.name in requested_tools] + unknown = set(requested_tools) - {t.name for t in sub_tools} + if unknown: + sub_tools = self._available_tools # fall back to all tools on bad names + else: + sub_tools = self._available_tools + + mode_manager = ModeManager(default_mode=AgentMode.AUTO) + executor = ToolExecutor(tools=sub_tools, mode_manager=mode_manager) + planner = SubAgentPlanner() + + sub_agent = Agent( + provider=self._provider, + planner=planner, + executor=executor, + tools=sub_tools, + mode_manager=mode_manager, + ) + + return sub_agent.run(task, max_iterations=10)