PRODUCTAGENTharrisonhjohnson/productagent · /harnesses/todo/bot/agent_executor.py
000%

agent_executor.py

view on github ↗344 lines · python
"""
Agent Executor for NAVI - Generate and execute TODO-based agents

This module provides:
1. TODO-to-Agent: Generate plans for TODOs using Claude Code in plan mode
2. Plan approval and execution flow
3. Full Claude Code capabilities for execution (file editing, bash, Slack MCP, etc.)
"""
import os
import subprocess
import json
import logging
from datetime import datetime
from typing import Dict, Optional, Tuple, List
try:
    from config import USER_NAME
except ImportError:  # pragma: no cover
    USER_NAME = "you"

logger = logging.getLogger(__name__)

# Claude CLI path - configurable via environment variable
CLAUDE_PATH = os.getenv("CLAUDE_PATH", os.path.expanduser("~/.local/bin/claude"))

# Pinned so bot runs never inherit the interactive /model (which may be a
# capped premium tier). Override with CLAUDE_MODEL for a one-off.
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-5")

# Plans directory - persists across bot restarts
PLANS_DIR = os.path.expanduser("~/work/agent-plans")


class AgentExecutor:
    """
    Handles TODO-to-Agent planning and execution.

    User flow:
    1. /agent TXXX - Generate plan for TODO
    2. /approve TXXX - Execute approved plan
    3. /plans - List pending plans
    """

    def __init__(self, todo_manager):
        """
        Initialize agent executor.

        Args:
            todo_manager: TodoManager instance for TODO operations
        """
        self.todo_manager = todo_manager
        self.pending_plans = {}  # In-memory cache, backed by filesystem

        # Ensure plans directory exists
        os.makedirs(PLANS_DIR, exist_ok=True)

        logger.info("AgentExecutor initialized")

    def generate_agent_prompt(self, todo: dict) -> str:
        """
        Build context-rich prompt for Claude agent in plan mode.

        Gathers:
        - TODO text and metadata
        - Due date if present
        """
        todo_id = todo.get('id', 'UNKNOWN')
        todo_text = todo.get('text', '')
        metadata = todo.get('metadata', {})

        context_parts = [
            "# TODO Context",
            f"**ID:** {todo_id}",
            f"**Task:** {todo_text}",
            ""
        ]

        # Due date if present
        if metadata.get('due'):
            context_parts.append(f"**Due Date:** {metadata['due']}")
            context_parts.append("")

        context = "\n".join(context_parts)

        prompt = f"""You are an agent helping {USER_NAME} complete a TODO item.

{context}

## Your Task
Create a detailed plan to accomplish this TODO. You have full Claude Code capabilities:
- Edit files, run bash commands, access ~/TODO.md

## Plan Requirements
Provide a structured plan with:
1. **Goal** - One sentence summary
2. **Steps** - Numbered, specific actions
3. **Expected Outcome** - What success looks like
4. **Estimated Effort** - Quick (5 min), Medium (15-30 min), or Deep (1+ hour)

## Guidelines
- For confirmations/follow-ups, draft the actual message content
- If a task involves code changes, identify the specific files
- Consider dependencies (what needs to happen first)

Start your plan now:"""

        return prompt

    def invoke_plan_mode(self, todo_id: str) -> Tuple[bool, str]:
        """
        Run Claude in plan mode and return structured plan.

        Args:
            todo_id: The TODO ID to generate a plan for

        Returns:
            Tuple of (success: bool, plan_text_or_error: str)
        """
        # Get the TODO
        todos = self.todo_manager.get_todos(include_completed=False)
        todo = next((t for t in todos if t.get('id') == todo_id.upper()), None)

        if not todo:
            return False, f"TODO {todo_id} not found"

        # Generate prompt
        prompt = self.generate_agent_prompt(todo)

        try:
            # Run Claude in plan mode (read-only, no edits)
            result = subprocess.run(
                [CLAUDE_PATH, "-p", "--model", CLAUDE_MODEL, prompt],
                capture_output=True,
                text=True,
                timeout=300,  # 5 min for planning
                env={**os.environ, "CLAUDE_CODE_PERMISSION_MODE": "plan"}
            )

            if result.returncode != 0:
                error_msg = result.stderr or "Claude command failed"
                logger.error(f"Agent planning error: {error_msg}")
                return False, f"Agent error: {error_msg[:500]}"

            plan = result.stdout or "No plan generated"

            # Store plan
            self.pending_plans[todo_id.upper()] = {
                "todo_id": todo_id.upper(),
                "todo_text": todo.get('text', ''),
                "plan": plan,
                "created": datetime.now().isoformat(),
                "status": "pending"
            }
            self._save_plan(todo_id.upper())

            logger.info(f"Generated plan for TODO {todo_id}")
            return True, plan

        except subprocess.TimeoutExpired:
            logger.error(f"Agent planning timed out for {todo_id}")
            return False, "Agent timed out (5 min limit)"
        except FileNotFoundError:
            logger.error(f"Claude CLI not found at {CLAUDE_PATH}")
            return False, f"Claude CLI not found at {CLAUDE_PATH}"
        except Exception as e:
            logger.error(f"Error generating plan: {e}")
            return False, f"Error: {str(e)}"

    def approve_plan(self, todo_id: str) -> Tuple[bool, str]:
        """
        Execute an approved plan with full Claude Code capabilities.

        Args:
            todo_id: The TODO ID whose plan to execute

        Returns:
            Tuple of (success: bool, result_or_error: str)
        """
        # Load plan from memory or disk
        plan_data = self.pending_plans.get(todo_id.upper()) or self._load_plan(todo_id.upper())

        if not plan_data:
            return False, f"No pending plan for {todo_id}"

        if plan_data.get('status') != 'pending':
            return False, f"Plan for {todo_id} already executed (status: {plan_data.get('status')})"

        # Build execution prompt
        prompt = f"""Execute this plan for TODO {todo_id}:

{plan_data['plan']}

## Execution Guidelines
- Execute each step in order
- Report what you did for each step
- If a step fails, note it but continue with remaining steps
- For Slack messages, use the Slack MCP to send them
- For file edits, make the changes directly

You have full permissions. Execute the plan now and report results."""

        try:
            # Run Claude with full permissions
            result = subprocess.run(
                [CLAUDE_PATH, "-p", "--dangerously-skip-permissions", "--model", CLAUDE_MODEL, prompt],
                capture_output=True,
                text=True,
                timeout=600  # 10 min for execution
            )

            execution_result = result.stdout or "Execution completed (no output)"

            # Update plan status
            plan_data['status'] = 'executed'
            plan_data['executed_at'] = datetime.now().isoformat()
            plan_data['result'] = execution_result
            plan_data['exit_code'] = result.returncode
            self._save_plan(todo_id.upper())

            if result.returncode != 0:
                logger.warning(f"Plan execution for {todo_id} returned non-zero: {result.returncode}")
            else:
                logger.info(f"Plan executed for TODO {todo_id}")

            return True, execution_result

        except subprocess.TimeoutExpired:
            plan_data['status'] = 'timeout'
            self._save_plan(todo_id.upper())
            logger.error(f"Plan execution timed out for {todo_id}")
            return False, "Execution timed out (10 min limit)"
        except Exception as e:
            plan_data['status'] = 'error'
            plan_data['error'] = str(e)
            self._save_plan(todo_id.upper())
            logger.error(f"Execution error for {todo_id}: {e}")
            return False, f"Execution error: {str(e)}"

    def cancel_plan(self, todo_id: str) -> Tuple[bool, str]:
        """
        Cancel a pending plan.

        Args:
            todo_id: The TODO ID whose plan to cancel

        Returns:
            Tuple of (success: bool, message: str)
        """
        plan_data = self.pending_plans.get(todo_id.upper()) or self._load_plan(todo_id.upper())

        if not plan_data:
            return False, f"No plan found for {todo_id}"

        plan_data['status'] = 'cancelled'
        plan_data['cancelled_at'] = datetime.now().isoformat()
        self._save_plan(todo_id.upper())

        logger.info(f"Cancelled plan for TODO {todo_id}")
        return True, f"Plan for {todo_id} cancelled"

    def get_plan(self, todo_id: str) -> Optional[dict]:
        """Get plan data for a TODO."""
        return self.pending_plans.get(todo_id.upper()) or self._load_plan(todo_id.upper())

    def get_pending_plans(self) -> List[dict]:
        """Get all pending plans."""
        plans = []

        try:
            for filename in os.listdir(PLANS_DIR):
                if filename.endswith('.json'):
                    plan = self._load_plan(filename.replace('.json', ''))
                    if plan and plan.get('status') == 'pending':
                        plans.append(plan)
        except Exception as e:
            logger.error(f"Error listing plans: {e}")

        # Sort by creation date (newest first)
        plans.sort(key=lambda p: p.get('created', ''), reverse=True)

        return plans

    def _save_plan(self, todo_id: str):
        """Save plan to filesystem."""
        plan_data = self.pending_plans.get(todo_id)
        if plan_data:
            path = os.path.join(PLANS_DIR, f"{todo_id}.json")
            try:
                with open(path, 'w') as f:
                    json.dump(plan_data, f, indent=2)
                logger.debug(f"Saved plan to {path}")
            except Exception as e:
                logger.error(f"Failed to save plan: {e}")

    def _load_plan(self, todo_id: str) -> Optional[dict]:
        """Load plan from filesystem."""
        path = os.path.join(PLANS_DIR, f"{todo_id}.json")

        if os.path.exists(path):
            try:
                with open(path, 'r') as f:
                    plan_data = json.load(f)
                    self.pending_plans[todo_id] = plan_data
                    return plan_data
            except Exception as e:
                logger.error(f"Failed to load plan from {path}: {e}")

        return None

    def format_plan_summary(self, plan_data: dict, max_plan_length: int = 2000) -> str:
        """
        Format plan data for Telegram display.

        Args:
            plan_data: Plan dict from storage
            max_plan_length: Max characters for plan preview

        Returns:
            Formatted string for Telegram
        """
        todo_id = plan_data.get('todo_id', '?')
        todo_text = plan_data.get('todo_text', 'Unknown task')
        plan = plan_data.get('plan', 'No plan content')
        status = plan_data.get('status', 'unknown')

        # Truncate plan if too long
        if len(plan) > max_plan_length:
            plan = plan[:max_plan_length] + "\n\n... (truncated)"

        lines = [
            f"**Plan for {todo_id}**",
            f"Task: {todo_text[:100]}",
            f"Status: {status}",
            "",
            plan,
            "",
            "---",
        ]

        if status == 'pending':
            lines.append(f"'/approve {todo_id}' to execute | '/cancel {todo_id}' to abort")
        elif status == 'executed':
            lines.append(f"Executed at: {plan_data.get('executed_at', 'unknown')}")

        return "\n".join(lines)
flow-designglobal
SKILL.md74 lines
SKILL.md162 lines
pm-strategistglobal
pm-strategist.md51 lines
todosystem
bot31 items
agent_executor.py344 lines
behavior_state.py106 lines
BOT.md175 lines
config.py76 lines
git_sync.py87 lines
hyrule_server.py284 lines
manage_bot.sh47 lines
mcp_server.py324 lines
mobile_mode.py229 lines
navi_tab.py95 lines
prework_engine.py426 lines
run_bot.sh18 lines
run_hyrule.sh38 lines
scheduler.py958 lines
session.py264 lines
telegram_bot.py3328 lines
todo_manager.py1130 lines
ventures_signal.py314 lines
voice_handler.py203 lines
VOICE_SETUP.md112 lines
warroom_manager.py1004 lines
README.md44 lines
night-orderssystem
README.md84 lines
loops-spec.md212 lines
settings.json116 lines
README.md59 lines