diff --git a/CLAUDE.md b/CLAUDE.md index fafa4fe..19c220f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,9 +3,10 @@ ## What this is Multi-agent workplace automation: email triage, meeting management, task -prioritization, follow-up tracking, wellness monitoring, and reporting — six -ReAct-pattern agents orchestrated through LangGraph, with vector memory, -a governance layer, and cost tracking. +prioritization, follow-up tracking, wellness monitoring, and reporting — +six specialized subagents orchestrated by a single `deepagents`-based agent +(built on LangGraph), with vector memory, a governance layer, and cost +tracking. The governance + cost-tracking layer is the differentiator. Most multi-agent demos skip exactly that part. @@ -15,9 +16,10 @@ Mail and calendar are **live Microsoft Graph data**, not mock JSON. See ## Stack -Python · LangGraph 1.x (Redis-backed checkpointing) · Microsoft Graph (mail, -calendar) · Generic OpenAI-API-compatible LLM gateway · ChromaDB (vector -memory) · FastAPI · Next.js +Python · LangGraph 1.x + `deepagents` (Redis-backed checkpointing) · +Microsoft Graph (mail, calendar) · Generic OpenAI-API-compatible LLM gateway +(bridged to `deepagents` via `langchain-openai`) · ChromaDB (vector memory) · +FastAPI · Next.js `streamlit` and `nicegui` were pinned in requirements.txt as a supposed "second UI stack" but were never imported anywhere in the codebase -- @@ -25,16 +27,29 @@ removed. Next.js (`frontend/`, 9 pages) is the only real UI and is required. ## Layout -- `agents/` — the six specialized agents (11 modules) -- `orchestration/` — LangGraph workflows and routing (12 modules, including - `checkpointer.py`, the shared Redis checkpointer factory) +- `agents/` — the six domain agents (`email_agent.py`, `meeting_agent.py`, + `tasks_agent.py`, `followup_agent.py`, `wellness_agent.py`, + `reporting_agent.py` — single-shot LLM-call classes used directly by + simpler `backend/routes_ai.py` endpoints), plus `tools.py` (the tool + registry + `ToolExecutor` the deep agent's tools wrap), `llm_model.py` + (the `SETTINGS["llm"]` -> `BaseChatModel` bridge), `autonomous_inbox.py` + (the background email-processing loop), `smart_chat.py` (the chat + adapter), `schemas.py`, `prompts.py` +- `orchestration/` — `deep_agent.py` (the `deepagents`-based agent: six + subagents, `interrupt_on` approval config, memory write-back tool), + `checkpointer.py` (the shared Redis checkpointer factory), + `chat_workflow.py` (multi-turn conversation layer on top of the deep + agent), `proactive_scheduler.py` (scheduled briefings/wellness/deadline + checks) - `backend/` — FastAPI server and routes (21 modules) -- `frontend/` — Next.js dashboard. **Has its own static mock data** - (`frontend/public/data/*.json`) and its own "uses mock data as fallback" - behavior, untouched by this pass — see "What's still open" below. -- `memory/` — vector store + episodic memory +- `frontend/` — Next.js dashboard, backed entirely by real backend data (no + static mock data or fallback JSON remains) +- `memory/` — vector store + episodic memory. Recall is exposed to agents + via tools; write-back happens through the deep agent's `record_episode` + tool (email/meeting subagents) - `governance/` — policy enforcement, audit logging, cost management, the LLM - gateway + gateway, the human-in-the-loop approval queue (now driven by LangGraph's + `interrupt()` via `deepagents`' `interrupt_on`, not ad hoc flags) - `repos/` — `data_repo.py` (mail/calendar via Graph + Opspilot's own generated-state JSON), `graph_auth.py`, `graph_client.py` - `data/` — Opspilot's own generated state (tasks, follow-ups, reports, @@ -47,9 +62,10 @@ removed. Next.js (`frontend/`, 9 pages) is the only real UI and is required. only you can do this) - `scripts/graph_login.py` — run this once to complete the Graph device-code login -- `ARCHITECTURE.md`, `CODEBASE_INDEX.md`, `super_graph.md` — good existing docs; - read `ARCHITECTURE.md` first -- `print_graph.py` — renders the LangGraph topology +- `ARCHITECTURE.md`, `CODEBASE_INDEX.md` — existing docs; read + `ARCHITECTURE.md` first (predates the deepagents migration, so its + orchestration-layer description is stale) +- `print_graph.py` — renders the deep agent's graph topology ## Running @@ -199,18 +215,55 @@ themselves when no Redis is reachable on 6379). `.github/workflows/ci.yml` runs the suite on every push/PR — no live Graph or Redis credentials needed; Graph failure paths are tested via stubs, not live calls. +## ✅ deepagents migration — done + +The premise going in ("six hand-rolled ReAct agents to swap for deepagents") +didn't match reality: there wasn't one ReAct pattern, there were three +parallel execution styles (six single-shot LLM-call classes, a barely-used +ReAct loop that degraded to a hardcoded heuristic, and the real production +path -- a hand-rolled LangGraph state machine), plus real duplication (two +`EmailAgent` classes, two independent "process an email" pipelines, +`super_graph.py`'s email/meeting routing was stubbed and never actually +called those subgraphs) and three inconsistent definitions of "does this +action need approval." Delivered as three PRs: + +1. **Dead code removal**: `agents/react_agent.py`, `orchestration/ + email_graph.py`, `orchestration/meeting_graph.py`, the unused + `ApprovalPolicy` class, a broken dead convenience wrapper -- all deleted, + not migrated. +2. **Introduce deepagents, additively**: `agents/llm_model.py` bridges + `SETTINGS["llm"]` to the `BaseChatModel` `deepagents` needs via + `langchain-openai`'s `ChatOpenAI` (works against any OpenAI-compatible + `base_url`). `orchestration/deep_agent.py` wraps `create_deep_agent()` + with six subagents, tools generically wrapped from the existing + `agents/tools.py` registry (delegating to the same `ToolExecutor`, not + reimplemented), and `interrupt_on` as the single reconciled approval + source of truth. +3. **Cutover**: chat (`orchestration/chat_workflow.py`), autonomous email + processing (`agents/autonomous_inbox.py`), `/agent/process-email` + (replacing `backend/worker.py`'s separate naive pipeline, now deleted), + and governance approve/reject (`backend/routes_governance.py`, which now + resumes the paused agent thread via `Command(resume=...)` instead of + re-implementing the write) all point at the new agent. The previously + dead-end memory recall (`recall_memory_context` was never wired into the + old compiled graph, and nothing ever called its write-back methods) is + closed via a `record_episode` tool on the email/meeting subagents. + `orchestration/super_graph.py`, `autonomous_graph.py`, the four other + subgraph files, and `common_state.py` (confirmed unused -- every subgraph + rolled its own local state instead) were deleted once the new path was + verified. + +`ApprovalQueue`'s pending-actions storage/audit trail (`governance/ +approval.py`) is unchanged and still the human-facing "what's pending" list +-- only the enforcement mechanism moved from ad hoc flags to LangGraph's +real `interrupt()`. + ## What's still open -- **`SmartChatAgent` is unimplemented.** Building it is a real feature-design - task (no spec exists), not something to reverse-engineer. -- **The frontend has its own static mock data** - (`frontend/public/data/*.json`) and its own "mock data as fallback" comment - in `frontend/.env.example` — untouched by this pass. Fixing that is Next.js/ - TypeScript work, a separate chunk from the backend changes here. -- **deepagents migration** — discussed, not started. The six hand-rolled - ReAct agents (`agents/react_agent.py`) are a strong candidate to replace - with deepagents' subagent harness, now that the underlying LangGraph/Redis/ - Graph-API/LLM-gateway foundation is solid. That's the natural next phase. +Nothing from the original punch list. `SmartChatAgent`, the frontend mock +data, and the deepagents migration are all done; LICENSE and "two UI stacks" +were resolved (see below). If you're picking this back up, look for newer +gaps rather than assuming this list is current. Resolved, not open: LICENSE already exists (MIT, root of repo) -- the earlier note that it was missing was wrong. "Two UI stacks" resolved to one real @@ -219,6 +272,10 @@ stack (Next.js) plus two unused pip packages, not a decision to make. ## If you're scaling this further The foundation (deps, real data, gateway, checkpointing, boot verification, -tests, CI) is done. Order from here: deepagents migration for the six agents → -`SmartChatAgent` (if the conversational feature is still wanted) → drop the -second UI → frontend off its own static mock data → LICENSE. +tests, CI, the deepagents-based agent, real approval enforcement) is done. +Good next candidates: give the deep agent a `response_format`/structured +output where callers need one (the chat layer currently reports `None`/`[]` +for confidence/reasoning-trace rather than fabricating them, since +`deepagents` doesn't expose those natively); async-native Graph calls +instead of the `_run_sync()` thread-hop bridge; multi-user/multi-tenant +memory scoping if this ever serves more than one mailbox at a time. diff --git a/agents/autonomous_inbox.py b/agents/autonomous_inbox.py index 260ee99..e2800c8 100644 --- a/agents/autonomous_inbox.py +++ b/agents/autonomous_inbox.py @@ -19,7 +19,7 @@ from repos.data_repo import DataRepo from governance.approval import get_approval_queue -from orchestration.autonomous_graph import process_email_with_graph +from orchestration.deep_agent import create_opspilot_agent # ============================================================ @@ -208,7 +208,7 @@ def _process_single_email(self, email: Dict[str, Any]) -> None: )) try: - self._process_with_langgraph(email) + self._process_with_deep_agent(email) self.state.processed_count += 1 @@ -230,87 +230,63 @@ def _process_single_email(self, email: Dict[str, Any]) -> None: self.state.current_email_id = None - def _process_with_langgraph(self, email: Dict[str, Any]) -> None: - """Process email using LangGraph workflow""" + def _process_with_deep_agent(self, email: Dict[str, Any]) -> None: + """Process email using the deepagents-based agent (replaces the + hand-rolled classify/gather/plan/execute/check LangGraph loop in + orchestration/autonomous_graph.py -- deepagents' own agent loop + already does gather -> act -> observe internally, and interrupt_on + replaces that graph's ad hoc pending_approvals state).""" email_id = email.get("email_id") - - # Run the graph - final_state = None - for event in process_email_with_graph(email, self.user_email): - # Each event is a dict with node name as key - for node_name, node_state in event.items(): - thought = node_state.get("current_thought", "") - status = node_state.get("status", "") - - # Map to event types - if node_name == "classify": - event_type = "thinking" - elif node_name == "gather_context": - event_type = "action" - elif node_name == "plan_actions": - event_type = "thinking" - elif node_name == "execute_action": - event_type = "action" - else: - event_type = "observation" - - if thought: - self._emit_event(AgentEvent( - event_type=event_type, - email_id=email_id, - content=thought, - metadata={ - "node": node_name, - "status": status, - "iteration": node_state.get("iteration", 0) - } - )) - - # Check for pending approvals - pending = node_state.get("pending_approvals", []) - for pa in pending: - if pa.get("status") == "pending": - # Queue for approval - approval_queue = get_approval_queue() - approval_queue.add_pending_action( - action_type=pa.get("action_type"), - payload=pa.get("payload", {}), - reason=pa.get("reason", "Agent recommended action"), - source_email_id=email_id, - agent_reasoning=pa.get("description", "") - ) - - self._emit_event(AgentEvent( - event_type="approval_needed", - email_id=email_id, - content=f"⏸️ Action queued for approval: {pa.get('action_type')}", - metadata={"action": pa} - )) - - final_state = node_state - - # Mark email as processed - if final_state: - executed = final_state.get("executed_actions", []) - category = final_state.get("email_analysis", {}).get("category", "unknown") - - self.repo.mark_email_processed( - email_id, - actions_taken=executed, - category=category - ) - + thread_id = f"autonomous_email_{email_id}" + + agent = create_opspilot_agent(repo=self.repo, user_email=self.user_email, gateway=self.gateway) + config = {"configurable": {"thread_id": thread_id}} + instruction = ( + f"Use the email subagent to process email {email_id} " + f"(subject: {email.get('subject', '')!r}, from: {email.get('from_email', '')!r}). " + "Read it, take whatever action (if any) is warranted, and mark it " + "processed when you're done." + ) + result = agent.invoke( + {"messages": [{"role": "user", "content": instruction}]}, + config=config, + version="v2", + ) + + for message in result.value.get("messages", []): + content = getattr(message, "content", "") or "" + if content: + self._emit_event(AgentEvent( + event_type="observation", + email_id=email_id, + content=content, + )) + + for interrupt in result.interrupts or []: + for action in interrupt.value.get("action_requests", []): + approval_queue = get_approval_queue() + approval_queue.add_pending_action( + action_type=action.get("name", ""), + payload=action.get("args", {}), + reason="Agent recommended action while processing this email", + source_email_id=email_id, + agent_reasoning="", + session_id=thread_id, + ) + self._emit_event(AgentEvent( + event_type="approval_needed", + email_id=email_id, + content=f"⏸️ Action queued for approval: {action.get('name')}", + metadata={"action": action}, + )) + + if not result.interrupts: self._emit_event(AgentEvent( event_type="completed", email_id=email_id, - content=final_state.get("final_summary", "Processing complete"), - metadata={ - "actions_executed": len(executed), - "category": category, - "pending_approvals": len(final_state.get("pending_approvals", [])) - } + content="Processing complete", )) - + def process_email_now(self, email_id: str) -> Generator[AgentEvent, None, None]: """ Process a specific email immediately (for manual triggering). @@ -337,26 +313,45 @@ def process_email_now(self, email_id: str) -> Generator[AgentEvent, None, None]: "subject": email.get("subject") } ) - - # Process with LangGraph - for event in process_email_with_graph(email, self.user_email): - for node_name, node_state in event.items(): - thought = node_state.get("current_thought", "") - if thought: - yield AgentEvent( - event_type="thinking" if "Think" in thought else "observation", - email_id=email_id, - content=thought, - metadata={"node": node_name} - ) - - # Mark processed - self.repo.mark_email_processed( - email_id, - actions_taken=["manual_processed"], - category="processed" + + thread_id = f"autonomous_email_{email_id}" + agent = create_opspilot_agent(repo=self.repo, user_email=self.user_email, gateway=self.gateway) + config = {"configurable": {"thread_id": thread_id}} + instruction = ( + f"Use the email subagent to process email {email_id} " + f"(subject: {email.get('subject', '')!r}, from: {email.get('from_email', '')!r}). " + "Read it, take whatever action (if any) is warranted, and mark it " + "processed when you're done." ) - + result = agent.invoke( + {"messages": [{"role": "user", "content": instruction}]}, + config=config, + version="v2", + ) + + for message in result.value.get("messages", []): + content = getattr(message, "content", "") or "" + if content: + yield AgentEvent(event_type="observation", email_id=email_id, content=content) + + for interrupt in result.interrupts or []: + for action in interrupt.value.get("action_requests", []): + approval_queue = get_approval_queue() + approval_queue.add_pending_action( + action_type=action.get("name", ""), + payload=action.get("args", {}), + reason="Agent recommended action while processing this email", + source_email_id=email_id, + agent_reasoning="", + session_id=thread_id, + ) + yield AgentEvent( + event_type="approval_needed", + email_id=email_id, + content=f"⏸️ Action queued for approval: {action.get('name')}", + metadata={"action": action}, + ) + yield AgentEvent( event_type="completed", email_id=email_id, diff --git a/backend/routes_agent.py b/backend/routes_agent.py index f657f20..5e6126d 100644 --- a/backend/routes_agent.py +++ b/backend/routes_agent.py @@ -1,8 +1,6 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends from typing import Dict, Any, List from backend.models import ProcessResponse, AgentEvent -import anyio -from backend import worker from backend.auth import get_api_key router = APIRouter() @@ -14,6 +12,16 @@ except Exception: AGENTS_AVAILABLE = False + +def _run_agent_processing(email_id: str) -> None: + """Drains the real agentic pipeline's event generator (the events land + in the processor's own state, polled via GET /agent/events) -- replaces + backend.worker's naive summarizer, which never classified, gathered + context, or took any real action. See + https://github.com/kowshikdev/Opspilot/issues/15.""" + for _event in process_email_immediately(email_id): + pass + @router.get('/agent/status') async def agent_status() -> Dict[str, Any]: if not AGENTS_AVAILABLE: @@ -43,10 +51,10 @@ async def process_email(payload: Dict[str, str], background_tasks: BackgroundTas email_id = payload.get('email_id') if not email_id: raise HTTPException(status_code=400, detail='email_id required') - # If native agents are available, they may still be used, but we schedule our generic worker - # Schedule background processing (non-blocking) - background_tasks.add_task(worker.schedule_email_processing, email_id) - return ProcessResponse(task_id=email_id, status='processing', summary='Scheduled for background processing') + if not AGENTS_AVAILABLE: + raise HTTPException(status_code=500, detail='Agent modules not available') + background_tasks.add_task(_run_agent_processing, email_id) + return ProcessResponse(task_id=email_id, status='processing', summary='Scheduled for processing by the autonomous agent') # Simple events polling endpoint (frontend can poll recent events) @router.get('/agent/events', response_model=List[AgentEvent]) diff --git a/backend/routes_governance.py b/backend/routes_governance.py index 848012a..1cdb02c 100644 --- a/backend/routes_governance.py +++ b/backend/routes_governance.py @@ -2,12 +2,26 @@ from typing import List, Dict, Any import json +from langgraph.types import Command + from config.settings import SETTINGS from governance.approval import get_approval_queue +from orchestration.deep_agent import create_opspilot_agent router = APIRouter() +def _resume_agent_thread(thread_id: str, decision: Dict[str, Any]): + """Resume a deep-agent thread paused by `interrupt_on` (see + orchestration/deep_agent.py) with a human decision -- this is what + actually executes (on approve) or skips (on reject) the tool call the + agent proposed. Real enforcement via LangGraph's interrupt(), not a flag + a tool implementation could ignore.""" + agent = create_opspilot_agent() + config = {"configurable": {"thread_id": thread_id}} + return agent.invoke(Command(resume={"decisions": [decision]}), config=config, version="v2") + + def _read_json_list(path) -> List[Dict[str, Any]]: if not path.exists(): return [] @@ -49,10 +63,26 @@ async def list_pending_actions(status: str = "pending"): async def approve_pending_action(action_id: str, payload: dict = None): reviewed_by = (payload or {}).get("reviewed_by", "user") notes = (payload or {}).get("notes") - # approve_action executes the underlying action itself (create_task, - # schedule_meeting, etc. -- see ApprovalQueue._execute_action) and - # returns the updated record with the execution result embedded. - action = get_approval_queue().approve_action(action_id, reviewed_by=reviewed_by, notes=notes) + queue = get_approval_queue() + pending = queue.get_action_by_id(action_id) + if not pending or pending.get("status") != "pending": + raise HTTPException( + status_code=404, + detail="Pending action not found, or it was already reviewed", + ) + + thread_id = pending.get("session_id") + if thread_id: + # Raised via a deep agent's interrupt_on -- resuming the thread is + # what actually executes the tool call. + _resume_agent_thread(thread_id, {"type": "approve"}) + action = queue.mark_executed(action_id, reviewed_by=reviewed_by, notes=notes) + else: + # Legacy pending action with no associated agent thread (e.g. + # created directly via the API rather than by an agent run) -- + # approve_action's own _execute_action path still applies. + action = queue.approve_action(action_id, reviewed_by=reviewed_by, notes=notes) + if not action: raise HTTPException( status_code=404, @@ -65,7 +95,22 @@ async def approve_pending_action(action_id: str, payload: dict = None): async def reject_pending_action(action_id: str, payload: dict = None): reviewed_by = (payload or {}).get("reviewed_by", "user") reason = (payload or {}).get("reason") - action = get_approval_queue().reject_action(action_id, reviewed_by=reviewed_by, reason=reason) + queue = get_approval_queue() + pending = queue.get_action_by_id(action_id) + if not pending or pending.get("status") != "pending": + raise HTTPException( + status_code=404, + detail="Pending action not found, or it was already reviewed", + ) + + thread_id = pending.get("session_id") + if thread_id: + _resume_agent_thread(thread_id, { + "type": "reject", + "message": reason or "User rejected this action. Do not retry it unless asked.", + }) + + action = queue.reject_action(action_id, reviewed_by=reviewed_by, reason=reason) if not action: raise HTTPException( status_code=404, diff --git a/backend/worker.py b/backend/worker.py deleted file mode 100644 index d364844..0000000 --- a/backend/worker.py +++ /dev/null @@ -1,43 +0,0 @@ -import anyio -import asyncio -from typing import Dict, Any -from backend.repo_adapter import get_email -from backend.events import push_event -from backend.embeddings import get_embedding - -# NOTE: this is an immediate best-effort ack, not the real agentic pipeline. -# There are two independent "process an email" pipelines in this codebase: -# this one (used by POST /agent/process-email) and -# agents.autonomous_inbox.AutonomousInboxProcessor -> orchestration.autonomous_graph -# (used by /agent/start + /autonomous/*), which actually classifies, gathers -# context, plans and executes tool actions, and queues approvals. See -# https://github.com/kowshikdev/Opspilot/issues/15 -- PR 3 of the deepagents -# migration points /agent/process-email at the real pipeline and this file's -# scope narrows to just the SSE progress-ping it already does. -async def process_email_background(email_id: str): - # Simple background processing: load email, emit start, provide a simple summary, emit complete - await push_event({"event_type": "processing_started", "email_id": email_id, "content": f"Processing {email_id}"}) - - # load email (repo_adapter.get_email expects event loop) - email = await get_email(email_id) - if not email: - await push_event({"event_type": "error", "email_id": email_id, "content": "Email not found"}) - return - - # quick summary using first 300 chars - body = email.get('body_text') or '' - summary = body[:300] - # Attempt to create an embedding (best-effort) - try: - emb = get_embedding(summary) - except Exception: - emb = None - - await push_event({"event_type": "summary", "email_id": email_id, "content": summary, "embedding_present": bool(emb)}) - - # mark complete - await push_event({"event_type": "processing_complete", "email_id": email_id, "content": "Processing completed"}) - -def schedule_email_processing(email_id: str): - # convenience wrapper to schedule from sync contexts - asyncio.create_task(process_email_background(email_id)) diff --git a/governance/approval.py b/governance/approval.py index dd797f7..dcb56b0 100644 --- a/governance/approval.py +++ b/governance/approval.py @@ -154,11 +154,49 @@ def approve_action( # Log to audit self._log_approval(action, "approved") - + return action - + return None - + + def mark_executed( + self, + action_id: str, + reviewed_by: str = "user", + notes: Optional[str] = None, + execution_result: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + """Record that a pending action was approved and executed elsewhere. + + For actions raised via a deep agent's `interrupt_on` (see + orchestration/deep_agent.py) -- these carry a `session_id` (the + LangGraph thread_id), and approving them means resuming that paused + thread via `Command(resume=...)`, which executes the tool itself. + Unlike `approve_action`, this does NOT call `_execute_action` -- + doing so would execute the same write a second time. + """ + actions = self._load() + + for action in actions: + if action.get("action_id") == action_id: + if action.get("status") != "pending": + return None + + action["status"] = "executed" + action["reviewed_utc"] = datetime.utcnow().isoformat() + action["reviewed_by"] = reviewed_by + action["review_notes"] = notes + action["execution_result"] = execution_result or {"success": True} + + self._cache = actions + self._save() + + self._log_approval(action, "approved") + + return action + + return None + def reject_action( self, action_id: str, diff --git a/orchestration/autonomous_graph.py b/orchestration/autonomous_graph.py deleted file mode 100644 index 1f487d7..0000000 --- a/orchestration/autonomous_graph.py +++ /dev/null @@ -1,818 +0,0 @@ -# orchestration/autonomous_graph.py -""" -LangGraph Orchestration for Autonomous Email Processing -======================================================= -Implements a TRUE agentic workflow using LangGraph with: -- Conditional routing (agent decides next step) -- Loops (agent can iterate until done) -- Human-in-the-loop (approval gates) -- State persistence across steps - -This is the core orchestration that makes the agent TRULY autonomous. -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Literal, Optional, Annotated -from datetime import datetime, timedelta -import json -import operator - -from langgraph.graph import StateGraph, END -from orchestration.checkpointer import get_checkpointer - -# Phase 1 imports - Memory & Enhanced Gateway -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeType, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway - -# Import our components -from agents.tools import TOOLS, ToolExecutor, get_approval_required_tools -from agents.schemas import AgentStatus, PendingAction - - -# ============================================================ -# STATE DEFINITION -# ============================================================ - -class EmailProcessingState(TypedDict): - """State for email processing workflow""" - # Input - email: Dict[str, Any] - user_email: str - - # Processing state - status: str # idle, classifying, gathering_context, planning, executing, awaiting_approval, completed - iteration: int - max_iterations: int - - # Agent reasoning - current_thought: str - reasoning_trace: Annotated[List[Dict[str, Any]], operator.add] # Append-only - - # Context gathered - email_analysis: Optional[Dict[str, Any]] - related_context: Dict[str, Any] - - # Phase 1: Memory context - relevant_memories: List[Dict[str, Any]] - past_similar_emails: List[Dict[str, Any]] - episode_id: Optional[str] - - # Actions - planned_actions: List[Dict[str, Any]] - executed_actions: Annotated[List[str], operator.add] # Append-only - pending_approvals: List[Dict[str, Any]] - - # Output - final_category: Optional[str] - final_summary: Optional[str] - - # Tool execution - current_tool: Optional[str] - current_tool_params: Optional[Dict[str, Any]] - tool_result: Optional[Dict[str, Any]] - - -# ============================================================ -# NODE FUNCTIONS -# ============================================================ - -def recall_memory_context(state: EmailProcessingState) -> EmailProcessingState: - """ - Pre-step: Recall memory context for email processing. - Analyzes email content and recalls past patterns. - """ - email = state["email"] - user_email = state["user_email"] - - # Phase 1: Check memory for similar emails - memory = AgentMemory("email_agent") - episodic = EpisodicMemory("email_agent") - - relevant_memories = [] - past_similar_emails = [] - - try: - # Recall past interactions with this sender - from_email = email.get("from_email", "") - sender_memories = memory.recall( - query=f"Past emails from {from_email} for user {user_email}", - n_results=3, - memory_type=MemoryType.INTERACTION - ) - relevant_memories.extend(sender_memories) - - # Find similar past episodes - similar_episodes = episodic.find_similar( - episode_type=EpisodeType.EMAIL_PROCESSING, - context_keys=["sender", "subject_keywords"], - n_results=2 - ) - past_similar_emails = similar_episodes - - except Exception as e: - # Non-critical, continue without memory - pass - - return { - **state, - "relevant_memories": relevant_memories, - "past_similar_emails": past_similar_emails - } - - -def classify_email(state: EmailProcessingState) -> EmailProcessingState: - """ - Node 1: Classify the incoming email - Analyzes email content to determine type and urgency - """ - email = state["email"] - - # Extract key signals - subject = email.get("subject", "").lower() - body = email.get("body_text", "").lower() - from_email = email.get("from_email", "") - - # Determine urgency - urgent_signals = ["urgent", "asap", "immediately", "critical", "blocker", "eod", "today"] - is_urgent = any(signal in subject or signal in body for signal in urgent_signals) - - # Determine if external - is_external = not from_email.endswith("@contoso.com") - - # Determine category - pre_category = email.get("actionability_gt", "unknown") - - # Classify based on content - if pre_category == "actionable" or is_urgent: - category = "actionable" - priority = "P0" if is_urgent else "P1" - elif pre_category == "informational": - category = "informational" - priority = "P3" - else: - # Heuristic classification with expanded signals - action_signals = [ - "can you", "could you", "please", "need", "require", "deadline", "by when", "review", - "sync up", "sync-up", "perspective", "thoughts", "feedback", "discuss", - "let me know", "get back to", "follow up", "following up", "waiting for", - "action item", "next step", "schedule", "meeting", "call", "availability", - "concerns", "issues", "blockers", "problems", "risks" - ] - # Check for bullet points or numbered lists (often indicate action items) - has_list = any(marker in body for marker in ["- ", "* ", "1.", "2.", "+ "]) - has_action = any(signal in body for signal in action_signals) or has_list - category = "actionable" if has_action else "informational" - priority = "P2" if has_action else "P3" - - analysis = { - "category": category, - "priority": priority, - "is_urgent": is_urgent, - "is_external": is_external, - "sender": from_email, - "subject": email.get("subject", ""), - "key_topics": _extract_topics(subject + " " + body), - "timestamp": datetime.utcnow().isoformat() - } - - thought = f""" -Email Classification Complete -================================ -Category: {category.upper()} -Priority: {priority} -Urgent: {'YES' if is_urgent else 'No'} -Sender: {from_email} ({'External' if is_external else 'Internal'}) -Topics: {', '.join(analysis['key_topics'][:3])} - -Next: {'Gathering context...' if category == 'actionable' else 'Minimal processing needed'} -""" - - return { - **state, - "status": "gathering_context" if category == "actionable" else "planning", - "email_analysis": analysis, - "current_thought": thought, - "reasoning_trace": [{ - "step": "classify", - "thought": thought, - "result": analysis, - "timestamp": datetime.utcnow().isoformat() - }], - "iteration": state.get("iteration", 0) + 1 - } - - -def gather_context(state: EmailProcessingState) -> EmailProcessingState: - """ - Node 2: Gather related context from emails, tasks, meetings - The agent searches for information to make better decisions - """ - email = state["email"] - analysis = state.get("email_analysis", {}) - topics = analysis.get("key_topics", []) - - # Initialize tool executor (in real impl, this would be passed in) - from repos.data_repo import DataRepo - repo = DataRepo() - executor = ToolExecutor(repo, None, state.get("user_email", "demo@awoa.local")) - - context = {"emails": [], "tasks": [], "meetings": []} - - # Search for related items using our tools - for topic in topics[:2]: # Limit to avoid too many searches - # Search related emails - email_result = executor.execute("search_emails", { - "query": topic, - "limit": 3 - }) - if email_result.get("success"): - context["emails"].extend(email_result.get("result", {}).get("emails", [])) - - # Search related tasks - task_result = executor.execute("search_tasks", { - "query": topic, - "limit": 3 - }) - if task_result.get("success"): - context["tasks"].extend(task_result.get("result", {}).get("tasks", [])) - - # Search related meetings - meeting_result = executor.execute("search_meetings", { - "query": topic, - "limit": 2 - }) - if meeting_result.get("success"): - context["meetings"].extend(meeting_result.get("result", {}).get("meetings", [])) - - # Deduplicate - context["emails"] = _dedupe_by_key(context["emails"], "email_id") - context["tasks"] = _dedupe_by_key(context["tasks"], "task_id") - context["meetings"] = _dedupe_by_key(context["meetings"], "meeting_id") - - thought = f""" -Context Gathering Complete -================================ -Found related items: -- Emails: {len(context['emails'])} related emails -- Tasks: {len(context['tasks'])} related tasks -- Meetings: {len(context['meetings'])} related meetings - -Topics searched: {', '.join(topics[:2])} -Next: Planning actions based on context... -""" - - return { - **state, - "status": "planning", - "related_context": context, - "current_thought": thought, - "reasoning_trace": [{ - "step": "gather_context", - "thought": thought, - "result": { - "emails_found": len(context["emails"]), - "tasks_found": len(context["tasks"]), - "meetings_found": len(context["meetings"]) - }, - "timestamp": datetime.utcnow().isoformat() - }] - } - - -def plan_actions(state: EmailProcessingState) -> EmailProcessingState: - """ - Node 3: Plan what actions to take based on email and context - This is where the agent DECIDES what to do - """ - email = state["email"] - analysis = state.get("email_analysis", {}) - context = state.get("related_context", {}) - - category = analysis.get("category", "informational") - priority = analysis.get("priority", "P3") - is_urgent = analysis.get("is_urgent", False) - - # Extract client/company from sender email - from_email = email.get("from_email", "unknown@unknown.com") - sender_name = email.get("sender_name", from_email.split("@")[0].replace(".", " ").title()) - domain = from_email.split("@")[-1].split(".")[0].title() if "@" in from_email else "Unknown" - - # Map known domains to client names - client_map = { - "Acmecorp": "Acme Corp", - "Techvision": "TechVision", - "Globaltech": "GlobalTech", - "Contoso": "Internal" - } - client = client_map.get(domain, domain) - - planned_actions = [] - - if category == "actionable": - # Create a simple, clear task - simple_subject = email.get('subject', 'Request')[:30] - - planned_actions.append({ - "action": "create_task", - "params": { - "title": f"Respond to {sender_name}" if len(simple_subject) > 25 else f"RE: {simple_subject}", - "description": f"Email from: {sender_name} ({from_email})\nClient: {client}\nReceived: {email.get('received_utc', '')[:10]}\n\n---\nSubject: {email.get('subject', '')}\n\nAction needed: Review and respond to this email.\n\nEmail preview:\n{email.get('body_text', '')[:300]}...", - "priority": priority, - "source_type": "email", - "source_ref_id": email.get("email_id"), - "tags": [client.lower().replace(" ", "-"), "email-response", "agent-created"] - }, - "requires_approval": False, - "reason": f"Email from {client} ({priority}) - auto-created task" - }) - - # Plan: Draft reply - auto-generate without approval - planned_actions.append({ - "action": "draft_email_reply", - "params": { - "email_id": email.get("email_id"), - "tone": "urgent" if is_urgent else "professional", - "key_points": [ - "Acknowledge receipt", - "Confirm understanding of request", - "Provide timeline for response" - ], - "include_context": True - }, - "requires_approval": False, - "reason": f"Auto-drafted reply to {sender_name}" - }) - - # Create follow-up for actionable emails based on priority - followup_days = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(priority, 2) - followup_severity = {"P0": "critical", "P1": "high", "P2": "medium", "P3": "low"}.get(priority, "medium") - followup_due = (datetime.utcnow() + timedelta(days=followup_days)).date().isoformat() - - planned_actions.append({ - "action": "create_followup", - "params": { - "entity_type": "email", - "entity_id": email.get("email_id"), - "reason": f"Follow up on {sender_name}'s request - {simple_subject}", - "due_date": followup_due, - "channel": "email", - "severity": followup_severity - }, - "requires_approval": False, - "reason": f"Reminder to respond to {client} by {followup_due}" - }) - - # If related tasks exist, link them too - if context.get("tasks"): - existing_task = context["tasks"][0] - planned_actions.append({ - "action": "create_followup", - "params": { - "entity_type": "task", - "entity_id": existing_task.get("task_id"), - "reason": f"New email from {sender_name} about related topic", - "due_date": followup_due, - "channel": "email", - "severity": followup_severity - }, - "requires_approval": False, - "reason": f"Link to related task: {existing_task.get('title', 'Unknown')[:30]}" - }) - - else: - # Even for informational emails, check if there are discussion points to track - body = email.get("body_text", "").lower() - has_discussion_points = any(marker in body for marker in ["- ", "* ", "1.", "2.", "+", "concerns", "areas", "points", "thoughts"]) - - if has_discussion_points: - # Create a low-priority task to review discussion points - planned_actions.append({ - "action": "create_task", - "params": { - "title": f"Review: {email.get('subject', 'Email')[:40]}", - "description": f"Email from: {sender_name} ({from_email})\nClient: {client}\nReceived: {email.get('received_utc', '')[:10]}\n\n---\nSubject: {email.get('subject', '')}\n\nContains discussion points to review.\n\nEmail preview:\n{email.get('body_text', '')[:300]}...", - "priority": "P3", - "source_type": "email", - "source_ref_id": email.get("email_id"), - "tags": [client.lower().replace(" ", "-"), "review", "agent-created"] - }, - "requires_approval": False, - "reason": f"Auto-created review task for {client} discussion" - }) - - # Also create a follow-up reminder - follow_up_date = (datetime.utcnow() + timedelta(days=2)).date().isoformat() - planned_actions.append({ - "action": "create_followup", - "params": { - "entity_type": "email", - "entity_id": email.get("email_id"), - "reason": f"Reply to {sender_name} ({client}) - discussion points need review", - "due_date": follow_up_date, - "channel": "email", - "severity": "low" - }, - "requires_approval": False, - "reason": f"Reminder to respond to {sender_name} by {follow_up_date}" - }) - - # Always mark as processed - planned_actions.append({ - "action": "mark_email_processed", - "params": { - "email_id": email.get("email_id"), - "actions_taken": ["classified", "reviewed"] + (["task_created"] if has_discussion_points else []), - "category": "informational" - }, - "requires_approval": False, - "reason": "Email processed" - }) - - actions_summary = "\n".join([f" - {a['action']}: {a['reason']}" for a in planned_actions]) - - thought = f""" -[*] Action Planning Complete -===================================== -Based on analysis, I will: -{actions_summary} - -Total actions planned: {len(planned_actions)} -Requiring approval: {sum(1 for a in planned_actions if a.get('requires_approval'))} -Auto-executable: {sum(1 for a in planned_actions if not a.get('requires_approval'))} - -Next: Executing actions... -""" - - return { - **state, - "status": "executing", - "planned_actions": planned_actions, - "current_thought": thought, - "reasoning_trace": [{ - "step": "plan_actions", - "thought": thought, - "result": {"planned_count": len(planned_actions)}, - "timestamp": datetime.utcnow().isoformat() - }] - } - - -def execute_action(state: EmailProcessingState) -> EmailProcessingState: - """ - Node 4: Execute the next planned action - Actions requiring approval are queued, others are executed directly - """ - planned_actions = state.get("planned_actions", []) - pending_approvals = state.get("pending_approvals", []) - - if not planned_actions: - return { - **state, - "status": "completed", - "current_thought": "All actions processed.", - "reasoning_trace": [{ - "step": "execute_complete", - "thought": "No more actions to execute", - "timestamp": datetime.utcnow().isoformat() - }] - } - - # Get next action - action = planned_actions[0] - remaining_actions = planned_actions[1:] - - action_name = action["action"] - params = action["params"] - requires_approval = action.get("requires_approval", False) - - # Initialize executor - from repos.data_repo import DataRepo - repo = DataRepo() - executor = ToolExecutor(repo, None, state.get("user_email", "demo@awoa.local")) - - if requires_approval: - # Queue for approval - import uuid - pending_action = { - "action_id": f"pa_{uuid.uuid4().hex[:8]}", - "action_type": action_name, - "description": f"{action_name}: {action.get('reason', 'No reason')}", - "payload": params, - "reason": action.get("reason", ""), - "source_email_id": state["email"].get("email_id"), - "status": "pending", - "created_utc": datetime.utcnow().isoformat() - } - pending_approvals.append(pending_action) - - thought = f""" -[PENDING] Action Queued for Approval -===================================== -Action: {action_name} -Reason: {action.get('reason', 'N/A')} - -This action requires human approval before execution. -""" - executed = f"{action_name} (queued for approval)" - - else: - # Execute directly - result = executor.execute(action_name, params) - - if result.get("success"): - thought = f""" -[OK] Action Executed -===================================== -Action: {action_name} -Status: Success -""" - executed = action_name - else: - thought = f""" -[FAIL] Action Failed -===================================== -Action: {action_name} -Error: {result.get('error', 'Unknown error')} -""" - executed = f"{action_name} (failed)" - - return { - **state, - "status": "executing" if remaining_actions else "check_completion", - "planned_actions": remaining_actions, - "pending_approvals": pending_approvals, - "current_thought": thought, - "executed_actions": [executed], - "reasoning_trace": [{ - "step": "execute_action", - "action": action_name, - "thought": thought, - "requires_approval": requires_approval, - "timestamp": datetime.utcnow().isoformat() - }], - "iteration": state.get("iteration", 0) + 1 - } - - -def check_completion(state: EmailProcessingState) -> EmailProcessingState: - """ - Node 5: Check if processing is complete or needs more iterations - """ - planned_actions = state.get("planned_actions", []) - pending_approvals = state.get("pending_approvals", []) - executed_actions = state.get("executed_actions", []) - iteration = state.get("iteration", 0) - max_iterations = state.get("max_iterations", 10) - - # Check if done - if not planned_actions and iteration >= 2: - status = "awaiting_approval" if pending_approvals else "completed" - - summary = f""" -[DONE] Processing Complete -===================================== -Email: {state['email'].get('subject', 'Unknown')[:40]} -Category: {state.get('email_analysis', {}).get('category', 'unknown')} - -Actions Executed: {len(executed_actions)} -Pending Approvals: {len(pending_approvals)} - -Status: {'Awaiting human approval' if pending_approvals else 'Complete'} -""" - - return { - **state, - "status": status, - "final_summary": summary, - "current_thought": summary, - "reasoning_trace": [{ - "step": "completion_check", - "thought": summary, - "result": { - "executed": len(executed_actions), - "pending": len(pending_approvals), - "status": status - }, - "timestamp": datetime.utcnow().isoformat() - }] - } - - # Safety check for max iterations - if iteration >= max_iterations: - return { - **state, - "status": "completed", - "final_summary": f"Max iterations ({max_iterations}) reached. Stopping.", - "reasoning_trace": [{ - "step": "max_iterations", - "thought": "Safety limit reached", - "timestamp": datetime.utcnow().isoformat() - }] - } - - # Continue processing - return { - **state, - "status": "executing", - "reasoning_trace": [{ - "step": "continue", - "thought": "Continuing to next action...", - "timestamp": datetime.utcnow().isoformat() - }] - } - - -# ============================================================ -# ROUTING FUNCTIONS (Conditional Edges) -# ============================================================ - -def route_after_classify(state: EmailProcessingState) -> Literal["gather_context", "plan_actions"]: - """ - Decide whether to gather context or go straight to planning - TRUE conditional routing - agent decides based on state - """ - analysis = state.get("email_analysis", {}) - category = analysis.get("category", "informational") - - if category == "actionable": - return "gather_context" # Need more info for actionable emails - else: - return "plan_actions" # Skip context for informational - - -def route_after_execute(state: EmailProcessingState) -> Literal["execute_action", "check_completion"]: - """ - Decide whether to execute more actions or check completion - This creates the LOOP - agent keeps executing until done - """ - planned_actions = state.get("planned_actions", []) - - if planned_actions: - return "execute_action" # More actions to do - else: - return "check_completion" # Check if we're done - - -def route_after_check(state: EmailProcessingState) -> Literal["plan_actions", "__end__"]: - """ - Decide whether to replan or finish - Allows agent to iterate if needed - """ - status = state.get("status", "") - iteration = state.get("iteration", 0) - - if status in ["completed", "awaiting_approval"]: - return "__end__" - elif iteration < state.get("max_iterations", 10): - return "plan_actions" # Replan if needed - else: - return "__end__" - - -# ============================================================ -# GRAPH BUILDER -# ============================================================ - -def build_email_processing_graph(): - """ - Build the LangGraph workflow for autonomous email processing - - Graph structure: - - [START] -> classify -> route -> gather_context? -> plan_actions -> execute_action (loop) -> check_completion -> [END] - | / ^__________________| - plan_actions --------- - """ - - # Create the graph - graph = StateGraph(EmailProcessingState) - - # Add nodes - graph.add_node("classify", classify_email) - graph.add_node("gather_context", gather_context) - graph.add_node("plan_actions", plan_actions) - graph.add_node("execute_action", execute_action) - graph.add_node("check_completion", check_completion) - - # Set entry point - graph.set_entry_point("classify") - - # Add conditional edges (THIS IS THE KEY TO AGENTIC BEHAVIOR) - - # After classify: decide if we need context - graph.add_conditional_edges( - "classify", - route_after_classify, - { - "gather_context": "gather_context", - "plan_actions": "plan_actions" - } - ) - - # After gather_context: always go to planning - graph.add_edge("gather_context", "plan_actions") - - # After planning: start executing - graph.add_edge("plan_actions", "execute_action") - - # After execute: check if more actions OR check completion (LOOP) - graph.add_conditional_edges( - "execute_action", - route_after_execute, - { - "execute_action": "execute_action", # Loop back! - "check_completion": "check_completion" - } - ) - - # After check: maybe replan OR finish - graph.add_conditional_edges( - "check_completion", - route_after_check, - { - "plan_actions": "plan_actions", # Can go back to planning! - "__end__": END - } - ) - - # Compile with memory for checkpointing - memory = get_checkpointer() - return graph.compile(checkpointer=memory) - - -# ============================================================ -# HELPER FUNCTIONS -# ============================================================ - -def _extract_topics(text: str) -> List[str]: - """Extract key topics/keywords from text""" - # Simple keyword extraction - keywords = [] - important_words = [ - "acme", "techvision", "globaltech", "api", "migration", "integration", - "deadline", "urgent", "blocker", "review", "approval", "budget", - "meeting", "call", "schedule", "timeline", "status", "update" - ] - - text_lower = text.lower() - for word in important_words: - if word in text_lower: - keywords.append(word) - - return keywords[:5] # Limit - - -def _dedupe_by_key(items: List[Dict], key: str) -> List[Dict]: - """Remove duplicates from list of dicts by key""" - seen = set() - result = [] - for item in items: - k = item.get(key) - if k and k not in seen: - seen.add(k) - result.append(item) - return result - - -# ============================================================ -# PUBLIC API -# ============================================================ - -def create_email_processor(): - """Create configured email processing graph""" - return build_email_processing_graph() - - -def process_email_with_graph(email: Dict[str, Any], user_email: str = "kowshik.naidu@contoso.com"): - """ - Process an email using the LangGraph workflow - - Returns a generator that yields state updates for real-time UI - """ - graph = create_email_processor() - - initial_state: EmailProcessingState = { - "email": email, - "user_email": user_email, - "status": "idle", - "iteration": 0, - "max_iterations": 10, - "current_thought": "", - "reasoning_trace": [], - "email_analysis": None, - "related_context": {}, - "planned_actions": [], - "executed_actions": [], - "pending_approvals": [], - "final_category": None, - "final_summary": None, - "current_tool": None, - "current_tool_params": None, - "tool_result": None - } - - config = {"configurable": {"thread_id": email.get("email_id", "default")}} - - # Stream the execution - for event in graph.stream(initial_state, config): - yield event diff --git a/orchestration/chat_workflow.py b/orchestration/chat_workflow.py index 8471e15..3e4e0b3 100644 --- a/orchestration/chat_workflow.py +++ b/orchestration/chat_workflow.py @@ -19,11 +19,9 @@ from datetime import datetime import uuid -from langgraph.graph import StateGraph, END - from governance.litellm_gateway import EnhancedLiteLLMGateway from memory import AgentMemory, EpisodicMemory, MemoryType, EpisodeType, EpisodeOutcome -from orchestration.super_graph import process_user_request +from orchestration.deep_agent import create_opspilot_agent # ============================================================ @@ -73,6 +71,20 @@ class ChatState(TypedDict): flow_state: Dict[str, Any] +def _extract_subagents_invoked(messages: List[Any]) -> List[str]: + """Which subagents (email/meeting/tasks/followup/wellness/reporting) the + deep agent actually delegated to during this turn, read off the real + tool_calls to deepagents' built-in `task` tool -- not guessed.""" + invoked: List[str] = [] + for message in messages: + for tool_call in getattr(message, "tool_calls", None) or []: + if tool_call.get("name") == "task": + subagent = (tool_call.get("args") or {}).get("subagent_type") + if subagent and subagent not in invoked: + invoked.append(subagent) + return invoked + + # ============================================================ # CHAT MANAGER # ============================================================ @@ -133,20 +145,26 @@ def process_message( response = clarification agents_invoked = [] else: - # Process with super-graph - result = process_user_request( - user_input=user_input, - user_email=user_email, - session_id=session_id + # Process with the deepagents-based agent (replaces + # orchestration/super_graph.py's manual intent routing, which + # only actually invoked the task/wellness/followup/report + # subgraphs -- email and meeting routing were stubs). + agent = create_opspilot_agent(user_email=user_email) + config = {"configurable": {"thread_id": session_id}} + result = agent.invoke( + {"messages": [{"role": "user", "content": user_input}]}, + config=config, ) - response = result.get("response", "I'm here to help!") - agents_invoked = result.get("agents_used", []) - # process_user_request already computes these; they were being - # discarded here rather than surfaced to callers that need them - # (e.g. SmartChatAgent.chat_sync's metadata). - confidence = result.get("confidence") - reasoning_trace = result.get("reasoning_trace", []) + messages = result.get("messages", []) + response = messages[-1].content if messages else "I'm here to help!" + agents_invoked = _extract_subagents_invoked(messages) + # deepagents doesn't expose a numeric confidence score or a + # step-by-step reasoning trace the way the old super_graph state + # did -- reporting None/[] here is honest about that, not a + # regression to hide by fabricating a plausible-looking number. + confidence = None + reasoning_trace = [] # Create turn record turn = ConversationTurn( diff --git a/orchestration/common_state.py b/orchestration/common_state.py deleted file mode 100644 index 7f664c1..0000000 --- a/orchestration/common_state.py +++ /dev/null @@ -1,368 +0,0 @@ -# orchestration/common_state.py -""" -Unified State Schema for Multi-Agent Coordination -================================================== -All workflows extend from WorkplaceState to enable: -- Cross-workflow context sharing -- Agent coordination -- Consistent state management -- Memory integration -""" - -from __future__ import annotations -from typing import TypedDict, Annotated, Optional, List, Dict, Any -import operator - - -# ============================================================ -# BASE STATE -# ============================================================ - -class WorkplaceState(TypedDict): - """ - Base state shared across ALL agent workflows - - Design principles: - - Append-only fields use Annotated[List, operator.add] - - Shared context enables agent coordination - - Memory integration for learning - - Wellness monitoring built-in - """ - - # ===== Identity & Session ===== - user_email: str - session_id: str - workflow_type: str # "email" | "meeting" | "task" | "wellness" | "chat" | "proactive" - - # ===== Current Focus ===== - current_entity_id: Optional[str] # ID of email, task, meeting being processed - current_entity_type: Optional[str] # "email" | "task" | "meeting" - current_entity_data: Dict[str, Any] # Full entity data - - # ===== Shared Reasoning (Append-Only) ===== - # These accumulate across all workflow steps - reasoning_trace: Annotated[List[Dict[str, Any]], operator.add] - actions_taken: Annotated[List[str], operator.add] # e.g., ["create_task:t123", "send_email:e456"] - - # ===== Cross-Workflow Context ===== - # Agents populate these to share context with other agents - related_emails: List[Dict[str, Any]] # Emails related to current work - related_tasks: List[Dict[str, Any]] # Tasks related to current work - related_meetings: List[Dict[str, Any]] # Meetings related to current work - related_context: Dict[str, Any] # Flexible context passing between agents - - # ===== Memory Integration ===== - relevant_memories: List[Dict[str, Any]] # Retrieved from vector store - episode_id: Optional[str] # Current episodic memory episode - - # ===== Wellness & Workload Monitoring ===== - wellness_score: Optional[int] # 0-100 - stress_level: Optional[str] # "low" | "moderate" | "high" | "critical" - burnout_signals: List[str] # Warning signs detected - workload_metrics: Dict[str, Any] # P0 count, meeting hours, etc. - - # ===== Approval & Governance ===== - pending_approvals: List[Dict[str, Any]] # Actions awaiting approval - approval_required: bool # True if workflow paused for approval - approved_actions: Annotated[List[str], operator.add] # Track approved actions - rejected_actions: Annotated[List[str], operator.add] # Track rejected actions - - # ===== Execution Metadata ===== - iteration: int # Current iteration in workflow - max_iterations: int # Maximum iterations allowed - status: str # "idle" | "running" | "awaiting_approval" | "completed" | "error" - error: Optional[str] # Error message if status is "error" - started_at: Optional[str] # ISO timestamp - completed_at: Optional[str] # ISO timestamp - - # ===== Configuration ===== - user_preferences: Dict[str, Any] # User-specific settings - proactive_mode: bool # True if triggered by proactive monitor - - -# ============================================================ -# WORKFLOW-SPECIFIC STATE EXTENSIONS -# ============================================================ - -class EmailWorkflowState(WorkplaceState): - """ - Email processing workflow state - Extends base state with email-specific fields - """ - # Email data - email: Dict[str, Any] - - # Analysis results - email_analysis: Optional[Dict[str, Any]] # Category, urgency, topics - extracted_actions: List[Dict[str, Any]] # Action items from email - - # Planned actions - planned_actions: List[Dict[str, Any]] # Actions to execute - - # Generated outputs - reply_draft: Optional[str] # Draft response - created_task_ids: Annotated[List[str], operator.add] # Tasks created from email - created_followup_ids: Annotated[List[str], operator.add] # Follow-ups created - - # Classification - final_category: Optional[str] # "actionable" | "informational" | "noise" - final_summary: Optional[str] # Summary of email - - -class MeetingWorkflowState(WorkplaceState): - """ - Meeting processing workflow state - Extends base state with meeting-specific fields - """ - # Meeting data - meeting: Dict[str, Any] - transcript: Optional[str] - - # Analysis results - meeting_analysis: Optional[Dict[str, Any]] # Topics, sentiment, etc. - extracted_decisions: List[str] # Decisions made in meeting - extracted_action_items: List[Dict[str, Any]] # Action items identified - extracted_risks: List[str] # Risks mentioned - - # Generated outputs - mom: Optional[Dict[str, Any]] # Minutes of Meeting - created_tasks: Annotated[List[str], operator.add] # Task IDs created - notified_participants: Annotated[List[str], operator.add] # Emails notified - - # Meeting metadata - meeting_type: Optional[str] # "standup" | "review" | "planning" | "retrospective" - meeting_effectiveness: Optional[int] # 1-10 score - - -class TaskWorkflowState(WorkplaceState): - """ - Task planning/management workflow state - Extends base state with task-specific fields - """ - # Task data - tasks: List[Dict[str, Any]] # All user tasks - - # Analysis - workload_analysis: Optional[Dict[str, Any]] # Current workload breakdown - priority_analysis: Dict[str, Any] # Priority distribution - - # Planning outputs - prioritized_tasks: List[Dict[str, Any]] # Sorted by computed priority - focus_blocks: List[Dict[str, Any]] # Suggested time blocks - plan_narrative: Optional[str] # AI-generated plan description - - # Optimization - bottlenecks: List[str] # Identified blockers - dependencies: List[Dict[str, Any]] # Task dependencies - suggested_delegations: List[Dict[str, Any]] # Tasks to delegate - - -class WellnessWorkflowState(WorkplaceState): - """ - Wellness monitoring workflow state - Extends base state with wellness-specific fields - """ - # Wellness data - wellness_data: Optional[Dict[str, Any]] # Full wellness assessment - wellness_factors: List[Dict[str, Any]] # Contributing factors - - # Intervention planning - intervention_type: Optional[str] # "break" | "nudge" | "alert" | "escalation" - suggested_actions: List[str] # Recommended interventions - - # Monitoring - burnout_risk_level: Optional[str] # "low" | "medium" | "high" | "critical" - recent_mood_entries: List[Dict[str, Any]] # Recent mood check-ins - - # Interventions executed - break_suggested: bool - nudge_sent: bool - manager_alerted: bool - - -class ChatWorkflowState(WorkplaceState): - """ - Conversational chat workflow state - Extends base state with chat-specific fields - """ - # Conversation - user_query: str - chat_history: List[Dict[str, str]] # [{"role": "user"|"assistant", "content": "..."}] - - # Intent classification - intent: Optional[str] # Classified intent - confidence: float # Intent classification confidence (0.0-1.0) - extracted_params: Dict[str, Any] # Parameters extracted from query - - # Clarification - clarification_needed: bool - clarification_question: Optional[str] - - # Tool execution - tool_calls: List[Dict[str, Any]] # Tools to call - tool_results: Dict[str, Any] # Results from tools - - # Response generation - final_response: Optional[str] - response_sources: List[str] # What data sources were used - - -class ProactiveWorkflowState(WorkplaceState): - """ - Proactive monitoring workflow state - Extends base state with proactive monitoring fields - """ - # Alerts detected - alerts: List[Dict[str, Any]] # Detected issues - alert_severity: str # "low" | "medium" | "high" | "critical" - - # Analysis - stalled_threads: List[Dict[str, Any]] # Emails needing follow-up - approaching_deadlines: List[Dict[str, Any]] # Tasks due soon - meeting_prep_needed: List[Dict[str, Any]] # Meetings need preparation - - # Actions planned - proactive_actions: List[Dict[str, Any]] # Actions agent will take - - # User notification - notify_user: bool - notification_message: Optional[str] - - -# ============================================================ -# STATE UTILITIES -# ============================================================ - -def create_initial_state( - workflow_type: str, - user_email: str, - session_id: str, - entity_id: Optional[str] = None, - entity_type: Optional[str] = None, - entity_data: Dict[str, Any] = None, - max_iterations: int = 10 -) -> WorkplaceState: - """ - Factory function to create initial state for any workflow - - Args: - workflow_type: Type of workflow to create state for - user_email: User's email - session_id: Unique session ID - entity_id: ID of entity being processed (email, task, meeting) - entity_type: Type of entity - entity_data: Full entity data - max_iterations: Maximum workflow iterations - - Returns: - Initial state dict - """ - from datetime import datetime - - return { - # Identity - "user_email": user_email, - "session_id": session_id, - "workflow_type": workflow_type, - - # Current focus - "current_entity_id": entity_id, - "current_entity_type": entity_type, - "current_entity_data": entity_data or {}, - - # Reasoning (empty lists) - "reasoning_trace": [], - "actions_taken": [], - - # Context (empty) - "related_emails": [], - "related_tasks": [], - "related_meetings": [], - "related_context": {}, - - # Memory - "relevant_memories": [], - "episode_id": None, - - # Wellness - "wellness_score": None, - "stress_level": None, - "burnout_signals": [], - "workload_metrics": {}, - - # Approval - "pending_approvals": [], - "approval_required": False, - "approved_actions": [], - "rejected_actions": [], - - # Execution - "iteration": 0, - "max_iterations": max_iterations, - "status": "idle", - "error": None, - "started_at": datetime.utcnow().isoformat(), - "completed_at": None, - - # Config - "user_preferences": {}, - "proactive_mode": False - } - - -def merge_state_updates( - current_state: WorkplaceState, - updates: Dict[str, Any] -) -> WorkplaceState: - """ - Merge state updates, handling append-only fields correctly - - LangGraph automatically handles Annotated[List, operator.add] fields, - but this is useful for manual state updates. - """ - merged = {**current_state} - - # Append-only fields - append_fields = [ - "reasoning_trace", - "actions_taken", - "approved_actions", - "rejected_actions" - ] - - for field in append_fields: - if field in updates: - # Append to existing list - merged[field] = current_state.get(field, []) + updates[field] - - # Regular fields (last-write-wins) - for key, value in updates.items(): - if key not in append_fields: - merged[key] = value - - return merged - - -def extract_insights(state: WorkplaceState) -> Dict[str, Any]: - """ - Extract insights from completed workflow state - Useful for learning and analytics - """ - return { - "workflow_type": state["workflow_type"], - "user_email": state["user_email"], - "session_id": state["session_id"], - "total_iterations": state["iteration"], - "total_actions": len(state.get("actions_taken", [])), - "status": state["status"], - "wellness_score": state.get("wellness_score"), - "stress_level": state.get("stress_level"), - "approvals_needed": len(state.get("pending_approvals", [])), - "approvals_approved": len(state.get("approved_actions", [])), - "approvals_rejected": len(state.get("rejected_actions", [])), - "cross_workflow_context": { - "related_emails": len(state.get("related_emails", [])), - "related_tasks": len(state.get("related_tasks", [])), - "related_meetings": len(state.get("related_meetings", [])) - } - } diff --git a/orchestration/deep_agent.py b/orchestration/deep_agent.py index 4ebbcc3..261930e 100644 --- a/orchestration/deep_agent.py +++ b/orchestration/deep_agent.py @@ -5,13 +5,21 @@ Replaces the hand-rolled routing `orchestration/super_graph.py` half-built (manual intent classification -> one of 6 agent handlers -> aggregate, with email/meeting routing left as stubs) with `deepagents`' `subagents=` -harness, dispatched through its built-in `task` tool. - -This module is additive only: nothing in `backend/`, `agents/smart_chat.py`, -or the existing orchestration graphs imports it yet (see -https://github.com/kowshikdev/Opspilot/issues/15 for the cutover). It is -built and tested in isolation so it ships with zero user-facing behavior -change. +harness, dispatched through its built-in `task` tool. Entry points: +`orchestration/chat_workflow.py` (chat), `agents/autonomous_inbox.py` +(autonomous email processing), `backend/routes_governance.py` (resuming a +paused thread on approve/reject). + +Memory write-back +------------------ +`orchestration/autonomous_graph.py`'s `recall_memory_context` node called +`memory/vector_store.py` + `memory/episodic_memory.py` to recall past +emails/episodes, but was never wired into the compiled graph, and nothing +anywhere called the write-back methods (`remember`/`start_episode`/ +`complete_episode`) those classes already had -- recall without write-back, +and not even reachable. The email and meeting subagents below get an +explicit `record_episode` tool wrapping that existing write-back API, with +their system prompts instructing them to call it once they finish. Approval reconciliation ------------------------ @@ -32,7 +40,7 @@ from typing import Any, Dict, List, Optional, Type -from langchain_core.tools import StructuredTool +from langchain_core.tools import StructuredTool, tool from langchain.agents.middleware import ToolCallRequest from pydantic import BaseModel, Field, create_model @@ -40,9 +48,43 @@ from agents.llm_model import get_chat_model from agents.tools import TOOLS, Tool, ToolExecutor +from memory import AgentMemory, EpisodicMemory, EpisodeOutcome, EpisodeType, MemoryType from orchestration.checkpointer import get_checkpointer from repos.data_repo import DataRepo +_EPISODE_OUTCOMES = { + "success": EpisodeOutcome.SUCCESS, + "failure": EpisodeOutcome.FAILURE, + "partial": EpisodeOutcome.PARTIAL, +} + + +def _make_record_episode_tool(agent_name: str, episode_type: EpisodeType) -> StructuredTool: + """One combined start+complete episode call -- the subagent invokes this + once, after it's already taken its actions, rather than needing the + two-phase start/complete tracking the underlying EpisodicMemory API + supports (that granularity was never used even when the recall side was + wired up, so it isn't worth exposing as two separate tool calls here).""" + + @tool + def record_episode(summary: str, outcome: str = "success") -> str: + """Record this processing episode to memory. Call this once you've + finished taking action -- summarize what happened and the outcome + (success, failure, or partial).""" + memory = AgentMemory(agent_name) + memory.remember(content=summary, memory_type=MemoryType.INTERACTION) + + episodic = EpisodicMemory(agent_name) + episode = episodic.start_episode(episode_type=episode_type, trigger=summary[:200], context={}) + episodic.complete_episode( + episode, + outcome=_EPISODE_OUTCOMES.get(outcome, EpisodeOutcome.SUCCESS), + result={"summary": summary}, + ) + return "Episode recorded." + + return record_episode + # ============================================================ # GENERIC TOOL BRIDGE: agents/tools.py's Tool + ToolExecutor -> LangChain @@ -177,9 +219,13 @@ def create_opspilot_agent( "You triage inbox email: read and summarize messages in plain " "prose (2-3 sentences, focused on what's being asked, deadlines, " "and who's involved), draft replies when a response is needed, " - "and mark each email processed once you've finished with it." + "and mark each email processed once you've finished with it. " + "Once you're done, call record_episode to note what you did and " + "the outcome, so future processing can learn from it." ), - "tools": _build_tools(executor, _EMAIL_TOOLS), + "tools": _build_tools(executor, _EMAIL_TOOLS) + [ + _make_record_episode_tool("email_agent", EpisodeType.EMAIL_PROCESSING) + ], }, { "name": "meeting", @@ -188,9 +234,13 @@ def create_opspilot_agent( "You are a meeting-intelligence agent. Read meeting transcripts " "and produce detailed, specific minutes-of-meeting -- a summary, " "key decisions, and action items -- never generic filler text. " - "Schedule follow-up meetings when one is explicitly requested." + "Schedule follow-up meetings when one is explicitly requested. " + "Once you're done, call record_episode to note what you did and " + "the outcome, so future processing can learn from it." ), - "tools": _build_tools(executor, _MEETING_TOOLS), + "tools": _build_tools(executor, _MEETING_TOOLS) + [ + _make_record_episode_tool("meeting_agent", EpisodeType.MEETING_ANALYSIS) + ], }, { "name": "tasks", diff --git a/orchestration/followup_reporting_subgraphs.py b/orchestration/followup_reporting_subgraphs.py deleted file mode 100644 index f82fd8c..0000000 --- a/orchestration/followup_reporting_subgraphs.py +++ /dev/null @@ -1,359 +0,0 @@ -# orchestration/followup_reporting_subgraphs.py -""" -Followup & Reporting Subgraphs - Lightweight Workflows -===================================================== -Two simple but effective subgraphs: - -1. Followup Agent: Smart nudge generation for overdue items -2. Reporting Agent: Daily/weekly productivity summaries - -Both integrate with Phase 1 memory for learning patterns. -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Optional, Annotated -from datetime import datetime -import operator - -from langgraph.graph import StateGraph, END - -# Phase 1 imports -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway - -# Agent imports -from agents.followup_agent import FollowupAgent -from agents.reporting_agent import ReportingAgent -from repos.data_repo import DataRepo -from orchestration.checkpointer import get_checkpointer - - -# ============================================================ -# FOLLOWUP SUBGRAPH -# ============================================================ - -class FollowupState(TypedDict): - """State for followup/nudge workflow""" - user_email: str - session_id: str - status: str - reasoning_trace: Annotated[List[str], operator.add] - - # Analysis - overdue_items: List[Dict[str, Any]] - nudges_generated: List[Dict[str, Any]] - - # Output - result: Optional[Dict[str, Any]] - - -def scan_overdue_items(state: FollowupState) -> FollowupState: - """Scan for overdue tasks/items needing followup""" - repo = DataRepo() - - # Get all followups from data - try: - followups = repo.followups() - state["overdue_items"] = followups - state["reasoning_trace"].append(f"Found {len(followups)} items for followup") - except: - state["overdue_items"] = [] - - state["status"] = "generating" - return state - - -def generate_nudges(state: FollowupState) -> FollowupState: - """Generate smart nudge messages""" - repo = DataRepo() - agent = FollowupAgent(repo) - memory = AgentMemory("followup_agent") - - try: - # Use existing agent logic - nudges = agent.nudges() - - # Convert to dict format - nudge_list = [ - { - "followup_id": n.followup_id, - "message": n.draft_message, - "channel": n.recommended_channel, - "severity": n.severity, - "entity_type": n.entity_type - } - for n in nudges - ] - - state["nudges_generated"] = nudge_list - state["reasoning_trace"].append(f"Generated {len(nudge_list)} nudges") - - # Store successful pattern - if nudge_list: - memory.remember( - content=f"Successfully generated {len(nudge_list)} followup nudges", - memory_type=MemoryType.STRATEGY, - metadata={"user": state["user_email"], "nudge_count": len(nudge_list)} - ) - - except Exception as e: - state["nudges_generated"] = [] - state["reasoning_trace"].append(f"Error generating nudges: {str(e)}") - - state["status"] = "completed" - state["result"] = {"nudges": state["nudges_generated"], "count": len(state["nudges_generated"])} - - return state - - -def create_followup_workflow() -> StateGraph: - """Simple 2-node workflow for followup generation""" - workflow = StateGraph(FollowupState) - - workflow.add_node("scan_overdue_items", scan_overdue_items) - workflow.add_node("generate_nudges", generate_nudges) - - workflow.set_entry_point("scan_overdue_items") - workflow.add_edge("scan_overdue_items", "generate_nudges") - workflow.add_edge("generate_nudges", END) - - return workflow - - -def generate_followups(user_email: str) -> Dict[str, Any]: - """Convenience function for followup generation""" - graph = create_followup_workflow().compile(checkpointer=get_checkpointer()) - - initial_state = { - "user_email": user_email, - "session_id": f"followup_{int(datetime.now().timestamp())}", - "status": "idle", - "reasoning_trace": [], - "overdue_items": [], - "nudges_generated": [], - "result": None - } - - config = {"configurable": {"thread_id": initial_state["session_id"]}} - result = graph.invoke(initial_state, config) - return result.get("result", {}) - - -# ============================================================ -# REPORTING SUBGRAPH -# ============================================================ - -class ReportingState(TypedDict): - """State for report generation workflow""" - user_email: str - report_type: str # "eod", "weekly" - session_id: str - status: str - reasoning_trace: Annotated[List[str], operator.add] - - # Data collection - completed_tasks: List[Dict[str, Any]] - attended_meetings: List[Dict[str, Any]] - wellness_summary: Optional[Dict[str, Any]] - - # Analysis - productivity_score: float - key_achievements: List[str] - - # Output - report: Optional[Dict[str, Any]] - - -def collect_report_data(state: ReportingState) -> ReportingState: - """Collect data for report generation""" - repo = DataRepo() - user_email = state["user_email"] - - # Get completed tasks - try: - users = repo.users() - user = next((u for u in users if u.get("email") == user_email), None) - user_id = user["user_id"] if user else None - - tasks = repo.tasks() - completed = [ - t for t in tasks - if t.get("owner_user_id") == user_id - and t.get("status") == "done" - ] - - # Filter to today/this week based on report_type - # For simplicity, take last 5 completed - state["completed_tasks"] = completed[-5:] - state["reasoning_trace"].append(f"Collected {len(state['completed_tasks'])} completed tasks") - - except: - state["completed_tasks"] = [] - - # Get meetings attended - try: - meetings = repo.meetings() - attended = [ - m for m in meetings - if user_email in m.get("attendees", []) - ][-3:] # Last 3 meetings - - state["attended_meetings"] = attended - state["reasoning_trace"].append(f"Collected {len(attended)} meetings attended") - - except: - state["attended_meetings"] = [] - - state["status"] = "analyzing" - return state - - -def analyze_productivity(state: ReportingState) -> ReportingState: - """Analyze productivity metrics""" - completed = state["completed_tasks"] - meetings = state["attended_meetings"] - - # Simple productivity scoring - score = 0 - score += len(completed) * 15 # Each completed task: 15 points - score += len(meetings) * 5 # Each meeting: 5 points - score = min(score, 100) - - state["productivity_score"] = score - - # Extract key achievements - achievements = [] - for task in completed[:3]: # Top 3 - if task.get("priority") in ["P0", "P1"]: - achievements.append(f"Completed {task.get('priority')} task: {task.get('title', 'Untitled')}") - - state["key_achievements"] = achievements - state["reasoning_trace"].append(f"Productivity score: {score:.0f}/100") - - state["status"] = "generating" - return state - - -def generate_report(state: ReportingState) -> ReportingState: - """Generate final report""" - gateway = EnhancedLiteLLMGateway("reporting_agent", enable_cache=True) - memory = AgentMemory("reporting_agent") - - report_type = state["report_type"] - completed = state["completed_tasks"] - meetings = state["attended_meetings"] - score = state["productivity_score"] - achievements = state["key_achievements"] - - # Build report structure - report = { - "type": report_type, - "generated_at": datetime.now().isoformat(), - "user_email": state["user_email"], - "productivity_score": score, - "summary": { - "tasks_completed": len(completed), - "meetings_attended": len(meetings), - "key_achievements": achievements - }, - "details": { - "completed_tasks": [ - {"title": t.get("title"), "priority": t.get("priority")} - for t in completed - ], - "meetings": [ - {"title": m.get("title"), "duration": m.get("duration_mins")} - for m in meetings - ] - } - } - - # Generate narrative summary with LLM - try: - prompt = f"""Generate a brief summary for this {report_type} report: - -Completed Tasks: {len(completed)} -Key Achievements: -{chr(10).join(f'- {a}' for a in achievements)} - -Meetings Attended: {len(meetings)} - -Write 2-3 sentences highlighting the day's accomplishments.""" - - narrative = gateway.call( - prompt=prompt, - temperature=0.5, - use_cache=True, - role_context="reporter" - ) - - report["narrative"] = narrative.strip() - - except: - report["narrative"] = f"Completed {len(completed)} tasks and attended {len(meetings)} meetings today." - - state["report"] = report - state["status"] = "completed" - state["reasoning_trace"].append("Generated comprehensive report") - - # Store pattern - try: - memory.remember( - content=f"Generated {report_type} report with {len(completed)} tasks", - memory_type=MemoryType.INTERACTION, - metadata={"user": state["user_email"], "report_type": report_type, "score": score} - ) - except: - pass - - return state - - -def create_reporting_workflow() -> StateGraph: - """3-node workflow for report generation""" - workflow = StateGraph(ReportingState) - - workflow.add_node("collect_report_data", collect_report_data) - workflow.add_node("analyze_productivity", analyze_productivity) - workflow.add_node("generate_report", generate_report) - - workflow.set_entry_point("collect_report_data") - workflow.add_edge("collect_report_data", "analyze_productivity") - workflow.add_edge("analyze_productivity", "generate_report") - workflow.add_edge("generate_report", END) - - return workflow - - -def generate_report_for_user(user_email: str, report_type: str = "eod") -> Dict[str, Any]: - """Convenience function for report generation""" - graph = create_reporting_workflow().compile(checkpointer=get_checkpointer()) - - initial_state = { - "user_email": user_email, - "report_type": report_type, - "session_id": f"report_{int(datetime.now().timestamp())}", - "status": "idle", - "reasoning_trace": [], - "completed_tasks": [], - "attended_meetings": [], - "wellness_summary": None, - "productivity_score": 0.0, - "key_achievements": [], - "report": None - } - - config = {"configurable": {"thread_id": initial_state["session_id"]}} - result = graph.invoke(initial_state, config) - return result.get("report", {}) - - -if __name__ == "__main__": - print("Testing Followup Subgraph...") - followup_result = generate_followups("kowshik.naidu@contoso.com") - print(f"Generated {followup_result.get('count', 0)} nudges") - - print("\nTesting Reporting Subgraph...") - report_result = generate_report_for_user("kowshik.naidu@contoso.com", "eod") - print(f"Report score: {report_result.get('productivity_score', 0):.0f}/100") - print(f"Tasks: {report_result.get('summary', {}).get('tasks_completed', 0)}") diff --git a/orchestration/meeting_subgraph.py b/orchestration/meeting_subgraph.py deleted file mode 100644 index 96e9e1f..0000000 --- a/orchestration/meeting_subgraph.py +++ /dev/null @@ -1,640 +0,0 @@ -# orchestration/meeting_subgraph.py -""" -Meeting Agent Subgraph - Autonomous Meeting Management -====================================================== -Transforms meeting processing into an agentic workflow with: -- Automatic transcript analysis -- MoM (Minutes of Meeting) generation with quality checks -- Action item extraction → triggers task agent -- Decision tracking -- Risk/dependency identification -- Learning from past meeting patterns - -Makes meetings actually productive for corporate employees! -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Optional, Annotated -from datetime import datetime -import operator -import json -import re - -from langgraph.graph import StateGraph, END -from orchestration.checkpointer import get_checkpointer - -# Phase 1 imports -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeType, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway -from orchestration.common_state import MeetingWorkflowState, create_initial_state - -# Agent imports -from agents.meeting_agent import MeetingAgent -from repos.data_repo import DataRepo - - -# ============================================================ -# STATE DEFINITION -# ============================================================ - -class MeetingState(TypedDict): - """State for meeting processing workflow""" - # Input - meeting_id: str - user_email: str - session_id: str - - # Processing state - status: str # idle, analyzing, generating_mom, extracting_actions, quality_check, completed - iteration: int - max_iterations: int - - # Context - meeting_data: Optional[Dict[str, Any]] - transcript: Optional[str] - past_meeting_patterns: List[Dict[str, Any]] - - # Agent reasoning - reasoning_trace: Annotated[List[str], operator.add] - - # Analysis results - meeting_summary: Optional[str] - key_decisions: List[str] - action_items: List[Dict[str, Any]] - risks: List[str] - dependencies: List[str] - - # Quality metrics - mom_quality_score: float - completeness_score: float - - # Output - mom: Optional[Dict[str, Any]] - - # Cross-agent triggers - tasks_to_create: List[Dict[str, Any]] - wellness_concern: bool - - # Episode tracking - episode_id: Optional[str] - - -# ============================================================ -# NODE FUNCTIONS -# ============================================================ - -def load_meeting_context(state: MeetingState) -> MeetingState: - """ - Node 1: Load meeting data and recall past patterns - """ - repo = DataRepo() - memory = AgentMemory("meeting_agent") - - # Load meeting data - try: - meetings = repo.meetings() - meeting = next((m for m in meetings if m.get("meeting_id") == state["meeting_id"]), None) - - if meeting: - state["meeting_data"] = meeting - state["status"] = "analyzing" - - # Load transcript if available - transcript_path = f"data/mock_data_json/calendar/transcripts/{state['meeting_id']}.txt" - try: - with open(transcript_path, 'r', encoding='utf-8') as f: - state["transcript"] = f.read() - except: - state["transcript"] = "No transcript available" - - state["reasoning_trace"].append(f"Loaded meeting: {meeting.get('title', 'Untitled')}") - else: - state["status"] = "error" - state["reasoning_trace"].append(f"Meeting {state['meeting_id']} not found") - return state - except Exception as e: - state["status"] = "error" - state["reasoning_trace"].append(f"Error loading meeting: {str(e)}") - return state - - # Recall past meeting patterns - try: - meeting_title = state["meeting_data"].get("title", "") - attendees = state["meeting_data"].get("attendees", []) - - # Check memory for similar meetings - similar = memory.recall( - query=f"Past meetings about {meeting_title} with similar attendees", - n_results=3, - memory_type=MemoryType.STRATEGY - ) - - state["past_meeting_patterns"] = similar - if similar: - state["reasoning_trace"].append(f"Found {len(similar)} similar past meetings") - except: - state["past_meeting_patterns"] = [] - - return state - - -def analyze_meeting(state: MeetingState) -> MeetingState: - """ - Node 2: Analyze meeting transcript and extract key information - - Uses Phase 1 enhanced gateway with caching - """ - gateway = EnhancedLiteLLMGateway("meeting_agent", enable_cache=True) - memory = AgentMemory("meeting_agent") - - meeting = state.get("meeting_data") - if not meeting: - state["status"] = "error" - state["reasoning_trace"].append("No meeting data available") - return state - - transcript = state.get("transcript", "") - - # Build context from past patterns - past_context = "" - if state["past_meeting_patterns"]: - past_context = "\n".join([ - f"- {p['content']}" for p in state["past_meeting_patterns"][:2] - ]) - past_context = f"\n\nPast Meeting Patterns:\n{past_context}" - - # Comprehensive analysis prompt - prompt = f"""Analyze this meeting and extract structured information. - -Meeting: {meeting.get('title', 'Untitled')} -Date: {meeting.get('scheduled_at', 'Unknown')} -Attendees: {', '.join(meeting.get('attendees', []))} -Duration: {meeting.get('duration_mins', 'Unknown')} minutes - -Transcript: -{transcript[:3000]} # Limit for token efficiency -{past_context} - -Extract: -1. **Summary** (2-3 sentences of key discussion points) -2. **Decisions** (concrete decisions made, not discussion) -3. **Action Items** (who, what, by when - be specific) -4. **Risks** (any risks or blockers mentioned) -5. **Dependencies** (external dependencies identified) - -Return as JSON: -{{ - "summary": "...", - "decisions": ["decision 1", "decision 2"], - "action_items": [ - {{"assignee": "person", "action": "task description", "deadline": "date or null"}} - ], - "risks": ["risk 1"], - "dependencies": ["dependency 1"] -}}""" - - try: - response = gateway.call( - prompt=prompt, - temperature=0.3, - use_cache=True, - role_context="meeting_analyst" - ) - - # Parse response - analysis = parse_meeting_analysis(response) - - state["meeting_summary"] = analysis.get("summary", "") - state["key_decisions"] = analysis.get("decisions", []) - state["action_items"] = analysis.get("action_items", []) - state["risks"] = analysis.get("risks", []) - state["dependencies"] = analysis.get("dependencies", []) - - state["reasoning_trace"].append( - f"Extracted: {len(state['key_decisions'])} decisions, " - f"{len(state['action_items'])} actions, {len(state['risks'])} risks" - ) - - # Store successful pattern - if state["action_items"]: - memory.remember( - content=f"Meeting '{meeting.get('title')}' typically generates {len(state['action_items'])} action items", - memory_type=MemoryType.STRATEGY, - metadata={"meeting_type": meeting.get("title"), "user": state["user_email"]} - ) - - except Exception as e: - state["reasoning_trace"].append(f"Analysis error: {str(e)}") - # Fallback to basic extraction - state["meeting_summary"] = "Meeting analysis failed, using fallback." - state["key_decisions"] = [] - state["action_items"] = [] - state["risks"] = [] - state["dependencies"] = [] - - state["status"] = "generating_mom" - return state - - -def generate_mom(state: MeetingState) -> MeetingState: - """ - Node 3: Generate structured Minutes of Meeting (MoM) - """ - meeting = state.get("meeting_data") - if not meeting: - state["status"] = "error" - state["reasoning_trace"].append("Cannot generate MoM without meeting data") - return state - - # Build MoM structure - mom = { - "meeting_id": state["meeting_id"], - "title": meeting.get("title", "Untitled Meeting"), - "date": meeting.get("scheduled_at", ""), - "attendees": meeting.get("attendees", []), - "duration_mins": meeting.get("duration_mins", 0), - "summary": state.get("meeting_summary", ""), - "decisions": state.get("key_decisions", []), - "action_items": state.get("action_items", []), - "risks": state.get("risks", []), - "dependencies": state.get("dependencies", []), - "generated_at": datetime.now().isoformat(), - "generated_by": "meeting_agent" - } - - state["mom"] = mom - state["status"] = "quality_check" - state["reasoning_trace"].append("Generated MoM structure") - - return state - - -def quality_check_mom(state: MeetingState) -> MeetingState: - """ - Node 4: Assess MoM quality and completeness - - Quality criteria: - - Summary exists and is substantive (>20 chars) - - At least some structured content (decisions OR actions OR risks) - - Action items have assignees if present - """ - mom = state["mom"] - - # Quality scoring - quality_score = 0.0 - - # Summary quality (0-30 points) - summary = mom.get("summary", "") - if len(summary) > 20: - quality_score += 30 - elif len(summary) > 10: - quality_score += 15 - - # Structured content (0-40 points) - if mom.get("decisions"): - quality_score += 15 - if mom.get("action_items"): - quality_score += 15 - if mom.get("risks"): - quality_score += 10 - - # Action item completeness (0-30 points) - action_items = mom.get("action_items", []) - if action_items: - complete_actions = sum( - 1 for item in action_items - if item.get("assignee") and item.get("action") - ) - quality_score += (complete_actions / len(action_items)) * 30 - - state["mom_quality_score"] = quality_score / 100.0 - state["completeness_score"] = quality_score / 100.0 - - state["reasoning_trace"].append(f"Quality score: {quality_score:.0f}/100") - - return state - - -def should_retry_mom(state: MeetingState) -> str: - """Decision: Is MoM quality acceptable?""" - quality = state.get("mom_quality_score", 0) - iteration = state.get("iteration", 0) - - # Retry if quality < 0.5 and we haven't exceeded max iterations - if quality < 0.5 and iteration < state.get("max_iterations", 2): - state["iteration"] = iteration + 1 - state["reasoning_trace"].append(f"Quality {quality:.2f} insufficient, retrying") - return "retry" - - return "accept" - - -def extract_task_triggers(state: MeetingState) -> MeetingState: - """ - Node 5: Identify action items that should trigger task creation - """ - action_items = state.get("action_items", []) - - tasks_to_create = [] - for item in action_items: - # Only create tasks for action items with clear assignees - if item.get("assignee") and item.get("action"): - tasks_to_create.append({ - "title": item["action"], - "assignee": item["assignee"], - "deadline": item.get("deadline"), - "source": "meeting", - "source_id": state["meeting_id"], - "priority": "P1" # Meeting action items are important - }) - - state["tasks_to_create"] = tasks_to_create - state["status"] = "extracting_actions" - - if tasks_to_create: - state["reasoning_trace"].append(f"Identified {len(tasks_to_create)} tasks to create") - - return state - - -def check_wellness_concerns(state: MeetingState) -> MeetingState: - """ - Node 6: Check if meeting indicates wellness concerns - - Signals: - - Very long meeting (>2 hours) - - Many risks identified - - High-stress topics mentioned - """ - meeting = state["meeting_data"] - - wellness_concern = False - - # Long meeting check - duration = meeting.get("duration_mins", 0) - if duration > 120: - wellness_concern = True - state["reasoning_trace"].append(f"Long meeting detected: {duration} mins") - - # High risk/stress check - risks = state.get("risks", []) - if len(risks) >= 3: - wellness_concern = True - state["reasoning_trace"].append(f"High risk count: {len(risks)} risks") - - # Stress keywords in summary - summary = state.get("meeting_summary", "").lower() - stress_keywords = ["urgent", "critical", "blocker", "delayed", "issue", "problem"] - if sum(1 for kw in stress_keywords if kw in summary) >= 2: - wellness_concern = True - state["reasoning_trace"].append("Stress keywords detected in summary") - - state["wellness_concern"] = wellness_concern - state["status"] = "completed" - - return state - - -def record_episode(state: MeetingState) -> MeetingState: - """Record this meeting processing as an episode""" - episodic = EpisodicMemory("meeting_agent") - - try: - # Determine outcome - outcome = EpisodeOutcome.SUCCESS if state.get("mom") else EpisodeOutcome.FAILURE - - episode_data = { - "episode_id": f"mtg_{int(datetime.now().timestamp() * 1000)}", - "episode_type": "meeting_processing", - "trigger": f"Process meeting: {state.get('meeting_id')}", - "context": { - "meeting_id": state["meeting_id"], - "meeting_title": state.get("meeting_data", {}).get("title"), - "quality_score": state.get("mom_quality_score", 0), - "actions_extracted": len(state.get("action_items", [])), - "decisions_captured": len(state.get("key_decisions", [])) - }, - "actions": state.get("reasoning_trace", []), - "outcome": outcome.value, - "status": "completed", - "started_at": datetime.now().isoformat(), - "completed_at": datetime.now().isoformat() - } - - episodes = episodic._load_episodes() - episodes.append(episode_data) - episodic._save_episodes(episodes) - - except Exception as e: - pass - - return state - - -# ============================================================ -# HELPER FUNCTIONS -# ============================================================ - -def parse_meeting_analysis(llm_response: str) -> Dict[str, Any]: - """Parse LLM response for meeting analysis""" - try: - # Try JSON parsing - if llm_response.strip().startswith("{"): - return json.loads(llm_response) - except: - pass - - # Fallback: heuristic parsing - result = { - "summary": "", - "decisions": [], - "action_items": [], - "risks": [], - "dependencies": [] - } - - lines = llm_response.split("\n") - current_section = None - - for line in lines: - line = line.strip() - if not line: - continue - - lower = line.lower() - - # Section detection - if "summary" in lower and not result["summary"]: - current_section = "summary" - # Try to extract summary from same line - if ":" in line: - result["summary"] = line.split(":", 1)[1].strip() - continue - elif "decision" in lower: - current_section = "decisions" - continue - elif "action" in lower: - current_section = "action_items" - continue - elif "risk" in lower: - current_section = "risks" - continue - elif "depend" in lower: - current_section = "dependencies" - continue - - # Content extraction - clean_line = re.sub(r'^[-•*]\s*', '', line) - - if current_section == "summary" and not result["summary"]: - result["summary"] = clean_line - elif current_section == "decisions": - result["decisions"].append(clean_line) - elif current_section == "action_items": - # Try to parse action item structure - result["action_items"].append({ - "assignee": "TBD", - "action": clean_line, - "deadline": None - }) - elif current_section == "risks": - result["risks"].append(clean_line) - elif current_section == "dependencies": - result["dependencies"].append(clean_line) - - return result - - -# ============================================================ -# GRAPH CONSTRUCTION -# ============================================================ - -def create_meeting_workflow() -> StateGraph: - """ - Build the Meeting Agent workflow - - Flow: - load_context → analyze → generate_mom → quality_check → - [retry if needed] → extract_tasks → check_wellness → record → END - """ - - workflow = StateGraph(MeetingState) - - # Add nodes - workflow.add_node("load_meeting_context", load_meeting_context) - workflow.add_node("analyze_meeting", analyze_meeting) - workflow.add_node("generate_mom", generate_mom) - workflow.add_node("quality_check_mom", quality_check_mom) - workflow.add_node("extract_task_triggers", extract_task_triggers) - workflow.add_node("check_wellness_concerns", check_wellness_concerns) - workflow.add_node("record_episode", record_episode) - - # Set entry point - workflow.set_entry_point("load_meeting_context") - - # Linear flow with quality check loop - workflow.add_edge("load_meeting_context", "analyze_meeting") - workflow.add_edge("analyze_meeting", "generate_mom") - workflow.add_edge("generate_mom", "quality_check_mom") - - # Conditional: retry or accept MoM? - workflow.add_conditional_edges( - "quality_check_mom", - should_retry_mom, - { - "retry": "analyze_meeting", # Loop back - "accept": "extract_task_triggers" - } - ) - - workflow.add_edge("extract_task_triggers", "check_wellness_concerns") - workflow.add_edge("check_wellness_concerns", "record_episode") - workflow.add_edge("record_episode", END) - - return workflow - - -def create_meeting_workflow_with_memory() -> StateGraph: - """Create meeting workflow with memory persistence""" - graph = create_meeting_workflow() - memory = get_checkpointer() - return graph.compile(checkpointer=memory) - - -# ============================================================ -# CONVENIENCE FUNCTIONS -# ============================================================ - -def process_meeting( - meeting_id: str, - user_email: str, - session_id: Optional[str] = None -) -> Dict[str, Any]: - """ - Main entry point for processing a meeting - - Args: - meeting_id: ID of the meeting to process - user_email: User's email - session_id: Optional session ID - - Returns: - Dict with MoM and metadata - """ - if not session_id: - session_id = f"mtg_session_{int(datetime.now().timestamp())}" - - # Create initial state - initial_state = { - "meeting_id": meeting_id, - "user_email": user_email, - "session_id": session_id, - "status": "idle", - "iteration": 0, - "max_iterations": 2, - "meeting_data": None, - "transcript": None, - "past_meeting_patterns": [], - "reasoning_trace": [], - "meeting_summary": None, - "key_decisions": [], - "action_items": [], - "risks": [], - "dependencies": [], - "mom_quality_score": 0.0, - "completeness_score": 0.0, - "mom": None, - "tasks_to_create": [], - "wellness_concern": False, - "episode_id": None - } - - # Create and run graph - graph = create_meeting_workflow_with_memory() - - config = {"configurable": {"thread_id": session_id}} - result = graph.invoke(initial_state, config) - - return { - "mom": result.get("mom"), - "quality_score": result.get("mom_quality_score", 0), - "tasks_to_create": result.get("tasks_to_create", []), - "wellness_concern": result.get("wellness_concern", False), - "reasoning": result.get("reasoning_trace", []), - "session_id": session_id - } - - -if __name__ == "__main__": - # Quick test - result = process_meeting( - meeting_id="mtg_311523c4", - user_email="kowshik.naidu@contoso.com" - ) - - print("Meeting Subgraph Test:") - print(f"Quality: {result['quality_score']:.2f}") - print(f"Tasks to create: {len(result['tasks_to_create'])}") - print(f"Wellness concern: {result['wellness_concern']}") - if result['mom']: - print(f"Summary: {result['mom']['summary'][:100]}...") diff --git a/orchestration/proactive_scheduler.py b/orchestration/proactive_scheduler.py index 0d9f996..faa3f72 100644 --- a/orchestration/proactive_scheduler.py +++ b/orchestration/proactive_scheduler.py @@ -22,9 +22,8 @@ import schedule from repos.data_repo import DataRepo -from orchestration.super_graph import process_user_request -from orchestration.wellness_subgraph import check_wellness -from orchestration.task_subgraph import plan_tasks_for_user +from agents.wellness_agent import WellnessAgent +from orchestration.deep_agent import create_opspilot_agent # ============================================================ @@ -72,18 +71,23 @@ def __init__(self, threshold: int = 50): def check(self, user_email: str) -> Optional[ProactiveEvent]: """Check if user is at burnout risk""" try: - result = check_wellness(user_email, trigger_source="proactive_monitor") - score = result.get("score", 100) - stress = result.get("stress_level", "low") - indicators = result.get("burnout_indicators", []) - + # Calls WellnessAgent directly (it's the same agent + # orchestration/wellness_subgraph.py -- deleted as part of the + # deepagents migration, see issue #15 -- wrapped; this is + # mostly deterministic scoring, not something that needs an + # LLM call for a background threshold check). + wellness = WellnessAgent(DataRepo()).get_wellness_score(user_email) + score = wellness.score + stress = wellness.level + indicators = wellness.factors + if score < self.threshold: # High burnout risk detected return ProactiveEvent( event_type="alert", priority="high" if score < 40 else "medium", title="Burnout Risk Detected", - message=f"Your wellness score is {score:.0f}/100 with {len(indicators)} burnout indicators. Immediate action recommended.", + message=f"Your wellness score is {score:.0f}/100 with {len(indicators)} contributing factors. Immediate action recommended.", actions=[ {"type": "take_break", "label": "Take 15 min break now", "duration_mins": 15}, {"type": "view_recommendations", "label": "View full wellness report"}, @@ -93,7 +97,7 @@ def check(self, user_email: str) -> Optional[ProactiveEvent]: "score": score, "stress_level": stress, "indicators_count": len(indicators), - "indicators": indicators[:3] # Top 3 + "indicators": [f.detail for f in indicators[:3]], # Top 3 }, user_email=user_email, requires_approval=score < 40 # Critical cases need approval @@ -180,21 +184,24 @@ class WorkloadMonitor: """Monitors workload trends and predicts overload""" def check(self, user_email: str) -> Optional[ProactiveEvent]: - """Check workload trends""" + """Check workload trends. + + Uses WellnessAgent's score too (inverted: low wellness == high + workload) rather than orchestration/task_subgraph.py's now-deleted + workload_score field -- that field was the subgraph's own node + logic, not something TasksAgent itself computed, and this is the + same underlying signal BurnoutMonitor already uses.""" try: - result = plan_tasks_for_user(user_email) - plan = result.get("plan", {}) - score = plan.get("workload_score", 0) - stress = plan.get("stress_level", "low") - - # Predict next week based on current trend - # (In production, this would use historical data) - if score >= 90: + wellness = WellnessAgent(DataRepo()).get_wellness_score(user_email) + score = wellness.score + stress = wellness.level + + if score < 20: return ProactiveEvent( event_type="recommendation", priority="high", title="High Workload Detected", - message=f"Your workload is {score:.0f}/100 (CRITICAL). Consider rescheduling or delegating tasks.", + message=f"Your wellness score is {score:.0f}/100 (CRITICAL). Consider rescheduling or delegating tasks.", actions=[ {"type": "delegate_tasks", "label": "Suggest delegation options"}, {"type": "reschedule_meetings", "label": "Reschedule low-priority meetings"}, @@ -203,13 +210,33 @@ def check(self, user_email: str) -> Optional[ProactiveEvent]: metadata={"workload_score": score, "stress_level": stress}, user_email=user_email ) - + except Exception as e: print(f"[ERROR] Workload monitor failed: {e}") return None +# ============================================================ +# DEEP AGENT HELPER (free-text briefings/reports) +# ============================================================ + +def _ask_deep_agent(user_email: str, instruction: str) -> "tuple[str, List[str]]": + """Ask the deepagents-based agent (orchestration/deep_agent.py) a + free-text question -- replaces orchestration/super_graph.py's + process_user_request(), which this proactive scheduler used to call + for the same purpose.""" + from orchestration.chat_workflow import _extract_subagents_invoked + + agent = create_opspilot_agent(user_email=user_email) + thread_id = f"proactive_{user_email}_{int(datetime.now().timestamp())}" + config = {"configurable": {"thread_id": thread_id}} + result = agent.invoke({"messages": [{"role": "user", "content": instruction}]}, config=config) + messages = result.get("messages", []) + response = messages[-1].content if messages else "No response generated" + return response, _extract_subagents_invoked(messages) + + # ============================================================ # SCHEDULED ACTIONS # ============================================================ @@ -276,62 +303,51 @@ def _run_scheduler(self): def _morning_briefing(self): """Automated morning briefing for all active users""" print(f"\n[PROACTIVE] Morning Briefing - {datetime.now().strftime('%H:%M')}") - + # In production, loop through all active users users = ["kowshik.naidu@contoso.com"] - + for user in users: try: - result = process_user_request( - user_input="Give me my morning briefing", - user_email=user - ) - + response, agents_invoked = _ask_deep_agent(user, "Give me my morning briefing") + event = ProactiveEvent( event_type="briefing", priority="medium", title="Good Morning! Here's Your Daily Briefing", - message=result.get("response", "Briefing generated"), - metadata={ - "agents_used": result.get("agents_used", []), - "workload": result.get("task_result", {}).get("plan", {}).get("workload_score", 0) - }, + message=response, + metadata={"agents_used": agents_invoked}, user_email=user ) - + self._queue_event(event) print(f" -> Briefing generated for {user}") - + except Exception as e: print(f" -> Failed for {user}: {e}") - + def _eod_summary(self): """Automated end-of-day summary""" print(f"\n[PROACTIVE] EOD Summary - {datetime.now().strftime('%H:%M')}") - + users = ["kowshik.naidu@contoso.com"] - + for user in users: try: - result = process_user_request( - user_input="Give me my end-of-day report", - user_email=user - ) - + response, agents_invoked = _ask_deep_agent(user, "Give me my end-of-day report") + event = ProactiveEvent( event_type="briefing", priority="low", title="End of Day Summary", - message=result.get("response", "Report generated"), - metadata={ - "productivity_score": result.get("report_result", {}).get("productivity_score", 0) - }, + message=response, + metadata={"agents_used": agents_invoked}, user_email=user ) - + self._queue_event(event) print(f" -> EOD summary for {user}") - + except Exception as e: print(f" -> Failed for {user}: {e}") diff --git a/orchestration/super_graph.py b/orchestration/super_graph.py deleted file mode 100644 index 8b8b5c7..0000000 --- a/orchestration/super_graph.py +++ /dev/null @@ -1,850 +0,0 @@ -# orchestration/super_graph.py -""" -Super-Graph Router - Multi-Agent Orchestration -============================================== -Central orchestrator that: -- Classifies user intent -- Routes to specialized agent subgraphs -- Manages cross-agent triggers -- Enables parallel execution -- Learns routing patterns from memory - -This is the brain that coordinates all specialized agents. -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Literal, Optional, Annotated -from datetime import datetime -import operator -import json - -from langgraph.graph import StateGraph, END -from orchestration.checkpointer import get_checkpointer - -# Phase 1 imports -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeType, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway -from orchestration.common_state import WorkplaceState, create_initial_state - -# Agent imports -from repos.data_repo import DataRepo - - -# ============================================================ -# STATE DEFINITION -# ============================================================ - -class SuperGraphState(TypedDict): - """Central state for multi-agent orchestration""" - # User input - user_input: str - user_email: str - session_id: str - - # Intent classification - intent: Optional[str] # email, meeting, task, wellness, followup, report, chat, briefing - confidence: float - intent_reasoning: str - - # Routing - current_agent: Optional[str] - agents_invoked: List[str] # Changed: removed operator.add to prevent duplicates - - # Context - workplace_state: WorkplaceState - cross_agent_context: Dict[str, Any] - - # Subgraph results - email_result: Optional[Dict[str, Any]] - meeting_result: Optional[Dict[str, Any]] - task_result: Optional[Dict[str, Any]] - wellness_result: Optional[Dict[str, Any]] - followup_result: Optional[Dict[str, Any]] - report_result: Optional[Dict[str, Any]] - - # Triggers - triggered_agents: List[Dict[str, Any]] # Changed: removed operator.add - - # Output - final_response: Optional[str] - actions_taken: List[str] # Changed: removed operator.add - - # Agent reasoning trace (append-only) - reasoning_trace: Annotated[List[str], operator.add] - - # Episode tracking - episode_id: Optional[str] - - -# ============================================================ -# HELPER FUNCTIONS -# ============================================================ - -def parse_llm_intent(llm_response: str) -> tuple[str, float, str]: - """Parse LLM response for intent classification""" - try: - # Try JSON parsing first - if llm_response.strip().startswith("{"): - data = json.loads(llm_response) - return ( - data.get("intent", "chat"), - data.get("confidence", 0.5), - data.get("reasoning", "") - ) - except: - pass - - # Fallback: keyword matching - text = llm_response.lower() - - # Intent patterns - patterns = { - "email": ["email", "inbox", "message", "reply", "draft", "urgent email"], - "meeting": ["meeting", "mom", "minutes", "transcript", "agenda"], - "task": ["task", "todo", "plan", "deadline", "priority", "workload", "my tasks"], - "wellness": ["wellness", "stress", "burnout", "break", "health", "at risk"], - "followup": ["followup", "nudge", "reminder", "overdue"], - "report": ["report", "summary", "eod", "weekly", "productivity", "end-of-day"], - "briefing": ["brief", "briefing", "overview", "status", "daily", "morning", "catch me up", "what's going on"] - } - - for intent, keywords in patterns.items(): - if any(kw in text for kw in keywords): - confidence = 0.7 if len([kw for kw in keywords if kw in text]) > 1 else 0.5 - return intent, confidence, f"Detected keywords: {keywords}" - - return "chat", 0.3, "No clear intent detected, defaulting to chat" - - -# ============================================================ -# NODE FUNCTIONS -# ============================================================ - -def classify_intent(state: SuperGraphState) -> SuperGraphState: - """ - Node 1: Classify user intent using memory + LLM - - Strategy: - 1. Check episodic memory for similar requests - 2. If high confidence match found, use that - 3. Otherwise, use LLM with prompt optimization - """ - user_input = state["user_input"] - user_email = state["user_email"] - - # Initialize memory - episodic = EpisodicMemory("super_graph") - agent_memory = AgentMemory("super_graph") - - # Check memory for similar requests - similar_episodes = [] - try: - # Look for past user requests - all_episodes = episodic._load_episodes() - similar_episodes = [ - ep for ep in all_episodes - if ep.get("episode_type") == "user_request" - and ep.get("status") == "completed" - and ep.get("outcome") == "success" - ][:5] # Top 5 recent successes - except: - pass - - # If we have high-confidence memory, use it - memory_intent = None - if similar_episodes: - # Simple keyword matching against past requests - for ep in similar_episodes: - past_input = ep.get("context", {}).get("user_input", "").lower() - if any(word in user_input.lower() for word in past_input.split()[:5]): - memory_intent = ep.get("context", {}).get("classified_intent") - if memory_intent: - state["intent"] = memory_intent - state["confidence"] = 0.9 - state["intent_reasoning"] = f"High confidence from past success: {ep['episode_id']}" - return state - - # Fallback to LLM classification - gateway = EnhancedLiteLLMGateway("super_graph", enable_cache=True) - - # Recall user preferences for context - preferences = [] - try: - prefs = agent_memory.recall( - query=f"How does {user_email} typically use the system?", - n_results=3, - memory_type=MemoryType.PREFERENCE - ) - preferences = [p['content'] for p in prefs] - except: - pass - - pref_context = "\n".join(preferences) if preferences else "No user preferences stored yet." - - prompt = f"""Classify the user's intent for this workplace assistant request. - -User: {user_email} -Request: {user_input} - -User Preferences Context: -{pref_context} - -Available intents: -- email: Inbox management, email analysis, drafting replies -- meeting: Meeting summaries, MoM generation, transcript analysis -- task: Task planning, prioritization, workload management -- wellness: Stress monitoring, break suggestions, burnout prevention -- followup: Reminders, nudges for overdue items -- report: End-of-day reports, productivity summaries, weekly reviews -- briefing: Morning briefing, daily overview, status updates -- chat: General conversation, questions, clarifications - -Return JSON: -{{ - "intent": "", - "confidence": <0.0-1.0>, - "reasoning": "" -}}""" - - try: - response = gateway.call( - prompt=prompt, - temperature=0.2, - use_cache=True, - role_context="classifier" - ) - - intent, confidence, reasoning = parse_llm_intent(response) - - state["intent"] = intent - state["confidence"] = confidence - state["intent_reasoning"] = reasoning - state["reasoning_trace"] = [f"Classified intent as '{intent}' with confidence {confidence:.2f}: {reasoning}"] - - # Store this classification in memory for future - agent_memory.remember( - content=f"User request '{user_input[:50]}...' classified as '{intent}'", - memory_type=MemoryType.INTERACTION, - metadata={"user": user_email, "intent": intent, "confidence": confidence} - ) - - except Exception as e: - # Ultimate fallback - intent, confidence, reasoning = parse_llm_intent(user_input) - state["intent"] = intent - state["confidence"] = confidence - state["intent_reasoning"] = f"Fallback classification: {reasoning}" - state["reasoning_trace"] = [f"Fallback classification: '{intent}' with confidence {confidence:.2f}"] - - return state - - -def route_to_agent(state: SuperGraphState) -> str: - """ - Routing decision: Which agent subgraph to invoke? - - Returns the next node name based on intent - """ - intent = state.get("intent", "chat") - - # Map intents to agent nodes - routing_map = { - "email": "invoke_email_agent", - "meeting": "invoke_meeting_agent", - "task": "invoke_task_agent", - "wellness": "invoke_wellness_agent", - "followup": "invoke_followup_agent", - "report": "invoke_report_agent", - "briefing": "invoke_briefing", # Special: parallel execution - "chat": "handle_chat" - } - - return routing_map.get(intent, "handle_chat") - - -def invoke_email_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke email agent subgraph (autonomous_graph.py)""" - from orchestration.autonomous_graph import create_email_workflow - - # For now using placeholder - full integration would invoke the actual email workflow - # with memory context from Phase 1 - state["email_result"] = { - "status": "completed", - "message": "Email processed with memory context", - "requires_task": False # Would be determined by email analysis - } - if "email" not in state.get("agents_invoked", []): - state["agents_invoked"].append("email") - state["actions_taken"].append("Processed email with learned patterns") - state["reasoning_trace"] = [f"Invoked email agent: {state['email_result'].get('message', 'completed')}"] - - return state - - -def invoke_meeting_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke meeting agent subgraph""" - from orchestration.meeting_subgraph import process_meeting - - # Check if we have a meeting_id in the user input or context - # For demo, using placeholder - state["meeting_result"] = { - "status": "completed", - "message": "Meeting MoM generated", - "tasks_created": 2, - "wellness_concern": False - } - if "meeting" not in state.get("agents_invoked", []): - state["agents_invoked"].append("meeting") - state["actions_taken"].append("Generated meeting minutes with action items") - state["reasoning_trace"] = [f"Invoked meeting agent: {state['meeting_result'].get('message', 'completed')}"] - - return state - - -def invoke_task_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke task agent subgraph""" - from orchestration.task_subgraph import plan_tasks_for_user - - try: - # Actual invocation - result = plan_tasks_for_user( - user_email=state["user_email"] - ) - - state["task_result"] = { - "status": "completed", - "message": f"Task plan generated (workload: {result['workload_score']:.0f}/100)", - "workload_high": result['workload_score'] > 70, - "stress_detected": result['stress_level'] in ["high", "critical"], - "workload_score": result['workload_score'] - } - - # Capture actual reasoning from subgraph - subgraph_reasoning = result.get("reasoning", []) - state["reasoning_trace"] = [ - f"[TaskAgent] Workload: {result['workload_score']:.0f}/100, Stress: {result['stress_level']}" - ] + [f"[TaskAgent] {r}" for r in subgraph_reasoning[:5]] # Top 5 reasoning steps - - # Check if we should trigger wellness agent - if result.get("trigger_wellness"): - state["task_result"]["trigger_wellness"] = True - - except Exception as e: - state["task_result"] = { - "status": "completed", - "message": f"Task planning completed" - } - state["reasoning_trace"] = [f"[TaskAgent] Completed with fallback"] - - if "task" not in state.get("agents_invoked", []): - state["agents_invoked"].append("task") - state["actions_taken"].append("Created daily task plan with wellness check") - - return state - - -def invoke_wellness_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke wellness agent subgraph""" - from orchestration.wellness_subgraph import check_wellness - - try: - # Determine trigger context - trigger_context = {} - if state.get("task_result"): - trigger_context = {"workload_score": state["task_result"].get("workload_score", 0)} - - # Actual invocation - result = check_wellness( - user_email=state["user_email"], - trigger_source="super_graph", - trigger_context=trigger_context - ) - - state["wellness_result"] = { - "status": "completed", - "message": f"Wellness check: {result['stress_level']} stress (score: {result['score']:.0f}/100)", - "score": result["score"], - "stress_level": result["stress_level"], - "burnout_risk": result["score"] < 40, - "recommendations": len(result["recommendations"]["breaks"]) - } - - # Capture actual reasoning from subgraph - subgraph_reasoning = result.get("reasoning", []) - burnout_count = len(result.get("burnout_indicators", [])) - state["reasoning_trace"] = [ - f"[WellnessAgent] Score: {result['score']:.0f}/100, Stress: {result['stress_level']}, Burnout indicators: {burnout_count}" - ] + [f"[WellnessAgent] {r}" for r in subgraph_reasoning[:5]] # Top 5 reasoning steps - - except Exception as e: - state["wellness_result"] = { - "status": "completed", - "message": "Wellness check completed" - } - state["reasoning_trace"] = [f"[WellnessAgent] Completed with fallback"] - - if "wellness" not in state.get("agents_invoked", []): - state["agents_invoked"].append("wellness") - state["actions_taken"].append("Performed wellness assessment") - - return state - - -def invoke_followup_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke followup agent subgraph""" - from orchestration.followup_reporting_subgraphs import generate_followups - - try: - result = generate_followups(state["user_email"]) - - state["followup_result"] = { - "status": "completed", - "message": f"Generated {result.get('count', 0)} nudges for overdue items", - "nudge_count": result.get("count", 0) - } - - except Exception as e: - state["followup_result"] = { - "status": "completed", - "message": "Followup nudges generated" - } - - if "followup" not in state.get("agents_invoked", []): - state["agents_invoked"].append("followup") - state["actions_taken"].append("Generated followup nudges") - state["reasoning_trace"] = [f"Invoked followup agent: {state['followup_result'].get('message', 'completed')}"] - - return state - - -def invoke_report_agent(state: SuperGraphState) -> SuperGraphState: - """Invoke reporting agent subgraph""" - from orchestration.followup_reporting_subgraphs import generate_report_for_user - - try: - result = generate_report_for_user(state["user_email"], report_type="eod") - - state["report_result"] = { - "status": "completed", - "message": f"EOD report generated (score: {result.get('productivity_score', 0):.0f}/100)", - "productivity_score": result.get("productivity_score", 0), - "tasks_completed": result.get("summary", {}).get("tasks_completed", 0) - } - - except Exception as e: - state["report_result"] = { - "status": "completed", - "message": "Report generated" - } - - if "report" not in state.get("agents_invoked", []): - state["agents_invoked"].append("report") - state["actions_taken"].append("Generated productivity report") - state["reasoning_trace"] = [f"Invoked report agent: {state['report_result'].get('message', 'completed')}"] - - return state - - -def invoke_briefing(state: SuperGraphState) -> SuperGraphState: - """ - Special node: Parallel execution of multiple agents for morning briefing - - This demonstrates cross-agent coordination for a comprehensive view - """ - from orchestration.task_subgraph import plan_tasks_for_user - from orchestration.wellness_subgraph import check_wellness - from orchestration.followup_reporting_subgraphs import generate_followups - - user_email = state["user_email"] - reasoning_entries = [] - - # Execute all agents (would be parallel in production) - try: - task_result = plan_tasks_for_user(user_email) - plan = task_result.get('plan', {}) - state["task_result"] = { - "plan": f"{plan.get('priority_breakdown', {})}", - "workload_score": task_result.get('workload_score', 0), - "stress_level": task_result.get('stress_level', 'unknown') - } - reasoning_entries.append(f"[Briefing:Task] Workload: {task_result.get('workload_score', 0):.0f}/100, Stress: {task_result.get('stress_level', 'unknown')}") - # Add subgraph reasoning - for r in task_result.get('reasoning', [])[:3]: - reasoning_entries.append(f"[Briefing:Task] {r}") - except Exception as e: - state["task_result"] = {"plan": "N/A"} - reasoning_entries.append(f"[Briefing:Task] Failed to load: {str(e)[:50]}") - - try: - wellness_result = check_wellness(user_email, trigger_source="briefing") - state["wellness_result"] = { - "score": wellness_result.get("score", 0), - "status": wellness_result.get("stress_level", "unknown"), - "burnout_indicators": len(wellness_result.get("burnout_indicators", [])) - } - reasoning_entries.append(f"[Briefing:Wellness] Score: {wellness_result.get('score', 0):.0f}/100, Burnout indicators: {len(wellness_result.get('burnout_indicators', []))}") - # Add subgraph reasoning - for r in wellness_result.get('reasoning', [])[:3]: - reasoning_entries.append(f"[Briefing:Wellness] {r}") - except Exception as e: - state["wellness_result"] = {"score": 70, "status": "moderate"} - reasoning_entries.append(f"[Briefing:Wellness] Using defaults: score 70, moderate stress") - - try: - followup_result = generate_followups(user_email) - state["followup_result"] = {"nudges": followup_result.get("count", 0)} - reasoning_entries.append(f"[Briefing:Followup] Generated {followup_result.get('count', 0)} nudges for overdue items") - except: - state["followup_result"] = {"nudges": 0} - reasoning_entries.append("[Briefing:Followup] No nudges generated") - - # Mock email summary (would actually scan inbox) - state["email_result"] = {"summary": "3 urgent emails, 12 unread"} - reasoning_entries.append("[Briefing:Email] Inbox scan: 3 urgent, 12 unread (mock data)") - - # Add agents only if not already present - for agent in ["email", "task", "wellness", "followup"]: - if agent not in state.get("agents_invoked", []): - state["agents_invoked"].append(agent) - state["actions_taken"].append("Generated comprehensive morning briefing") - state["reasoning_trace"] = reasoning_entries - - return state - - -def handle_chat(state: SuperGraphState) -> SuperGraphState: - """Handle general chat queries""" - gateway = EnhancedLiteLLMGateway("super_graph", enable_cache=True) - - prompt = f"""You are a helpful workplace assistant. Respond to this query: - -User: {state['user_email']} -Query: {state['user_input']} - -Provide a helpful, concise response.""" - - try: - response = gateway.call( - prompt=prompt, - temperature=0.7, - use_cache=True, - role_context="chat" - ) - state["final_response"] = response - except: - state["final_response"] = "I'm here to help! Could you please rephrase your request?" - - state["actions_taken"].append("Responded to chat query") - state["reasoning_trace"] = [f"Handled chat query: generated direct response"] - return state - - -def check_cross_agent_triggers(state: SuperGraphState) -> SuperGraphState: - """ - Check if any agent results trigger other agents - - Examples: - - Email with action items → Trigger task agent - - High workload detected → Trigger wellness agent - - Overdue tasks → Trigger followup agent - """ - triggers = [] - agents_invoked = state.get("agents_invoked", []) - - # Prevent infinite loops - limit total agents to 4 - if len(agents_invoked) >= 4: - state["triggered_agents"] = [] - return state - - # Check email result for task triggers - if state.get("email_result") and "task" not in agents_invoked: - email_res = state["email_result"] - if email_res.get("requires_task"): - triggers.append({ - "target": "task", - "reason": "Email contains action items", - "context": {"source": "email", "email_id": email_res.get("email_id")} - }) - - # Check task result for wellness triggers (only once) - if state.get("task_result") and "wellness" not in agents_invoked and len(agents_invoked) < 3: - task_res = state["task_result"] - if task_res.get("workload_high") or task_res.get("stress_detected"): - triggers.append({ - "target": "wellness", - "reason": "High workload detected", - "context": {"source": "task", "workload": task_res.get("workload_score")} - }) - - state["triggered_agents"] = triggers - return state - - -def should_trigger_more_agents(state: SuperGraphState) -> str: - """Decision: Should we trigger additional agents?""" - if state.get("triggered_agents"): - return "execute_triggers" - return "generate_response" - - -def execute_triggers(state: SuperGraphState) -> SuperGraphState: - """Execute cross-agent triggers (one-time only)""" - agents_invoked = state.get("agents_invoked", []) - - for trigger in state.get("triggered_agents", []): - target = trigger["target"] - - # Double-check agent hasn't been invoked already - if target == "task" and "task" not in agents_invoked: - state = invoke_task_agent(state) - agents_invoked = state.get("agents_invoked", []) # Refresh list - elif target == "wellness" and "wellness" not in agents_invoked: - state = invoke_wellness_agent(state) - agents_invoked = state.get("agents_invoked", []) # Refresh list - elif target == "followup" and "followup" not in agents_invoked: - state = invoke_followup_agent(state) - agents_invoked = state.get("agents_invoked", []) # Refresh list - - # Clear triggers after execution - state["triggered_agents"] = [] - return state - - -def generate_response(state: SuperGraphState) -> SuperGraphState: - """ - Final node: Generate comprehensive response - - Combines all agent results into user-friendly response - """ - if state.get("final_response"): - # Chat already generated response - return state - - # Aggregate results from all invoked agents - results = [] - - if state.get("email_result"): - results.append(f"📧 Email: {state['email_result'].get('message', 'Processed')}") - - if state.get("meeting_result"): - results.append(f"📅 Meeting: {state['meeting_result'].get('message', 'Processed')}") - - if state.get("task_result"): - results.append(f"[OK] Tasks: {state['task_result'].get('message', 'Processed')}") - - if state.get("wellness_result"): - results.append(f"🧘 Wellness: {state['wellness_result'].get('message', 'Processed')}") - - if state.get("followup_result"): - results.append(f"🔔 Followups: {state['followup_result'].get('message', 'Processed')}") - - if state.get("report_result"): - results.append(f"📊 Report: {state['report_result'].get('message', 'Generated')}") - - state["final_response"] = "\n".join(results) if results else "Request processed successfully." - state["reasoning_trace"] = [f"Generated final response combining {len(results)} agent results"] - - return state - - -def record_episode(state: SuperGraphState) -> SuperGraphState: - """Record this orchestration as an episode for learning""" - episodic = EpisodicMemory("super_graph") - - try: - # Determine outcome based on whether we generated a response - outcome = EpisodeOutcome.SUCCESS if state.get("final_response") else EpisodeOutcome.FAILURE - - # Create episode record - episode_data = { - "episode_id": f"sg_{int(datetime.now().timestamp() * 1000)}", - "episode_type": "user_request", - "trigger": state["user_input"][:100], - "context": { - "user_input": state["user_input"], - "user_email": state["user_email"], - "classified_intent": state.get("intent"), - "confidence": state.get("confidence", 0), - "agents_invoked": state.get("agents_invoked", []) - }, - "actions": state.get("actions_taken", []), - "reasoning_trace": state.get("reasoning_trace", []), - "outcome": outcome.value, - "status": "completed", - "started_at": datetime.now().isoformat(), - "completed_at": datetime.now().isoformat() - } - - # Save episode - episodes = episodic._load_episodes() - episodes.append(episode_data) - episodic._save_episodes(episodes) - - except Exception as e: - # Non-critical, don't fail the workflow - pass - - return state - - -# ============================================================ -# GRAPH CONSTRUCTION -# ============================================================ - -def create_super_graph() -> StateGraph: - """ - Build the Super-Graph workflow - - Flow: - classify_intent → route_to_agent → [agent subgraph] → - check_triggers → [optional: more agents] → generate_response → END - """ - - workflow = StateGraph(SuperGraphState) - - # Add nodes - workflow.add_node("classify_intent", classify_intent) - workflow.add_node("invoke_email_agent", invoke_email_agent) - workflow.add_node("invoke_meeting_agent", invoke_meeting_agent) - workflow.add_node("invoke_task_agent", invoke_task_agent) - workflow.add_node("invoke_wellness_agent", invoke_wellness_agent) - workflow.add_node("invoke_followup_agent", invoke_followup_agent) - workflow.add_node("invoke_report_agent", invoke_report_agent) - workflow.add_node("invoke_briefing", invoke_briefing) - workflow.add_node("handle_chat", handle_chat) - workflow.add_node("check_cross_agent_triggers", check_cross_agent_triggers) - workflow.add_node("execute_triggers", execute_triggers) - workflow.add_node("generate_response", generate_response) - workflow.add_node("record_episode", record_episode) - - # Set entry point - workflow.set_entry_point("classify_intent") - - # Add conditional routing from classify_intent - workflow.add_conditional_edges( - "classify_intent", - route_to_agent, - { - "invoke_email_agent": "invoke_email_agent", - "invoke_meeting_agent": "invoke_meeting_agent", - "invoke_task_agent": "invoke_task_agent", - "invoke_wellness_agent": "invoke_wellness_agent", - "invoke_followup_agent": "invoke_followup_agent", - "invoke_report_agent": "invoke_report_agent", - "invoke_briefing": "invoke_briefing", - "handle_chat": "handle_chat" - } - ) - - # All agent nodes flow to trigger check - for node in ["invoke_email_agent", "invoke_meeting_agent", "invoke_task_agent", - "invoke_wellness_agent", "invoke_followup_agent", "invoke_report_agent", - "invoke_briefing", "handle_chat"]: - workflow.add_edge(node, "check_cross_agent_triggers") - - # Conditional: trigger more agents or finish? - workflow.add_conditional_edges( - "check_cross_agent_triggers", - should_trigger_more_agents, - { - "execute_triggers": "execute_triggers", - "generate_response": "generate_response" - } - ) - - # Triggers go directly to response generation (no loop) - workflow.add_edge("execute_triggers", "generate_response") - - # Generate response → record episode → END - workflow.add_edge("generate_response", "record_episode") - workflow.add_edge("record_episode", END) - - return workflow - - -def create_super_graph_with_memory() -> StateGraph: - """Create super-graph with memory persistence""" - graph = create_super_graph() - memory = get_checkpointer() - return graph.compile(checkpointer=memory) - - -# ============================================================ -# CONVENIENCE FUNCTIONS -# ============================================================ - -def process_user_request( - user_input: str, - user_email: str, - session_id: Optional[str] = None -) -> Dict[str, Any]: - """ - Main entry point for processing user requests - - Args: - user_input: The user's request text - user_email: User's email for context - session_id: Optional session ID for continuity - - Returns: - Dict with final_response and metadata - """ - if not session_id: - session_id = f"session_{int(datetime.now().timestamp())}" - - # Create initial state - initial_state = { - "user_input": user_input, - "user_email": user_email, - "session_id": session_id, - "intent": None, - "confidence": 0.0, - "intent_reasoning": "", - "current_agent": None, - "agents_invoked": [], - "workplace_state": create_initial_state("general", user_email, session_id), - "cross_agent_context": {}, - "email_result": None, - "meeting_result": None, - "task_result": None, - "wellness_result": None, - "followup_result": None, - "report_result": None, - "triggered_agents": [], - "final_response": None, - "actions_taken": [], - "reasoning_trace": [], - "episode_id": None - } - - # Create and run graph - graph = create_super_graph_with_memory() - - config = { - "configurable": {"thread_id": session_id}, - "recursion_limit": 15 # Prevent infinite loops - } - result = graph.invoke(initial_state, config) - - return { - "response": result.get("final_response", "Request processed."), - "intent": result.get("intent"), - "confidence": result.get("confidence"), - "agents_used": result.get("agents_invoked", []), - "actions": result.get("actions_taken", []), - "reasoning_trace": result.get("reasoning_trace", []), - "session_id": session_id - } - - -if __name__ == "__main__": - # Quick test - result = process_user_request( - user_input="Show me my urgent emails", - user_email="kowshik.naidu@contoso.com" - ) - - print("Super-Graph Test:") - print(f"Intent: {result['intent']} (confidence: {result['confidence']:.2f})") - print(f"Agents: {', '.join(result['agents_used'])}") - print(f"Response: {result['response']}") diff --git a/orchestration/task_subgraph.py b/orchestration/task_subgraph.py deleted file mode 100644 index 91e0f75..0000000 --- a/orchestration/task_subgraph.py +++ /dev/null @@ -1,619 +0,0 @@ -# orchestration/task_subgraph.py -""" -Task Agent Subgraph - Intelligent Workload Management -==================================================== -Transforms task planning into an agentic workflow with: -- Eisenhower matrix prioritization -- Workload analysis with wellness integration -- Burnout risk detection → triggers wellness agent -- Focus block recommendations -- Smart deadline management -- Learning from user's task completion patterns - -Helps corporate employees manage workload sustainably! -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Optional, Annotated -from datetime import datetime, timedelta -import operator -import json - -from langgraph.graph import StateGraph, END -from orchestration.checkpointer import get_checkpointer - -# Phase 1 imports -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeType, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway -from orchestration.common_state import TaskWorkflowState, create_initial_state - -# Agent imports -from agents.tasks_agent import TasksAgent -from repos.data_repo import DataRepo - - -# ============================================================ -# STATE DEFINITION -# ============================================================ - -class TaskState(TypedDict): - """State for task planning workflow""" - # Input - user_email: str - session_id: str - request_type: str # "plan_today", "prioritize", "focus_blocks", "add_task" - - # Optional: for add_task requests - new_task: Optional[Dict[str, Any]] - - # Processing state - status: str # idle, loading, analyzing, prioritizing, planning, completed - iteration: int - - # Context - user_tasks: List[Dict[str, Any]] - user_meetings: List[Dict[str, Any]] - past_completion_patterns: List[Dict[str, Any]] - - # Agent reasoning - reasoning_trace: Annotated[List[str], operator.add] - - # Analysis results - eisenhower_board: Dict[str, List[Dict[str, Any]]] # P0, P1, P2, P3 - workload_score: float # 0-100 - stress_level: str # low, moderate, high, critical - burnout_risk: bool - - # Planning outputs - focus_blocks: List[Dict[str, Any]] - recommended_breaks: List[Dict[str, Any]] - tasks_to_defer: List[Dict[str, Any]] - - # Wellness integration - wellness_score: Optional[float] - wellness_concern: bool - - # Cross-agent triggers - trigger_wellness_agent: bool - trigger_followup_agent: bool - - # Output - today_plan: Optional[Dict[str, Any]] - - # Episode tracking - episode_id: Optional[str] - - -# ============================================================ -# NODE FUNCTIONS -# ============================================================ - -def load_task_context(state: TaskState) -> TaskState: - """ - Node 1: Load user's tasks, meetings, and recall past patterns - """ - repo = DataRepo() - memory = AgentMemory("task_agent") - - user_email = state["user_email"] - - # Load user's tasks - try: - # Find user - users = repo.users() - user = next((u for u in users if u.get("email") == user_email), None) - - if not user: - state["status"] = "error" - state["reasoning_trace"].append(f"User {user_email} not found") - return state - - user_id = user["user_id"] - - # Get user's tasks - all_tasks = repo.tasks() - user_tasks = [t for t in all_tasks if t.get("owner_user_id") == user_id] - - # Filter to active tasks only - active_tasks = [ - t for t in user_tasks - if t.get("status") not in ["done", "cancelled"] - ] - - state["user_tasks"] = active_tasks - state["reasoning_trace"].append(f"Loaded {len(active_tasks)} active tasks") - - except Exception as e: - state["status"] = "error" - state["reasoning_trace"].append(f"Error loading tasks: {str(e)}") - return state - - # Load user's meetings (for time availability) - try: - meetings = repo.meetings() - today = datetime.now().date() - - # Filter to today's meetings - user_meetings = [ - m for m in meetings - if user_email in m.get("attendees", []) - and m.get("scheduled_at", "").startswith(str(today)) - ] - - state["user_meetings"] = user_meetings - state["reasoning_trace"].append(f"Found {len(user_meetings)} meetings today") - - except: - state["user_meetings"] = [] - - # Recall past completion patterns - try: - patterns = memory.recall( - query=f"Task completion patterns for {user_email}", - n_results=5, - memory_type=MemoryType.STRATEGY - ) - - state["past_completion_patterns"] = patterns - if patterns: - state["reasoning_trace"].append(f"Recalled {len(patterns)} past patterns") - except: - state["past_completion_patterns"] = [] - - state["status"] = "analyzing" - return state - - -def analyze_workload(state: TaskState) -> TaskState: - """ - Node 2: Analyze workload and calculate stress metrics - - Factors: - - Number of P0/P1 tasks - - Overdue tasks - - Meeting load - - Available focus time - """ - tasks = state["user_tasks"] - meetings = state["user_meetings"] - - # Count by priority - p0_count = len([t for t in tasks if t.get("priority") == "P0"]) - p1_count = len([t for t in tasks if t.get("priority") == "P1"]) - p2_count = len([t for t in tasks if t.get("priority") == "P2"]) - p3_count = len([t for t in tasks if t.get("priority") == "P3"]) - - # Count overdue - today = datetime.now().isoformat() - overdue_count = len([ - t for t in tasks - if t.get("due_date_utc") and t["due_date_utc"] < today - ]) - - # Meeting time (assume 30 min default if not specified) - total_meeting_mins = sum(m.get("duration_mins", 30) for m in meetings) - - # Workload scoring (0-100, higher = more stress) - workload_score = 0 - workload_score += p0_count * 15 # Each P0 adds 15 points - workload_score += p1_count * 8 # Each P1 adds 8 points - workload_score += p2_count * 3 # Each P2 adds 3 points - workload_score += overdue_count * 10 # Each overdue adds 10 points - workload_score += (total_meeting_mins / 60) * 5 # Each hour of meetings adds 5 points - - # Cap at 100 - workload_score = min(workload_score, 100) - - # Determine stress level - if workload_score >= 80: - stress_level = "critical" - burnout_risk = True - elif workload_score >= 60: - stress_level = "high" - burnout_risk = False - elif workload_score >= 40: - stress_level = "moderate" - burnout_risk = False - else: - stress_level = "low" - burnout_risk = False - - state["workload_score"] = workload_score - state["stress_level"] = stress_level - state["burnout_risk"] = burnout_risk - - state["reasoning_trace"].append( - f"Workload: {workload_score:.0f}/100 ({stress_level}), " - f"P0={p0_count}, P1={p1_count}, overdue={overdue_count}, " - f"meetings={total_meeting_mins}min" - ) - - # Flag wellness concern if needed - if burnout_risk or stress_level == "critical": - state["wellness_concern"] = True - state["trigger_wellness_agent"] = True - state["reasoning_trace"].append("⚠️ Wellness concern detected - will trigger wellness agent") - - state["status"] = "prioritizing" - return state - - -def prioritize_tasks(state: TaskState) -> TaskState: - """ - Node 3: Organize tasks using Eisenhower matrix - - Learns from past user behavior to refine prioritization - """ - tasks = state["user_tasks"] - memory = AgentMemory("task_agent") - - # Eisenhower matrix: P0, P1, P2, P3 - board = { - "P0": [], # Urgent & Important - "P1": [], # Important, Not Urgent - "P2": [], # Urgent, Not Important - "P3": [] # Neither Urgent Nor Important - } - - # Sort tasks into buckets - for task in tasks: - priority = task.get("priority", "P2") - board.setdefault(priority, []).append(task) - - # Sort each bucket by due date - for priority in board: - board[priority].sort( - key=lambda t: t.get("due_date_utc") or "9999-12-31T23:59:59+00:00" - ) - - state["eisenhower_board"] = board - - # Check past patterns for priority adjustments - if state["past_completion_patterns"]: - # Learn: Does user typically complete certain types faster? - # This is where we'd apply learned adjustments - # For now, just log that we have patterns - state["reasoning_trace"].append( - f"Applied {len(state['past_completion_patterns'])} learned patterns" - ) - - state["reasoning_trace"].append( - f"Prioritized: P0={len(board['P0'])}, P1={len(board['P1'])}, " - f"P2={len(board['P2'])}, P3={len(board['P3'])}" - ) - - state["status"] = "planning" - return state - - -def create_focus_blocks(state: TaskState) -> TaskState: - """ - Node 4: Recommend focus blocks for deep work - - Considers: - - Meeting gaps - - Task complexity - - User's past productive times - """ - gateway = EnhancedLiteLLMGateway("task_agent", enable_cache=True) - - board = state["eisenhower_board"] - meetings = state["user_meetings"] - stress_level = state["stress_level"] - - # Calculate available focus time (8hr day - meetings) - total_meeting_mins = sum(m.get("duration_mins", 30) for m in meetings) - available_mins = (8 * 60) - total_meeting_mins - - # Recommend focus blocks based on workload - focus_blocks = [] - - # High priority tasks get focus blocks - p0_tasks = board.get("P0", []) - p1_tasks = board.get("P1", []) - - if p0_tasks: - # P0 tasks need immediate focus - for task in p0_tasks[:3]: # Top 3 P0s - focus_blocks.append({ - "task_id": task["task_id"], - "title": task.get("title", "Untitled"), - "recommended_duration": 90 if task.get("complexity") == "high" else 60, - "priority": "P0", - "rationale": "Urgent and important task" - }) - - if p1_tasks and len(focus_blocks) < 3: - # Add P1 focus blocks if we have capacity - for task in p1_tasks[:2]: - focus_blocks.append({ - "task_id": task["task_id"], - "title": task.get("title", "Untitled"), - "recommended_duration": 60, - "priority": "P1", - "rationale": "Important task requiring focused attention" - }) - - state["focus_blocks"] = focus_blocks[:3] # Max 3 focus blocks per day - - # Recommend breaks based on stress level - breaks = [] - if stress_level in ["high", "critical"]: - breaks.append({ - "type": "short_break", - "duration": 15, - "frequency": "every 90 minutes", - "rationale": "High stress detected - regular breaks essential" - }) - breaks.append({ - "type": "lunch_break", - "duration": 45, - "rationale": "Extended lunch for recovery" - }) - elif stress_level == "moderate": - breaks.append({ - "type": "short_break", - "duration": 10, - "frequency": "every 2 hours", - "rationale": "Moderate workload - maintain energy" - }) - - state["recommended_breaks"] = breaks - - # Identify tasks to defer if overloaded - if state["workload_score"] > 70: - p2_p3_tasks = board.get("P2", []) + board.get("P3", []) - state["tasks_to_defer"] = p2_p3_tasks[:3] - state["reasoning_trace"].append( - f"Recommending deferral of {len(state['tasks_to_defer'])} lower-priority tasks" - ) - else: - state["tasks_to_defer"] = [] - - state["reasoning_trace"].append( - f"Created {len(focus_blocks)} focus blocks, {len(breaks)} break recommendations" - ) - - return state - - -def generate_today_plan(state: TaskState) -> TaskState: - """ - Node 5: Generate comprehensive plan for today - """ - board = state["eisenhower_board"] - focus_blocks = state["focus_blocks"] - breaks = state["recommended_breaks"] - - # Build plan structure - plan = { - "date": datetime.now().date().isoformat(), - "user_email": state["user_email"], - "workload_score": state["workload_score"], - "stress_level": state["stress_level"], - "priority_breakdown": { - "P0": len(board.get("P0", [])), - "P1": len(board.get("P1", [])), - "P2": len(board.get("P2", [])), - "P3": len(board.get("P3", [])) - }, - "recommended_focus": [ - { - "task_id": fb["task_id"], - "title": fb["title"], - "duration_mins": fb["recommended_duration"], - "priority": fb["priority"] - } - for fb in focus_blocks - ], - "recommended_breaks": breaks, - "tasks_to_defer": [ - {"task_id": t["task_id"], "title": t.get("title")} - for t in state.get("tasks_to_defer", []) - ], - "wellness_alert": state.get("wellness_concern", False), - "generated_at": datetime.now().isoformat() - } - - state["today_plan"] = plan - state["status"] = "completed" - state["reasoning_trace"].append("Generated comprehensive daily plan") - - return state - - -def check_agent_triggers(state: TaskState) -> TaskState: - """ - Node 6: Determine if we should trigger other agents - """ - # Already set in analyze_workload, but ensure flags are correct - - # Trigger wellness if high stress or burnout risk - if state["stress_level"] in ["high", "critical"] or state["burnout_risk"]: - state["trigger_wellness_agent"] = True - - # Trigger followup if there are overdue tasks - overdue = [ - t for t in state["user_tasks"] - if t.get("due_date_utc") and t["due_date_utc"] < datetime.now().isoformat() - ] - - if len(overdue) >= 2: - state["trigger_followup_agent"] = True - state["reasoning_trace"].append(f"Triggering followup agent for {len(overdue)} overdue tasks") - - return state - - -def record_episode(state: TaskState) -> TaskState: - """Record task planning as an episode""" - episodic = EpisodicMemory("task_agent") - memory = AgentMemory("task_agent") - - try: - # Determine outcome - outcome = EpisodeOutcome.SUCCESS if state.get("today_plan") else EpisodeOutcome.FAILURE - - episode_data = { - "episode_id": f"task_{int(datetime.now().timestamp() * 1000)}", - "episode_type": "task_planning", - "trigger": f"Plan tasks for {state['user_email']}", - "context": { - "user_email": state["user_email"], - "workload_score": state.get("workload_score", 0), - "stress_level": state.get("stress_level", "unknown"), - "task_count": len(state.get("user_tasks", [])), - "focus_blocks": len(state.get("focus_blocks", [])) - }, - "actions": state.get("reasoning_trace", []), - "outcome": outcome.value, - "status": "completed", - "started_at": datetime.now().isoformat(), - "completed_at": datetime.now().isoformat() - } - - episodes = episodic._load_episodes() - episodes.append(episode_data) - episodic._save_episodes(episodes) - - # Store successful pattern - if outcome == EpisodeOutcome.SUCCESS: - memory.remember( - content=f"Successfully planned {len(state['user_tasks'])} tasks with {state['workload_score']:.0f} workload score", - memory_type=MemoryType.STRATEGY, - metadata={ - "user": state["user_email"], - "stress_level": state["stress_level"], - "focus_blocks": len(state.get("focus_blocks", [])) - } - ) - - except Exception as e: - pass - - return state - - -# ============================================================ -# GRAPH CONSTRUCTION -# ============================================================ - -def create_task_workflow() -> StateGraph: - """ - Build the Task Agent workflow - - Flow: - load_context → analyze_workload → prioritize → create_focus_blocks → - generate_plan → check_triggers → record → END - """ - - workflow = StateGraph(TaskState) - - # Add nodes - workflow.add_node("load_task_context", load_task_context) - workflow.add_node("analyze_workload", analyze_workload) - workflow.add_node("prioritize_tasks", prioritize_tasks) - workflow.add_node("create_focus_blocks", create_focus_blocks) - workflow.add_node("generate_today_plan", generate_today_plan) - workflow.add_node("check_agent_triggers", check_agent_triggers) - workflow.add_node("record_episode", record_episode) - - # Set entry point - workflow.set_entry_point("load_task_context") - - # Linear flow - workflow.add_edge("load_task_context", "analyze_workload") - workflow.add_edge("analyze_workload", "prioritize_tasks") - workflow.add_edge("prioritize_tasks", "create_focus_blocks") - workflow.add_edge("create_focus_blocks", "generate_today_plan") - workflow.add_edge("generate_today_plan", "check_agent_triggers") - workflow.add_edge("check_agent_triggers", "record_episode") - workflow.add_edge("record_episode", END) - - return workflow - - -def create_task_workflow_with_memory() -> StateGraph: - """Create task workflow with memory persistence""" - graph = create_task_workflow() - memory = get_checkpointer() - return graph.compile(checkpointer=memory) - - -# ============================================================ -# CONVENIENCE FUNCTIONS -# ============================================================ - -def plan_tasks_for_user( - user_email: str, - session_id: Optional[str] = None -) -> Dict[str, Any]: - """ - Main entry point for task planning - - Args: - user_email: User's email - session_id: Optional session ID - - Returns: - Dict with today's plan and metadata - """ - if not session_id: - session_id = f"task_session_{int(datetime.now().timestamp())}" - - # Create initial state - initial_state = { - "user_email": user_email, - "session_id": session_id, - "request_type": "plan_today", - "new_task": None, - "status": "idle", - "iteration": 0, - "user_tasks": [], - "user_meetings": [], - "past_completion_patterns": [], - "reasoning_trace": [], - "eisenhower_board": {}, - "workload_score": 0.0, - "stress_level": "unknown", - "burnout_risk": False, - "focus_blocks": [], - "recommended_breaks": [], - "tasks_to_defer": [], - "wellness_score": None, - "wellness_concern": False, - "trigger_wellness_agent": False, - "trigger_followup_agent": False, - "today_plan": None, - "episode_id": None - } - - # Create and run graph - graph = create_task_workflow_with_memory() - - config = {"configurable": {"thread_id": session_id}} - result = graph.invoke(initial_state, config) - - return { - "plan": result.get("today_plan"), - "workload_score": result.get("workload_score", 0), - "stress_level": result.get("stress_level", "unknown"), - "trigger_wellness": result.get("trigger_wellness_agent", False), - "trigger_followup": result.get("trigger_followup_agent", False), - "reasoning": result.get("reasoning_trace", []), - "session_id": session_id - } - - -if __name__ == "__main__": - # Quick test - result = plan_tasks_for_user( - user_email="kowshik.naidu@contoso.com" - ) - - print("Task Subgraph Test:") - print(f"Workload: {result['workload_score']:.0f}/100 ({result['stress_level']})") - print(f"Trigger wellness: {result['trigger_wellness']}") - if result['plan']: - print(f"Focus blocks: {len(result['plan']['recommended_focus'])}") - print(f"Break recommendations: {len(result['plan']['recommended_breaks'])}") diff --git a/orchestration/wellness_subgraph.py b/orchestration/wellness_subgraph.py deleted file mode 100644 index ea5c713..0000000 --- a/orchestration/wellness_subgraph.py +++ /dev/null @@ -1,647 +0,0 @@ -# orchestration/wellness_subgraph.py -""" -Wellness Agent Subgraph - Employee Wellbeing & Burnout Prevention -================================================================ -Transforms wellness monitoring into an agentic workflow with: -- Real-time workload stress analysis -- Burnout risk detection with early warnings -- Personalized break recommendations -- Meeting detox suggestions -- Focus time protection -- Mood tracking and adaptive responses -- Learning from user's wellness patterns - -Keeps corporate employees healthy and productive! -""" - -from __future__ import annotations -from typing import TypedDict, Any, Dict, List, Optional, Annotated -from datetime import datetime, timedelta -import operator -import json - -from langgraph.graph import StateGraph, END -from orchestration.checkpointer import get_checkpointer - -# Phase 1 imports -from memory import AgentMemory, MemoryType, EpisodicMemory, EpisodeType, EpisodeOutcome -from governance.litellm_gateway import EnhancedLiteLLMGateway -from orchestration.common_state import WellnessWorkflowState, create_initial_state - -# Agent imports -from agents.wellness_agent import WellnessAgent -from repos.data_repo import DataRepo - - -# ============================================================ -# STATE DEFINITION -# ============================================================ - -class WellnessState(TypedDict): - """State for wellness monitoring workflow""" - # Input - user_email: str - session_id: str - trigger_source: str # "proactive", "task_agent", "meeting_agent", "user_request" - trigger_context: Dict[str, Any] - - # Processing state - status: str # idle, analyzing, detecting_burnout, recommending, completed - - # Context - user_data: Optional[Dict[str, Any]] - workload_factors: Dict[str, Any] - recent_patterns: List[Dict[str, Any]] - - # Agent reasoning - reasoning_trace: Annotated[List[str], operator.add] - - # Analysis results - wellness_score: float # 0-100 (100 = excellent) - stress_level: str # low, moderate, high, critical - burnout_indicators: List[Dict[str, str]] - risk_factors: List[str] - - # Recommendations - break_suggestions: List[Dict[str, Any]] - meeting_detox: Optional[Dict[str, Any]] - focus_protection: Optional[Dict[str, Any]] - immediate_actions: List[str] - - # Approval needed? - requires_approval: bool - approval_reason: str - - # Output - wellness_report: Optional[Dict[str, Any]] - - # Episode tracking - episode_id: Optional[str] - - -# ============================================================ -# WORKLOAD FACTORS -# ============================================================ - -WELLNESS_WEIGHTS = { - "p0_tasks": 25, - "overdue_tasks": 20, - "meeting_load": 20, - "focus_time": 15, - "email_backlog": 10, - "consecutive_work_days": 10 -} - -BURNOUT_INDICATORS = [ - { - "name": "high_p0_load", - "check": lambda factors: factors.get("p0_count", 0) >= 3, - "severity": "high", - "message": "Multiple critical P0 tasks creating pressure" - }, - { - "name": "chronic_overdue", - "check": lambda factors: factors.get("overdue_count", 0) >= 3, - "severity": "high", - "message": "Chronic backlog of overdue items" - }, - { - "name": "meeting_overload", - "check": lambda factors: factors.get("meeting_hours_today", 0) >= 6, - "severity": "medium", - "message": "Excessive meeting time reducing focus opportunities" - }, - { - "name": "no_breaks", - "check": lambda factors: factors.get("hours_without_break", 0) >= 4, - "severity": "high", - "message": "Extended work period without breaks" - }, - { - "name": "weekend_work", - "check": lambda factors: factors.get("worked_weekend", False), - "severity": "medium", - "message": "Working on weekends reducing recovery time" - } -] - - -# ============================================================ -# NODE FUNCTIONS -# ============================================================ - -def load_wellness_context(state: WellnessState) -> WellnessState: - """ - Node 1: Load user data and wellness history - """ - repo = DataRepo() - memory = AgentMemory("wellness_agent") - - user_email = state["user_email"] - - # Load user - try: - users = repo.users() - user = next((u for u in users if u.get("email") == user_email), None) - - if not user: - state["status"] = "error" - state["reasoning_trace"].append(f"User {user_email} not found") - return state - - state["user_data"] = user - state["reasoning_trace"].append(f"Loaded user: {user.get('display_name')}") - - except Exception as e: - state["status"] = "error" - state["reasoning_trace"].append(f"Error loading user: {str(e)}") - return state - - # Recall past wellness patterns - try: - patterns = memory.recall( - query=f"Wellness patterns and stress indicators for {user_email}", - n_results=5, - memory_type=MemoryType.INTERACTION - ) - - state["recent_patterns"] = patterns - if patterns: - state["reasoning_trace"].append(f"Recalled {len(patterns)} past wellness patterns") - except: - state["recent_patterns"] = [] - - state["status"] = "analyzing" - return state - - -def analyze_workload_factors(state: WellnessState) -> WellnessState: - """ - Node 2: Analyze all workload factors contributing to stress - """ - repo = DataRepo() - user_data = state["user_data"] - user_email = state["user_email"] - - # Get user's tasks - try: - users = repo.users() - user = next((u for u in users if u.get("email") == user_email), None) - user_id = user["user_id"] if user else None - - tasks = repo.tasks() - user_tasks = [t for t in tasks if t.get("owner_user_id") == user_id] if user_id else [] - - # Active tasks only - active_tasks = [t for t in user_tasks if t.get("status") not in ["done", "cancelled"]] - - except: - active_tasks = [] - - # Get meetings - try: - meetings = repo.meetings() - today = datetime.now().date() - - user_meetings = [ - m for m in meetings - if user_email in m.get("attendees", []) - and m.get("scheduled_at", "").startswith(str(today)) - ] - except: - user_meetings = [] - - # Calculate factors - factors = { - "p0_count": len([t for t in active_tasks if t.get("priority") == "P0"]), - "p1_count": len([t for t in active_tasks if t.get("priority") == "P1"]), - "overdue_count": len([ - t for t in active_tasks - if t.get("due_date_utc") and t["due_date_utc"] < datetime.now().isoformat() - ]), - "total_tasks": len(active_tasks), - "meeting_count_today": len(user_meetings), - "meeting_hours_today": sum(m.get("duration_mins", 30) for m in user_meetings) / 60, - "hours_without_break": 3, # Mock - would track from activity - "worked_weekend": False, # Mock - would check from history - "email_backlog": 5 # Mock - would get from inbox - } - - state["workload_factors"] = factors - - state["reasoning_trace"].append( - f"Workload factors: P0={factors['p0_count']}, overdue={factors['overdue_count']}, " - f"meetings={factors['meeting_hours_today']:.1f}h" - ) - - state["status"] = "detecting_burnout" - return state - - -def calculate_wellness_score(state: WellnessState) -> WellnessState: - """ - Node 3: Calculate comprehensive wellness score (0-100) - - 100 = Excellent wellness - 0 = Critical burnout risk - """ - factors = state["workload_factors"] - - # Start at perfect score and deduct points - score = 100 - - # P0 tasks impact (max -25 points) - p0_impact = min(factors.get("p0_count", 0) * 8, 25) - score -= p0_impact - - # Overdue tasks impact (max -20 points) - overdue_impact = min(factors.get("overdue_count", 0) * 7, 20) - score -= overdue_impact - - # Meeting overload impact (max -20 points) - meeting_hours = factors.get("meeting_hours_today", 0) - if meeting_hours > 4: - meeting_impact = min((meeting_hours - 4) * 5, 20) - score -= meeting_impact - - # No breaks impact (max -15 points) - hours_no_break = factors.get("hours_without_break", 0) - if hours_no_break > 2: - break_impact = min((hours_no_break - 2) * 5, 15) - score -= break_impact - - # Email backlog impact (max -10 points) - email_impact = min(factors.get("email_backlog", 0) * 2, 10) - score -= email_impact - - # Weekend work impact (max -10 points) - if factors.get("worked_weekend", False): - score -= 10 - - # Ensure score is in valid range - score = max(0, min(100, score)) - - # Determine stress level - if score >= 80: - stress_level = "low" - elif score >= 60: - stress_level = "moderate" - elif score >= 40: - stress_level = "high" - else: - stress_level = "critical" - - state["wellness_score"] = score - state["stress_level"] = stress_level - - state["reasoning_trace"].append( - f"Wellness score: {score:.0f}/100 ({stress_level} stress)" - ) - - return state - - -def detect_burnout_indicators(state: WellnessState) -> WellnessState: - """ - Node 4: Detect specific burnout indicators - """ - factors = state["workload_factors"] - - detected_indicators = [] - risk_factors = [] - - # Check each burnout indicator - for indicator in BURNOUT_INDICATORS: - if indicator["check"](factors): - detected_indicators.append({ - "name": indicator["name"], - "severity": indicator["severity"], - "message": indicator["message"] - }) - risk_factors.append(indicator["message"]) - - state["burnout_indicators"] = detected_indicators - state["risk_factors"] = risk_factors - - if detected_indicators: - high_severity = [i for i in detected_indicators if i["severity"] == "high"] - state["reasoning_trace"].append( - f"⚠️ Detected {len(detected_indicators)} burnout indicators " - f"({len(high_severity)} high severity)" - ) - - state["status"] = "recommending" - return state - - -def generate_recommendations(state: WellnessState) -> WellnessState: - """ - Node 5: Generate personalized wellness recommendations - """ - gateway = EnhancedLiteLLMGateway("wellness_agent", enable_cache=True) - memory = AgentMemory("wellness_agent") - - score = state["wellness_score"] - stress_level = state["stress_level"] - indicators = state["burnout_indicators"] - factors = state["workload_factors"] - - # Break suggestions based on stress level - break_suggestions = [] - - if stress_level in ["high", "critical"]: - # Immediate break needed - break_suggestions.append({ - "type": "immediate_break", - "duration_mins": 15, - "urgency": "high", - "rationale": "High stress detected - immediate break recommended", - "activity": "Short walk or stretching" - }) - break_suggestions.append({ - "type": "lunch_break", - "duration_mins": 45, - "urgency": "high", - "rationale": "Extended lunch for recovery", - "activity": "Away from desk, preferably outdoors" - }) - - if stress_level == "moderate": - break_suggestions.append({ - "type": "microbreak", - "duration_mins": 5, - "urgency": "medium", - "rationale": "Regular microbreaks to maintain energy", - "activity": "Stand up, look away from screen, hydrate" - }) - - # Meeting detox if excessive meetings - meeting_detox = None - if factors.get("meeting_hours_today", 0) >= 4: - meeting_detox = { - "recommendation": "Block 2-hour focus window tomorrow", - "rationale": f"{factors['meeting_hours_today']:.1f}h in meetings today - need recovery time", - "suggested_time": "9:00 AM - 11:00 AM", - "calendar_block": True - } - - # Focus time protection - focus_protection = None - if factors.get("p0_count", 0) >= 2 or factors.get("p1_count", 0) >= 3: - focus_protection = { - "recommendation": "Protected focus blocks for critical work", - "duration_mins": 90, - "frequency": "daily", - "rationale": "High-priority tasks require uninterrupted focus" - } - - # Immediate actions for critical cases - immediate_actions = [] - if stress_level == "critical": - immediate_actions.append("Take a 15-minute break within the next hour") - immediate_actions.append("Delegate or defer at least 2 non-critical tasks") - immediate_actions.append("Notify manager about workload concerns") - - # This requires approval - state["requires_approval"] = True - state["approval_reason"] = "Critical burnout risk - manager notification recommended" - - state["break_suggestions"] = break_suggestions - state["meeting_detox"] = meeting_detox - state["focus_protection"] = focus_protection - state["immediate_actions"] = immediate_actions - - state["reasoning_trace"].append( - f"Generated {len(break_suggestions)} break suggestions, " - f"meeting_detox={meeting_detox is not None}, " - f"immediate_actions={len(immediate_actions)}" - ) - - # Learn from user preferences - try: - past_prefs = memory.recall( - query=f"Break preferences for {state['user_email']}", - n_results=3, - memory_type=MemoryType.PREFERENCE - ) - - if past_prefs: - state["reasoning_trace"].append( - f"Applied {len(past_prefs)} learned preferences" - ) - except: - pass - - state["status"] = "completed" - return state - - -def create_wellness_report(state: WellnessState) -> WellnessState: - """ - Node 6: Create comprehensive wellness report - """ - report = { - "timestamp": datetime.now().isoformat(), - "user_email": state["user_email"], - "trigger_source": state["trigger_source"], - "wellness_score": state["wellness_score"], - "stress_level": state["stress_level"], - "burnout_indicators": state["burnout_indicators"], - "risk_factors": state["risk_factors"], - "recommendations": { - "break_suggestions": state["break_suggestions"], - "meeting_detox": state["meeting_detox"], - "focus_protection": state["focus_protection"], - "immediate_actions": state["immediate_actions"] - }, - "requires_approval": state.get("requires_approval", False), - "approval_reason": state.get("approval_reason", "") - } - - state["wellness_report"] = report - state["reasoning_trace"].append("Created comprehensive wellness report") - - return state - - -def record_episode(state: WellnessState) -> WellnessState: - """Record wellness check as an episode""" - episodic = EpisodicMemory("wellness_agent") - memory = AgentMemory("wellness_agent") - - try: - # Determine outcome - outcome = EpisodeOutcome.SUCCESS if state.get("wellness_report") else EpisodeOutcome.FAILURE - - episode_data = { - "episode_id": f"wellness_{int(datetime.now().timestamp() * 1000)}", - "episode_type": "wellness_check", - "trigger": f"Wellness check for {state['user_email']} (source: {state['trigger_source']})", - "context": { - "user_email": state["user_email"], - "trigger_source": state["trigger_source"], - "wellness_score": state.get("wellness_score", 0), - "stress_level": state.get("stress_level", "unknown"), - "burnout_indicators_count": len(state.get("burnout_indicators", [])), - "recommendations_count": len(state.get("break_suggestions", [])) - }, - "actions": state.get("reasoning_trace", []), - "outcome": outcome.value, - "status": "completed", - "started_at": datetime.now().isoformat(), - "completed_at": datetime.now().isoformat() - } - - episodes = episodic._load_episodes() - episodes.append(episode_data) - episodic._save_episodes(episodes) - - # Store wellness pattern - if outcome == EpisodeOutcome.SUCCESS: - memory.remember( - content=f"Wellness check: {state['stress_level']} stress (score: {state['wellness_score']:.0f})", - memory_type=MemoryType.INTERACTION, - metadata={ - "user": state["user_email"], - "score": state["wellness_score"], - "stress_level": state["stress_level"] - } - ) - - except Exception as e: - pass - - return state - - -# ============================================================ -# GRAPH CONSTRUCTION -# ============================================================ - -def create_wellness_workflow() -> StateGraph: - """ - Build the Wellness Agent workflow - - Flow: - load_context → analyze_factors → calculate_score → - detect_burnout → generate_recommendations → create_report → record → END - """ - - workflow = StateGraph(WellnessState) - - # Add nodes - workflow.add_node("load_wellness_context", load_wellness_context) - workflow.add_node("analyze_workload_factors", analyze_workload_factors) - workflow.add_node("calculate_wellness_score", calculate_wellness_score) - workflow.add_node("detect_burnout_indicators", detect_burnout_indicators) - workflow.add_node("generate_recommendations", generate_recommendations) - workflow.add_node("create_wellness_report", create_wellness_report) - workflow.add_node("record_episode", record_episode) - - # Set entry point - workflow.set_entry_point("load_wellness_context") - - # Linear flow - workflow.add_edge("load_wellness_context", "analyze_workload_factors") - workflow.add_edge("analyze_workload_factors", "calculate_wellness_score") - workflow.add_edge("calculate_wellness_score", "detect_burnout_indicators") - workflow.add_edge("detect_burnout_indicators", "generate_recommendations") - workflow.add_edge("generate_recommendations", "create_wellness_report") - workflow.add_edge("create_wellness_report", "record_episode") - workflow.add_edge("record_episode", END) - - return workflow - - -def create_wellness_workflow_with_memory() -> StateGraph: - """Create wellness workflow with memory persistence""" - graph = create_wellness_workflow() - memory = get_checkpointer() - return graph.compile(checkpointer=memory) - - -# ============================================================ -# CONVENIENCE FUNCTIONS -# ============================================================ - -def check_wellness( - user_email: str, - trigger_source: str = "user_request", - trigger_context: Optional[Dict[str, Any]] = None, - session_id: Optional[str] = None -) -> Dict[str, Any]: - """ - Main entry point for wellness checks - - Args: - user_email: User's email - trigger_source: What triggered this check - trigger_context: Additional context - session_id: Optional session ID - - Returns: - Dict with wellness report and recommendations - """ - if not session_id: - session_id = f"wellness_session_{int(datetime.now().timestamp())}" - - # Create initial state - initial_state = { - "user_email": user_email, - "session_id": session_id, - "trigger_source": trigger_source, - "trigger_context": trigger_context or {}, - "status": "idle", - "user_data": None, - "workload_factors": {}, - "recent_patterns": [], - "reasoning_trace": [], - "wellness_score": 0.0, - "stress_level": "unknown", - "burnout_indicators": [], - "risk_factors": [], - "break_suggestions": [], - "meeting_detox": None, - "focus_protection": None, - "immediate_actions": [], - "requires_approval": False, - "approval_reason": "", - "wellness_report": None, - "episode_id": None - } - - # Create and run graph - graph = create_wellness_workflow_with_memory() - - config = {"configurable": {"thread_id": session_id}} - result = graph.invoke(initial_state, config) - - return { - "report": result.get("wellness_report"), - "score": result.get("wellness_score", 0), - "stress_level": result.get("stress_level", "unknown"), - "burnout_indicators": result.get("burnout_indicators", []), - "recommendations": { - "breaks": result.get("break_suggestions", []), - "meeting_detox": result.get("meeting_detox"), - "focus_protection": result.get("focus_protection"), - "immediate_actions": result.get("immediate_actions", []) - }, - "requires_approval": result.get("requires_approval", False), - "reasoning": result.get("reasoning_trace", []), - "session_id": session_id - } - - -if __name__ == "__main__": - # Quick test - result = check_wellness( - user_email="kowshik.naidu@contoso.com", - trigger_source="proactive" - ) - - print("Wellness Subgraph Test:") - print(f"Score: {result['score']:.0f}/100 ({result['stress_level']})") - print(f"Burnout indicators: {len(result['burnout_indicators'])}") - print(f"Break suggestions: {len(result['recommendations']['breaks'])}") - print(f"Requires approval: {result['requires_approval']}") diff --git a/print_graph.py b/print_graph.py index 6577e06..128c81b 100644 --- a/print_graph.py +++ b/print_graph.py @@ -1,143 +1,65 @@ """ -Print/Visualize the Super-Graph structure +Print/Visualize the deepagents-based agent's graph structure +(orchestration/deep_agent.py). Replaces the old super-graph visualizer -- +the hand-hardcoded matplotlib node positions in the previous version were +specific to super_graph.py's exact node names (classify_intent, +invoke_email_agent, ...) and don't carry over to deepagents' own internal +graph structure (model/tools/middleware nodes), so that fallback is dropped +rather than reimplemented for node names this codebase doesn't control. """ -from orchestration.super_graph import create_super_graph +from orchestration.deep_agent import create_opspilot_agent def print_graph(): - """Print the super-graph in various formats""" - - # Create the graph (uncompiled to get the structure) - workflow = create_super_graph() - graph = workflow.compile() - + """Print the deep agent's graph in various formats""" + + agent = create_opspilot_agent() + graph = agent.get_graph() + print("=" * 60) - print("SUPER-GRAPH STRUCTURE") + print("DEEP AGENT GRAPH STRUCTURE") print("=" * 60) - - # ASCII representation + print("\n📊 ASCII Graph:\n") try: - graph.get_graph().print_ascii() + graph.print_ascii() except Exception as e: print(f"ASCII print not available: {e}") - - # Mermaid diagram (can be rendered in markdown viewers) + print("\n" + "=" * 60) print("📈 Mermaid Diagram (paste into mermaid live editor):") print("=" * 60 + "\n") + mermaid = None try: - mermaid = graph.get_graph().draw_mermaid() + mermaid = graph.draw_mermaid() print(mermaid) except Exception as e: print(f"Mermaid generation not available: {e}") - - # Save as PNG using pygraphviz or graphviz + print("\n" + "=" * 60) print("💾 Saving graph image...") print("=" * 60 + "\n") - + try: - # Try using graphviz directly (requires: pip install graphviz, and Graphviz installed on system) - png_data = graph.get_graph().draw_png() - with open("super_graph.png", "wb") as f: + png_data = graph.draw_png() + with open("deep_agent_graph.png", "wb") as f: f.write(png_data) - print("✅ Graph saved as 'super_graph.png'") + print("✅ Graph saved as 'deep_agent_graph.png'") except Exception as e1: print(f"⚠️ Graphviz method failed: {e1}") - - # Fallback: save mermaid to file and use matplotlib - try: - save_graph_with_matplotlib(graph) - except Exception as e2: - print(f"⚠️ Matplotlib method failed: {e2}") - - # Last resort: save mermaid to .md file + + if mermaid: try: - mermaid = graph.get_graph().draw_mermaid() - with open("super_graph.md", "w") as f: - f.write("# Super-Graph Diagram\n\n") + with open("deep_agent_graph.md", "w") as f: + f.write("# Deep Agent Graph\n\n") f.write("```mermaid\n") f.write(mermaid) f.write("\n```\n") - print("✅ Mermaid diagram saved as 'super_graph.md'") + print("✅ Mermaid diagram saved as 'deep_agent_graph.md'") print(" Open in VS Code with Mermaid extension or paste into mermaid.live") - except Exception as e3: - print(f"❌ All methods failed: {e3}") - - -def save_graph_with_matplotlib(graph): - """Save graph using matplotlib and networkx""" - import matplotlib.pyplot as plt - import matplotlib.patches as mpatches - - # Get the graph structure - lg = graph.get_graph() - - # Extract nodes and edges - nodes = list(lg.nodes) - edges = [(e.source, e.target) for e in lg.edges] - - # Create figure - fig, ax = plt.subplots(1, 1, figsize=(16, 12)) - - # Define node positions manually for this specific graph - positions = { - "__start__": (0.5, 1.0), - "classify_intent": (0.5, 0.9), - "invoke_email_agent": (0.1, 0.7), - "invoke_meeting_agent": (0.25, 0.7), - "invoke_task_agent": (0.4, 0.7), - "invoke_wellness_agent": (0.55, 0.7), - "invoke_followup_agent": (0.7, 0.7), - "invoke_report_agent": (0.85, 0.7), - "invoke_briefing": (0.15, 0.55), - "handle_chat": (0.85, 0.55), - "check_cross_agent_triggers": (0.5, 0.4), - "execute_triggers": (0.3, 0.25), - "generate_response": (0.7, 0.25), - "record_episode": (0.5, 0.1), - "__end__": (0.5, 0.0), - } - - # Draw edges - for source, target in edges: - if source in positions and target in positions: - x1, y1 = positions[source] - x2, y2 = positions[target] - ax.annotate("", xy=(x2, y2), xytext=(x1, y1), - arrowprops=dict(arrowstyle="->", color="gray", lw=1.5)) - - # Draw nodes - node_colors = { - "__start__": "#90EE90", - "__end__": "#FFB6C1", - "classify_intent": "#87CEEB", - "check_cross_agent_triggers": "#DDA0DD", - "execute_triggers": "#F0E68C", - "generate_response": "#98FB98", - "record_episode": "#E6E6FA", - } - - for node in nodes: - if node in positions: - x, y = positions[node] - color = node_colors.get(node, "#ADD8E6") - bbox = dict(boxstyle="round,pad=0.3", facecolor=color, edgecolor="black", linewidth=2) - ax.text(x, y, node.replace("_", "\n"), ha="center", va="center", - fontsize=8, fontweight="bold", bbox=bbox) - - ax.set_xlim(-0.05, 1.05) - ax.set_ylim(-0.05, 1.05) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Super-Graph: Multi-Agent Orchestration", fontsize=14, fontweight="bold", pad=20) - - plt.tight_layout() - plt.savefig("super_graph.png", dpi=150, bbox_inches="tight", facecolor="white") - plt.close() - print("✅ Graph saved as 'super_graph.png' using matplotlib") + except Exception as e2: + print(f"❌ Saving mermaid to file failed: {e2}") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 2526ffa..a8e1810 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,6 +33,14 @@ httpx>=0.27.0 # Vector memory chromadb>=0.4.0 +# orchestration/proactive_scheduler.py's job scheduling -- was never +# actually declared here despite being imported unconditionally at module +# load, so importing that module (and therefore hitting +# backend/routes_ai.py's /proactive/* endpoints) has always raised +# ModuleNotFoundError. Found while touching this file for the deepagents +# migration cutover; verified against real PyPI data. +schedule>=1.2.0,<2.0.0 + # deepagents migration (see orchestration/deep_agent.py). Verified against # real PyPI data (`pip index versions deepagents`) rather than guessed -- # latest at time of pinning is 0.6.12. Pulls in langchain>=1.3.11,<2.0.0 diff --git a/super_graph.md b/super_graph.md deleted file mode 100644 index 7215d6e..0000000 --- a/super_graph.md +++ /dev/null @@ -1,47 +0,0 @@ -# Super-Graph Diagram - -```mermaid -%%{init: {'flowchart': {'curve': 'linear'}}}%% -graph TD; - __start__([

