From 05e92bae7988ed3cb05fe5d85d4278ffa24d7749 Mon Sep 17 00:00:00 2001 From: vin124 Date: Sun, 29 Mar 2026 07:16:59 -0400 Subject: [PATCH] feat: add Layer 5 automation system with multi-step workflow executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Automation parser: NL request → structured spec (schedule/event/batch triggers) - Automation executor: 3-step chain (fetch spec → generate body → MCP dispatch) - Multi-step workflow executor: full tool-use loop for chained actions (e.g. search emails → summarize → send digest) - Content generator: ghostwriter for message bodies in user's voice - Batch executor: parallel swarm pattern for batch-trigger automations - Automation manager: NL commands for pause/resume/edit/delete/run - APScheduler daemon for cron-based background execution - Firestore CRUD for automation specs and run history - Token retrieval for background execution (get_access_token_for_uid) - Tool denylist prevents recursion in workflow executor - run_automation tool for on-demand /automation [name] triggering - Google Sheets scope added to OAuth - Fixed model ID (claude-opus-4-5-20250514 → claude-sonnet-4-20250514) - 711 tests passing --- agents/__init__.py | 0 agents/automation_executor.py | 555 ++++++++++++++++++++++++++++ agents/automation_manager.py | 431 +++++++++++++++++++++ agents/automation_parser.py | 444 ++++++++++++++++++++++ agents/batch_executor.py | 371 +++++++++++++++++++ agents/content_generator.py | 187 ++++++++++ daemons/__init__.py | 0 daemons/automation_scheduler.py | 293 +++++++++++++++ requirements.txt | 1 + src/agent/chat.py | 24 +- src/agent/tool_defs.py | 223 +++++++++++ src/auth/token_store.py | 19 + src/db/automation_repository.py | 260 +++++++++++++ src/server.py | 148 +++++++- tests/test_automation_executor.py | 319 ++++++++++++++++ tests/test_automation_manager.py | 400 ++++++++++++++++++++ tests/test_automation_parser.py | 304 +++++++++++++++ tests/test_automation_repository.py | 362 ++++++++++++++++++ tests/test_automation_scheduler.py | 263 +++++++++++++ tests/test_automation_wiring.py | 218 +++++++++++ tests/test_batch_executor.py | 462 +++++++++++++++++++++++ tests/test_content_generator.py | 193 ++++++++++ tests/test_firebase_context.py | 128 +++++++ tests/test_workflow_executor.py | 432 ++++++++++++++++++++++ utils/automation_store.py | 27 ++ utils/firebase_context.py | 97 +++++ 26 files changed, 6156 insertions(+), 5 deletions(-) create mode 100644 agents/__init__.py create mode 100644 agents/automation_executor.py create mode 100644 agents/automation_manager.py create mode 100644 agents/automation_parser.py create mode 100644 agents/batch_executor.py create mode 100644 agents/content_generator.py create mode 100644 daemons/__init__.py create mode 100644 daemons/automation_scheduler.py create mode 100644 src/db/automation_repository.py create mode 100644 tests/test_automation_executor.py create mode 100644 tests/test_automation_manager.py create mode 100644 tests/test_automation_parser.py create mode 100644 tests/test_automation_repository.py create mode 100644 tests/test_automation_scheduler.py create mode 100644 tests/test_automation_wiring.py create mode 100644 tests/test_batch_executor.py create mode 100644 tests/test_content_generator.py create mode 100644 tests/test_firebase_context.py create mode 100644 tests/test_workflow_executor.py create mode 100644 utils/automation_store.py create mode 100644 utils/firebase_context.py diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/automation_executor.py b/agents/automation_executor.py new file mode 100644 index 0000000..7f0dca4 --- /dev/null +++ b/agents/automation_executor.py @@ -0,0 +1,555 @@ +"""Automation Executor — 3-step subagent chain that fires an automation. + +Called by the scheduler every time an automation triggers. Runs: + Step 1: Re-fetch spec from Firestore (catches edits since boot) + Step 2: If body_mode == "generate", call content_generator to produce body + Step 3: Call a Claude executor agent with MCP server to dispatch the action + +Usage: + from agents.automation_executor import execute_automation + result = await execute_automation("uid_123", "auto_a1b2c3", user_context) +""" + +import asyncio +import json +import logging +import os +import re +from datetime import datetime, timezone +from typing import Any + +import anthropic +from dotenv import load_dotenv + +from agents.content_generator import generate_content +from utils.automation_store import get_automation, update_run_result +from src.agent.tool_defs import TOOL_DEFINITIONS, dispatch_tool + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MODEL = "claude-sonnet-4-20250514" +_MAX_TOKENS = 1024 +_TEMPERATURE = 0 + +# MCP server endpoints for each tool category +MCP_SERVERS: dict[str, str] = { + "gmail": "https://gmail.mcp.claude.com/mcp", + "gcal": "https://gcal.mcp.claude.com/mcp", + "notion": "https://mcp.notion.com/mcp", +} + +# Workflow executor settings +_WORKFLOW_MODEL = "claude-sonnet-4-20250514" +_WORKFLOW_MAX_TOKENS = 2048 +_WORKFLOW_MAX_TURNS = 6 + +# Tools that must NOT be available inside a workflow (prevent recursion) +DENIED_TOOLS: set[str] = { + "create_automation", + "manage_automations", + "list_automations", + "draft_email", # background execution has no human to review drafts +} + +# --------------------------------------------------------------------------- +# Executor agent prompt +# --------------------------------------------------------------------------- + +EXECUTOR_SYSTEM_PROMPT = """You are a precise automation executor. You receive a tool name and +exact parameters. Your job is to call exactly ONE tool with exactly the parameters given. + +Rules: +1. Call the tool specified in "function" with EXACTLY the params provided. Do not modify, + add, or remove any parameters. +2. If the tool call succeeds, return a JSON object: {"status": "success", "summary": "", "error": null} +3. If the tool call fails, return a JSON object: {"status": "error", "summary": "", "error": ""} +4. Return ONLY the JSON object. No preamble, no explanation, no markdown fences.""" + +EXECUTOR_USER_TEMPLATE = """Execute this automation action: + +Tool: {tool} +Function: {function} +Parameters: +{params_json} + +Call the function now with exactly these parameters.""" + + +# --------------------------------------------------------------------------- +# Workflow executor prompts +# --------------------------------------------------------------------------- + +WORKFLOW_SYSTEM_PROMPT = """You are a background automation executor for Second Self. You have access +to the user's tools (email, calendar, documents, web search) and must complete a +multi-step task on their behalf. + +User context: +{user_context} + +Rules: +1. Execute the task described in the user message step by step. +2. Use tools to gather information and take actions. Don't just describe — actually do it. +3. When sending emails, write in the user's voice using their style profile above. +4. If a tool call fails, try to recover or skip that step and continue. +5. After completing all steps, respond with a brief summary of what you did. +6. You are running in the background — there is no human watching. Do not ask for + confirmation or clarification. Make reasonable decisions and proceed. +7. If you cannot complete the task due to missing information, explain what's missing + in your final summary.""" + +WORKFLOW_USER_TEMPLATE = """Automation: {name} +Task: {workflow_instruction} + +Today's date: {today} +Current time: {current_time}""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _strip_markdown_fences(text: str) -> str: + """Remove markdown code fences from LLM output.""" + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +def _parse_result_json(raw_text: str) -> dict[str, Any]: + """Parse executor response into a result dict. Returns fallback on failure.""" + cleaned = _strip_markdown_fences(raw_text) + try: + result = json.loads(cleaned) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + pass + + # Fallback: treat raw text as summary + return { + "status": "success", + "summary": raw_text[:200] if raw_text else "Executed (no details)", + "error": None, + } + + +def _resolve_mcp_server(tool: str) -> str | None: + """Map a tool category to its MCP server URL.""" + return MCP_SERVERS.get(tool) + + +# --------------------------------------------------------------------------- +# Step 1: Fetch spec +# --------------------------------------------------------------------------- + +def _fetch_spec(user_id: str, auto_id: str) -> dict[str, Any] | None: + """Re-fetch automation spec from Firestore.""" + return get_automation(user_id, auto_id) + + +# --------------------------------------------------------------------------- +# Step 2: Generate body (if needed) +# --------------------------------------------------------------------------- + +async def _generate_body_if_needed( + action: dict[str, Any], + user_context: dict[str, Any], +) -> dict[str, Any]: + """If body_mode is 'generate', produce the body and return updated params. + + Returns a new action dict with body injected into params. Never mutates input. + """ + if action.get("body_mode") != "generate": + return action + + generation_prompt = action.get("generation_prompt", "") + if not generation_prompt: + log.warning("body_mode is 'generate' but no generation_prompt provided") + return action + + body = await generate_content(generation_prompt, user_context) + + # Inject generated body into params (immutable — new dict) + updated_params = {**action.get("params", {}), "body": body} + return {**action, "params": updated_params} + + +# --------------------------------------------------------------------------- +# Step 3: Execute via Claude + MCP +# --------------------------------------------------------------------------- + +async def _execute_with_mcp( + action: dict[str, Any], +) -> dict[str, Any]: + """Call the Claude executor agent with MCP to dispatch the tool.""" + tool = action.get("tool", "") + function = action.get("function", "") + params = action.get("params", {}) + + mcp_url = _resolve_mcp_server(tool) + if not mcp_url: + return { + "status": "error", + "summary": f"No MCP server configured for tool '{tool}'", + "error": f"Unknown tool category: {tool}", + } + + client = anthropic.AsyncAnthropic() + + user_message = EXECUTOR_USER_TEMPLATE.format( + tool=tool, + function=function, + params_json=json.dumps(params, indent=2), + ) + + mcp_config = { + "type": "url", + "url": mcp_url, + "name": f"{tool}_mcp", + } + + try: + response = await client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + temperature=_TEMPERATURE, + system=EXECUTOR_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + mcp_servers=[mcp_config], + ) + + raw_text = "" + for block in response.content: + if hasattr(block, "text"): + raw_text += block.text + + return _parse_result_json(raw_text) + + except anthropic.APIError as exc: + log.warning("Executor API error: %s", exc) + return { + "status": "error", + "summary": f"Attempted {function} via {tool}", + "error": str(exc), + } + + +# --------------------------------------------------------------------------- +# Workflow executor (multi-step tool-use loop) +# --------------------------------------------------------------------------- + +def _get_workflow_tools() -> list[dict[str, Any]]: + """Return tool definitions with denied tools filtered out.""" + return [t for t in TOOL_DEFINITIONS if t["name"] not in DENIED_TOOLS] + + +async def execute_automation_workflow( + user_id: str, + auto_id: str, + spec: dict[str, Any], + user_context: dict[str, Any], +) -> dict[str, Any]: + """Execute a multi-step workflow using a tool-use loop. + + Similar to chat.py's handle_chat but runs headless with no conversation + history. Uses the user's real tools (email, calendar, etc.) via dispatch_tool. + + Returns result dict with: status, summary, error, actions_taken. + """ + from src.auth.token_store import get_access_token_for_uid + + action = spec.get("action", {}) + name = spec.get("name", auto_id) + workflow_instruction = action.get("workflow_instruction", "") + + if not workflow_instruction: + return { + "status": "error", + "summary": "No workflow_instruction in spec", + "error": "body_mode is 'workflow' but no workflow_instruction provided", + "actions_taken": [], + } + + # Pre-flight: retrieve token for background execution + access_token = get_access_token_for_uid(user_id) + if not access_token: + log.warning("No access token for user %s — workflow may fail on Google tools", user_id) + + # Build system prompt with user context + style_profile = user_context.get("style_profile", "No style profile.") + session_log = user_context.get("session_log", "No recent activity.") + pending_tasks = user_context.get("pending_tasks", "No pending tasks.") + context_block = ( + f"Style profile:\n{style_profile}\n\n" + f"Recent activity:\n{session_log}\n\n" + f"Pending tasks:\n{pending_tasks}" + ) + system_prompt = WORKFLOW_SYSTEM_PROMPT.format(user_context=context_block) + + now = datetime.now(timezone.utc) + user_message = WORKFLOW_USER_TEMPLATE.format( + name=name, + workflow_instruction=workflow_instruction, + today=now.strftime("%Y-%m-%d"), + current_time=now.strftime("%H:%M UTC"), + ) + + # Tool-use loop + client = anthropic.AsyncAnthropic() + tools = _get_workflow_tools() + messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}] + actions_taken: list[dict[str, str]] = [] + had_tool_error = False + response = None + + for turn in range(_WORKFLOW_MAX_TURNS): + log.info("Workflow '%s' turn %d/%d", name, turn + 1, _WORKFLOW_MAX_TURNS) + + try: + response = await client.messages.create( + model=_WORKFLOW_MODEL, + max_tokens=_WORKFLOW_MAX_TOKENS, + temperature=0, + system=system_prompt, + tools=tools, + messages=messages, + ) + except anthropic.APIError as exc: + log.warning("Workflow API error on turn %d: %s", turn + 1, exc) + return { + "status": "error", + "summary": f"API error on turn {turn + 1}", + "error": str(exc), + "actions_taken": actions_taken, + } + + # Serialize assistant response for message history + serialized_content: list[dict[str, Any]] = [] + for block in response.content: + if block.type == "text": + serialized_content.append({"type": "text", "text": block.text}) + elif block.type == "tool_use": + serialized_content.append({ + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + }) + messages.append({"role": "assistant", "content": serialized_content}) + + if response.stop_reason == "end_turn": + break + + if response.stop_reason == "tool_use": + tool_results: list[dict[str, Any]] = [] + for block in response.content: + if block.type == "tool_use": + log.info("Workflow tool call: %s", block.name) + actions_taken.append({ + "tool": block.name, + "input_summary": str(block.input)[:200], + }) + + try: + result = await dispatch_tool( + block.name, block.input, access_token, uid=user_id, + ) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + }) + except Exception as exc: + had_tool_error = True + log.warning("Workflow tool %s failed: %s", block.name, exc) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": f"Error: {exc}", + "is_error": True, + }) + + messages.append({"role": "user", "content": tool_results}) + else: + break + + # Extract final text summary + summary = "" + if response: + for block in response.content: + if hasattr(block, "text"): + summary += block.text + + if not actions_taken: + status = "completed" + elif had_tool_error: + status = "partial" + else: + status = "success" + + return { + "status": status, + "summary": summary[:500] if summary else "Workflow completed (no summary)", + "error": None, + "actions_taken": actions_taken, + } + + +# --------------------------------------------------------------------------- +# Main orchestrator +# --------------------------------------------------------------------------- + +async def execute_automation( + user_id: str, + auto_id: str, + user_context: dict[str, Any], +) -> dict[str, Any]: + """Execute an automation through the 3-step subagent chain. + + Step 1: Re-fetch spec from Firestore + Step 2: Generate body if body_mode == "generate" + Step 3: Dispatch via Claude + MCP + + Args: + user_id: Firebase UID of the automation owner. + auto_id: Automation document ID (e.g. "auto_a1b2c3"). + user_context: Dict with keys style_profile, session_log, pending_tasks + (passed to content generator if needed). + + Returns: + Result dict with keys: status, summary, error, auto_id, executed_at. + """ + executed_at = datetime.now(timezone.utc).isoformat() + + # Step 1: Re-fetch spec + log.info("Step 1: Fetching automation %s for user %s", auto_id, user_id) + spec = _fetch_spec(user_id, auto_id) + + if spec is None: + result = { + "status": "error", + "summary": "Automation not found", + "error": f"No automation with id {auto_id} for user {user_id}", + "auto_id": auto_id, + "executed_at": executed_at, + } + log.warning("Automation %s not found — skipping", auto_id) + return result + + if not spec.get("enabled", True): + result = { + "status": "skipped", + "summary": "Automation is disabled", + "error": None, + "auto_id": auto_id, + "executed_at": executed_at, + } + log.info("Automation %s is disabled — skipping", auto_id) + return result + + action = spec.get("action", {}) + name = spec.get("name", auto_id) + + # Workflow path: multi-step tool-use loop + if action.get("body_mode") == "workflow": + log.info("Routing '%s' to workflow executor", name) + result = await execute_automation_workflow(user_id, auto_id, spec, user_context) + result["auto_id"] = auto_id + result["executed_at"] = executed_at + + # Log to Firestore + try: + update_run_result( + user_id=user_id, + auto_id=auto_id, + status=result.get("status", "unknown"), + error=result.get("error"), + actions_taken=result.get("actions_taken"), + ) + except Exception as exc: + log.warning("Failed to log workflow result for %s: %s", auto_id, exc) + + log.info("Workflow '%s' completed: %s", name, result.get("status")) + print(f"[executor] {auto_id} '{name}' → {result.get('status')} (workflow)") + return result + + # Step 2: Generate body if needed + log.info("Step 2: Checking body generation for '%s'", name) + try: + action = await _generate_body_if_needed(action, user_context) + except Exception as exc: + log.warning("Body generation failed for %s: %s", auto_id, exc) + # Continue with original action — body will be missing but tool may still work + + # Step 3: Execute via MCP + log.info("Step 3: Executing '%s' via %s.%s", + name, action.get("tool"), action.get("function")) + result = await _execute_with_mcp(action) + + # Attach metadata + result["auto_id"] = auto_id + result["executed_at"] = executed_at + + # Log to Firestore + try: + update_run_result( + user_id=user_id, + auto_id=auto_id, + status=result.get("status", "unknown"), + error=result.get("error"), + ) + except Exception as exc: + log.warning("Failed to log run result for %s: %s", auto_id, exc) + + status_str = result.get("status", "unknown") + log.info("Automation '%s' completed: %s", name, status_str) + print(f"[executor] {auto_id} '{name}' → {status_str}") + + return result + + +# --------------------------------------------------------------------------- +# CLI test harness +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + print("=" * 60) + print("Automation Executor — Dry-run test") + print("Note: This requires Firestore + Anthropic API access.") + print("=" * 60) + + SAMPLE_CONTEXT: dict[str, Any] = { + "style_profile": ( + "Tone: casual-professional\n" + "Avg sentence length: 12 words\n" + "Openers: 'Hey team,' / 'Quick update —'\n" + "Sign-offs: 'Best,'" + ), + "session_log": "2026-03-29 — Merged automation executor\n" + "2026-03-29 — Fixed rate-limit bug", + "pending_tasks": "- Write docs for /automations\n- Review Sarah's PR", + } + + async def run() -> None: + # This will fail without a real Firestore automation — that's expected + result = await execute_automation( + user_id="test_user", + auto_id="auto_000000", + user_context=SAMPLE_CONTEXT, + ) + print(f"\nResult: {json.dumps(result, indent=2)}") + + asyncio.run(run()) diff --git a/agents/automation_manager.py b/agents/automation_manager.py new file mode 100644 index 0000000..b08945b --- /dev/null +++ b/agents/automation_manager.py @@ -0,0 +1,431 @@ +"""Automation Manager — handles natural language commands for managing automations. + +Supports: list, pause, resume, edit, delete, run now. + +Architecture: one LLM call (claude-opus-4-5, no tools) classifies the user's intent +into a structured JSON command, then dispatches to the right store/scheduler/executor. + +Delete requires explicit user confirmation before executing. + +Usage: + from agents.automation_manager import handle_manage_request + reply = await handle_manage_request("uid_123", "pause my standup email", ctx) +""" + +import asyncio +import json +import logging +import os +import re +from datetime import datetime, timezone +from typing import Any + +import anthropic +from dotenv import load_dotenv + +from agents.automation_executor import execute_automation +from utils.automation_store import ( + delete_automation, + get_all_automations, + get_automation, + toggle_automation, + update_automation, +) + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MODEL = "claude-sonnet-4-20250514" +_MAX_TOKENS = 1000 +_TEMPERATURE = 0 + +# Pending deletes: { "user_id:auto_id": {"id": ..., "name": ..., "expires": ...} } +_pending_deletes: dict[str, dict[str, Any]] = {} + +# Optional scheduler reference — set via set_scheduler() by the boot process +_scheduler = None + + +# --------------------------------------------------------------------------- +# Classifier prompt +# --------------------------------------------------------------------------- + +MANAGER_SYSTEM_PROMPT = """You are the Automation Manager for Second Self. The user wants to +manage their existing automations (list, pause, resume, edit, delete, or run one now). + +You receive: +- The user's natural language request +- A JSON array of their current automations (id, name, enabled, trigger, action summary) + +Your job: classify the request into exactly ONE command. Return ONLY a JSON object. + +Commands: + +1. List automations: + {"action": "list"} + +2. Pause (disable) an automation: + {"action": "pause", "id": ""} + +3. Resume (enable) an automation: + {"action": "resume", "id": ""} + +4. Edit an automation (change schedule, recipients, subject, etc.): + {"action": "edit", "id": "", "changes": {"": ""}} + Use dot-notation for nested fields: "trigger.cron", "action.params.to", "action.params.subject" + For schedule changes, always include the updated cron string in "trigger.cron". + +5. Delete an automation: + {"action": "delete", "id": "", "name": ""} + +6. Run an automation immediately (one-shot): + {"action": "run_now", "id": ""} + +7. If the request is unclear or you can't match it to an automation: + {"action": "clarify", "question": ""} + +Rules: +- Match the user's request to the correct automation by name, description, or ID. +- If the user says "turn off", "stop", "disable" → pause. +- If the user says "turn on", "start", "enable" → resume. +- If the user says "fire", "trigger", "run", "execute", "test" → run_now. +- If the user says "change the time to 10am" for a weekly automation, compute the + new cron string and put it in changes["trigger.cron"]. +- Return ONLY the JSON object. No preamble, no explanation, no markdown fences.""" + +MANAGER_USER_TEMPLATE = """User's request: "{user_input}" + +Current automations: +{automations_json} + +Today: {today}""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def set_scheduler(scheduler: Any) -> None: + """Set the scheduler reference so edit/delete can update live jobs.""" + global _scheduler + _scheduler = scheduler + + +def _strip_markdown_fences(text: str) -> str: + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +def _parse_command(raw_text: str) -> dict[str, Any] | None: + cleaned = _strip_markdown_fences(raw_text) + try: + result = json.loads(cleaned) + if isinstance(result, dict) and "action" in result: + return result + except json.JSONDecodeError: + pass + return None + + +def _summarize_automations(automations: list[dict[str, Any]]) -> str: + """Build a concise JSON summary of automations for the LLM.""" + summaries = [] + for a in automations: + trigger = a.get("trigger", {}) + action = a.get("action", {}) + summaries.append({ + "id": a.get("id"), + "name": a.get("name"), + "enabled": a.get("enabled"), + "trigger_type": trigger.get("type"), + "cron": trigger.get("cron"), + "human_readable": trigger.get("human_readable", ""), + "tool": action.get("tool"), + "function": action.get("function"), + "to": action.get("params", {}).get("to", ""), + }) + return json.dumps(summaries, indent=2) + + +def _format_automation_list(automations: list[dict[str, Any]]) -> str: + """Format automations as a human-readable list.""" + if not automations: + return "You don't have any automations set up yet." + + lines = [f"You have {len(automations)} automation(s):\n"] + for a in automations: + trigger = a.get("trigger", {}) + action = a.get("action", {}) + status = "enabled" if a.get("enabled") else "paused" + schedule = trigger.get("human_readable") or trigger.get("cron", "?") + tool_fn = f"{action.get('tool', '?')}.{action.get('function', '?')}" + to = action.get("params", {}).get("to", "") + to_str = f" to {to}" if to else "" + runs = a.get("run_count", 0) + + lines.append( + f" {a.get('id')} — **{a.get('name')}** [{status}]\n" + f" Schedule: {schedule} | Action: {tool_fn}{to_str} | Runs: {runs}" + ) + + return "\n".join(lines) + + +def _pending_key(user_id: str, auto_id: str) -> str: + return f"{user_id}:{auto_id}" + + +# --------------------------------------------------------------------------- +# LLM classifier +# --------------------------------------------------------------------------- + +async def _classify_request( + user_input: str, + automations: list[dict[str, Any]], +) -> dict[str, Any]: + """Call Claude to classify the user's management request.""" + client = anthropic.AsyncAnthropic() + + user_message = MANAGER_USER_TEMPLATE.format( + user_input=user_input, + automations_json=_summarize_automations(automations), + today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + + try: + response = await client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + temperature=_TEMPERATURE, + system=MANAGER_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + + raw_text = "" + for block in response.content: + if hasattr(block, "text"): + raw_text += block.text + + command = _parse_command(raw_text) + if command: + return command + + except anthropic.APIError as exc: + log.warning("Manager classifier API error: %s", exc) + + return {"action": "clarify", "question": "I couldn't understand that. Could you rephrase?"} + + +# --------------------------------------------------------------------------- +# Command dispatchers +# --------------------------------------------------------------------------- + +def _dispatch_list(automations: list[dict[str, Any]]) -> str: + return _format_automation_list(automations) + + +def _dispatch_pause(user_id: str, command: dict[str, Any]) -> str: + auto_id = command.get("id", "") + auto = get_automation(user_id, auto_id) + if not auto: + return f"I couldn't find automation `{auto_id}`." + + toggle_automation(user_id, auto_id, enabled=False) + + if _scheduler: + from daemons.automation_scheduler import unregister_automation + unregister_automation(_scheduler, user_id, auto_id) + + name = auto.get("name", auto_id) + return f"Paused **{name}** (`{auto_id}`). It won't fire until you resume it." + + +def _dispatch_resume(user_id: str, command: dict[str, Any], user_context: dict[str, Any]) -> str: + auto_id = command.get("id", "") + auto = get_automation(user_id, auto_id) + if not auto: + return f"I couldn't find automation `{auto_id}`." + + toggle_automation(user_id, auto_id, enabled=True) + + if _scheduler: + from daemons.automation_scheduler import register_automation + auto["enabled"] = True + register_automation(_scheduler, user_id, auto, user_context) + + name = auto.get("name", auto_id) + return f"Resumed **{name}** (`{auto_id}`). It's back on schedule." + + +def _dispatch_edit(user_id: str, command: dict[str, Any], user_context: dict[str, Any]) -> str: + auto_id = command.get("id", "") + changes = command.get("changes", {}) + + if not changes: + return "No changes specified. What would you like to change?" + + auto = get_automation(user_id, auto_id) + if not auto: + return f"I couldn't find automation `{auto_id}`." + + update_automation(user_id, auto_id, changes) + + # If cron changed, re-register the scheduler job + if "trigger.cron" in changes and _scheduler: + from daemons.automation_scheduler import register_automation + updated = get_automation(user_id, auto_id) + if updated: + register_automation(_scheduler, user_id, updated, user_context) + + name = auto.get("name", auto_id) + changed_fields = ", ".join(changes.keys()) + return f"Updated **{name}** — changed: {changed_fields}." + + +def _dispatch_delete_request(user_id: str, command: dict[str, Any]) -> str: + """Stage a delete — returns confirmation prompt, does NOT delete yet.""" + auto_id = command.get("id", "") + name = command.get("name", auto_id) + + auto = get_automation(user_id, auto_id) + if not auto: + return f"I couldn't find automation `{auto_id}`." + + name = auto.get("name", name) + key = _pending_key(user_id, auto_id) + _pending_deletes[key] = { + "id": auto_id, + "name": name, + "user_id": user_id, + "requested_at": datetime.now(timezone.utc).isoformat(), + } + + return ( + f"Are you sure you want to delete **{name}** (`{auto_id}`)? " + f"This can't be undone. Reply \"yes\" to confirm." + ) + + +async def _dispatch_run_now( + user_id: str, + command: dict[str, Any], + user_context: dict[str, Any], +) -> str: + auto_id = command.get("id", "") + auto = get_automation(user_id, auto_id) + if not auto: + return f"I couldn't find automation `{auto_id}`." + + name = auto.get("name", auto_id) + result = await execute_automation(user_id, auto_id, user_context) + status = result.get("status", "unknown") + summary = result.get("summary", "") + + if status == "success": + return f"Ran **{name}** — {summary}" + elif status == "skipped": + return f"**{name}** is disabled. Resume it first, or use edit to re-enable." + else: + error = result.get("error", "unknown error") + return f"**{name}** failed: {error}" + + +# --------------------------------------------------------------------------- +# Delete confirmation +# --------------------------------------------------------------------------- + +def confirm_delete(user_id: str, auto_id: str) -> str | None: + """Execute a pending delete. Returns confirmation string or None if no pending delete.""" + key = _pending_key(user_id, auto_id) + pending = _pending_deletes.pop(key, None) + if not pending: + return None + + name = pending.get("name", auto_id) + delete_automation(user_id, auto_id) + + if _scheduler: + from daemons.automation_scheduler import unregister_automation + unregister_automation(_scheduler, user_id, auto_id) + + return f"Deleted **{name}** (`{auto_id}`)." + + +def has_pending_delete(user_id: str) -> dict[str, Any] | None: + """Check if user has a pending delete confirmation. Returns the pending entry or None.""" + for key, entry in _pending_deletes.items(): + if entry.get("user_id") == user_id: + return entry + return None + + +def cancel_pending_deletes(user_id: str) -> None: + """Cancel all pending deletes for a user.""" + to_remove = [k for k, v in _pending_deletes.items() if v.get("user_id") == user_id] + for k in to_remove: + _pending_deletes.pop(k, None) + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +async def handle_manage_request( + user_id: str, + user_input: str, + user_context: dict[str, Any], +) -> str: + """Handle a natural language automation management request. + + Returns a human-readable string to say back to the user. + """ + # Check if this is a "yes" confirming a pending delete + pending = has_pending_delete(user_id) + if pending and user_input.strip().lower() in ("yes", "y", "confirm", "do it"): + result = confirm_delete(user_id, pending["id"]) + return result or "No pending delete found." + + # Cancel any stale pending deletes on new requests + if pending and user_input.strip().lower() not in ("yes", "y", "confirm", "do it"): + cancel_pending_deletes(user_id) + + # Load all automations for context + automations = get_all_automations(user_id, enabled_only=False) + + # Classify the request + command = await _classify_request(user_input, automations) + action = command.get("action", "") + + log.info("Manager command: %s for user %s", action, user_id) + + if action == "list": + return _dispatch_list(automations) + + elif action == "pause": + return _dispatch_pause(user_id, command) + + elif action == "resume": + return _dispatch_resume(user_id, command, user_context) + + elif action == "edit": + return _dispatch_edit(user_id, command, user_context) + + elif action == "delete": + return _dispatch_delete_request(user_id, command) + + elif action == "run_now": + return await _dispatch_run_now(user_id, command, user_context) + + elif action == "clarify": + return command.get("question", "Could you clarify what you'd like to do?") + + else: + return f"I don't know how to handle action '{action}'. Try: list, pause, resume, edit, delete, or run." diff --git a/agents/automation_parser.py b/agents/automation_parser.py new file mode 100644 index 0000000..a07dd85 --- /dev/null +++ b/agents/automation_parser.py @@ -0,0 +1,444 @@ +"""Automation Parser — converts natural language automation requests to structured specs. + +Single Claude API call (no tools) that takes a user's automation request and returns +a JSON spec compatible with automation_repository.save_automation(). + +Usage: + from agents.automation_parser import parse_automation + spec = await parse_automation("Every Monday email my team a standup", user_context) +""" + +import asyncio +import json +import logging +import os +import re +from datetime import datetime, timezone +from typing import Any + +import anthropic +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MODEL = "claude-sonnet-4-20250514" +_MAX_TOKENS = 1500 +_TEMPERATURE = 0 +_RETRY_TEMPERATURE = 0.3 + +# Available tools the automation can target (kept in sync with tool_defs.py) +_AVAILABLE_TOOLS: list[dict[str, str]] = [ + {"name": "send_email", "tool": "gmail", "description": "Send a new email"}, + {"name": "draft_email", "tool": "gmail", "description": "Draft an email for review"}, + {"name": "reply_to_email", "tool": "gmail", "description": "Reply to an email thread"}, + {"name": "read_emails", "tool": "gmail", "description": "Search and read emails"}, + {"name": "get_contact_info", "tool": "gmail", "description": "Look up a contact by name"}, + {"name": "summarize_emails", "tool": "gmail", "description": "Summarize recent emails"}, + {"name": "create_event", "tool": "gcal", "description": "Create a calendar event"}, + {"name": "update_event", "tool": "gcal", "description": "Update an existing event"}, + {"name": "delete_event", "tool": "gcal", "description": "Delete a calendar event"}, + {"name": "list_events", "tool": "gcal", "description": "List upcoming calendar events"}, + {"name": "create_document", "tool": "gdocs", "description": "Create a Google Doc"}, + {"name": "create_presentation", "tool": "gslides", "description": "Create a Google Slides deck"}, + {"name": "share_document", "tool": "gdrive", "description": "Share a document with someone"}, + {"name": "search_web", "tool": "tavily", "description": "Search the web"}, +] + + +# --------------------------------------------------------------------------- +# System prompt +# --------------------------------------------------------------------------- + +PARSER_SYSTEM_PROMPT = """You are the Automation Parser for Second Self, a digital twin AI assistant. + +Your job is to take a natural language automation request from the user and convert it +into a structured JSON spec. You must extract: + +1. trigger — what starts this automation: + - type: "schedule" | "event" | "batch" + - if schedule: a valid cron string (5-field, e.g. "0 8 * * 1" for Monday 8am) + - if event: a description of the event (e.g. "email received from john@co.com") + - if batch: a list reference (e.g. "everyone on the team list") + - human_readable: a plain-English description of the trigger (always required) + +2. action — what the automation does: + - tool: the service category (e.g. "gmail", "gcal", "gdocs", "gslides", "gdrive", "tavily") + - function: the specific function name (e.g. "send_email", "create_event", "create_document") + - params: all required parameters for that function as a JSON object + - body_mode: "static" | "generate" | "workflow" + CRITICAL — choosing the right body_mode: + "static" → The user gave you the EXACT body text to use verbatim. Very rare. + "generate" → A SINGLE send_email/create_document call where the body is written + from the user's general context (style, recent activity, pending tasks). + ONLY use "generate" when the content can be written WITHOUT looking up + any specific data first. Example: "email team a weekly standup" — the + body is written from general context, no search needed. + "workflow" → USE THIS whenever the task requires LOOKING UP specific information + before acting. This includes: + • "summarize emails about X and send to Y" → needs read_emails first + • "find emails from person X and forward to Y" → needs read_emails first + • "check my calendar and email conflicts to Z" → needs list_events first + • "search the web for X and create a doc about it" → needs search_web first + • ANY request that mentions summarizing, finding, searching, checking, + or looking up specific content before sending/creating + When body_mode is "workflow", set tool to "multi" and function to "workflow". + When in doubt between "generate" and "workflow", ALWAYS choose "workflow". + - generation_prompt: if body_mode is "generate", a clear instruction telling the + content agent what to write. Include tone, length, and content guidance. + If body_mode is "static", set to null. + If body_mode is "workflow", set to null (use workflow_instruction instead). + - workflow_instruction: if body_mode is "workflow", a step-by-step instruction + describing the full chain of actions the executor should perform. Be specific + about what tools to use, what data to gather, and what to do with it. + If body_mode is not "workflow", set to null. + When body_mode is "workflow", set tool to "multi" and function to "workflow". + +3. name — a short human-readable name for this automation (max 6 words) + +4. confirmation_message — a single friendly sentence the Second Self will say back to + the user to confirm the automation was created (e.g. "Got it — I'll send your standup + to the team every Monday at 8am.") + +5. clarification_needed — if the request is ambiguous or missing critical info (like + who to email, what time, what to do), set this to a single clarifying question. + Leave null if everything is clear enough to proceed. + +Available functions you can use in the action: +{tools_block} + +Respond ONLY with a valid JSON object. No preamble, no explanation, no markdown fences. + +Example output (single-step): +{ + "name": "Monday standup to team", + "trigger": { + "type": "schedule", + "cron": "0 8 * * 1", + "human_readable": "Every Monday at 8am" + }, + "action": { + "tool": "gmail", + "function": "send_email", + "params": { + "to": "team@company.com", + "subject": "Standup Update" + }, + "body_mode": "generate", + "generation_prompt": "Write a concise standup update email in the user's voice. Use their recent activity and pending tasks as context. Keep it under 150 words.", + "workflow_instruction": null + }, + "confirmation_message": "Got it — I'll send your standup to the team every Monday at 8am.", + "clarification_needed": null +} + +Example output (multi-step workflow): +{ + "name": "Weekly email digest to boss", + "trigger": { + "type": "schedule", + "cron": "0 17 * * 5", + "human_readable": "Every Friday at 5pm" + }, + "action": { + "tool": "multi", + "function": "workflow", + "params": {}, + "body_mode": "workflow", + "generation_prompt": null, + "workflow_instruction": "1. Use read_emails to search for emails from this week (query: 'after:today-7d'). 2. Summarize the key threads and action items. 3. Use send_email to send the summary to boss@company.com with subject 'Weekly Email Digest'." + }, + "confirmation_message": "Got it — every Friday at 5pm I'll review your week's emails and send a digest to your boss.", + "clarification_needed": null +}""" + +PARSER_USER_TEMPLATE = """User's automation request: "{user_input}" + +User's known contacts and context: +{user_context} + +Today's date: {today} +Current time: {current_time} +User's timezone: {timezone}""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _build_tools_block() -> str: + """Format the available tools list for the system prompt.""" + lines: list[str] = [] + for t in _AVAILABLE_TOOLS: + lines.append(f" - {t['name']} ({t['tool']}): {t['description']}") + return "\n".join(lines) + + +def _build_user_message(user_input: str, user_context: dict[str, Any]) -> str: + """Build the user message from template + context.""" + contacts = user_context.get("contacts", []) + if contacts: + contact_lines = [] + for c in contacts[:15]: + email = c.get("email", "unknown") + name = c.get("name") or c.get("address_style") or email.split("@")[0] + contact_lines.append(f" - {name} <{email}>") + contacts_str = "Contacts:\n" + "\n".join(contact_lines) + else: + contacts_str = "Contacts: none available" + + tools_str = "Available tools: " + ", ".join(t["name"] for t in _AVAILABLE_TOOLS) + context_str = f"{contacts_str}\n{tools_str}" + + now = datetime.now(timezone.utc) + tz = user_context.get("timezone", "UTC") + + return PARSER_USER_TEMPLATE.format( + user_input=user_input, + user_context=context_str, + today=now.strftime("%Y-%m-%d"), + current_time=now.strftime("%H:%M"), + timezone=tz, + ) + + +def _strip_markdown_fences(text: str) -> str: + """Remove markdown code fences from LLM output.""" + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +def _parse_json_response(raw_text: str) -> dict[str, Any] | None: + """Strip fences and parse JSON. Returns None on failure.""" + cleaned = _strip_markdown_fences(raw_text) + try: + result = json.loads(cleaned) + if isinstance(result, dict): + return result + log.warning("Parser returned non-dict JSON type: %s", type(result).__name__) + return None + except json.JSONDecodeError as exc: + log.warning("JSON parse failed: %s", exc) + return None + + +def _validate_spec(spec: dict[str, Any]) -> tuple[bool, list[str]]: + """Validate parsed spec has required fields. Returns (is_valid, issues).""" + issues: list[str] = [] + + if not spec.get("name") or not isinstance(spec.get("name"), str): + issues.append("missing or invalid 'name'") + + trigger = spec.get("trigger") + if not isinstance(trigger, dict): + issues.append("missing or invalid 'trigger'") + else: + if trigger.get("type") not in ("schedule", "event", "batch"): + issues.append(f"invalid trigger.type: {trigger.get('type')}") + if trigger.get("type") == "schedule" and not trigger.get("cron"): + issues.append("schedule trigger missing 'cron'") + + action = spec.get("action") + if not isinstance(action, dict): + issues.append("missing or invalid 'action'") + else: + if not action.get("function"): + issues.append("action missing 'function'") + if action.get("body_mode") not in ("static", "generate", "workflow", None): + issues.append(f"invalid body_mode: {action.get('body_mode')}") + if action.get("body_mode") == "generate" and not action.get("generation_prompt"): + issues.append("body_mode is 'generate' but no generation_prompt provided") + if action.get("body_mode") == "workflow" and not action.get("workflow_instruction"): + issues.append("body_mode is 'workflow' but no workflow_instruction provided") + + if "confirmation_message" not in spec: + issues.append("missing 'confirmation_message'") + + return (len(issues) == 0, issues) + + +# --------------------------------------------------------------------------- +# Main parse function +# --------------------------------------------------------------------------- + +async def parse_automation( + user_input: str, + user_context: dict[str, Any], +) -> dict[str, Any]: + """Parse a natural language automation request into a structured spec. + + Makes a single Claude API call. Retries once on JSON parse failure. + Returns a dict compatible with automation_repository.save_automation(). + """ + client = anthropic.AsyncAnthropic() + + system_prompt = PARSER_SYSTEM_PROMPT.replace("{tools_block}", _build_tools_block()) + user_message = _build_user_message(user_input, user_context) + + # Attempt 1: temperature=0 for deterministic output + spec = await _call_and_parse(client, system_prompt, user_message, _TEMPERATURE) + + # Attempt 2: slightly higher temperature on failure + if spec is None: + log.info("Parser retry with temperature=%.1f", _RETRY_TEMPERATURE) + spec = await _call_and_parse(client, system_prompt, user_message, _RETRY_TEMPERATURE) + + # Total failure — return fallback asking user to rephrase + if spec is None: + log.warning("Parser failed after 2 attempts for input: %s", user_input[:100]) + return { + "name": "Unknown automation", + "trigger": {"type": "schedule", "human_readable": "unknown"}, + "action": {"tool": "unknown", "function": "unknown", "params": {}, + "body_mode": "static", "generation_prompt": None}, + "confirmation_message": "", + "clarification_needed": ( + "I wasn't able to understand that automation request. " + "Could you rephrase it? For example: 'Every Monday at 9am, " + "email team@company.com a standup summary.'" + ), + } + + # Validate and log any issues + is_valid, issues = _validate_spec(spec) + if not is_valid: + log.warning("Parser spec validation issues: %s", issues) + + # Ensure required keys are present (immutable — new dict) + return { + "clarification_needed": None, + "confirmation_message": "Automation created.", + **spec, + } + + +async def _call_and_parse( + client: anthropic.AsyncAnthropic, + system_prompt: str, + user_message: str, + temperature: float, +) -> dict[str, Any] | None: + """Make the API call and parse the response. Returns None on failure.""" + try: + response = await client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + temperature=temperature, + system=system_prompt, + messages=[{"role": "user", "content": user_message}], + ) + + raw_text = "" + for block in response.content: + if hasattr(block, "text"): + raw_text += block.text + + return _parse_json_response(raw_text) + + except anthropic.APIError as exc: + log.warning("Anthropic API error: %s", exc) + return None + + +# --------------------------------------------------------------------------- +# CLI test harness +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + TEST_CASES = [ + { + "label": "Schedule + generate (standup)", + "input": ( + "Every Monday at 9am, email my team a standup update " + "summarizing what happened last week" + ), + "expect_trigger_type": "schedule", + "expect_body_mode": "generate", + "expect_clarification": False, + }, + { + "label": "Schedule + static (weekend msg)", + "input": ( + "Every Friday at 5pm, send an email to sarah@company.com " + "saying 'Have a great weekend!'" + ), + "expect_trigger_type": "schedule", + "expect_body_mode": "static", + "expect_clarification": False, + }, + { + "label": "Schedule + generate (daily doc)", + "input": ( + "At the end of each day, create a Google Doc summarizing " + "today's emails" + ), + "expect_trigger_type": "schedule", + "expect_body_mode": "generate", + "expect_clarification": False, + }, + { + "label": "Ambiguous (should clarify)", + "input": "Do something with the thing for Sarah", + "expect_trigger_type": None, + "expect_body_mode": None, + "expect_clarification": True, + }, + ] + + MOCK_CONTEXT: dict[str, Any] = { + "contacts": [ + {"email": "sarah@company.com", "name": "Sarah Chen"}, + {"email": "team@company.com", "name": "Engineering Team"}, + {"email": "boss@company.com", "name": "Alex Kim"}, + ], + "timezone": "America/New_York", + } + + async def run_tests() -> None: + for i, case in enumerate(TEST_CASES, 1): + print(f"\n{'='*60}") + print(f"Test {i}: {case['label']}") + print(f"Input: {case['input']}") + print(f"{'='*60}") + + spec = await parse_automation(case["input"], MOCK_CONTEXT) + print(json.dumps(spec, indent=2)) + + # Check expectations + checks: list[str] = [] + if case["expect_trigger_type"]: + actual = spec.get("trigger", {}).get("type") + status = "PASS" if actual == case["expect_trigger_type"] else "FAIL" + checks.append(f" trigger.type={actual} [{status}]") + + if case["expect_body_mode"]: + actual = spec.get("action", {}).get("body_mode") + status = "PASS" if actual == case["expect_body_mode"] else "FAIL" + checks.append(f" body_mode={actual} [{status}]") + + if case["expect_clarification"]: + has_clar = spec.get("clarification_needed") is not None + status = "PASS" if has_clar else "FAIL" + checks.append(f" clarification_needed={has_clar} [{status}]") + else: + has_clar = spec.get("clarification_needed") is not None + status = "PASS" if not has_clar else "WARN" + checks.append(f" clarification_needed={has_clar} [{status}]") + + for c in checks: + print(c) + + asyncio.run(run_tests()) diff --git a/agents/batch_executor.py b/agents/batch_executor.py new file mode 100644 index 0000000..d56e33c --- /dev/null +++ b/agents/batch_executor.py @@ -0,0 +1,371 @@ +"""Batch Executor — parallel swarm pattern for batch-trigger automations. + +Fires one async Claude subagent per recipient concurrently via asyncio.gather. +Each subagent generates a personalised message body in the sender's voice, +then dispatches it via the Gmail MCP server. + +Example use case: "Send a personalised thank-you to everyone who attended the event" + +Usage: + from agents.batch_executor import execute_batch_async + results = await execute_batch_async( + "uid_123", "auto_a1b2c3", recipients, action, user_context + ) +""" + +import asyncio +import json +import logging +import os +import re +from datetime import datetime, timezone +from typing import Any + +import anthropic +from dotenv import load_dotenv + +from utils.automation_store import update_run_result + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MODEL = "claude-sonnet-4-20250514" +_MAX_TOKENS = 1024 +_TEMPERATURE = 0.4 # natural prose for personalised messages +_MCP_GMAIL_URL = "https://gmail.mcp.claude.com/mcp" + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + +BATCH_SYSTEM_PROMPT = """You are a personalised email automation agent for a specific person. +For each email you: +1. Generate a message body in the SENDER's voice +2. Send it via the Gmail send_email tool + +PERSONALISATION RULES: +- Address the recipient by name using their preferred style +- Reference the recipient's personal notes naturally in the body + (e.g. shared project, recent conversation topic, specific contribution) +- Every email must feel individually written, not templated +- If personal notes are empty, keep the message warm but generic + +VOICE CONSISTENCY RULES: +- Match the sender's tone, sentence length, vocabulary, opener patterns, + and sign-off patterns from their style profile +- Do NOT include AI disclaimers or meta-commentary +- Do NOT use phrases like "As your AI assistant" or "I was asked to write this" +- Keep it concise — match the sender's typical email length + +EXECUTION RULES: +1. First compose the personalised email body +2. Then call the send_email tool with: to, subject, and the composed body +3. If the tool call succeeds, return JSON: {"status": "success", "summary": "", "error": null} +4. If the tool call fails, return JSON: {"status": "error", "summary": "", "error": ""} +5. Return ONLY the JSON object. No preamble, no explanation, no markdown fences.""" + +BATCH_USER_TEMPLATE = """Send a personalised email to this recipient: + +Recipient name: {recipient_name} +Recipient email: {recipient_email} +Personal notes: {recipient_notes} + +Task: {generation_prompt} +Subject line: {subject} + +=== SENDER'S STYLE PROFILE === +{style_profile} + +=== SENDER'S RECENT ACTIVITY === +{session_log} + +Today's date: {today} + +Compose the personalised body, then send the email.""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _strip_markdown_fences(text: str) -> str: + """Remove markdown code fences from LLM output.""" + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +def _parse_result_json(raw_text: str) -> dict[str, Any]: + """Parse subagent response into a result dict. Returns fallback on failure.""" + cleaned = _strip_markdown_fences(raw_text) + try: + result = json.loads(cleaned) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + pass + + return { + "status": "success", + "summary": raw_text[:200] if raw_text else "Executed (no details)", + "error": None, + } + + +def _build_user_message( + recipient: dict[str, str], + action: dict[str, Any], + user_context: dict[str, Any], +) -> str: + """Build the user prompt for a single recipient's subagent call.""" + return BATCH_USER_TEMPLATE.format( + recipient_name=recipient.get("name", ""), + recipient_email=recipient.get("email", ""), + recipient_notes=recipient.get("notes", ""), + generation_prompt=action.get("generation_prompt", "Write a personalised message"), + subject=action.get("params", {}).get("subject", ""), + style_profile=user_context.get("style_profile", "No style profile available."), + session_log=user_context.get("session_log", "No recent activity."), + today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + + +def _compute_aggregate_status(results: list[dict[str, Any]]) -> str: + """Compute 'success' | 'partial' | 'error' from per-recipient results.""" + if not results: + return "error" + + success_count = sum(1 for r in results if r.get("status") == "success") + total = len(results) + + if success_count == total: + return "success" + elif success_count == 0: + return "error" + else: + return "partial" + + +# --------------------------------------------------------------------------- +# Single-recipient subagent +# --------------------------------------------------------------------------- + +async def _execute_single_recipient( + client: anthropic.AsyncAnthropic, + recipient: dict[str, str], + action: dict[str, Any], + user_context: dict[str, Any], +) -> dict[str, Any]: + """Generate personalised body and send via Gmail MCP for one recipient. + + Never raises — all exceptions are caught and normalised into error result dicts. + """ + recipient_email = recipient.get("email", "unknown") + + try: + user_message = _build_user_message(recipient, action, user_context) + + mcp_config = { + "type": "url", + "url": _MCP_GMAIL_URL, + "name": "gmail_mcp", + } + + response = await client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + temperature=_TEMPERATURE, + system=BATCH_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + mcp_servers=[mcp_config], + ) + + raw_text = "" + for block in response.content: + if hasattr(block, "text"): + raw_text += block.text + + result = _parse_result_json(raw_text) + result["recipient"] = recipient_email + return result + + except anthropic.APIError as exc: + log.warning("Batch subagent API error for %s: %s", recipient_email, exc) + return { + "recipient": recipient_email, + "status": "error", + "summary": f"API error sending to {recipient_email}", + "error": str(exc), + } + except Exception as exc: + log.warning("Batch subagent unexpected error for %s: %s", recipient_email, exc) + return { + "recipient": recipient_email, + "status": "error", + "summary": f"Unexpected error sending to {recipient_email}", + "error": str(exc), + } + + +# --------------------------------------------------------------------------- +# Main entry points +# --------------------------------------------------------------------------- + +async def execute_batch_async( + user_id: str, + auto_id: str, + recipients: list[dict[str, str]], + action: dict[str, Any], + user_context: dict[str, Any], +) -> list[dict[str, Any]]: + """Execute a batch automation: one concurrent subagent per recipient. + + Args: + user_id: Firebase UID of the automation owner. + auto_id: Automation document ID for Firestore logging. + recipients: List of dicts, each with keys: name, email, notes. + action: Action spec dict with keys: params (subject, etc.), + generation_prompt (what to write). + user_context: Dict with keys: style_profile, session_log, pending_tasks. + + Returns: + List of per-recipient result dicts with keys: + recipient, status, summary, error. + """ + if not recipients: + log.info("Batch %s: empty recipients list — nothing to do", auto_id) + return [] + + log.info("Batch %s: firing %d subagents for user %s", + auto_id, len(recipients), user_id) + + client = anthropic.AsyncAnthropic() + + tasks = [ + _execute_single_recipient(client, recipient, action, user_context) + for recipient in recipients + ] + results = list(await asyncio.gather(*tasks)) + + # Compute and log aggregate status + aggregate = _compute_aggregate_status(results) + + failed = [r for r in results if r.get("status") != "success"] + error_summary = "; ".join( + f"{r.get('recipient', '?')}: {r.get('error', 'unknown')}" + for r in failed + ) + error_str = error_summary[:500] if error_summary else None + + try: + update_run_result( + user_id=user_id, + auto_id=auto_id, + status=aggregate, + error=error_str, + ) + except Exception as exc: + log.warning("Failed to log batch result for %s: %s", auto_id, exc) + + log.info("Batch %s complete: %s (%d/%d succeeded)", + auto_id, aggregate, len(recipients) - len(failed), len(recipients)) + + return results + + +def execute_batch( + user_id: str, + auto_id: str, + recipients: list[dict[str, str]], + action: dict[str, Any], + user_context: dict[str, Any], +) -> list[dict[str, Any]]: + """Sync wrapper around execute_batch_async. + + For script/CLI use only. FastAPI handlers should call + execute_batch_async directly. + """ + return asyncio.run( + execute_batch_async(user_id, auto_id, recipients, action, user_context) + ) + + +# --------------------------------------------------------------------------- +# CLI test harness +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + FAKE_RECIPIENTS = [ + { + "name": "Sarah Chen", + "email": "sarah@example.com", + "notes": "Led the Q1 design sprint; loves hiking", + }, + { + "name": "Marcus Johnson", + "email": "marcus@example.com", + "notes": "New hire from Stanford; mentoring on the API team", + }, + { + "name": "Priya Patel", + "email": "priya@example.com", + "notes": "Organised the team offsite; vegetarian for catering", + }, + ] + + SAMPLE_ACTION: dict[str, Any] = { + "tool": "gmail", + "function": "send_email", + "params": {"subject": "Thanks for an amazing offsite!"}, + "generation_prompt": ( + "Write a personalised thank-you for attending the team offsite. " + "Reference each person's specific contribution." + ), + } + + SAMPLE_CONTEXT: dict[str, Any] = { + "style_profile": ( + "Tone: casual-professional\n" + "Avg sentence length: 12 words\n" + "Openers: 'Hey [name],' / 'Quick note —'\n" + "Sign-offs: 'Best,' / 'Cheers,'" + ), + "session_log": "2026-03-29 — Team offsite wrapped up\n" + "2026-03-29 — Sent follow-up survey", + "pending_tasks": "- Write offsite retrospective\n- Book Q2 venue", + } + + print("=" * 60) + print("Batch Executor — Dry-run test (3 fake recipients)") + print("Note: Requires Anthropic API key + Gmail MCP access.") + print("=" * 60) + + results = execute_batch( + user_id="test_user", + auto_id="auto_batch_test", + recipients=FAKE_RECIPIENTS, + action=SAMPLE_ACTION, + user_context=SAMPLE_CONTEXT, + ) + + print(f"\nResults ({len(results)} recipients):") + for r in results: + status_icon = "OK" if r.get("status") == "success" else "FAIL" + print(f" [{status_icon}] {r.get('recipient')}: {r.get('summary', '')}") + if r.get("error"): + print(f" Error: {r['error']}") + + successes = sum(1 for r in results if r.get("status") == "success") + print(f"\nAggregate: {successes}/{len(results)} succeeded") diff --git a/agents/content_generator.py b/agents/content_generator.py new file mode 100644 index 0000000..709b403 --- /dev/null +++ b/agents/content_generator.py @@ -0,0 +1,187 @@ +"""Content Generator — writes email/message bodies in the user's voice. + +Called by the automation executor when action.body_mode == "generate". +Single Claude API call, no tools. Returns the raw message body text. + +Usage: + from agents.content_generator import generate_content + body = await generate_content( + "Write a concise standup email covering this week's progress.", + {"style_profile": "...", "session_log": "...", "pending_tasks": "..."}, + ) +""" + +import asyncio +import logging +import os +from datetime import datetime, timezone +from typing import Any + +import anthropic +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MODEL = "claude-sonnet-4-20250514" +_MAX_TOKENS = 1000 +_TEMPERATURE = 0.4 # slight creativity for natural-sounding prose + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + +GENERATOR_SYSTEM_PROMPT = """You are a ghostwriter for a specific person. Your ONLY job is to produce +the message body they would write themselves. + +Rules: +1. Write ENTIRELY in the user's voice. Match their tone, sentence length, + vocabulary, opener patterns, and sign-off patterns from the style profile. +2. Ground every claim in the real context provided (session log, pending tasks). + NEVER fabricate projects, metrics, names, or events that aren't in the context. +3. Do NOT include AI disclaimers, meta-commentary, or phrases like + "As your AI assistant" or "Here's what I came up with". +4. Do NOT add a greeting or sign-off unless the generation prompt explicitly asks + for one. The caller will handle salutations and signatures separately. +5. Return ONLY the message body text. No subject line, no markdown fences, + no preamble, no explanation — just the content the person would type. +6. Keep it concise. Match the user's typical email length from their style profile. + When in doubt, lean shorter.""" + +GENERATOR_USER_TEMPLATE = """Generation task: {generation_prompt} + +=== STYLE PROFILE === +{style_profile} + +=== SESSION LOG (recent activity) === +{session_log} + +=== PENDING TASKS === +{pending_tasks} + +Today's date: {today}""" + + +# --------------------------------------------------------------------------- +# Core function +# --------------------------------------------------------------------------- + +async def generate_content( + generation_prompt: str, + user_context: dict[str, Any], +) -> str: + """Generate message body text in the user's voice. + + Args: + generation_prompt: What to write (e.g. "Write a standup email for the team"). + user_context: Dict with keys: style_profile, session_log, pending_tasks. + + Returns: + The generated message body as a plain string. + """ + client = anthropic.AsyncAnthropic() + + user_message = GENERATOR_USER_TEMPLATE.format( + generation_prompt=generation_prompt, + style_profile=user_context.get("style_profile", "No style profile available."), + session_log=user_context.get("session_log", "No recent activity."), + pending_tasks=user_context.get("pending_tasks", "No pending tasks."), + today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + + try: + response = await client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + temperature=_TEMPERATURE, + system=GENERATOR_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + + body = "" + for block in response.content: + if hasattr(block, "text"): + body += block.text + + body = body.strip() + if not body: + log.warning("Content generator returned empty response") + return "[Generation produced no content]" + + return body + + except anthropic.APIError as exc: + log.warning("Content generation API error: %s", exc) + return "[Content generation failed — please write manually]" + + +# --------------------------------------------------------------------------- +# CLI test harness +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + SAMPLE_STYLE = """Tone: casual-professional +Avg sentence length: 12 words +Vocabulary fingerprint: ship, sync, flag, loop in, heads up, LGTM, blocker +Openers: "Hey team," / "Quick update —" +Sign-offs: "Best," / (none for internal) +Emoji usage: 0.3 per email +Question tendency: 15% +Length preference: mostly short (<50 words) +Code-switching: casual with internal team, slightly more formal with external""" + + SAMPLE_SESSION_LOG = """2026-03-28 — Reviewed PR #42 for auth token refresh +2026-03-28 — Sent 3 emails to design team re: dashboard mockups +2026-03-29 — Merged calendar integration branch into main +2026-03-29 — Had 1:1 with Alex about Q2 roadmap priorities +2026-03-29 — Fixed rate-limit bug in deep pipeline (reduced workers 5→2)""" + + SAMPLE_PENDING = """- Finish preferences builder unit tests +- Review Sarah's PR for notification system +- Write API docs for /automations endpoint +- Schedule Q2 planning sync with product team""" + + async def run() -> None: + print("=" * 60) + print("Content Generator — Standup Email Test") + print("=" * 60) + + body = await generate_content( + generation_prompt=( + "Write a concise standup update email for the engineering team. " + "Cover what was done this week, what's in progress, and any blockers. " + "Keep it under 150 words." + ), + user_context={ + "style_profile": SAMPLE_STYLE, + "session_log": SAMPLE_SESSION_LOG, + "pending_tasks": SAMPLE_PENDING, + }, + ) + + print(f"\nGenerated body ({len(body.split())} words):") + print("-" * 40) + print(body) + print("-" * 40) + + # Basic checks + checks = [ + ("Non-empty", len(body) > 0), + ("Under 200 words", len(body.split()) < 200), + ("No AI disclaimers", "as your ai" not in body.lower()), + ("No markdown fences", "```" not in body), + ] + for label, passed in checks: + print(f" {label}: {'PASS' if passed else 'FAIL'}") + + asyncio.run(run()) diff --git a/daemons/__init__.py b/daemons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daemons/automation_scheduler.py b/daemons/automation_scheduler.py new file mode 100644 index 0000000..21c1569 --- /dev/null +++ b/daemons/automation_scheduler.py @@ -0,0 +1,293 @@ +"""Automation Scheduler — background daemon that fires automations on cron schedules. + +Uses APScheduler (BackgroundScheduler) to manage cron jobs. On boot, loads all +enabled schedule-type automations from Firestore and registers a cron job for each. + +Entry point: + boot(user_id, user_context) — blocks until SIGINT/SIGTERM. + Also supports __main__ with SECOND_SELF_USER_ID env var. + +Only schedule-type triggers are handled here. +Event triggers (e.g. "when I receive an email from X") would require a polling +loop or webhook listener — not implemented in this module. +Batch triggers (e.g. "for everyone on the team list") would require a list +resolver + fan-out — not implemented in this module. +""" + +import asyncio +import logging +import os +import signal +import sys +import threading +from typing import Any + +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, JobExecutionEvent +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from dotenv import load_dotenv + +from agents.automation_executor import execute_automation +from utils.automation_store import get_all_automations +from utils.firebase_context import load_user_context + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MISFIRE_GRACE_TIME = 300 # 5 minutes grace for missed fires +_JOB_ID_PREFIX = "auto_job_" + +# --------------------------------------------------------------------------- +# Cron parsing +# --------------------------------------------------------------------------- + +def _parse_cron(cron_str: str) -> dict[str, str] | None: + """Convert a 5-field cron string to APScheduler CronTrigger kwargs. + + Format: minute hour day_of_month month day_of_week + Example: "0 9 * * 1" → {"minute": "0", "hour": "9", "day_of_week": "1"} + """ + parts = cron_str.strip().split() + if len(parts) != 5: + log.warning("Invalid cron string (expected 5 fields): %s", cron_str) + return None + + return { + "minute": parts[0], + "hour": parts[1], + "day": parts[2], + "month": parts[3], + "day_of_week": parts[4], + } + + +def _make_job_id(user_id: str, auto_id: str) -> str: + """Deterministic job ID from user + automation IDs.""" + return f"{_JOB_ID_PREFIX}{user_id}_{auto_id}" + + +# --------------------------------------------------------------------------- +# Job wrapper +# --------------------------------------------------------------------------- + +def _run_automation_job( + user_id: str, + auto_id: str, + cached_context: dict[str, Any], +) -> None: + """Job function called by APScheduler. Runs in a thread. + + Re-fetches user_context fresh before execution. Falls back to + cached_context if the fetch fails. + """ + log.info("Job firing: user=%s auto=%s", user_id, auto_id) + + # Re-fetch fresh context + try: + user_context = load_user_context(user_id) + log.debug("Loaded fresh user context for %s", user_id) + except Exception as exc: + log.warning("Failed to load fresh context for %s, using cached: %s", user_id, exc) + user_context = cached_context + + # Execute — the executor is async, so we need an event loop + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete( + execute_automation(user_id, auto_id, user_context) + ) + status = result.get("status", "unknown") + log.info("Job completed: %s → %s", auto_id, status) + print(f"[scheduler] {auto_id} → {status}") + except Exception as exc: + log.error("Job failed: %s — %s", auto_id, exc) + print(f"[scheduler] {auto_id} → ERROR: {exc}") + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# Event listeners +# --------------------------------------------------------------------------- + +def _on_job_executed(event: JobExecutionEvent) -> None: + """APScheduler listener for successful job execution.""" + log.info("APScheduler job executed: %s", event.job_id) + + +def _on_job_error(event: JobExecutionEvent) -> None: + """APScheduler listener for job errors.""" + log.error( + "APScheduler job error: %s — %s", + event.job_id, + event.exception, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def register_automation( + scheduler: BackgroundScheduler, + user_id: str, + automation: dict[str, Any], + user_context: dict[str, Any], +) -> bool: + """Register a single automation as a cron job. Returns True on success.""" + auto_id = automation.get("id", "") + trigger = automation.get("trigger", {}) + name = automation.get("name", auto_id) + + if trigger.get("type") != "schedule": + log.debug("Skipping non-schedule automation: %s (type=%s)", auto_id, trigger.get("type")) + return False + + cron_str = trigger.get("cron", "") + cron_kwargs = _parse_cron(cron_str) + if cron_kwargs is None: + log.warning("Cannot register %s — invalid cron: %s", auto_id, cron_str) + return False + + job_id = _make_job_id(user_id, auto_id) + + # Remove existing job if re-registering + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + log.debug("Removed existing job %s for re-registration", job_id) + + try: + scheduler.add_job( + func=_run_automation_job, + trigger=CronTrigger(**cron_kwargs), + id=job_id, + name=f"{name} ({auto_id})", + args=[user_id, auto_id, user_context], + misfire_grace_time=_MISFIRE_GRACE_TIME, + replace_existing=True, + ) + log.info("Registered job %s: '%s' cron=%s", job_id, name, cron_str) + print(f"[scheduler] registered {auto_id} '{name}' cron={cron_str}") + return True + except Exception as exc: + log.error("Failed to register job %s: %s", job_id, exc) + return False + + +def unregister_automation( + scheduler: BackgroundScheduler, + user_id: str, + auto_id: str, +) -> bool: + """Remove a cron job for an automation. Returns True if found and removed.""" + job_id = _make_job_id(user_id, auto_id) + job = scheduler.get_job(job_id) + if job is None: + log.debug("No job found for %s — nothing to remove", job_id) + return False + + scheduler.remove_job(job_id) + log.info("Unregistered job %s", job_id) + print(f"[scheduler] unregistered {auto_id}") + return True + + +# --------------------------------------------------------------------------- +# Boot sequence +# --------------------------------------------------------------------------- + +def _load_and_register_all( + scheduler: BackgroundScheduler, + user_id: str, + user_context: dict[str, Any], +) -> int: + """Load all enabled automations from Firestore and register cron jobs. + + Returns the number of jobs registered. + """ + automations = get_all_automations(user_id, enabled_only=True) + log.info("Found %d enabled automations for user %s", len(automations), user_id) + + registered = 0 + for auto in automations: + if register_automation(scheduler, user_id, auto, user_context): + registered += 1 + + return registered + + +def boot(user_id: str, user_context: dict[str, Any]) -> None: + """Start the automation scheduler daemon. Blocks until signal. + + 1. Creates a BackgroundScheduler + 2. Loads all enabled schedule-type automations from Firestore + 3. Registers cron jobs for each + 4. Blocks until SIGINT or SIGTERM + """ + scheduler = BackgroundScheduler( + job_defaults={"misfire_grace_time": _MISFIRE_GRACE_TIME}, + ) + + # Attach event listeners + scheduler.add_listener(_on_job_executed, EVENT_JOB_EXECUTED) + scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR) + + # Load and register automations + count = _load_and_register_all(scheduler, user_id, user_context) + print(f"[scheduler] booted with {count} job(s) for user {user_id}") + + # Start scheduler + scheduler.start() + log.info("Scheduler started — waiting for signals") + + # Graceful shutdown on signal + shutdown_event = threading.Event() + + def _handle_signal(signum: int, _frame: Any) -> None: + sig_name = signal.Signals(signum).name + log.info("Received %s — shutting down scheduler", sig_name) + print(f"[scheduler] received {sig_name}, shutting down...") + scheduler.shutdown(wait=True) + shutdown_event.set() + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + + # Block until shutdown + shutdown_event.wait() + log.info("Scheduler shutdown complete") + print("[scheduler] shutdown complete") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + user_id = os.getenv("SECOND_SELF_USER_ID") + if not user_id: + print("Error: set SECOND_SELF_USER_ID env var") + sys.exit(1) + + print(f"[scheduler] loading context for user {user_id}...") + try: + user_context = load_user_context(user_id) + except Exception as exc: + log.warning("Could not load user context: %s — using empty defaults", exc) + user_context = { + "style_profile": "No style profile available.", + "session_log": "No recent activity.", + "pending_tasks": "No pending tasks.", + } + + boot(user_id, user_context) diff --git a/requirements.txt b/requirements.txt index 5cfcb57..321e624 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pydantic beautifulsoup4 tavily-python filelock +apscheduler diff --git a/src/agent/chat.py b/src/agent/chat.py index bcaaa33..a4597fe 100644 --- a/src/agent/chat.py +++ b/src/agent/chat.py @@ -127,7 +127,14 @@ def _build_rich_system_prompt(p: RichProfile) -> str: f"you're missing — just ask {first_name}. Better to check than to guess wrong.\n\n" f"If {first_name} corrects something you drafted or rejects an action, acknowledge " f"what you got wrong. Adjust for next time.\n\n" - "You have full conversation history. Reference earlier messages when relevant." + "You have full conversation history. Reference earlier messages when relevant.\n\n" + "When the user asks to set up a recurring task, scheduled action, or automation, " + "use the create_automation tool with their exact request.\n\n" + "When the user asks to manage, pause, resume, edit, delete, or test-run an " + "automation, use the manage_automations tool.\n\n" + "When the user asks what automations they have, use the list_automations tool.\n\n" + "When the user types '/automation [name]' or asks to run an automation by name, " + "use the run_automation tool with that name." ) return "\n\n".join(sections) @@ -215,7 +222,14 @@ def _build_slim_system_prompt(profile: SecondSelfProfile) -> str: f"a contact or context, just ask. Better to check than to guess wrong.\n\n" f"If {first_name} corrects something you drafted or rejects an action, acknowledge what you " f"got wrong. Adjust for next time.\n\n" - f"You remember everything from this conversation. Never re-ask for something {first_name} already told you." + f"You remember everything from this conversation. Never re-ask for something {first_name} already told you.\n\n" + f"When {first_name} asks to set up a recurring task, scheduled action, or automation, " + f"use the create_automation tool with their exact request.\n\n" + f"When {first_name} asks to manage, pause, resume, edit, delete, or test-run an " + f"automation, use the manage_automations tool.\n\n" + f"When {first_name} asks what automations they have, use the list_automations tool.\n\n" + f"When {first_name} types '/automation [name]' or asks to run an automation by name, " + f"use the run_automation tool with that name." ) return "\n\n".join(sections) @@ -237,6 +251,10 @@ def _summarize_tool_input(tool_name: str, tool_input: dict) -> str: "create_presentation": lambda a: f"Created Google Slides: '{a.get('title')}'", "share_document": lambda a: f"Shared file with {a.get('email')} as {a.get('role', 'writer')}", "search_web": lambda a: f"Web search: {a.get('query')}", + "create_automation": lambda a: f"Creating automation: {a.get('user_request', '')[:60]}", + "manage_automations": lambda a: f"Managing automations: {a.get('user_request', '')[:60]}", + "list_automations": lambda a: "Listed all automations", + "run_automation": lambda a: f"Running automation: {a.get('name', '')[:60]}", } fn = summaries.get(tool_name) return fn(tool_input) if fn else str(tool_input)[:200] @@ -312,7 +330,7 @@ async def handle_chat( actions_taken.append(ActionTaken(tool=block.name, summary=summary)) try: - result = await dispatch_tool(block.name, block.input, access_token) + result = await dispatch_tool(block.name, block.input, access_token, uid=uid) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, diff --git a/src/agent/tool_defs.py b/src/agent/tool_defs.py index 523ad7f..0ea64f4 100644 --- a/src/agent/tool_defs.py +++ b/src/agent/tool_defs.py @@ -6,11 +6,30 @@ import asyncio import base64 +import logging from email.mime.text import MIMEText from typing import Any from src.connectors.tavily import search_user +log = logging.getLogger("second-self") + +# --------------------------------------------------------------------------- +# Scheduler reference (set by server lifespan) +# --------------------------------------------------------------------------- + +_scheduler_ref = None + + +def set_scheduler_ref(scheduler) -> None: + """Store a reference to the BackgroundScheduler for live job registration.""" + global _scheduler_ref + _scheduler_ref = scheduler + + +def _get_scheduler(): + return _scheduler_ref + # --------------------------------------------------------------------------- # Tool definitions (Anthropic Messages API format) # --------------------------------------------------------------------------- @@ -234,6 +253,73 @@ "required": ["query"], }, }, + { + "name": "create_automation", + "description": ( + "Create a recurring automation for the user. Use when they ask to schedule " + "something to happen regularly (e.g. 'every Monday email the team a standup', " + "'at 5pm daily summarize my emails'). Parses the request into a structured spec " + "and saves it. Returns a confirmation message or a clarifying question." + ), + "input_schema": { + "type": "object", + "properties": { + "user_request": { + "type": "string", + "description": "The user's natural language automation request, exactly as they phrased it", + }, + }, + "required": ["user_request"], + }, + }, + { + "name": "manage_automations", + "description": ( + "Manage the user's existing automations: pause, resume, edit, delete, or run " + "one immediately. Use when they ask to change, stop, restart, test, or remove " + "an automation. Also use for 'run my standup now' or 'fire that automation'." + ), + "input_schema": { + "type": "object", + "properties": { + "user_request": { + "type": "string", + "description": "The user's natural language management request", + }, + }, + "required": ["user_request"], + }, + }, + { + "name": "list_automations", + "description": ( + "List all of the user's automations with their status, schedule, and run count. " + "Use when the user asks 'what automations do I have', 'show my scheduled tasks', " + "or 'list my recurring actions'. This is a fast read-only operation." + ), + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "run_automation", + "description": ( + "Run a saved automation immediately by name. Use when the user types " + "'/automation [name]' or asks to 'run [automation name] now'. Looks up the " + "automation by name and executes it on the spot." + ), + "input_schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name or partial name of the automation to run", + }, + }, + "required": ["name"], + }, + }, ] @@ -290,9 +376,23 @@ async def dispatch_tool( name: str, args: dict[str, Any], access_token: str | None, + uid: str = "", ) -> str: """Execute a tool by name and return a string result.""" + # --- Automation tools (no Google token needed) --- + if name == "create_automation": + return await _create_automation_handler(uid, args.get("user_request", "")) + + if name == "manage_automations": + return await _manage_automations_handler(uid, args.get("user_request", "")) + + if name == "list_automations": + return _list_automations_handler(uid) + + if name == "run_automation": + return await _run_automation_handler(uid, args.get("name", ""), access_token) + if name == "search_web": return await search_user(args["query"]) @@ -627,3 +727,126 @@ def _share(): file_meta = drive.files().get(fileId=file_id, fields="name,webViewLink").execute() return f"Shared '{file_meta.get('name', file_id)}' with {email} as {role}. Link: {file_meta.get('webViewLink', '')}" return await asyncio.to_thread(_share) + + +# --------------------------------------------------------------------------- +# Automation tool handlers +# --------------------------------------------------------------------------- + +async def _create_automation_handler(uid: str, user_request: str) -> str: + """Parse and save a new automation from natural language.""" + if not uid: + return "Error: You must be signed in to create automations." + + from agents.automation_parser import parse_automation + from utils.automation_store import save_automation + from utils.firebase_context import load_user_context + + user_context = load_user_context(uid) + spec = await parse_automation(user_request, user_context) + + if spec.get("clarification_needed"): + return spec["clarification_needed"] + + try: + auto_id = save_automation(uid, spec, source_message=user_request) + except (RuntimeError, ValueError) as exc: + return f"Error saving automation: {exc}" + + # Register with scheduler if running + try: + from daemons.automation_scheduler import register_automation + scheduler = _get_scheduler() + if scheduler: + spec_with_id = {**spec, "id": auto_id, "enabled": True} + register_automation(scheduler, uid, spec_with_id, user_context) + except Exception as exc: + log.warning("Scheduler registration failed for %s: %s", auto_id, exc) + + confirmation = spec.get("confirmation_message", f"Automation '{spec.get('name')}' created.") + trigger = spec.get("trigger", {}) + schedule_str = trigger.get("human_readable", trigger.get("cron", "")) + return f"{confirmation}\n\nAutomation ID: {auto_id}\nSchedule: {schedule_str}" + + +async def _manage_automations_handler(uid: str, user_request: str) -> str: + """Route management requests to the automation manager.""" + if not uid: + return "Error: You must be signed in to manage automations." + + from agents.automation_manager import handle_manage_request, set_scheduler + from utils.firebase_context import load_user_context + + scheduler = _get_scheduler() + if scheduler: + set_scheduler(scheduler) + + user_context = load_user_context(uid) + return await handle_manage_request(uid, user_request, user_context) + + +def _list_automations_handler(uid: str) -> str: + """List all automations for the user (no LLM call).""" + if not uid: + return "Error: You must be signed in to view automations." + + from utils.automation_store import get_all_automations + + automations = get_all_automations(uid, enabled_only=False) + if not automations: + return "You don't have any automations set up yet. Ask me to create one!" + + lines = [f"You have {len(automations)} automation(s):\n"] + for a in automations: + trigger = a.get("trigger", {}) + action = a.get("action", {}) + status = "active" if a.get("enabled") else "paused" + schedule = trigger.get("human_readable") or trigger.get("cron", "?") + fn = action.get("function", "?") + to = action.get("params", {}).get("to", "") + to_str = f" -> {to}" if to else "" + runs = a.get("run_count", 0) + lines.append( + f" {a.get('id')} | {a.get('name')} [{status}]\n" + f" Schedule: {schedule} | Action: {fn}{to_str} | Runs: {runs}" + ) + return "\n".join(lines) + + +async def _run_automation_handler(uid: str, name_query: str, access_token: str | None) -> str: + """Look up an automation by name and execute it immediately.""" + if not uid: + return "Error: You must be signed in to run automations." + + if not name_query: + return "Error: Please provide the name of the automation to run." + + from utils.automation_store import find_by_name + from utils.firebase_context import load_user_context + from agents.automation_executor import execute_automation + + matches = find_by_name(uid, name_query) + if not matches: + return f"No automation found matching '{name_query}'. Use list_automations to see your automations." + + if len(matches) > 1: + names = ", ".join(f"'{m.get('name')}'" for m in matches[:5]) + return f"Multiple automations match '{name_query}': {names}. Please be more specific." + + auto = matches[0] + auto_id = auto.get("id", "") + auto_name = auto.get("name", auto_id) + + user_context = load_user_context(uid) + log.info("Running automation '%s' (%s) on demand", auto_name, auto_id) + + result = await execute_automation(uid, auto_id, user_context) + + status = result.get("status", "unknown") + summary = result.get("summary", "") + error = result.get("error") + + if status == "error": + return f"Automation '{auto_name}' failed: {error or summary}" + + return f"Ran '{auto_name}' — {summary[:300] if summary else status}" diff --git a/src/auth/token_store.py b/src/auth/token_store.py index 38e003b..f7bd232 100644 --- a/src/auth/token_store.py +++ b/src/auth/token_store.py @@ -145,6 +145,25 @@ def get_uid_for_session(session_id: str) -> str: return hashlib.sha256(session_id.encode()).hexdigest()[:28] +def get_access_token_for_uid(uid: str) -> str | None: + """Look up Google access token by Firebase UID. + + Searches all sessions for a matching UID. Used by the background + scheduler to get tokens without an active HTTP session. + Returns None if no session found for that UID. + """ + if not uid: + return None + + store = _file_load() + # Iterate in reverse to return the most recently created session's token + for _session_id in reversed(list(store.keys())): + data = store[_session_id] + if data.get("uid") == uid and data.get("google_access_token"): + return data["google_access_token"] + return None + + def delete_session(session_id: str) -> None: store = _file_load() store.pop(session_id, None) diff --git a/src/db/automation_repository.py b/src/db/automation_repository.py new file mode 100644 index 0000000..8e3df44 --- /dev/null +++ b/src/db/automation_repository.py @@ -0,0 +1,260 @@ +"""Automation CRUD — Firestore-backed storage for user automations. + +Firestore path: users/{user_id}/automations/{auto_id} + +Each document stores trigger config, action config, run history, and metadata. +""" + +import logging +import secrets +from datetime import datetime, timezone +from typing import Any + +from google.cloud.firestore_v1 import SERVER_TIMESTAMP + +from src.db.firestore_client import get_db + +log = logging.getLogger("second-self") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _automations_ref(user_id: str): + """Return the automations subcollection ref, or None if db unavailable.""" + db = get_db() + if db is None: + return None + return db.collection("users").document(user_id).collection("automations") + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +def save_automation( + user_id: str, + spec: dict[str, Any], + source_message: str, +) -> str: + """Save a new automation to Firestore. Returns the generated auto_id. + + Raises RuntimeError if Firestore is unavailable. + Raises ValueError if spec is missing required fields. + """ + ref = _automations_ref(user_id) + if ref is None: + raise RuntimeError("Firestore not available") + + name = spec.get("name") + trigger = spec.get("trigger") + action = spec.get("action") + + if not name or not isinstance(name, str): + raise ValueError("spec must contain a non-empty 'name' string") + if not isinstance(trigger, dict): + raise ValueError("spec['trigger'] must be a dict") + if not isinstance(action, dict): + raise ValueError("spec['action'] must be a dict") + + auto_id = f"auto_{secrets.token_hex(3)}" + + doc_data: dict[str, Any] = { + "id": auto_id, + "name": name, + "user_id": user_id, + "enabled": spec.get("enabled", True), + "trigger": trigger, + "action": action, + "created_at": SERVER_TIMESTAMP, + "last_run": None, + "last_run_status": None, + "run_count": 0, + "run_history": [], + "source_message": source_message, + } + + ref.document(auto_id).set(doc_data) + print(f"[automation] saved {auto_id} '{name}' for user {user_id}") + log.info("Automation saved: %s '%s' for user %s", auto_id, name, user_id) + return auto_id + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + +def get_automation(user_id: str, auto_id: str) -> dict[str, Any] | None: + """Get a single automation by ID. Returns dict or None.""" + ref = _automations_ref(user_id) + if ref is None: + return None + + doc = ref.document(auto_id).get() + return doc.to_dict() if doc.exists else None + + +def get_all_automations( + user_id: str, + enabled_only: bool = True, +) -> list[dict[str, Any]]: + """List all automations for a user. Filters by enabled if requested.""" + ref = _automations_ref(user_id) + if ref is None: + return [] + + query = ref + if enabled_only: + query = query.where("enabled", "==", True) + + return [doc.to_dict() for doc in query.get()] + + +def find_by_name( + user_id: str, + name_fragment: str, +) -> list[dict[str, Any]]: + """Find automations by case-insensitive substring match on name. + + Fetches all automations client-side since Firestore has no LIKE query. + Acceptable because per-user automation counts are small. + """ + ref = _automations_ref(user_id) + if ref is None: + return [] + + fragment_lower = name_fragment.lower() + results: list[dict[str, Any]] = [] + for doc in ref.get(): + data = doc.to_dict() + if fragment_lower in (data.get("name") or "").lower(): + results.append(data) + return results + + +# --------------------------------------------------------------------------- +# Update +# --------------------------------------------------------------------------- + +def toggle_automation(user_id: str, auto_id: str, enabled: bool) -> None: + """Enable or disable an automation.""" + ref = _automations_ref(user_id) + if ref is None: + return + + try: + ref.document(auto_id).update({"enabled": enabled}) + print(f"[automation] toggled {auto_id} enabled={enabled}") + log.info("Automation toggled: %s enabled=%s", auto_id, enabled) + except Exception as exc: + log.warning("toggle_automation failed for %s: %s", auto_id, exc) + + +def update_automation( + user_id: str, + auto_id: str, + changes: dict[str, Any], +) -> None: + """Update specific fields on an automation. Supports dot-notation keys + for nested field updates (e.g. 'trigger.cron'). + """ + if not changes: + return + + ref = _automations_ref(user_id) + if ref is None: + return + + try: + ref.document(auto_id).update(changes) + print(f"[automation] updated {auto_id}: {list(changes.keys())}") + log.info("Automation updated: %s fields=%s", auto_id, list(changes.keys())) + except Exception as exc: + log.warning("update_automation failed for %s: %s", auto_id, exc) + + +# --------------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------------- + +def delete_automation(user_id: str, auto_id: str) -> None: + """Delete an automation document. Idempotent — no error if not found.""" + ref = _automations_ref(user_id) + if ref is None: + return + + ref.document(auto_id).delete() + print(f"[automation] deleted {auto_id}") + log.info("Automation deleted: %s", auto_id) + + +# --------------------------------------------------------------------------- +# Run tracking +# --------------------------------------------------------------------------- + +def update_run_result( + user_id: str, + auto_id: str, + status: str, + error: str | None = None, + actions_taken: list[dict[str, str]] | None = None, +) -> None: + """Record the result of an automation run. + + Uses a Firestore transaction to atomically: + - Append to run_history (trimmed to max 10 entries) + - Update last_run timestamp and last_run_status + - Increment run_count + """ + db = get_db() + if db is None: + return + + doc_ref = ( + db.collection("users").document(user_id) + .collection("automations").document(auto_id) + ) + + now = datetime.now(timezone.utc) + run_entry: dict[str, Any] = { + "status": status, + "timestamp": now.isoformat(), + } + if error is not None: + run_entry["error"] = error + if actions_taken: + run_entry["actions_taken"] = actions_taken[:20] # cap for Firestore doc size + + @_transactional + def _do_update(transaction): + snapshot = doc_ref.get(transaction=transaction) + if not snapshot.exists: + log.warning("update_run_result: automation %s not found", auto_id) + return + + data = snapshot.to_dict() + history = list(data.get("run_history") or []) + history.append(run_entry) + trimmed = history[-10:] + + transaction.update(doc_ref, { + "run_history": trimmed, + "last_run": now, + "last_run_status": status, + "run_count": (data.get("run_count") or 0) + 1, + }) + + try: + from google.cloud import firestore as fs + _do_update(db.transaction()) + print(f"[automation] run result for {auto_id}: {status}") + log.info("Automation run result: %s status=%s", auto_id, status) + except Exception as exc: + log.warning("update_run_result failed for %s: %s", auto_id, exc) + + +def _transactional(func): + """Decorator that marks a function as a Firestore transactional callback.""" + from google.cloud.firestore_v1 import transaction as txn_module + return txn_module.transactional(func) diff --git a/src/server.py b/src/server.py index d336d81..a986054 100644 --- a/src/server.py +++ b/src/server.py @@ -11,12 +11,13 @@ import logging import os import uuid +from contextlib import asynccontextmanager from datetime import datetime, timezone from dotenv import load_dotenv load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) -from fastapi import FastAPI, Cookie, HTTPException +from fastapi import FastAPI, Cookie, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from src.auth.auth0_oauth import router as auth_router @@ -51,7 +52,73 @@ log = logging.getLogger("second-self") logging.basicConfig(level=logging.INFO) -app = FastAPI(title="Second Self — Deep Memory Pipeline", version="0.3.0") + +# --------------------------------------------------------------------------- +# Scheduler boot +# --------------------------------------------------------------------------- + +def _boot_scheduler(): + """Create and start the background scheduler. Returns scheduler or None.""" + try: + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED + from daemons.automation_scheduler import ( + register_automation, + _on_job_executed, + _on_job_error, + ) + from utils.automation_store import get_all_automations + from utils.firebase_context import load_user_context + + scheduler = BackgroundScheduler( + job_defaults={"misfire_grace_time": 300}, + ) + scheduler.add_listener(_on_job_executed, EVENT_JOB_EXECUTED) + scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR) + scheduler.start() + log.info("Background scheduler started") + + # Load automations for the latest authenticated user + result = get_latest_session() + if result: + session_id, token_data = result + uid = token_data.uid or get_uid_for_session(session_id) + if uid: + user_context = load_user_context(uid) + automations = get_all_automations(uid, enabled_only=True) + count = sum( + 1 for auto in automations + if register_automation(scheduler, uid, auto, user_context) + ) + log.info("Registered %d automations for user %s on boot", count, uid) + + return scheduler + except Exception as exc: + log.warning("Scheduler boot failed (non-fatal): %s", exc) + return None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Boot scheduler on startup, shut down on exit.""" + scheduler = _boot_scheduler() + app.state.scheduler = scheduler + + from src.agent.tool_defs import set_scheduler_ref + set_scheduler_ref(scheduler) + + yield + + if scheduler: + scheduler.shutdown(wait=False) + log.info("Scheduler shut down") + + +app = FastAPI( + title="Second Self — Deep Memory Pipeline", + version="0.4.0", + lifespan=lifespan, +) app.add_middleware( CORSMiddleware, @@ -188,6 +255,83 @@ async def chat(body: ChatRequest): ) +# --- Automation endpoints --- + +@app.post("/automations/create") +async def create_automation_endpoint(body: ChatRequest): + """Parse and save a new automation from natural language.""" + uid = get_uid_for_session(body.session_id) + + from agents.automation_parser import parse_automation + from utils.automation_store import save_automation + from utils.firebase_context import load_user_context + + user_context = load_user_context(uid) + spec = await parse_automation(body.message, user_context) + + if spec.get("clarification_needed"): + return {"auto_id": None, "clarification": spec["clarification_needed"]} + + auto_id = save_automation(uid, spec, source_message=body.message) + + # Register with live scheduler + try: + from daemons.automation_scheduler import register_automation + scheduler = app.state.scheduler + if scheduler: + spec_with_id = {**spec, "id": auto_id, "enabled": True} + register_automation(scheduler, uid, spec_with_id, user_context) + except Exception as exc: + log.warning("Scheduler registration failed: %s", exc) + + return { + "auto_id": auto_id, + "name": spec.get("name", ""), + "confirmation": spec.get("confirmation_message", "Created."), + } + + +@app.post("/automations/manage") +async def manage_automation_endpoint(body: ChatRequest): + """Manage automations via natural language (pause, resume, edit, delete, run).""" + uid = get_uid_for_session(body.session_id) + + from agents.automation_manager import handle_manage_request, set_scheduler + from utils.firebase_context import load_user_context + + scheduler = app.state.scheduler + if scheduler: + set_scheduler(scheduler) + + user_context = load_user_context(uid) + reply = await handle_manage_request(uid, body.message, user_context) + return {"reply": reply} + + +@app.get("/automations/list") +async def list_automations_endpoint(session_id: str = Query(...)): + """List all automations for the authenticated user.""" + uid = get_uid_for_session(session_id) + + from utils.automation_store import get_all_automations + + automations = get_all_automations(uid, enabled_only=False) + items = [] + for a in automations: + trigger = a.get("trigger", {}) + action = a.get("action", {}) + items.append({ + "id": a.get("id", ""), + "name": a.get("name", ""), + "enabled": a.get("enabled", False), + "trigger_type": trigger.get("type", ""), + "schedule": trigger.get("human_readable", trigger.get("cron", "")), + "action_function": action.get("function", ""), + "run_count": a.get("run_count", 0), + }) + return {"automations": items, "count": len(items)} + + def _fallback_profile(name: str) -> SecondSelfProfile: """Minimal profile when no data sources are available (demo mode).""" return SecondSelfProfile( diff --git a/tests/test_automation_executor.py b/tests/test_automation_executor.py new file mode 100644 index 0000000..bd11529 --- /dev/null +++ b/tests/test_automation_executor.py @@ -0,0 +1,319 @@ +"""Unit tests for agents/automation_executor — all external calls mocked.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import anthropic +import pytest + +from agents.automation_executor import ( + EXECUTOR_SYSTEM_PROMPT, + MCP_SERVERS, + _generate_body_if_needed, + _parse_result_json, + _resolve_mcp_server, + _strip_markdown_fences, + execute_automation, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_context() -> dict: + return { + "style_profile": "Tone: casual", + "session_log": "2026-03-29 — Fixed bug", + "pending_tasks": "- Write tests", + } + + +def _mock_spec( + enabled: bool = True, + body_mode: str = "static", + tool: str = "gmail", + function: str = "send_email", +) -> dict: + action = { + "tool": tool, + "function": function, + "params": {"to": "team@co.com", "subject": "Update", "body": "Hello"}, + "body_mode": body_mode, + } + if body_mode == "generate": + action["generation_prompt"] = "Write a standup email." + return { + "id": "auto_abc123", + "name": "Test automation", + "enabled": enabled, + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": action, + } + + +def _mock_api_response(text: str) -> MagicMock: + block = MagicMock() + block.text = text + block.type = "text" + resp = MagicMock() + resp.content = [block] + return resp + + +# --------------------------------------------------------------------------- +# _strip_markdown_fences +# --------------------------------------------------------------------------- + +class TestStripFences: + def test_no_fences(self) -> None: + assert _strip_markdown_fences('{"a": 1}') == '{"a": 1}' + + def test_json_fences(self) -> None: + assert _strip_markdown_fences('```json\n{"a": 1}\n```') == '{"a": 1}' + + +# --------------------------------------------------------------------------- +# _parse_result_json +# --------------------------------------------------------------------------- + +class TestParseResultJson: + def test_valid_json(self) -> None: + result = _parse_result_json('{"status": "success", "summary": "Sent", "error": null}') + assert result["status"] == "success" + + def test_fenced_json(self) -> None: + result = _parse_result_json('```json\n{"status": "error", "summary": "fail", "error": "timeout"}\n```') + assert result["status"] == "error" + assert result["error"] == "timeout" + + def test_invalid_json_fallback(self) -> None: + result = _parse_result_json("Email sent to team@co.com") + assert result["status"] == "success" + assert "Email sent" in result["summary"] + + def test_empty_string_fallback(self) -> None: + result = _parse_result_json("") + assert result["status"] == "success" + assert "no details" in result["summary"].lower() + + +# --------------------------------------------------------------------------- +# _resolve_mcp_server +# --------------------------------------------------------------------------- + +class TestResolveMcpServer: + def test_gmail(self) -> None: + assert _resolve_mcp_server("gmail") == MCP_SERVERS["gmail"] + + def test_gcal(self) -> None: + assert _resolve_mcp_server("gcal") == MCP_SERVERS["gcal"] + + def test_notion(self) -> None: + assert _resolve_mcp_server("notion") == MCP_SERVERS["notion"] + + def test_unknown(self) -> None: + assert _resolve_mcp_server("slack") is None + + +# --------------------------------------------------------------------------- +# _generate_body_if_needed +# --------------------------------------------------------------------------- + +class TestGenerateBodyIfNeeded: + @pytest.mark.asyncio + async def test_static_returns_unchanged(self) -> None: + action = {"body_mode": "static", "params": {"body": "Hello"}} + result = await _generate_body_if_needed(action, _mock_context()) + assert result is action # same object, no mutation + + @pytest.mark.asyncio + @patch("agents.automation_executor.generate_content", new_callable=AsyncMock) + async def test_generate_calls_content_generator(self, mock_gen: AsyncMock) -> None: + mock_gen.return_value = "Generated standup body" + action = { + "body_mode": "generate", + "generation_prompt": "Write a standup.", + "params": {"to": "team@co.com", "subject": "Standup"}, + } + + result = await _generate_body_if_needed(action, _mock_context()) + + mock_gen.assert_called_once_with("Write a standup.", _mock_context()) + assert result["params"]["body"] == "Generated standup body" + # Original not mutated + assert "body" not in action["params"] + + @pytest.mark.asyncio + async def test_generate_no_prompt_returns_unchanged(self) -> None: + action = {"body_mode": "generate", "params": {"body": "Hello"}} + result = await _generate_body_if_needed(action, _mock_context()) + # No generation_prompt → returns as-is + assert result["params"]["body"] == "Hello" + + +# --------------------------------------------------------------------------- +# execute_automation — full 3-step chain +# --------------------------------------------------------------------------- + +class TestExecuteAutomation: + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor._fetch_spec") + async def test_success_static( + self, mock_fetch: MagicMock, mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec(body_mode="static") + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Email sent to team@co.com", "error": null}' + ) + + result = await execute_automation("user1", "auto_abc123", _mock_context()) + + assert result["status"] == "success" + assert result["auto_id"] == "auto_abc123" + assert "executed_at" in result + mock_log_run.assert_called_once_with( + user_id="user1", auto_id="auto_abc123", status="success", error=None, + ) + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor.generate_content", new_callable=AsyncMock) + @patch("agents.automation_executor._fetch_spec") + async def test_success_generate( + self, mock_fetch: MagicMock, mock_gen: AsyncMock, + mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec(body_mode="generate") + mock_gen.return_value = "Hey team, quick update on this week." + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Email sent", "error": null}' + ) + + result = await execute_automation("user1", "auto_abc123", _mock_context()) + + assert result["status"] == "success" + mock_gen.assert_called_once() + # Verify generated body was passed to MCP call + call_kwargs = mock_client.messages.create.call_args.kwargs + user_msg = call_kwargs["messages"][0]["content"] + assert "Hey team, quick update" in user_msg + + @pytest.mark.asyncio + @patch("agents.automation_executor._fetch_spec") + async def test_spec_not_found(self, mock_fetch: MagicMock) -> None: + mock_fetch.return_value = None + + result = await execute_automation("user1", "auto_gone", _mock_context()) + + assert result["status"] == "error" + assert "not found" in result["summary"].lower() + + @pytest.mark.asyncio + @patch("agents.automation_executor._fetch_spec") + async def test_disabled_automation_skipped(self, mock_fetch: MagicMock) -> None: + mock_fetch.return_value = _mock_spec(enabled=False) + + result = await execute_automation("user1", "auto_abc123", _mock_context()) + + assert result["status"] == "skipped" + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor._fetch_spec") + async def test_unknown_tool_errors( + self, mock_fetch: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec(tool="slack", function="send_message") + + result = await execute_automation("user1", "auto_abc123", _mock_context()) + + assert result["status"] == "error" + assert "no mcp server" in result["summary"].lower() + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor._fetch_spec") + async def test_api_error_handled( + self, mock_fetch: MagicMock, mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec() + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.side_effect = anthropic.APIError( + message="rate limited", request=MagicMock(), body=None, + ) + + result = await execute_automation("user1", "auto_abc123", _mock_context()) + + assert result["status"] == "error" + assert result["error"] is not None + mock_log_run.assert_called_once() + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor._fetch_spec") + async def test_mcp_config_passed( + self, mock_fetch: MagicMock, mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec(tool="gcal", function="create_event") + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Event created", "error": null}' + ) + + await execute_automation("user1", "auto_abc123", _mock_context()) + + call_kwargs = mock_client.messages.create.call_args.kwargs + mcp = call_kwargs["mcp_servers"][0] + assert mcp["url"] == MCP_SERVERS["gcal"] + assert mcp["name"] == "gcal_mcp" + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result") + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor._fetch_spec") + async def test_run_result_logged_on_error( + self, mock_fetch: MagicMock, mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec() + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "error", "summary": "Failed to send", "error": "auth expired"}' + ) + + await execute_automation("user1", "auto_abc123", _mock_context()) + + mock_log_run.assert_called_once_with( + user_id="user1", auto_id="auto_abc123", + status="error", error="auth expired", + ) + + @pytest.mark.asyncio + @patch("agents.automation_executor.update_run_result", side_effect=Exception("db down")) + @patch("agents.automation_executor.anthropic.AsyncAnthropic") + @patch("agents.automation_executor._fetch_spec") + async def test_run_result_log_failure_doesnt_crash( + self, mock_fetch: MagicMock, mock_api_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_fetch.return_value = _mock_spec() + mock_client = AsyncMock() + mock_api_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + # Should not raise even though update_run_result throws + result = await execute_automation("user1", "auto_abc123", _mock_context()) + assert result["status"] == "success" diff --git a/tests/test_automation_manager.py b/tests/test_automation_manager.py new file mode 100644 index 0000000..9b191b1 --- /dev/null +++ b/tests/test_automation_manager.py @@ -0,0 +1,400 @@ +"""Unit tests for agents/automation_manager — all external calls mocked.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import anthropic +import pytest + +from agents.automation_manager import ( + _format_automation_list, + _parse_command, + _pending_deletes, + _strip_markdown_fences, + _summarize_automations, + cancel_pending_deletes, + confirm_delete, + handle_manage_request, + has_pending_delete, + set_scheduler, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_context() -> dict: + return { + "style_profile": "Tone: casual", + "session_log": "2026-03-29 — Fixed bug", + "pending_tasks": "- Write tests", + } + + +def _mock_auto( + auto_id: str = "auto_abc123", + name: str = "Monday standup", + enabled: bool = True, + cron: str = "0 9 * * 1", +) -> dict: + return { + "id": auto_id, + "name": name, + "enabled": enabled, + "trigger": {"type": "schedule", "cron": cron, "human_readable": "Every Monday at 9am"}, + "action": { + "tool": "gmail", "function": "send_email", + "params": {"to": "team@co.com", "subject": "Standup"}, + }, + "run_count": 3, + } + + +def _mock_api_response(text: str) -> MagicMock: + block = MagicMock() + block.text = text + block.type = "text" + resp = MagicMock() + resp.content = [block] + return resp + + +def _clear_pending(): + _pending_deletes.clear() + + +# --------------------------------------------------------------------------- +# _strip_markdown_fences / _parse_command +# --------------------------------------------------------------------------- + +class TestParseCommand: + def test_valid_json(self) -> None: + assert _parse_command('{"action": "list"}') == {"action": "list"} + + def test_fenced_json(self) -> None: + result = _parse_command('```json\n{"action": "pause", "id": "auto_x"}\n```') + assert result["action"] == "pause" + + def test_missing_action_returns_none(self) -> None: + assert _parse_command('{"id": "auto_x"}') is None + + def test_invalid_json_returns_none(self) -> None: + assert _parse_command("not json") is None + + +# --------------------------------------------------------------------------- +# _summarize_automations +# --------------------------------------------------------------------------- + +class TestSummarizeAutomations: + def test_includes_key_fields(self) -> None: + autos = [_mock_auto()] + summary = _summarize_automations(autos) + parsed = json.loads(summary) + assert parsed[0]["id"] == "auto_abc123" + assert parsed[0]["name"] == "Monday standup" + assert parsed[0]["cron"] == "0 9 * * 1" + assert parsed[0]["to"] == "team@co.com" + + def test_empty_list(self) -> None: + assert _summarize_automations([]) == "[]" + + +# --------------------------------------------------------------------------- +# _format_automation_list +# --------------------------------------------------------------------------- + +class TestFormatList: + def test_empty(self) -> None: + assert "don't have any" in _format_automation_list([]) + + def test_shows_name_and_status(self) -> None: + text = _format_automation_list([_mock_auto()]) + assert "Monday standup" in text + assert "enabled" in text + + def test_shows_paused(self) -> None: + text = _format_automation_list([_mock_auto(enabled=False)]) + assert "paused" in text + + def test_shows_run_count(self) -> None: + text = _format_automation_list([_mock_auto()]) + assert "Runs: 3" in text + + +# --------------------------------------------------------------------------- +# Pending delete helpers +# --------------------------------------------------------------------------- + +class TestPendingDeletes: + def setup_method(self) -> None: + _clear_pending() + + def test_has_pending_none(self) -> None: + assert has_pending_delete("user1") is None + + def test_has_pending_found(self) -> None: + _pending_deletes["user1:auto_x"] = {"id": "auto_x", "name": "test", "user_id": "user1"} + result = has_pending_delete("user1") + assert result is not None + assert result["id"] == "auto_x" + + def test_cancel_pending(self) -> None: + _pending_deletes["user1:auto_x"] = {"id": "auto_x", "user_id": "user1"} + _pending_deletes["user1:auto_y"] = {"id": "auto_y", "user_id": "user1"} + _pending_deletes["user2:auto_z"] = {"id": "auto_z", "user_id": "user2"} + + cancel_pending_deletes("user1") + + assert has_pending_delete("user1") is None + assert has_pending_delete("user2") is not None + + @patch("agents.automation_manager.delete_automation") + def test_confirm_delete_executes(self, mock_del: MagicMock) -> None: + _pending_deletes["user1:auto_x"] = {"id": "auto_x", "name": "Test", "user_id": "user1"} + + result = confirm_delete("user1", "auto_x") + + assert result is not None + assert "Deleted" in result + assert "Test" in result + mock_del.assert_called_once_with("user1", "auto_x") + assert "user1:auto_x" not in _pending_deletes + + def test_confirm_delete_not_pending(self) -> None: + assert confirm_delete("user1", "auto_none") is None + + +# --------------------------------------------------------------------------- +# handle_manage_request — full integration (LLM mocked) +# --------------------------------------------------------------------------- + +class TestHandleManageRequest: + def setup_method(self) -> None: + _clear_pending() + set_scheduler(None) + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_list(self, mock_cls: MagicMock, mock_get_all: MagicMock) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response('{"action": "list"}') + + result = await handle_manage_request("user1", "show my automations", _mock_context()) + + assert "Monday standup" in result + assert "enabled" in result + + @pytest.mark.asyncio + @patch("agents.automation_manager.toggle_automation") + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_pause( + self, mock_cls: MagicMock, mock_get_all: MagicMock, + mock_get: MagicMock, mock_toggle: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = _mock_auto() + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "pause", "id": "auto_abc123"}' + ) + + result = await handle_manage_request("user1", "pause my standup", _mock_context()) + + assert "Paused" in result + mock_toggle.assert_called_once_with("user1", "auto_abc123", enabled=False) + + @pytest.mark.asyncio + @patch("agents.automation_manager.toggle_automation") + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_resume( + self, mock_cls: MagicMock, mock_get_all: MagicMock, + mock_get: MagicMock, mock_toggle: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto(enabled=False)] + mock_get.return_value = _mock_auto(enabled=False) + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "resume", "id": "auto_abc123"}' + ) + + result = await handle_manage_request("user1", "resume my standup", _mock_context()) + + assert "Resumed" in result + mock_toggle.assert_called_once_with("user1", "auto_abc123", enabled=True) + + @pytest.mark.asyncio + @patch("agents.automation_manager.update_automation") + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_edit( + self, mock_cls: MagicMock, mock_get_all: MagicMock, + mock_get: MagicMock, mock_update: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = _mock_auto() + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "edit", "id": "auto_abc123", "changes": {"trigger.cron": "0 10 * * 1"}}' + ) + + result = await handle_manage_request("user1", "change standup to 10am", _mock_context()) + + assert "Updated" in result + assert "trigger.cron" in result + mock_update.assert_called_once_with( + "user1", "auto_abc123", {"trigger.cron": "0 10 * * 1"} + ) + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_delete_stages_confirmation( + self, mock_cls: MagicMock, mock_get_all: MagicMock, mock_get: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = _mock_auto() + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "delete", "id": "auto_abc123", "name": "Monday standup"}' + ) + + result = await handle_manage_request("user1", "delete my standup", _mock_context()) + + assert "Are you sure" in result + assert has_pending_delete("user1") is not None + + @pytest.mark.asyncio + @patch("agents.automation_manager.delete_automation") + async def test_confirm_delete_via_yes(self, mock_del: MagicMock) -> None: + # Stage a pending delete + _pending_deletes["user1:auto_abc123"] = { + "id": "auto_abc123", "name": "Monday standup", "user_id": "user1", + } + + result = await handle_manage_request("user1", "yes", _mock_context()) + + assert "Deleted" in result + mock_del.assert_called_once_with("user1", "auto_abc123") + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_new_request_cancels_pending_delete( + self, mock_cls: MagicMock, mock_get_all: MagicMock, + ) -> None: + _pending_deletes["user1:auto_abc123"] = { + "id": "auto_abc123", "name": "test", "user_id": "user1", + } + mock_get_all.return_value = [_mock_auto()] + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response('{"action": "list"}') + + await handle_manage_request("user1", "list my automations", _mock_context()) + + assert has_pending_delete("user1") is None + + @pytest.mark.asyncio + @patch("agents.automation_manager.execute_automation") + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_run_now( + self, mock_cls: MagicMock, mock_get_all: MagicMock, + mock_get: MagicMock, mock_exec: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = _mock_auto() + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "run_now", "id": "auto_abc123"}' + ) + + async def fake_exec(*a, **kw): + return {"status": "success", "summary": "Email sent to team@co.com"} + mock_exec.side_effect = fake_exec + + result = await handle_manage_request("user1", "run my standup now", _mock_context()) + + assert "Ran" in result + assert "Email sent" in result + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_not_found( + self, mock_cls: MagicMock, mock_get_all: MagicMock, mock_get: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = None # not found + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "pause", "id": "auto_gone"}' + ) + + result = await handle_manage_request("user1", "pause auto_gone", _mock_context()) + assert "couldn't find" in result + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_clarify(self, mock_cls: MagicMock, mock_get_all: MagicMock) -> None: + mock_get_all.return_value = [] + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "clarify", "question": "Which automation do you mean?"}' + ) + + result = await handle_manage_request("user1", "do the thing", _mock_context()) + assert "Which automation" in result + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_api_error_fallback(self, mock_cls: MagicMock, mock_get_all: MagicMock) -> None: + mock_get_all.return_value = [] + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.side_effect = anthropic.APIError( + message="rate limited", request=MagicMock(), body=None, + ) + + result = await handle_manage_request("user1", "list", _mock_context()) + assert "rephrase" in result.lower() or "couldn't understand" in result.lower() + + @pytest.mark.asyncio + @patch("agents.automation_manager.get_automation") + @patch("agents.automation_manager.get_all_automations") + @patch("agents.automation_manager.anthropic.AsyncAnthropic") + async def test_edit_empty_changes( + self, mock_cls: MagicMock, mock_get_all: MagicMock, mock_get: MagicMock, + ) -> None: + mock_get_all.return_value = [_mock_auto()] + mock_get.return_value = _mock_auto() + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"action": "edit", "id": "auto_abc123", "changes": {}}' + ) + + result = await handle_manage_request("user1", "edit standup", _mock_context()) + assert "No changes" in result diff --git a/tests/test_automation_parser.py b/tests/test_automation_parser.py new file mode 100644 index 0000000..62bffdb --- /dev/null +++ b/tests/test_automation_parser.py @@ -0,0 +1,304 @@ +"""Unit tests for agents/automation_parser — Anthropic calls mocked.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import anthropic +import pytest + +from agents.automation_parser import ( + _build_tools_block, + _build_user_message, + _parse_json_response, + _strip_markdown_fences, + _validate_spec, + parse_automation, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_context() -> dict: + return { + "contacts": [ + {"email": "alice@co.com", "name": "Alice"}, + {"email": "bob@co.com", "name": "Bob"}, + ], + "timezone": "America/New_York", + } + + +def _valid_spec_json() -> str: + return json.dumps({ + "name": "Monday standup", + "trigger": {"type": "schedule", "cron": "0 9 * * 1", "human_readable": "Every Monday at 9am"}, + "action": { + "tool": "gmail", + "function": "send_email", + "params": {"to": "team@co.com", "subject": "Standup"}, + "body_mode": "generate", + "generation_prompt": "Write a standup email.", + }, + "confirmation_message": "Got it — standup every Monday at 9am.", + "clarification_needed": None, + }) + + +def _mock_response(text: str) -> MagicMock: + """Build a mock Anthropic Messages response.""" + block = MagicMock() + block.text = text + block.type = "text" + resp = MagicMock() + resp.content = [block] + return resp + + +# --------------------------------------------------------------------------- +# _strip_markdown_fences +# --------------------------------------------------------------------------- + +class TestStripMarkdownFences: + def test_no_fences(self) -> None: + assert _strip_markdown_fences('{"a": 1}') == '{"a": 1}' + + def test_json_fences(self) -> None: + text = '```json\n{"a": 1}\n```' + assert _strip_markdown_fences(text) == '{"a": 1}' + + def test_plain_fences(self) -> None: + text = '```\n{"a": 1}\n```' + assert _strip_markdown_fences(text) == '{"a": 1}' + + def test_whitespace_preserved_inside(self) -> None: + text = '```json\n{\n "a": 1\n}\n```' + result = _strip_markdown_fences(text) + assert '"a": 1' in result + + +# --------------------------------------------------------------------------- +# _parse_json_response +# --------------------------------------------------------------------------- + +class TestParseJsonResponse: + def test_valid_json(self) -> None: + result = _parse_json_response('{"name": "test"}') + assert result == {"name": "test"} + + def test_valid_with_fences(self) -> None: + result = _parse_json_response('```json\n{"name": "test"}\n```') + assert result == {"name": "test"} + + def test_invalid_json(self) -> None: + assert _parse_json_response("not json at all") is None + + def test_non_dict_json(self) -> None: + assert _parse_json_response("[1, 2, 3]") is None + + def test_empty_string(self) -> None: + assert _parse_json_response("") is None + + +# --------------------------------------------------------------------------- +# _build_tools_block +# --------------------------------------------------------------------------- + +class TestBuildToolsBlock: + def test_contains_all_tools(self) -> None: + block = _build_tools_block() + assert "send_email" in block + assert "create_event" in block + assert "search_web" in block + + def test_format(self) -> None: + block = _build_tools_block() + # Each line should be indented with " - " + lines = block.strip().split("\n") + assert all(line.strip().startswith("- ") for line in lines) + + +# --------------------------------------------------------------------------- +# _build_user_message +# --------------------------------------------------------------------------- + +class TestBuildUserMessage: + def test_includes_user_input(self) -> None: + msg = _build_user_message("email my team", _mock_context()) + assert "email my team" in msg + + def test_includes_contacts(self) -> None: + msg = _build_user_message("test", _mock_context()) + assert "alice@co.com" in msg + assert "bob@co.com" in msg + + def test_includes_date_time(self) -> None: + msg = _build_user_message("test", _mock_context()) + assert "Today's date:" in msg + assert "Current time:" in msg + + def test_includes_timezone(self) -> None: + msg = _build_user_message("test", _mock_context()) + assert "America/New_York" in msg + + def test_empty_contacts(self) -> None: + msg = _build_user_message("test", {"contacts": []}) + assert "none available" in msg + + def test_no_contacts_key(self) -> None: + msg = _build_user_message("test", {}) + assert "none available" in msg + + +# --------------------------------------------------------------------------- +# _validate_spec +# --------------------------------------------------------------------------- + +class TestValidateSpec: + def test_valid_spec(self) -> None: + spec = json.loads(_valid_spec_json()) + is_valid, issues = _validate_spec(spec) + assert is_valid + assert issues == [] + + def test_missing_name(self) -> None: + spec = json.loads(_valid_spec_json()) + del spec["name"] + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("name" in i for i in issues) + + def test_invalid_trigger_type(self) -> None: + spec = json.loads(_valid_spec_json()) + spec["trigger"]["type"] = "invalid" + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("trigger.type" in i for i in issues) + + def test_schedule_missing_cron(self) -> None: + spec = json.loads(_valid_spec_json()) + del spec["trigger"]["cron"] + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("cron" in i for i in issues) + + def test_generate_missing_prompt(self) -> None: + spec = json.loads(_valid_spec_json()) + spec["action"]["body_mode"] = "generate" + del spec["action"]["generation_prompt"] + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("generation_prompt" in i for i in issues) + + def test_missing_action_function(self) -> None: + spec = json.loads(_valid_spec_json()) + del spec["action"]["function"] + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("function" in i for i in issues) + + def test_static_body_mode_valid(self) -> None: + spec = json.loads(_valid_spec_json()) + spec["action"]["body_mode"] = "static" + spec["action"]["generation_prompt"] = None + is_valid, issues = _validate_spec(spec) + assert is_valid + + def test_event_trigger_no_cron_ok(self) -> None: + spec = json.loads(_valid_spec_json()) + spec["trigger"]["type"] = "event" + del spec["trigger"]["cron"] + is_valid, issues = _validate_spec(spec) + assert is_valid + + +# --------------------------------------------------------------------------- +# parse_automation +# --------------------------------------------------------------------------- + +class TestParseAutomation: + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_success(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response(_valid_spec_json()) + + result = await parse_automation("every monday email team", _mock_context()) + + assert result["name"] == "Monday standup" + assert result["trigger"]["type"] == "schedule" + assert result["action"]["body_mode"] == "generate" + assert result["clarification_needed"] is None + + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_strips_fences(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + fenced = f"```json\n{_valid_spec_json()}\n```" + mock_client.messages.create.return_value = _mock_response(fenced) + + result = await parse_automation("test", _mock_context()) + assert result["name"] == "Monday standup" + + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_retry_on_first_failure(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + # First call returns garbage, second returns valid JSON + mock_client.messages.create.side_effect = [ + _mock_response("not valid json"), + _mock_response(_valid_spec_json()), + ] + + result = await parse_automation("test", _mock_context()) + assert result["name"] == "Monday standup" + assert mock_client.messages.create.call_count == 2 + + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_fallback_on_double_failure(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("garbage") + + result = await parse_automation("test", _mock_context()) + + assert result["clarification_needed"] is not None + assert "rephrase" in result["clarification_needed"].lower() + assert result["name"] == "Unknown automation" + + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_api_error_handled(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.side_effect = anthropic.APIError( + message="rate limited", + request=MagicMock(), + body=None, + ) + + result = await parse_automation("test", _mock_context()) + assert result["clarification_needed"] is not None + + @pytest.mark.asyncio + @patch("agents.automation_parser.anthropic.AsyncAnthropic") + async def test_defaults_filled_in(self, mock_cls: MagicMock) -> None: + """Spec missing clarification_needed and confirmation_message gets defaults.""" + mock_client = AsyncMock() + mock_cls.return_value = mock_client + minimal = json.dumps({ + "name": "test", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": {"tool": "gmail", "function": "send_email", + "params": {}, "body_mode": "static"}, + }) + mock_client.messages.create.return_value = _mock_response(minimal) + + result = await parse_automation("test", _mock_context()) + assert result["clarification_needed"] is None + assert result["confirmation_message"] == "Automation created." diff --git a/tests/test_automation_repository.py b/tests/test_automation_repository.py new file mode 100644 index 0000000..7e0c880 --- /dev/null +++ b/tests/test_automation_repository.py @@ -0,0 +1,362 @@ +"""Unit tests for src/db/automation_repository — all Firestore calls mocked.""" + +import re +from unittest.mock import MagicMock, patch, call +from typing import Any + +import pytest + +import src.db.automation_repository as repo + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_db() -> MagicMock: + """Create a mock Firestore client with chainable refs.""" + return MagicMock() + + +def _mock_doc(data: dict[str, Any] | None) -> MagicMock: + """Create a mock Firestore document snapshot.""" + doc = MagicMock() + doc.exists = data is not None + doc.to_dict.return_value = data + doc.id = "auto_abc123" + return doc + + +def _valid_spec() -> dict[str, Any]: + return { + "name": "Monday standup", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": {"tool": "gmail", "function": "send_email"}, + } + + +# --------------------------------------------------------------------------- +# save_automation +# --------------------------------------------------------------------------- + +class TestSaveAutomation: + @patch("src.db.automation_repository.get_db") + def test_success(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + auto_id = repo.save_automation("user1", _valid_spec(), "every monday email team") + + assert re.match(r"^auto_[0-9a-f]{6}$", auto_id) + db.collection().document().collection().document().set.assert_called_once() + call_data = db.collection().document().collection().document().set.call_args[0][0] + assert call_data["name"] == "Monday standup" + assert call_data["enabled"] is True + assert call_data["run_count"] == 0 + assert call_data["run_history"] == [] + assert call_data["last_run"] is None + + @patch("src.db.automation_repository.get_db") + def test_no_db_raises(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + with pytest.raises(RuntimeError, match="Firestore not available"): + repo.save_automation("user1", _valid_spec(), "test") + + @patch("src.db.automation_repository.get_db") + def test_missing_name_raises(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = _mock_db() + spec = {"trigger": {}, "action": {}} + with pytest.raises(ValueError, match="name"): + repo.save_automation("user1", spec, "test") + + @patch("src.db.automation_repository.get_db") + def test_invalid_trigger_raises(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = _mock_db() + spec = {"name": "test", "trigger": "bad", "action": {}} + with pytest.raises(ValueError, match="trigger"): + repo.save_automation("user1", spec, "test") + + @patch("src.db.automation_repository.get_db") + def test_invalid_action_raises(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = _mock_db() + spec = {"name": "test", "trigger": {}, "action": "bad"} + with pytest.raises(ValueError, match="action"): + repo.save_automation("user1", spec, "test") + + @patch("src.db.automation_repository.get_db") + def test_enabled_defaults_true(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + repo.save_automation("user1", _valid_spec(), "test") + call_data = db.collection().document().collection().document().set.call_args[0][0] + assert call_data["enabled"] is True + + @patch("src.db.automation_repository.get_db") + def test_enabled_explicit_false(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + spec = {**_valid_spec(), "enabled": False} + repo.save_automation("user1", spec, "test") + call_data = db.collection().document().collection().document().set.call_args[0][0] + assert call_data["enabled"] is False + + +# --------------------------------------------------------------------------- +# get_automation +# --------------------------------------------------------------------------- + +class TestGetAutomation: + @patch("src.db.automation_repository.get_db") + def test_found(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + db.collection().document().collection().document().get.return_value = _mock_doc( + {"id": "auto_abc123", "name": "test"} + ) + result = repo.get_automation("user1", "auto_abc123") + assert result is not None + assert result["name"] == "test" + + @patch("src.db.automation_repository.get_db") + def test_not_found(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + db.collection().document().collection().document().get.return_value = _mock_doc(None) + assert repo.get_automation("user1", "auto_none") is None + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + assert repo.get_automation("user1", "auto_abc") is None + + +# --------------------------------------------------------------------------- +# get_all_automations +# --------------------------------------------------------------------------- + +class TestGetAllAutomations: + @patch("src.db.automation_repository.get_db") + def test_enabled_only(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + doc1 = _mock_doc({"name": "a", "enabled": True}) + query = db.collection().document().collection() + query.where().get.return_value = [doc1] + + result = repo.get_all_automations("user1", enabled_only=True) + query.where.assert_called_with("enabled", "==", True) + assert len(result) == 1 + + @patch("src.db.automation_repository.get_db") + def test_all(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + doc1 = _mock_doc({"name": "a", "enabled": True}) + doc2 = _mock_doc({"name": "b", "enabled": False}) + db.collection().document().collection().get.return_value = [doc1, doc2] + + result = repo.get_all_automations("user1", enabled_only=False) + assert len(result) == 2 + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + assert repo.get_all_automations("user1") == [] + + +# --------------------------------------------------------------------------- +# find_by_name +# --------------------------------------------------------------------------- + +class TestFindByName: + @patch("src.db.automation_repository.get_db") + def test_case_insensitive_match(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + doc1 = _mock_doc({"name": "Monday Standup"}) + doc2 = _mock_doc({"name": "Friday Report"}) + doc3 = _mock_doc({"name": "daily standup reminder"}) + db.collection().document().collection().get.return_value = [doc1, doc2, doc3] + + result = repo.find_by_name("user1", "standup") + assert len(result) == 2 + names = [r["name"] for r in result] + assert "Monday Standup" in names + assert "daily standup reminder" in names + + @patch("src.db.automation_repository.get_db") + def test_no_match(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + doc1 = _mock_doc({"name": "Monday Standup"}) + db.collection().document().collection().get.return_value = [doc1] + + result = repo.find_by_name("user1", "zebra") + assert result == [] + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + assert repo.find_by_name("user1", "test") == [] + + +# --------------------------------------------------------------------------- +# toggle_automation +# --------------------------------------------------------------------------- + +class TestToggleAutomation: + @patch("src.db.automation_repository.get_db") + def test_toggle(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + repo.toggle_automation("user1", "auto_abc123", False) + db.collection().document().collection().document().update.assert_called_once_with( + {"enabled": False} + ) + + @patch("src.db.automation_repository.get_db") + def test_toggle_not_found(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + from google.cloud.exceptions import NotFound + db.collection().document().collection().document().update.side_effect = NotFound("nope") + + # Should not raise — logs warning instead + repo.toggle_automation("user1", "auto_none", True) + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + repo.toggle_automation("user1", "auto_abc", True) # no-op + + +# --------------------------------------------------------------------------- +# update_automation +# --------------------------------------------------------------------------- + +class TestUpdateAutomation: + @patch("src.db.automation_repository.get_db") + def test_dot_notation(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + repo.update_automation("user1", "auto_abc123", {"trigger.cron": "0 10 * * 1"}) + db.collection().document().collection().document().update.assert_called_once_with( + {"trigger.cron": "0 10 * * 1"} + ) + + @patch("src.db.automation_repository.get_db") + def test_empty_changes_noop(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + repo.update_automation("user1", "auto_abc123", {}) + db.collection().document().collection().document().update.assert_not_called() + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + repo.update_automation("user1", "auto_abc", {"name": "new"}) # no-op + + +# --------------------------------------------------------------------------- +# delete_automation +# --------------------------------------------------------------------------- + +class TestDeleteAutomation: + @patch("src.db.automation_repository.get_db") + def test_delete(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + repo.delete_automation("user1", "auto_abc123") + db.collection().document().collection().document().delete.assert_called_once() + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + repo.delete_automation("user1", "auto_abc") # no-op + + +# --------------------------------------------------------------------------- +# update_run_result +# --------------------------------------------------------------------------- + +class TestUpdateRunResult: + @patch("src.db.automation_repository.get_db") + def test_appends_and_increments(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + # Mock the doc snapshot inside the transaction + existing_data = { + "run_history": [{"status": "success", "timestamp": "2026-03-28T10:00:00+00:00"}], + "run_count": 1, + } + mock_snapshot = _mock_doc(existing_data) + + # Make doc_ref.get(transaction=...) return our snapshot + doc_ref = db.collection().document().collection().document() + doc_ref.get.return_value = mock_snapshot + + # Patch _transactional to just call the function directly (skip real transaction) + with patch("src.db.automation_repository._transactional", side_effect=lambda f: f): + # Mock db.transaction() to return a mock transaction object + mock_txn = MagicMock() + db.transaction.return_value = mock_txn + + repo.update_run_result("user1", "auto_abc123", "success") + + @patch("src.db.automation_repository.get_db") + def test_trims_to_10(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + # Start with 10 existing entries + existing_history = [ + {"status": "success", "timestamp": f"2026-03-{i:02d}T10:00:00+00:00"} + for i in range(1, 11) + ] + existing_data = {"run_history": existing_history, "run_count": 10} + mock_snapshot = _mock_doc(existing_data) + + doc_ref = db.collection().document().collection().document() + doc_ref.get.return_value = mock_snapshot + + with patch("src.db.automation_repository._transactional", side_effect=lambda f: f): + mock_txn = MagicMock() + db.transaction.return_value = mock_txn + + repo.update_run_result("user1", "auto_abc123", "error", error="timeout") + + # The transaction.update call should have trimmed history to 10 + if mock_txn.update.called: + update_data = mock_txn.update.call_args[0][1] + assert len(update_data["run_history"]) <= 10 + assert update_data["run_count"] == 11 + assert update_data["last_run_status"] == "error" + + @patch("src.db.automation_repository.get_db") + def test_no_db(self, mock_get_db: MagicMock) -> None: + mock_get_db.return_value = None + repo.update_run_result("user1", "auto_abc", "success") # no-op + + @patch("src.db.automation_repository.get_db") + def test_doc_not_found(self, mock_get_db: MagicMock) -> None: + db = _mock_db() + mock_get_db.return_value = db + + doc_ref = db.collection().document().collection().document() + doc_ref.get.return_value = _mock_doc(None) + + with patch("src.db.automation_repository._transactional", side_effect=lambda f: f): + mock_txn = MagicMock() + db.transaction.return_value = mock_txn + + # Should not raise + repo.update_run_result("user1", "auto_gone", "success") diff --git a/tests/test_automation_scheduler.py b/tests/test_automation_scheduler.py new file mode 100644 index 0000000..2b07358 --- /dev/null +++ b/tests/test_automation_scheduler.py @@ -0,0 +1,263 @@ +"""Unit tests for daemons/automation_scheduler — APScheduler mocked.""" + +from unittest.mock import MagicMock, patch, call + +import pytest + +from daemons.automation_scheduler import ( + _make_job_id, + _parse_cron, + register_automation, + unregister_automation, + _load_and_register_all, + _run_automation_job, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_scheduler() -> MagicMock: + sched = MagicMock() + sched.get_job.return_value = None # no existing job by default + return sched + + +def _mock_automation( + auto_id: str = "auto_abc123", + name: str = "Monday standup", + cron: str = "0 9 * * 1", + trigger_type: str = "schedule", + enabled: bool = True, +) -> dict: + return { + "id": auto_id, + "name": name, + "enabled": enabled, + "trigger": {"type": trigger_type, "cron": cron}, + "action": {"tool": "gmail", "function": "send_email", "params": {}}, + } + + +def _mock_context() -> dict: + return { + "style_profile": "Tone: casual", + "session_log": "2026-03-29 — Fixed bug", + "pending_tasks": "- Write tests", + } + + +# --------------------------------------------------------------------------- +# _parse_cron +# --------------------------------------------------------------------------- + +class TestParseCron: + def test_standard_5_field(self) -> None: + result = _parse_cron("0 9 * * 1") + assert result == { + "minute": "0", "hour": "9", "day": "*", + "month": "*", "day_of_week": "1", + } + + def test_every_weekday_at_5pm(self) -> None: + result = _parse_cron("0 17 * * 1-5") + assert result["hour"] == "17" + assert result["day_of_week"] == "1-5" + + def test_every_minute(self) -> None: + result = _parse_cron("* * * * *") + assert result["minute"] == "*" + + def test_invalid_too_few_fields(self) -> None: + assert _parse_cron("0 9 *") is None + + def test_invalid_too_many_fields(self) -> None: + assert _parse_cron("0 9 * * 1 2026") is None + + def test_empty_string(self) -> None: + assert _parse_cron("") is None + + def test_strips_whitespace(self) -> None: + result = _parse_cron(" 0 9 * * 1 ") + assert result is not None + assert result["minute"] == "0" + + +# --------------------------------------------------------------------------- +# _make_job_id +# --------------------------------------------------------------------------- + +class TestMakeJobId: + def test_format(self) -> None: + job_id = _make_job_id("user123", "auto_abc") + assert job_id == "auto_job_user123_auto_abc" + + def test_deterministic(self) -> None: + assert _make_job_id("u", "a") == _make_job_id("u", "a") + + +# --------------------------------------------------------------------------- +# register_automation +# --------------------------------------------------------------------------- + +class TestRegisterAutomation: + def test_schedule_registered(self) -> None: + sched = _mock_scheduler() + result = register_automation( + sched, "user1", _mock_automation(), _mock_context(), + ) + assert result is True + sched.add_job.assert_called_once() + + call_kwargs = sched.add_job.call_args.kwargs + assert call_kwargs["id"] == "auto_job_user1_auto_abc123" + assert call_kwargs["misfire_grace_time"] == 300 + + def test_non_schedule_skipped(self) -> None: + sched = _mock_scheduler() + auto = _mock_automation(trigger_type="event") + result = register_automation(sched, "user1", auto, _mock_context()) + assert result is False + sched.add_job.assert_not_called() + + def test_invalid_cron_rejected(self) -> None: + sched = _mock_scheduler() + auto = _mock_automation(cron="bad") + result = register_automation(sched, "user1", auto, _mock_context()) + assert result is False + sched.add_job.assert_not_called() + + def test_re_register_removes_old_job(self) -> None: + sched = _mock_scheduler() + sched.get_job.return_value = MagicMock() # existing job found + + register_automation(sched, "user1", _mock_automation(), _mock_context()) + + sched.remove_job.assert_called_once() + sched.add_job.assert_called_once() + + def test_add_job_failure_returns_false(self) -> None: + sched = _mock_scheduler() + sched.add_job.side_effect = Exception("scheduler error") + + result = register_automation( + sched, "user1", _mock_automation(), _mock_context(), + ) + assert result is False + + +# --------------------------------------------------------------------------- +# unregister_automation +# --------------------------------------------------------------------------- + +class TestUnregisterAutomation: + def test_found_and_removed(self) -> None: + sched = _mock_scheduler() + sched.get_job.return_value = MagicMock() # job exists + + result = unregister_automation(sched, "user1", "auto_abc123") + + assert result is True + sched.remove_job.assert_called_once_with("auto_job_user1_auto_abc123") + + def test_not_found(self) -> None: + sched = _mock_scheduler() + sched.get_job.return_value = None + + result = unregister_automation(sched, "user1", "auto_gone") + assert result is False + sched.remove_job.assert_not_called() + + +# --------------------------------------------------------------------------- +# _load_and_register_all +# --------------------------------------------------------------------------- + +class TestLoadAndRegisterAll: + @patch("daemons.automation_scheduler.get_all_automations") + def test_registers_schedule_automations(self, mock_get: MagicMock) -> None: + mock_get.return_value = [ + _mock_automation("auto_1", "Job A", "0 9 * * 1"), + _mock_automation("auto_2", "Job B", "0 17 * * 5"), + ] + sched = _mock_scheduler() + + count = _load_and_register_all(sched, "user1", _mock_context()) + + assert count == 2 + assert sched.add_job.call_count == 2 + + @patch("daemons.automation_scheduler.get_all_automations") + def test_skips_non_schedule(self, mock_get: MagicMock) -> None: + mock_get.return_value = [ + _mock_automation("auto_1", trigger_type="schedule"), + _mock_automation("auto_2", trigger_type="event"), + ] + sched = _mock_scheduler() + + count = _load_and_register_all(sched, "user1", _mock_context()) + assert count == 1 + + @patch("daemons.automation_scheduler.get_all_automations") + def test_empty_list(self, mock_get: MagicMock) -> None: + mock_get.return_value = [] + sched = _mock_scheduler() + + count = _load_and_register_all(sched, "user1", _mock_context()) + assert count == 0 + sched.add_job.assert_not_called() + + +# --------------------------------------------------------------------------- +# _run_automation_job +# --------------------------------------------------------------------------- + +class TestRunAutomationJob: + @patch("daemons.automation_scheduler.execute_automation") + @patch("daemons.automation_scheduler.load_user_context") + def test_fetches_fresh_context( + self, mock_load: MagicMock, mock_exec: MagicMock, + ) -> None: + fresh_ctx = {"style_profile": "fresh", "session_log": "", "pending_tasks": ""} + mock_load.return_value = fresh_ctx + + # Make execute_automation return a coroutine + async def fake_exec(*args, **kwargs): + return {"status": "success"} + mock_exec.side_effect = fake_exec + + _run_automation_job("user1", "auto_abc", _mock_context()) + + mock_load.assert_called_once_with("user1") + mock_exec.assert_called_once_with("user1", "auto_abc", fresh_ctx) + + @patch("daemons.automation_scheduler.execute_automation") + @patch("daemons.automation_scheduler.load_user_context") + def test_falls_back_to_cached_on_fetch_failure( + self, mock_load: MagicMock, mock_exec: MagicMock, + ) -> None: + mock_load.side_effect = Exception("Firestore down") + cached = _mock_context() + + async def fake_exec(*args, **kwargs): + return {"status": "success"} + mock_exec.side_effect = fake_exec + + _run_automation_job("user1", "auto_abc", cached) + + mock_exec.assert_called_once_with("user1", "auto_abc", cached) + + @patch("daemons.automation_scheduler.execute_automation") + @patch("daemons.automation_scheduler.load_user_context") + def test_handles_execution_error( + self, mock_load: MagicMock, mock_exec: MagicMock, + ) -> None: + mock_load.return_value = _mock_context() + + async def failing_exec(*args, **kwargs): + raise RuntimeError("boom") + mock_exec.side_effect = failing_exec + + # Should not raise + _run_automation_job("user1", "auto_abc", _mock_context()) diff --git a/tests/test_automation_wiring.py b/tests/test_automation_wiring.py new file mode 100644 index 0000000..8dc820b --- /dev/null +++ b/tests/test_automation_wiring.py @@ -0,0 +1,218 @@ +"""Integration tests for automation tool wiring in tool_defs.py and chat.py.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.agent.tool_defs import ( + TOOL_DEFINITIONS, + dispatch_tool, + set_scheduler_ref, + _list_automations_handler, +) +from src.agent.chat import _summarize_tool_input + + +# --------------------------------------------------------------------------- +# Tool definitions presence +# --------------------------------------------------------------------------- + +class TestToolDefinitions: + def _tool_names(self) -> set[str]: + return {t["name"] for t in TOOL_DEFINITIONS} + + def test_create_automation_defined(self) -> None: + assert "create_automation" in self._tool_names() + + def test_manage_automations_defined(self) -> None: + assert "manage_automations" in self._tool_names() + + def test_list_automations_defined(self) -> None: + assert "list_automations" in self._tool_names() + + def test_create_automation_has_user_request(self) -> None: + tool = next(t for t in TOOL_DEFINITIONS if t["name"] == "create_automation") + assert "user_request" in tool["input_schema"]["properties"] + assert "user_request" in tool["input_schema"]["required"] + + +# --------------------------------------------------------------------------- +# Tool summaries in chat.py +# --------------------------------------------------------------------------- + +class TestToolSummaries: + def test_create_automation_summary(self) -> None: + result = _summarize_tool_input( + "create_automation", {"user_request": "Every Monday email team a standup"}, + ) + assert "Creating automation" in result + assert "Monday" in result + + def test_manage_automations_summary(self) -> None: + result = _summarize_tool_input( + "manage_automations", {"user_request": "pause my standup"}, + ) + assert "Managing automations" in result + + def test_list_automations_summary(self) -> None: + result = _summarize_tool_input("list_automations", {}) + assert "Listed" in result + + +# --------------------------------------------------------------------------- +# dispatch_tool — create_automation +# --------------------------------------------------------------------------- + +class TestCreateAutomationDispatch: + @pytest.mark.asyncio + async def test_no_uid_returns_error(self) -> None: + result = await dispatch_tool( + "create_automation", + {"user_request": "every monday email team"}, + access_token=None, + uid="", + ) + assert "signed in" in result.lower() + + @pytest.mark.asyncio + @patch("utils.automation_store.save_automation", return_value="auto_abc123") + @patch("agents.automation_parser.parse_automation", new_callable=AsyncMock) + @patch("utils.firebase_context.load_user_context") + async def test_creates_and_saves( + self, mock_ctx: MagicMock, mock_parse: AsyncMock, mock_save: MagicMock, + ) -> None: + mock_ctx.return_value = {"style_profile": "", "session_log": "", "pending_tasks": ""} + mock_parse.return_value = { + "name": "Monday standup", + "trigger": {"type": "schedule", "cron": "0 9 * * 1", "human_readable": "Every Monday at 9am"}, + "action": {"tool": "gmail", "function": "send_email", "params": {}}, + "confirmation_message": "Got it!", + "clarification_needed": None, + } + + result = await dispatch_tool( + "create_automation", + {"user_request": "every monday email team a standup"}, + access_token=None, + uid="user1", + ) + + assert "Got it!" in result + assert "auto_abc123" in result + mock_save.assert_called_once() + + @pytest.mark.asyncio + @patch("agents.automation_parser.parse_automation", new_callable=AsyncMock) + @patch("utils.firebase_context.load_user_context") + async def test_returns_clarification( + self, mock_ctx: MagicMock, mock_parse: AsyncMock, + ) -> None: + mock_ctx.return_value = {"style_profile": "", "session_log": "", "pending_tasks": ""} + mock_parse.return_value = { + "name": "Unknown", + "clarification_needed": "Who should I email?", + } + + result = await dispatch_tool( + "create_automation", + {"user_request": "do the thing"}, + access_token=None, + uid="user1", + ) + + assert "Who should I email?" in result + + +# --------------------------------------------------------------------------- +# dispatch_tool — manage_automations +# --------------------------------------------------------------------------- + +class TestManageAutomationsDispatch: + @pytest.mark.asyncio + async def test_no_uid_returns_error(self) -> None: + result = await dispatch_tool( + "manage_automations", + {"user_request": "pause my standup"}, + access_token=None, + uid="", + ) + assert "signed in" in result.lower() + + @pytest.mark.asyncio + @patch("agents.automation_manager.handle_manage_request", new_callable=AsyncMock) + @patch("utils.firebase_context.load_user_context") + async def test_delegates_to_manager( + self, mock_ctx: MagicMock, mock_manage: AsyncMock, + ) -> None: + mock_ctx.return_value = {"style_profile": "", "session_log": "", "pending_tasks": ""} + mock_manage.return_value = "Paused **Monday standup**." + + result = await dispatch_tool( + "manage_automations", + {"user_request": "pause my standup"}, + access_token=None, + uid="user1", + ) + + assert "Paused" in result + mock_manage.assert_called_once() + + +# --------------------------------------------------------------------------- +# dispatch_tool — list_automations +# --------------------------------------------------------------------------- + +class TestListAutomationsDispatch: + @pytest.mark.asyncio + async def test_no_uid_returns_error(self) -> None: + result = await dispatch_tool( + "list_automations", {}, access_token=None, uid="", + ) + assert "signed in" in result.lower() + + @pytest.mark.asyncio + @patch("utils.automation_store.get_all_automations") + async def test_empty_list(self, mock_get: MagicMock) -> None: + mock_get.return_value = [] + + result = await dispatch_tool( + "list_automations", {}, access_token=None, uid="user1", + ) + + assert "don't have any" in result.lower() + + @pytest.mark.asyncio + @patch("utils.automation_store.get_all_automations") + async def test_shows_automations(self, mock_get: MagicMock) -> None: + mock_get.return_value = [ + { + "id": "auto_abc", + "name": "Monday standup", + "enabled": True, + "trigger": {"type": "schedule", "cron": "0 9 * * 1", "human_readable": "Every Monday at 9am"}, + "action": {"tool": "gmail", "function": "send_email", "params": {"to": "team@co.com"}}, + "run_count": 5, + }, + ] + + result = await dispatch_tool( + "list_automations", {}, access_token=None, uid="user1", + ) + + assert "Monday standup" in result + assert "active" in result + assert "team@co.com" in result + assert "Runs: 5" in result + + +# --------------------------------------------------------------------------- +# Scheduler ref +# --------------------------------------------------------------------------- + +class TestSchedulerRef: + def test_set_and_get(self) -> None: + mock_sched = MagicMock() + set_scheduler_ref(mock_sched) + from src.agent.tool_defs import _get_scheduler + assert _get_scheduler() is mock_sched + set_scheduler_ref(None) # cleanup diff --git a/tests/test_batch_executor.py b/tests/test_batch_executor.py new file mode 100644 index 0000000..b3b1b0b --- /dev/null +++ b/tests/test_batch_executor.py @@ -0,0 +1,462 @@ +"""Unit tests for agents/batch_executor — all external calls mocked.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import anthropic +import pytest + +from agents.batch_executor import ( + _build_user_message, + _compute_aggregate_status, + _execute_single_recipient, + _parse_result_json, + _strip_markdown_fences, + execute_batch_async, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_context() -> dict: + return { + "style_profile": "Tone: casual", + "session_log": "2026-03-29 — Team offsite", + "pending_tasks": "- Write retro", + } + + +def _mock_action() -> dict: + return { + "tool": "gmail", + "function": "send_email", + "params": {"subject": "Thank you!"}, + "generation_prompt": "Write a personalised thank-you note.", + } + + +def _mock_recipient( + name: str = "Sarah Chen", + email: str = "sarah@example.com", + notes: str = "Led the design sprint", +) -> dict: + return {"name": name, "email": email, "notes": notes} + + +def _mock_recipients(count: int = 3) -> list[dict]: + data = [ + ("Sarah Chen", "sarah@example.com", "Led the design sprint"), + ("Marcus Johnson", "marcus@example.com", "New hire, API team"), + ("Priya Patel", "priya@example.com", "Organised the offsite"), + ] + return [ + {"name": name, "email": email, "notes": notes} + for name, email, notes in data[:count] + ] + + +def _mock_api_response(text: str) -> MagicMock: + block = MagicMock() + block.text = text + block.type = "text" + resp = MagicMock() + resp.content = [block] + return resp + + +# --------------------------------------------------------------------------- +# _strip_markdown_fences +# --------------------------------------------------------------------------- + +class TestStripFences: + def test_no_fences(self) -> None: + assert _strip_markdown_fences('{"a": 1}') == '{"a": 1}' + + def test_json_fences(self) -> None: + assert _strip_markdown_fences('```json\n{"a": 1}\n```') == '{"a": 1}' + + def test_plain_fences(self) -> None: + assert _strip_markdown_fences('```\n{"a": 1}\n```') == '{"a": 1}' + + def test_whitespace(self) -> None: + assert _strip_markdown_fences(' {"a": 1} ') == '{"a": 1}' + + +# --------------------------------------------------------------------------- +# _parse_result_json +# --------------------------------------------------------------------------- + +class TestParseResultJson: + def test_valid_json(self) -> None: + result = _parse_result_json( + '{"status": "success", "summary": "Sent", "error": null}' + ) + assert result["status"] == "success" + + def test_fenced_json(self) -> None: + result = _parse_result_json( + '```json\n{"status": "error", "summary": "fail", "error": "timeout"}\n```' + ) + assert result["status"] == "error" + assert result["error"] == "timeout" + + def test_invalid_json_fallback(self) -> None: + result = _parse_result_json("Email sent to sarah@example.com") + assert result["status"] == "success" + assert "Email sent" in result["summary"] + + def test_empty_string_fallback(self) -> None: + result = _parse_result_json("") + assert result["status"] == "success" + assert "no details" in result["summary"].lower() + + +# --------------------------------------------------------------------------- +# _build_user_message +# --------------------------------------------------------------------------- + +class TestBuildUserMessage: + def test_includes_recipient_details(self) -> None: + msg = _build_user_message( + _mock_recipient(), _mock_action(), _mock_context(), + ) + assert "Sarah Chen" in msg + assert "sarah@example.com" in msg + assert "Led the design sprint" in msg + + def test_includes_action_details(self) -> None: + msg = _build_user_message( + _mock_recipient(), _mock_action(), _mock_context(), + ) + assert "personalised thank-you" in msg + assert "Thank you!" in msg + + def test_includes_style_profile(self) -> None: + msg = _build_user_message( + _mock_recipient(), _mock_action(), _mock_context(), + ) + assert "Tone: casual" in msg + + def test_defaults_for_missing_keys(self) -> None: + msg = _build_user_message( + {"email": "x@y.com"}, + {"params": {}}, + {}, + ) + assert "x@y.com" in msg + assert "No style profile" in msg + + +# --------------------------------------------------------------------------- +# _compute_aggregate_status +# --------------------------------------------------------------------------- + +class TestComputeAggregateStatus: + def test_all_success(self) -> None: + results = [{"status": "success"}, {"status": "success"}] + assert _compute_aggregate_status(results) == "success" + + def test_all_error(self) -> None: + results = [{"status": "error"}, {"status": "error"}] + assert _compute_aggregate_status(results) == "error" + + def test_mixed(self) -> None: + results = [{"status": "success"}, {"status": "error"}] + assert _compute_aggregate_status(results) == "partial" + + def test_empty_list(self) -> None: + assert _compute_aggregate_status([]) == "error" + + def test_single_success(self) -> None: + assert _compute_aggregate_status([{"status": "success"}]) == "success" + + def test_single_error(self) -> None: + assert _compute_aggregate_status([{"status": "error"}]) == "error" + + +# --------------------------------------------------------------------------- +# _execute_single_recipient +# --------------------------------------------------------------------------- + +class TestExecuteSingleRecipient: + @pytest.mark.asyncio + async def test_success(self) -> None: + client = AsyncMock() + client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent thank-you to Sarah", "error": null}' + ) + + result = await _execute_single_recipient( + client, _mock_recipient(), _mock_action(), _mock_context(), + ) + + assert result["status"] == "success" + assert result["recipient"] == "sarah@example.com" + assert "Sarah" in result["summary"] + + @pytest.mark.asyncio + async def test_mcp_config_passed(self) -> None: + client = AsyncMock() + client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + await _execute_single_recipient( + client, _mock_recipient(), _mock_action(), _mock_context(), + ) + + call_kwargs = client.messages.create.call_args.kwargs + mcp = call_kwargs["mcp_servers"][0] + assert mcp["url"] == "https://gmail.mcp.claude.com/mcp" + assert mcp["name"] == "gmail_mcp" + + @pytest.mark.asyncio + async def test_api_error_normalised(self) -> None: + client = AsyncMock() + client.messages.create.side_effect = anthropic.APIError( + message="rate limited", request=MagicMock(), body=None, + ) + + result = await _execute_single_recipient( + client, _mock_recipient(), _mock_action(), _mock_context(), + ) + + assert result["status"] == "error" + assert result["recipient"] == "sarah@example.com" + assert "rate limited" in result["error"] + + @pytest.mark.asyncio + async def test_unexpected_error_normalised(self) -> None: + client = AsyncMock() + client.messages.create.side_effect = RuntimeError("connection reset") + + result = await _execute_single_recipient( + client, _mock_recipient(), _mock_action(), _mock_context(), + ) + + assert result["status"] == "error" + assert result["recipient"] == "sarah@example.com" + assert "connection reset" in result["error"] + + @pytest.mark.asyncio + async def test_input_not_mutated(self) -> None: + client = AsyncMock() + client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + recipient = _mock_recipient() + action = _mock_action() + context = _mock_context() + original_recipient = {**recipient} + original_action = json.dumps(action, sort_keys=True) + original_context = json.dumps(context, sort_keys=True) + + await _execute_single_recipient(client, recipient, action, context) + + assert recipient == original_recipient + assert json.dumps(action, sort_keys=True) == original_action + assert json.dumps(context, sort_keys=True) == original_context + + @pytest.mark.asyncio + async def test_missing_email_defaults_to_unknown(self) -> None: + client = AsyncMock() + client.messages.create.side_effect = RuntimeError("fail") + + result = await _execute_single_recipient( + client, {"name": "Ghost"}, _mock_action(), _mock_context(), + ) + + assert result["recipient"] == "unknown" + assert result["status"] == "error" + + +# --------------------------------------------------------------------------- +# execute_batch_async +# --------------------------------------------------------------------------- + +class TestExecuteBatchAsync: + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_all_succeed( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + results = await execute_batch_async( + "user1", "auto_b1", _mock_recipients(3), + _mock_action(), _mock_context(), + ) + + assert len(results) == 3 + assert all(r["status"] == "success" for r in results) + mock_log_run.assert_called_once_with( + user_id="user1", auto_id="auto_b1", + status="success", error=None, + ) + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_partial_failure( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + + call_count = 0 + + async def varying_response(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise anthropic.APIError( + message="rate limited", request=MagicMock(), body=None, + ) + return _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + mock_client.messages.create.side_effect = varying_response + + results = await execute_batch_async( + "user1", "auto_b1", _mock_recipients(3), + _mock_action(), _mock_context(), + ) + + assert len(results) == 3 + statuses = [r["status"] for r in results] + assert statuses.count("success") == 2 + assert statuses.count("error") == 1 + + mock_log_run.assert_called_once() + call_kwargs = mock_log_run.call_args.kwargs + assert call_kwargs["status"] == "partial" + assert call_kwargs["error"] is not None + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_all_fail( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.side_effect = RuntimeError("total failure") + + results = await execute_batch_async( + "user1", "auto_b1", _mock_recipients(3), + _mock_action(), _mock_context(), + ) + + assert len(results) == 3 + assert all(r["status"] == "error" for r in results) + mock_log_run.assert_called_once() + assert mock_log_run.call_args.kwargs["status"] == "error" + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_empty_recipients( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + results = await execute_batch_async( + "user1", "auto_b1", [], _mock_action(), _mock_context(), + ) + + assert results == [] + mock_cls.assert_not_called() + mock_log_run.assert_not_called() + + @pytest.mark.asyncio + @patch( + "agents.batch_executor.update_run_result", + side_effect=Exception("db down"), + ) + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_firestore_failure_doesnt_crash( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + results = await execute_batch_async( + "user1", "auto_b1", _mock_recipients(2), + _mock_action(), _mock_context(), + ) + + assert len(results) == 2 + assert all(r["status"] == "success" for r in results) + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_each_recipient_gets_own_email( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + recipients = _mock_recipients(3) + results = await execute_batch_async( + "user1", "auto_b1", recipients, _mock_action(), _mock_context(), + ) + + emails = {r["recipient"] for r in results} + assert emails == { + "sarah@example.com", + "marcus@example.com", + "priya@example.com", + } + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_shared_client_instance( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_api_response( + '{"status": "success", "summary": "Sent", "error": null}' + ) + + await execute_batch_async( + "user1", "auto_b1", _mock_recipients(3), + _mock_action(), _mock_context(), + ) + + # One client instance shared across all 3 subagents + mock_cls.assert_called_once() + assert mock_client.messages.create.call_count == 3 + + @pytest.mark.asyncio + @patch("agents.batch_executor.update_run_result") + @patch("agents.batch_executor.anthropic.AsyncAnthropic") + async def test_error_summary_truncated( + self, mock_cls: MagicMock, mock_log_run: MagicMock, + ) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.side_effect = RuntimeError("x" * 600) + + await execute_batch_async( + "user1", "auto_b1", _mock_recipients(3), + _mock_action(), _mock_context(), + ) + + error_str = mock_log_run.call_args.kwargs["error"] + assert len(error_str) <= 500 diff --git a/tests/test_content_generator.py b/tests/test_content_generator.py new file mode 100644 index 0000000..2ad4099 --- /dev/null +++ b/tests/test_content_generator.py @@ -0,0 +1,193 @@ +"""Unit tests for agents/content_generator — Anthropic calls mocked.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import anthropic +import pytest + +from agents.content_generator import ( + GENERATOR_SYSTEM_PROMPT, + GENERATOR_USER_TEMPLATE, + generate_content, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_context() -> dict: + return { + "style_profile": "Tone: casual\nAvg sentence length: 10 words", + "session_log": "2026-03-29 — Fixed auth bug", + "pending_tasks": "- Write tests\n- Review PR", + } + + +def _mock_response(text: str) -> MagicMock: + block = MagicMock() + block.text = text + block.type = "text" + resp = MagicMock() + resp.content = [block] + return resp + + +# --------------------------------------------------------------------------- +# System prompt content +# --------------------------------------------------------------------------- + +class TestSystemPrompt: + def test_no_ai_disclaimers_rule(self) -> None: + assert "AI disclaimer" in GENERATOR_SYSTEM_PROMPT or "disclaimers" in GENERATOR_SYSTEM_PROMPT + + def test_voice_matching_rule(self) -> None: + assert "voice" in GENERATOR_SYSTEM_PROMPT.lower() + + def test_no_fabrication_rule(self) -> None: + assert "fabricate" in GENERATOR_SYSTEM_PROMPT.lower() + + def test_body_only_rule(self) -> None: + assert "ONLY" in GENERATOR_SYSTEM_PROMPT + + +# --------------------------------------------------------------------------- +# User template +# --------------------------------------------------------------------------- + +class TestUserTemplate: + def test_has_all_placeholders(self) -> None: + assert "{generation_prompt}" in GENERATOR_USER_TEMPLATE + assert "{style_profile}" in GENERATOR_USER_TEMPLATE + assert "{session_log}" in GENERATOR_USER_TEMPLATE + assert "{pending_tasks}" in GENERATOR_USER_TEMPLATE + assert "{today}" in GENERATOR_USER_TEMPLATE + + +# --------------------------------------------------------------------------- +# generate_content +# --------------------------------------------------------------------------- + +class TestGenerateContent: + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_returns_body_text(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response( + "Hey team, quick update on this week's progress." + ) + + result = await generate_content("Write a standup", _mock_context()) + + assert result == "Hey team, quick update on this week's progress." + mock_client.messages.create.assert_called_once() + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_strips_whitespace(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response( + "\n Some content with padding \n\n" + ) + + result = await generate_content("Write something", _mock_context()) + assert result == "Some content with padding" + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_empty_response_returns_fallback(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("") + + result = await generate_content("Write something", _mock_context()) + assert "no content" in result.lower() + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_api_error_returns_fallback(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.side_effect = anthropic.APIError( + message="rate limited", + request=MagicMock(), + body=None, + ) + + result = await generate_content("Write something", _mock_context()) + assert "failed" in result.lower() + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_uses_opus_model(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("content") + + await generate_content("Write something", _mock_context()) + + call_kwargs = mock_client.messages.create.call_args.kwargs + assert "opus" in call_kwargs["model"] + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_passes_system_prompt(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("content") + + await generate_content("Write something", _mock_context()) + + call_kwargs = mock_client.messages.create.call_args.kwargs + assert "ghostwriter" in call_kwargs["system"].lower() + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_injects_context_into_message(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("content") + + ctx = _mock_context() + await generate_content("Write a standup", ctx) + + call_kwargs = mock_client.messages.create.call_args.kwargs + user_msg = call_kwargs["messages"][0]["content"] + assert "Write a standup" in user_msg + assert "casual" in user_msg # from style_profile + assert "Fixed auth bug" in user_msg # from session_log + assert "Write tests" in user_msg # from pending_tasks + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_missing_context_keys_use_defaults(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + mock_client.messages.create.return_value = _mock_response("content") + + await generate_content("Write something", {}) + + call_kwargs = mock_client.messages.create.call_args.kwargs + user_msg = call_kwargs["messages"][0]["content"] + assert "No style profile available" in user_msg + assert "No recent activity" in user_msg + assert "No pending tasks" in user_msg + + @pytest.mark.asyncio + @patch("agents.content_generator.anthropic.AsyncAnthropic") + async def test_multi_block_response(self, mock_cls: MagicMock) -> None: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + + block1 = MagicMock() + block1.text = "First part. " + block2 = MagicMock() + block2.text = "Second part." + resp = MagicMock() + resp.content = [block1, block2] + mock_client.messages.create.return_value = resp + + result = await generate_content("Write something", _mock_context()) + assert result == "First part. Second part." diff --git a/tests/test_firebase_context.py b/tests/test_firebase_context.py new file mode 100644 index 0000000..f49cdfb --- /dev/null +++ b/tests/test_firebase_context.py @@ -0,0 +1,128 @@ +"""Unit tests for utils/firebase_context — Firestore calls mocked.""" + +from unittest.mock import MagicMock, patch + +from src.models.schemas import ( + Behavior, Context, Identity, RichProfile, SecondSelfProfile, Voice, +) +from utils.firebase_context import load_user_context + + +def _slim_profile() -> SecondSelfProfile: + return SecondSelfProfile( + identity=Identity(name="Test", role="Engineer", company="Acme"), + voice=Voice( + formality="casual", avg_email_length="medium", + signature_phrases=["cool"], opens_with="Hey", closes_with="Best", + tone="friendly", + ), + behavior=Behavior( + work_hours="9-5", meeting_load="medium", + response_style="concise", peak_focus_time="morning", + ), + context=Context( + active_projects=["API"], top_collaborators=["a@b.com"], + current_priorities=["ship"], + ), + ) + + +def _rich_profile() -> RichProfile: + slim = _slim_profile() + return RichProfile( + **slim.model_dump(), + identity_md="# Test Identity\nSenior engineer at Acme.", + voice_raw={ + "tone_descriptor": "casual", + "avg_sentence_length": 12, + "vocabulary_markers": ["ship", "sync", "flag"], + "emoji_frequency": 0.3, + "question_ratio": 15, + }, + ) + + +class TestLoadUserContext: + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_rich_profile_used(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = _rich_profile() + mock_slim.return_value = None + mock_events.return_value = [] + + ctx = load_user_context("user1") + + assert "Test Identity" in ctx["style_profile"] + assert "casual" in ctx["style_profile"] + assert "ship" in ctx["style_profile"] + + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_slim_fallback(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = None + mock_slim.return_value = _slim_profile() + mock_events.return_value = [] + + ctx = load_user_context("user1") + + assert "Test" in ctx["style_profile"] + assert "friendly" in ctx["style_profile"] + + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_no_profile(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = None + mock_slim.return_value = None + mock_events.return_value = [] + + ctx = load_user_context("user1") + + assert "No style profile" in ctx["style_profile"] + + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_session_log_from_events(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = None + mock_slim.return_value = None + mock_events.return_value = [ + {"date": "2026-03-29 10:00", "summary": "Fixed auth bug", "category": "agent_action"}, + {"date": "2026-03-29 11:00", "summary": "Sent email to Bob", "category": "agent_action"}, + ] + + ctx = load_user_context("user1") + + assert "Fixed auth bug" in ctx["session_log"] + assert "Sent email to Bob" in ctx["session_log"] + + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_pending_tasks_from_events(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = None + mock_slim.return_value = None + mock_events.return_value = [ + {"date": "2026-03-29", "summary": "Write tests", "category": "task"}, + {"date": "2026-03-29", "summary": "Fixed bug", "category": "agent_action"}, + ] + + ctx = load_user_context("user1") + + assert "Write tests" in ctx["pending_tasks"] + assert "Fixed bug" not in ctx["pending_tasks"] + + @patch("utils.firebase_context.get_recent_events") + @patch("utils.firebase_context.get_slim_profile") + @patch("utils.firebase_context.get_rich_profile") + def test_empty_events(self, mock_rich, mock_slim, mock_events) -> None: + mock_rich.return_value = None + mock_slim.return_value = None + mock_events.return_value = [] + + ctx = load_user_context("user1") + + assert ctx["session_log"] == "No recent activity." + assert ctx["pending_tasks"] == "No pending tasks." diff --git a/tests/test_workflow_executor.py b/tests/test_workflow_executor.py new file mode 100644 index 0000000..20c681a --- /dev/null +++ b/tests/test_workflow_executor.py @@ -0,0 +1,432 @@ +"""Tests for multi-step workflow executor and supporting changes.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Token retrieval tests +# --------------------------------------------------------------------------- + +class TestGetAccessTokenForUid: + def test_returns_token_for_matching_uid(self, tmp_path): + from src.auth.token_store import get_access_token_for_uid, _file_save, _FILE_STORE_PATH + + store = { + "session_abc": { + "google_access_token": "tok_123", + "email": "a@b.com", + "name": "Alice", + "uid": "uid_alice", + }, + } + with patch("src.auth.token_store._file_load", return_value=store): + assert get_access_token_for_uid("uid_alice") == "tok_123" + + def test_returns_none_for_unknown_uid(self): + store = { + "session_abc": { + "google_access_token": "tok_123", + "email": "a@b.com", + "name": "Alice", + "uid": "uid_alice", + }, + } + with patch("src.auth.token_store._file_load", return_value=store): + from src.auth.token_store import get_access_token_for_uid + assert get_access_token_for_uid("uid_unknown") is None + + def test_returns_none_for_empty_uid(self): + from src.auth.token_store import get_access_token_for_uid + assert get_access_token_for_uid("") is None + + def test_returns_none_for_empty_store(self): + with patch("src.auth.token_store._file_load", return_value={}): + from src.auth.token_store import get_access_token_for_uid + assert get_access_token_for_uid("uid_alice") is None + + def test_skips_sessions_without_token(self): + store = { + "session_1": { + "google_access_token": "", + "email": "a@b.com", + "name": "Alice", + "uid": "uid_alice", + }, + "session_2": { + "google_access_token": "tok_456", + "email": "a@b.com", + "name": "Alice", + "uid": "uid_alice", + }, + } + with patch("src.auth.token_store._file_load", return_value=store): + from src.auth.token_store import get_access_token_for_uid + assert get_access_token_for_uid("uid_alice") == "tok_456" + + +# --------------------------------------------------------------------------- +# Workflow tool filtering tests +# --------------------------------------------------------------------------- + +class TestWorkflowToolFiltering: + def test_denied_tools_excluded(self): + from agents.automation_executor import _get_workflow_tools, DENIED_TOOLS + + tools = _get_workflow_tools() + tool_names = {t["name"] for t in tools} + + for denied in DENIED_TOOLS: + assert denied not in tool_names + + def test_allowed_tools_present(self): + from agents.automation_executor import _get_workflow_tools + + tools = _get_workflow_tools() + tool_names = {t["name"] for t in tools} + + assert "send_email" in tool_names + assert "read_emails" in tool_names + assert "list_events" in tool_names + assert "search_web" in tool_names + + def test_denied_set_contains_automation_tools(self): + from agents.automation_executor import DENIED_TOOLS + + assert "create_automation" in DENIED_TOOLS + assert "manage_automations" in DENIED_TOOLS + assert "list_automations" in DENIED_TOOLS + + def test_denied_set_contains_draft_email(self): + from agents.automation_executor import DENIED_TOOLS + assert "draft_email" in DENIED_TOOLS + + +# --------------------------------------------------------------------------- +# Workflow executor tests +# --------------------------------------------------------------------------- + +class TestExecuteAutomationWorkflow: + SAMPLE_SPEC = { + "name": "Weekly digest", + "action": { + "body_mode": "workflow", + "workflow_instruction": "Search emails, summarize, send to boss", + }, + } + + SAMPLE_CONTEXT = { + "style_profile": "Tone: casual", + "session_log": "Did stuff", + "pending_tasks": "None", + } + + @pytest.mark.asyncio + async def test_missing_workflow_instruction_returns_error(self): + from agents.automation_executor import execute_automation_workflow + + spec = { + "name": "Bad spec", + "action": {"body_mode": "workflow"}, + } + result = await execute_automation_workflow("uid1", "auto_1", spec, self.SAMPLE_CONTEXT) + + assert result["status"] == "error" + assert "workflow_instruction" in result["error"] + assert result["actions_taken"] == [] + + @pytest.mark.asyncio + @patch("agents.automation_executor.dispatch_tool", new_callable=AsyncMock) + @patch("src.auth.token_store.get_access_token_for_uid", return_value="tok_abc") + async def test_executes_tool_use_loop(self, mock_token, mock_dispatch): + from agents.automation_executor import execute_automation_workflow + + # Simulate: turn 1 = tool_use, turn 2 = end_turn + mock_tool_block = MagicMock() + mock_tool_block.type = "tool_use" + mock_tool_block.id = "tu_1" + mock_tool_block.name = "read_emails" + mock_tool_block.input = {"query": "from:boss"} + + mock_text_block = MagicMock() + mock_text_block.type = "text" + mock_text_block.text = "Done! I searched your emails." + + response_1 = MagicMock() + response_1.content = [mock_tool_block] + response_1.stop_reason = "tool_use" + + response_2 = MagicMock() + response_2.content = [mock_text_block] + response_2.stop_reason = "end_turn" + + mock_dispatch.return_value = "Found 3 emails" + + with patch("agents.automation_executor.anthropic.AsyncAnthropic") as MockClient: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(side_effect=[response_1, response_2]) + MockClient.return_value = mock_client + + result = await execute_automation_workflow( + "uid1", "auto_1", self.SAMPLE_SPEC, self.SAMPLE_CONTEXT, + ) + + assert result["status"] == "success" + assert len(result["actions_taken"]) == 1 + assert result["actions_taken"][0]["tool"] == "read_emails" + assert "searched your emails" in result["summary"] + mock_dispatch.assert_called_once() + + @pytest.mark.asyncio + @patch("src.auth.token_store.get_access_token_for_uid", return_value=None) + async def test_continues_without_access_token(self, mock_token): + """Workflow should still attempt execution even without a Google token.""" + from agents.automation_executor import execute_automation_workflow + + mock_text = MagicMock() + mock_text.type = "text" + mock_text.text = "No tools needed for this." + + response = MagicMock() + response.content = [mock_text] + response.stop_reason = "end_turn" + + with patch("agents.automation_executor.anthropic.AsyncAnthropic") as MockClient: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=response) + MockClient.return_value = mock_client + + result = await execute_automation_workflow( + "uid1", "auto_1", self.SAMPLE_SPEC, self.SAMPLE_CONTEXT, + ) + + assert result["status"] == "completed" + assert result["error"] is None + + @pytest.mark.asyncio + @patch("src.auth.token_store.get_access_token_for_uid", return_value="tok") + async def test_api_error_returns_error_result(self, mock_token): + from agents.automation_executor import execute_automation_workflow + import anthropic + + with patch("agents.automation_executor.anthropic.AsyncAnthropic") as MockClient: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock( + side_effect=anthropic.APIError( + message="Rate limited", + request=MagicMock(), + body=None, + ), + ) + MockClient.return_value = mock_client + + result = await execute_automation_workflow( + "uid1", "auto_1", self.SAMPLE_SPEC, self.SAMPLE_CONTEXT, + ) + + assert result["status"] == "error" + assert "API error" in result["summary"] + + @pytest.mark.asyncio + @patch("agents.automation_executor.dispatch_tool", new_callable=AsyncMock) + @patch("src.auth.token_store.get_access_token_for_uid", return_value="tok") + async def test_tool_failure_captured_in_actions(self, mock_token, mock_dispatch): + from agents.automation_executor import execute_automation_workflow + + mock_dispatch.side_effect = Exception("Gmail API down") + + mock_tool_block = MagicMock() + mock_tool_block.type = "tool_use" + mock_tool_block.id = "tu_1" + mock_tool_block.name = "send_email" + mock_tool_block.input = {"to": "x@y.com", "subject": "Hi", "body": "Hello"} + + mock_text = MagicMock() + mock_text.type = "text" + mock_text.text = "Failed to send." + + response_1 = MagicMock() + response_1.content = [mock_tool_block] + response_1.stop_reason = "tool_use" + + response_2 = MagicMock() + response_2.content = [mock_text] + response_2.stop_reason = "end_turn" + + with patch("agents.automation_executor.anthropic.AsyncAnthropic") as MockClient: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(side_effect=[response_1, response_2]) + MockClient.return_value = mock_client + + result = await execute_automation_workflow( + "uid1", "auto_1", self.SAMPLE_SPEC, self.SAMPLE_CONTEXT, + ) + + assert result["status"] == "partial" + assert len(result["actions_taken"]) == 1 + assert result["actions_taken"][0]["tool"] == "send_email" + + +# --------------------------------------------------------------------------- +# execute_automation routing tests +# --------------------------------------------------------------------------- + +class TestExecuteAutomationRouting: + @pytest.mark.asyncio + @patch("agents.automation_executor.execute_automation_workflow", new_callable=AsyncMock) + @patch("agents.automation_executor.get_automation") + async def test_workflow_body_mode_routes_to_workflow(self, mock_get, mock_workflow): + from agents.automation_executor import execute_automation + + mock_get.return_value = { + "enabled": True, + "name": "Multi-step task", + "action": { + "body_mode": "workflow", + "workflow_instruction": "Do things", + }, + } + mock_workflow.return_value = { + "status": "success", + "summary": "Done", + "error": None, + "actions_taken": [], + } + + with patch("agents.automation_executor.update_run_result"): + result = await execute_automation("uid1", "auto_1", {}) + + mock_workflow.assert_called_once() + assert result["status"] == "success" + + @pytest.mark.asyncio + @patch("agents.automation_executor._execute_with_mcp", new_callable=AsyncMock) + @patch("agents.automation_executor._generate_body_if_needed", new_callable=AsyncMock) + @patch("agents.automation_executor.get_automation") + async def test_generate_body_mode_skips_workflow(self, mock_get, mock_gen, mock_mcp): + from agents.automation_executor import execute_automation + + action = { + "body_mode": "generate", + "generation_prompt": "Write standup", + "tool": "gmail", + "function": "send_email", + "params": {}, + } + mock_get.return_value = { + "enabled": True, + "name": "Standup", + "action": action, + } + mock_gen.return_value = action + mock_mcp.return_value = {"status": "success", "summary": "Sent", "error": None} + + with patch("agents.automation_executor.update_run_result"): + result = await execute_automation("uid1", "auto_1", {}) + + mock_mcp.assert_called_once() + # execute_automation_workflow should NOT have been called + + +# --------------------------------------------------------------------------- +# Parser workflow validation tests +# --------------------------------------------------------------------------- + +class TestParserWorkflowValidation: + def test_workflow_body_mode_accepted(self): + from agents.automation_parser import _validate_spec + + spec = { + "name": "Multi-step", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": { + "function": "workflow", + "body_mode": "workflow", + "workflow_instruction": "Search emails, summarize, send", + }, + "confirmation_message": "Got it!", + } + is_valid, issues = _validate_spec(spec) + assert is_valid, f"Expected valid but got issues: {issues}" + + def test_workflow_without_instruction_invalid(self): + from agents.automation_parser import _validate_spec + + spec = { + "name": "Multi-step", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": { + "function": "workflow", + "body_mode": "workflow", + }, + "confirmation_message": "Got it!", + } + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("workflow_instruction" in i for i in issues) + + def test_generate_still_valid(self): + from agents.automation_parser import _validate_spec + + spec = { + "name": "Standup", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": { + "function": "send_email", + "body_mode": "generate", + "generation_prompt": "Write standup", + }, + "confirmation_message": "Got it!", + } + is_valid, issues = _validate_spec(spec) + assert is_valid + + def test_invalid_body_mode_rejected(self): + from agents.automation_parser import _validate_spec + + spec = { + "name": "Bad", + "trigger": {"type": "schedule", "cron": "0 9 * * 1"}, + "action": { + "function": "send_email", + "body_mode": "invalid_mode", + }, + "confirmation_message": "Got it!", + } + is_valid, issues = _validate_spec(spec) + assert not is_valid + assert any("body_mode" in i for i in issues) + + +# --------------------------------------------------------------------------- +# Repository actions_taken tests +# --------------------------------------------------------------------------- + +class TestUpdateRunResultActionsTaken: + @patch("src.db.automation_repository.get_db") + def test_actions_taken_included_in_run_entry(self, mock_get_db): + """Verify actions_taken is passed through to run_entry when provided.""" + # We can't easily test the transactional write, but we can verify + # the function accepts the new parameter without error + mock_get_db.return_value = None # Firestore unavailable → early return + + from src.db.automation_repository import update_run_result + + # Should not raise + update_run_result( + user_id="uid1", + auto_id="auto_1", + status="success", + actions_taken=[{"tool": "read_emails", "input_summary": "query:boss"}], + ) + + @patch("src.db.automation_repository.get_db") + def test_actions_taken_defaults_to_none(self, mock_get_db): + mock_get_db.return_value = None + + from src.db.automation_repository import update_run_result + + # Should not raise — backwards compatible + update_run_result(user_id="uid1", auto_id="auto_1", status="success") diff --git a/utils/automation_store.py b/utils/automation_store.py new file mode 100644 index 0000000..3bd3397 --- /dev/null +++ b/utils/automation_store.py @@ -0,0 +1,27 @@ +"""Automation store — re-exports from src.db.automation_repository. + +Convenience module so callers can use: + from utils.automation_store import save_automation +""" + +from src.db.automation_repository import ( # noqa: F401 + delete_automation, + find_by_name, + get_all_automations, + get_automation, + save_automation, + toggle_automation, + update_automation, + update_run_result, +) + +__all__ = [ + "save_automation", + "get_automation", + "get_all_automations", + "find_by_name", + "toggle_automation", + "update_automation", + "delete_automation", + "update_run_result", +] diff --git a/utils/firebase_context.py b/utils/firebase_context.py new file mode 100644 index 0000000..4021685 --- /dev/null +++ b/utils/firebase_context.py @@ -0,0 +1,97 @@ +"""Load user context from Firestore for automation execution. + +Builds the user_context dict expected by the executor and content generator: + - style_profile: markdown string from RichProfile voice/identity data + - session_log: recent episodic events as text + - pending_tasks: extracted from episodic events with category "task" +""" + +import logging +from typing import Any + +from src.db.profile_repository import get_rich_profile, get_slim_profile +from src.db.episodic_repository import get_recent_events + +log = logging.getLogger("second-self") + + +def load_user_context(user_id: str) -> dict[str, Any]: + """Load fresh user context from Firestore. + + Returns dict with keys: style_profile, session_log, pending_tasks. + All values default to descriptive empty strings if data is unavailable. + """ + style_profile = _build_style_profile(user_id) + session_log, pending_tasks = _build_activity_context(user_id) + + return { + "style_profile": style_profile, + "session_log": session_log, + "pending_tasks": pending_tasks, + } + + +def _build_style_profile(user_id: str) -> str: + """Extract style profile text from RichProfile or slim fallback.""" + rich = get_rich_profile(user_id) + if rich: + sections: list[str] = [] + if rich.identity_md: + sections.append(rich.identity_md) + + v = rich.voice_raw + if v: + sections.append(f"Tone: {v.get('tone_descriptor', 'unknown')}") + sections.append(f"Avg sentence length: {v.get('avg_sentence_length', 'N/A')} words") + vocab = v.get("vocabulary_markers", [])[:10] + if vocab: + sections.append(f"Vocabulary: {', '.join(vocab)}") + sections.append(f"Emoji usage: {v.get('emoji_frequency', 0)} per email") + sections.append(f"Question tendency: {v.get('question_ratio', 0)}%") + + voice = rich.voice + sections.append(f"Openers: {voice.opens_with}") + sections.append(f"Sign-offs: {voice.closes_with}") + sections.append(f"Formality: {voice.formality}") + + return "\n".join(sections) + + slim = get_slim_profile(user_id) + if slim: + return ( + f"Name: {slim.identity.name}\n" + f"Tone: {slim.voice.tone}\n" + f"Formality: {slim.voice.formality}\n" + f"Openers: {slim.voice.opens_with}\n" + f"Sign-offs: {slim.voice.closes_with}" + ) + + return "No style profile available." + + +def _build_activity_context(user_id: str) -> tuple[str, str]: + """Build session_log and pending_tasks from episodic events. + + Returns (session_log, pending_tasks) as strings. + """ + events = get_recent_events(user_id, n=30) + if not events: + return ("No recent activity.", "No pending tasks.") + + log_lines: list[str] = [] + task_lines: list[str] = [] + + for event in reversed(events): # chronological order + date = event.get("date", "") + summary = event.get("summary", "") + category = event.get("category", "") + + log_lines.append(f"{date} — {summary}") + + if category in ("task", "pending", "todo"): + task_lines.append(f"- {summary}") + + session_log = "\n".join(log_lines) if log_lines else "No recent activity." + pending_tasks = "\n".join(task_lines) if task_lines else "No pending tasks." + + return (session_log, pending_tasks)