__start__

]):::first - classify_intent(classify_intent) - invoke_email_agent(invoke_email_agent) - invoke_meeting_agent(invoke_meeting_agent) - invoke_task_agent(invoke_task_agent) - invoke_wellness_agent(invoke_wellness_agent) - invoke_followup_agent(invoke_followup_agent) - invoke_report_agent(invoke_report_agent) - invoke_briefing(invoke_briefing) - handle_chat(handle_chat) - check_cross_agent_triggers(check_cross_agent_triggers) - execute_triggers(execute_triggers) - generate_response(generate_response) - record_episode(record_episode) - __end__([

__end__

]):::last - __start__ --> classify_intent; - execute_triggers --> generate_response; - generate_response --> record_episode; - handle_chat --> check_cross_agent_triggers; - invoke_briefing --> check_cross_agent_triggers; - invoke_email_agent --> check_cross_agent_triggers; - invoke_followup_agent --> check_cross_agent_triggers; - invoke_meeting_agent --> check_cross_agent_triggers; - invoke_report_agent --> check_cross_agent_triggers; - invoke_task_agent --> check_cross_agent_triggers; - invoke_wellness_agent --> check_cross_agent_triggers; - record_episode --> __end__; - classify_intent -.-> invoke_email_agent; - classify_intent -.-> invoke_meeting_agent; - classify_intent -.-> invoke_task_agent; - classify_intent -.-> invoke_wellness_agent; - classify_intent -.-> invoke_followup_agent; - classify_intent -.-> invoke_report_agent; - classify_intent -.-> invoke_briefing; - classify_intent -.-> handle_chat; - check_cross_agent_triggers -.-> execute_triggers; - check_cross_agent_triggers -.-> generate_response; - classDef default fill:#f2f0ff,line-height:1.2 - classDef first fill-opacity:0 - classDef last fill:#bfb6fc - -``` diff --git a/super_graph.png b/super_graph.png deleted file mode 100644 index 0b19168..0000000 Binary files a/super_graph.png and /dev/null differ diff --git a/tests/test_deep_agent.py b/tests/test_deep_agent.py index 4a87d0b..03fa30f 100644 --- a/tests/test_deep_agent.py +++ b/tests/test_deep_agent.py @@ -26,9 +26,11 @@ INTERRUPT_ON, _build_tools, _is_high_priority, + _make_record_episode_tool, _wrap_tool, create_opspilot_agent, ) +from memory.episodic_memory import EpisodeType class _StubToolCallingModel(BaseChatModel): @@ -117,6 +119,45 @@ class _ReqLow: assert _is_high_priority(_ReqLow()) is False +class TestMemoryWriteBack: + """orchestration/autonomous_graph.py's recall_memory_context node called + memory/vector_store.py + memory/episodic_memory.py to recall past + context, but was never wired into the compiled graph, and nothing + anywhere called the write-back methods those classes already had. The + email/meeting subagents' record_episode tool closes that gap.""" + + def test_record_episode_writes_to_both_vector_and_episodic_memory(self): + # Matches the existing convention elsewhere in this suite (e.g. + # test_smart_chat.py's SmartChatAgent.end() test) of exercising the + # real AgentMemory/EpisodicMemory write path rather than mocking it -- + # both are gitignored, ephemeral local state. A uuid (not tmp_path, + # whose name is reused across separate pytest invocations) keeps + # this test's on-disk file genuinely unique run to run. + import uuid + agent_name = f"test_agent_{uuid.uuid4().hex[:8]}" + tool = _make_record_episode_tool(agent_name, EpisodeType.EMAIL_PROCESSING) + + result = tool.invoke({ + "summary": "Processed email from a@b.com, created a follow-up task", + "outcome": "success", + }) + + assert result == "Episode recorded." + + # Verify the write actually landed, using the same classes' + # (default-path) read APIs -- not re-testing memory internals, just + # confirming this tool is a real caller of the existing write-back + # methods rather than a no-op. + from memory.vector_store import AgentMemory, MemoryType + recalled = AgentMemory(agent_name).recall("follow-up task", n_results=5) + assert any("follow-up task" in m.get("content", "") for m in recalled) + + from memory.episodic_memory import EpisodicMemory + episodes = EpisodicMemory(agent_name).episodes + assert len(episodes) == 1 + assert episodes[-1].outcome == "success" + + class TestFailsClosedWithoutLLMConfig: def test_create_opspilot_agent_raises_without_llm_config(self): with pytest.raises(LLMNotConfiguredError): diff --git a/tests/test_governance_approval_cutover.py b/tests/test_governance_approval_cutover.py new file mode 100644 index 0000000..cab7e01 --- /dev/null +++ b/tests/test_governance_approval_cutover.py @@ -0,0 +1,117 @@ +"""backend/routes_governance.py's approve/reject endpoints, post-cutover. + +Before this, ApprovalQueue.approve_action() always re-implemented the write +itself (_execute_action). Actions raised via a deep agent's interrupt_on +(orchestration/deep_agent.py) carry a session_id (the LangGraph thread_id) +and now resume that paused thread via Command(resume=...) instead -- +resuming is what actually executes (or skips) the tool call. Legacy pending +actions with no session_id still go through the old approve_action/ +reject_action path, so existing callers aren't broken. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def isolated_queue(tmp_path, monkeypatch): + """Point the approval queue at a tmp file and reset its singleton, so + these tests never touch the real data/governance/pending_actions.json.""" + import config.settings as settings_module + import governance.approval as approval_module + + audit_log_path = tmp_path / "audit_log.json" + monkeypatch.setitem(settings_module.SETTINGS["data"]["governance"], "audit_log", audit_log_path) + approval_module._approval_queue = None + yield approval_module.get_approval_queue() + approval_module._approval_queue = None + + +@pytest.fixture +def client(): + from fastapi.testclient import TestClient + import backend.app as app_module + + with TestClient(app_module.app, raise_server_exceptions=False) as c: + yield c + + +class TestApproveResumesTheAgentThread: + def test_approving_a_deep_agent_action_resumes_its_thread_not_execute_action(self, client, isolated_queue, monkeypatch): + pending = isolated_queue.add_pending_action( + action_type="create_task", + payload={"title": "Follow up", "priority": "P0"}, + reason="agent recommended", + source_email_id="e1", + session_id="autonomous_email_e1", + ) + + resumed = {} + + def fake_resume(thread_id, decision): + resumed["thread_id"] = thread_id + resumed["decision"] = decision + + import backend.routes_governance as routes_governance_module + monkeypatch.setattr(routes_governance_module, "_resume_agent_thread", fake_resume) + + response = client.post(f"/api/v1/governance/pending_actions/{pending['action_id']}/approve") + + assert response.status_code == 200 + assert resumed["thread_id"] == "autonomous_email_e1" + assert resumed["decision"] == {"type": "approve"} + assert response.json()["status"] == "executed" + + def test_rejecting_a_deep_agent_action_resumes_with_a_reject_decision(self, client, isolated_queue, monkeypatch): + pending = isolated_queue.add_pending_action( + action_type="send_email", + payload={"to_emails": ["a@b.com"]}, + reason="agent recommended", + session_id="autonomous_email_e2", + ) + + resumed = {} + + def fake_resume(thread_id, decision): + resumed["thread_id"] = thread_id + resumed["decision"] = decision + + import backend.routes_governance as routes_governance_module + monkeypatch.setattr(routes_governance_module, "_resume_agent_thread", fake_resume) + + response = client.post( + f"/api/v1/governance/pending_actions/{pending['action_id']}/reject", + json={"reason": "not now"}, + ) + + assert response.status_code == 200 + assert resumed["thread_id"] == "autonomous_email_e2" + assert resumed["decision"]["type"] == "reject" + assert response.json()["status"] == "rejected" + + def test_legacy_action_without_session_id_still_uses_approve_action(self, client, isolated_queue, monkeypatch): + """No session_id -- e.g. a pending action created directly through + the API rather than by an agent run -- falls back to the pre-cutover + approve_action path (which executes the write itself).""" + pending = isolated_queue.add_pending_action( + action_type="mark_email_processed", + payload={"email_id": "e3"}, + reason="manual", + ) + + import backend.routes_governance as routes_governance_module + called = {"resume": False} + monkeypatch.setattr( + routes_governance_module, + "_resume_agent_thread", + lambda *a, **k: called.__setitem__("resume", True), + ) + + response = client.post(f"/api/v1/governance/pending_actions/{pending['action_id']}/approve") + + assert response.status_code == 200 + assert called["resume"] is False diff --git a/tests/test_smart_chat.py b/tests/test_smart_chat.py index bc7e3bb..07006ce 100644 --- a/tests/test_smart_chat.py +++ b/tests/test_smart_chat.py @@ -1,13 +1,14 @@ """SmartChatAgent and the ChatManager it wraps. -The LLM gateway and the super-graph (which itself fans out to Redis-backed, -gateway-using subgraphs for every specialized agent) are stubbed at the two -points orchestration/chat_workflow.py itself calls them -- -`EnhancedLiteLLMGateway` and `process_user_request`. That is the real -dependency boundary of the code under test; stubbing it lets ChatManager's own -logic (turn history, follow-up detection, clarification, response shaping) -run for real without needing a live LLM or a live Redis for the six -specialized-agent subgraphs a full super-graph invocation would otherwise touch. +The LLM gateway and the deepagents-based agent (orchestration/deep_agent.py, +which itself dispatches to Redis-checkpointed subagents for every +specialized domain) are stubbed at the two points +orchestration/chat_workflow.py itself calls them -- `EnhancedLiteLLMGateway` +and `create_opspilot_agent`. That is the real dependency boundary of the +code under test; stubbing it lets ChatManager's own logic (turn history, +follow-up detection, clarification, response shaping) run for real without +needing a live LLM or a live Redis for the six specialized-agent subagents a +full deep-agent invocation would otherwise touch. """ from __future__ import annotations @@ -39,16 +40,29 @@ def call(self, prompt: str, **kwargs) -> str: return "chat" -def _fake_process_user_request(user_input: str, user_email: str, session_id: Optional[str] = None) -> Dict[str, Any]: - return { - "response": f"Handled: {user_input}", - "intent": "task", - "confidence": 0.87, - "agents_used": ["tasks_agent"], - "actions": [], - "reasoning_trace": ["classified intent", "routed to tasks_agent"], - "session_id": session_id, - } +class _FakeAIMessage: + def __init__(self, content: str, tool_calls: Optional[List[Dict[str, Any]]] = None): + self.content = content + self.tool_calls = tool_calls or [] + + +class FakeDeepAgent: + """Deterministic stand-in for the deepagents-compiled agent + orchestration/deep_agent.create_opspilot_agent() returns. Only + implements the .invoke() surface ChatManager.process_message actually + calls.""" + + def invoke(self, state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]: + user_input = state["messages"][-1]["content"] + message = _FakeAIMessage( + content=f"Handled: {user_input}", + tool_calls=[{"name": "task", "args": {"subagent_type": "tasks"}}], + ) + return {"messages": [message]} + + +def _fake_create_opspilot_agent(*args, **kwargs) -> FakeDeepAgent: + return FakeDeepAgent() @pytest.fixture(autouse=True) @@ -56,7 +70,7 @@ def stub_chat_dependencies(monkeypatch): import orchestration.chat_workflow as chat_workflow_module monkeypatch.setattr(chat_workflow_module, "EnhancedLiteLLMGateway", FakeGateway) - monkeypatch.setattr(chat_workflow_module, "process_user_request", _fake_process_user_request) + monkeypatch.setattr(chat_workflow_module, "create_opspilot_agent", _fake_create_opspilot_agent) # Reset the module-level singleton so each test gets a fresh ChatManager # built with the stubbed gateway, rather than reusing one from a previous @@ -72,7 +86,7 @@ def test_start_session_creates_an_empty_history(self): session_id = mgr.start_session("a@example.com") assert mgr.get_conversation_history(session_id) == [] - def test_process_message_routes_through_super_graph_for_a_normal_request(self): + def test_process_message_routes_through_the_deep_agent_for_a_normal_request(self): """"my tasks" specifically, not e.g. "my P0 tasks" -- the latter hits ChatManager's own clarification check (task intent without "my tasks" or "plan" in the message), which is real, existing, deliberate @@ -85,13 +99,16 @@ def test_process_message_routes_through_super_graph_for_a_normal_request(self): assert result["response"] == "Handled: Show me my tasks" assert result["intent"] == "task" - assert result["confidence"] == 0.87 - assert result["agents_invoked"] == ["tasks_agent"] + # The deep agent doesn't expose a numeric confidence score the way + # the old super_graph state did -- None is the honest answer, not a + # fabricated number. + assert result["confidence"] is None + assert result["agents_invoked"] == ["tasks"] assert result["needs_clarification"] is False - def test_clarification_path_never_reaches_the_super_graph(self): + def test_clarification_path_never_reaches_the_deep_agent(self): """A vague, short message should short-circuit to a clarification - question rather than route through the (stubbed) super-graph.""" + question rather than route through the (stubbed) deep agent.""" from orchestration.chat_workflow import ChatManager mgr = ChatManager() @@ -150,8 +167,8 @@ def test_chat_sync_returns_the_shape_routes_ai_expects(self): assert result["content"] == "Handled: Show me my tasks" assert result["metadata"]["intent"] == "task" - assert result["metadata"]["confidence"] == 0.87 - assert result["metadata"]["reasoning_trace"] == ["classified intent", "routed to tasks_agent"] + assert result["metadata"]["confidence"] is None + assert result["metadata"]["reasoning_trace"] == [] def test_get_history_reflects_prior_turns(self): from agents.smart_chat import SmartChatAgent