Refactor tests for execution plan and add comprehensive storage tests
- Updated `TestModuleSingletons` in `test_execution_plan.py` to reflect new agent templates and playbook names. - Changed assertions in playbook tests to match updated templates and agents. - Introduced `test_storage.py` to cover the storage layer, including encryption, BlobStore, and VectorStore functionalities. - Added tests for S3 interactions, ensuring upload, download, delete, and list operations work as expected. - Implemented mock tests for Pinecone and Qdrant vector stores to validate upsert, search, and delete operations.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Import all agent modules to trigger @registry.register decorators."""
|
||||
|
||||
from app.agents import analytics_agent, calendar_agent, email_agent, task_agent
|
||||
from app.agents import checkpoint_agent, note_agent, project_agent, task_agent
|
||||
|
||||
__all__ = ["analytics_agent", "calendar_agent", "email_agent", "task_agent"]
|
||||
__all__ = ["checkpoint_agent", "note_agent", "project_agent", "task_agent"]
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Analytics agent — metrics, reports, and trend analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a workspace analytics assistant. Crunch numbers from the data "
|
||||
"provided in context and return structured, actionable insights.\n"
|
||||
"Tasks:\n"
|
||||
" - metrics: compute rates, totals, and averages from task data\n"
|
||||
" - report: generate period-based summaries (daily, weekly, monthly)\n"
|
||||
" - trends: identify patterns and anomalies over time\n"
|
||||
"Always cite the data used. Do not fabricate figures."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def calculate_metrics(task_data: str) -> str:
|
||||
"""Calculate productivity metrics from a JSON array of task data."""
|
||||
return json.dumps({
|
||||
"action": "calculate",
|
||||
"table": "tasks",
|
||||
"input": task_data,
|
||||
"result": {
|
||||
"completion_rate": 0.0,
|
||||
"overdue_count": 0,
|
||||
"avg_priority": "medium",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def generate_report(period: str, data: str) -> str:
|
||||
"""Generate a structured report for a time period (e.g. 'last_7_days', 'last_month')."""
|
||||
return json.dumps({
|
||||
"action": "report",
|
||||
"period": period,
|
||||
"input": data,
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def trend_analysis(data_points: str) -> str:
|
||||
"""Analyse trends in a JSON array of time-series data points."""
|
||||
return json.dumps({
|
||||
"action": "trend",
|
||||
"input": data_points,
|
||||
"result": {"trend": "stable", "anomalies": []},
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class AnalyticsAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "analytics_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Workspace analytics: metrics, reports, trends"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [calculate_metrics, generate_report, trend_analysis]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Calendar agent — events, conflict detection, and scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a calendar management assistant. Help the user manage events, "
|
||||
"detect scheduling conflicts, and suggest reschedules.\n"
|
||||
"Rules:\n"
|
||||
" - Work exclusively with event metadata provided in context\n"
|
||||
" - Never store or reference raw calendar data\n"
|
||||
" - date_range format: ISO 8601 interval, e.g. '2024-01-01/2024-01-07'\n"
|
||||
" - Always confirm the date/time scope of any operation"
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_events(date_range: str) -> str:
|
||||
"""List calendar events in a date range (ISO 8601 interval, e.g. '2024-01-01/2024-01-07')."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "events",
|
||||
"filters": {"date_range": date_range},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def detect_conflicts(events: str) -> str:
|
||||
"""Detect scheduling conflicts in a JSON array of event metadata objects."""
|
||||
return json.dumps({
|
||||
"action": "analyse",
|
||||
"table": "events",
|
||||
"input": events,
|
||||
"result": "conflicts_detected",
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def suggest_reschedule(conflict: str) -> str:
|
||||
"""Suggest a reschedule for a conflicting event. Pass the conflict as a JSON string."""
|
||||
return json.dumps({
|
||||
"action": "suggest_reschedule",
|
||||
"table": "events",
|
||||
"input": conflict,
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class CalendarAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "calendar_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Calendar management: events, conflicts, scheduling"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [list_events, detect_conflicts, suggest_reschedule]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
122
app/agents/checkpoint_agent.py
Normal file
122
app/agents/checkpoint_agent.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Checkpoint agent — project milestone management (list, create, update, delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a project checkpoint assistant. Checkpoints are milestone dates that\n"
|
||||
"track progress on a project — they are not calendar events.\n\n"
|
||||
"Rules:\n"
|
||||
" - project_id is REQUIRED for every create; confirm with the user if unknown\n"
|
||||
" - date is a Unix timestamp in milliseconds; convert human-readable dates\n"
|
||||
" - is_ai_suggested: 1 when proactively proposing a checkpoint, 0 otherwise\n"
|
||||
" - is_approved: 0 until the user explicitly confirms; then 1\n"
|
||||
" - For update_checkpoint, use -1 for integer fields you do not want to change\n"
|
||||
" - Listing without a project_id returns all checkpoints across projects\n"
|
||||
" - Always echo the title and formatted date in your confirmation."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_checkpoints(project_id: str = "") -> str:
|
||||
"""List checkpoints. Provide project_id to scope to a specific project."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "checkpoints",
|
||||
"filters": {"projectId": project_id or None},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def create_checkpoint(
|
||||
project_id: str,
|
||||
title: str,
|
||||
date: int,
|
||||
is_ai_suggested: int = 0,
|
||||
is_approved: int = 0,
|
||||
) -> str:
|
||||
"""Create a project checkpoint (milestone).
|
||||
project_id: REQUIRED UUID of the parent project
|
||||
title: descriptive name for the milestone
|
||||
date: Unix timestamp in milliseconds
|
||||
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
||||
is_approved: 0 until the user confirms
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "checkpoints",
|
||||
"data": {
|
||||
"projectId": project_id,
|
||||
"title": title,
|
||||
"date": date,
|
||||
"isAiSuggested": is_ai_suggested,
|
||||
"isApproved": is_approved,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def update_checkpoint(
|
||||
checkpoint_id: str,
|
||||
title: str = "",
|
||||
date: int = -1,
|
||||
is_approved: int = -1,
|
||||
) -> str:
|
||||
"""Update a checkpoint. Only pass fields that should change.
|
||||
checkpoint_id: UUID of the checkpoint (required)
|
||||
date: -1 means unchanged; any other value sets the new date (ms timestamp)
|
||||
is_approved: -1 means unchanged; 0 or 1 sets the approval state
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if date != -1:
|
||||
updates["date"] = date
|
||||
if is_approved != -1:
|
||||
updates["isApproved"] = is_approved
|
||||
return json.dumps({
|
||||
"action": "update_record",
|
||||
"table": "checkpoints",
|
||||
"data": {"id": checkpoint_id, "updates": updates},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def delete_checkpoint(checkpoint_id: str) -> str:
|
||||
"""Delete a checkpoint permanently by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "delete_record",
|
||||
"table": "checkpoints",
|
||||
"data": {"id": checkpoint_id},
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class CheckpointAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "checkpoint_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages project checkpoints (milestones): list, create, update, delete"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [list_checkpoints, create_checkpoint, update_checkpoint, delete_checkpoint]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Email agent — classify, extract action items, draft responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are an email analysis assistant. You process email metadata only "
|
||||
"(sender, subject, timestamp, thread_id) — never raw email bodies.\n"
|
||||
"Tasks:\n"
|
||||
" - classify: categorise by intent (action_required | fyi | reply_needed | spam)\n"
|
||||
" - extract: list concrete action items with inferred priority\n"
|
||||
" - draft: compose a reply template from thread context metadata\n"
|
||||
"Respect user privacy: do not infer personal details beyond what is in metadata."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def classify_email(metadata: str) -> str:
|
||||
"""Classify an email from its metadata JSON. Returns category and confidence score."""
|
||||
return json.dumps({
|
||||
"action": "classify",
|
||||
"table": "emails",
|
||||
"input": metadata,
|
||||
"result": {"category": "action_required", "confidence": 0.9},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def extract_action_items(metadata: str) -> str:
|
||||
"""Extract action items from email metadata JSON. Returns a list of task descriptions."""
|
||||
return json.dumps({
|
||||
"action": "extract",
|
||||
"table": "emails",
|
||||
"input": metadata,
|
||||
"result": {"action_items": []},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def draft_response(thread_context: str) -> str:
|
||||
"""Draft a reply template from email thread context JSON."""
|
||||
return json.dumps({
|
||||
"action": "draft",
|
||||
"table": "emails",
|
||||
"input": thread_context,
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "email_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Email analysis: classify, extract actions, draft responses"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [classify_email, extract_action_items, draft_response]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
123
app/agents/note_agent.py
Normal file
123
app/agents/note_agent.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Note agent — Markdown note management (list, get, create, update, delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a note-taking assistant. You help users create, retrieve, update,\n"
|
||||
"and delete Markdown notes in their workspace.\n\n"
|
||||
"Rules:\n"
|
||||
" - content is always Markdown; preserve formatting when updating\n"
|
||||
" - project_id is optional; link a note to a project when mentioned\n"
|
||||
" - When updating, call get_note first if you need to read existing content\n"
|
||||
" before appending or replacing sections\n"
|
||||
" - list_notes without project_id returns all notes; scope with project_id\n"
|
||||
" when the user is working within a specific project\n"
|
||||
" - Do not fabricate note content — reflect what the user provides or what\n"
|
||||
" is already in the note (retrieved via get_note)."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_notes(project_id: str = "") -> str:
|
||||
"""List notes, optionally scoped to a project by project_id."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "notes",
|
||||
"filters": {"projectId": project_id or None},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def get_note(note_id: str) -> str:
|
||||
"""Fetch a single note by its UUID to read its full Markdown content."""
|
||||
return json.dumps({
|
||||
"action": "get",
|
||||
"table": "notes",
|
||||
"data": {"id": note_id},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def create_note(
|
||||
title: str,
|
||||
content: str,
|
||||
project_id: str = "",
|
||||
) -> str:
|
||||
"""Create a new note.
|
||||
title: note heading (required)
|
||||
content: Markdown body text (required)
|
||||
project_id: optional UUID linking this note to a project
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "notes",
|
||||
"data": {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"projectId": project_id or None,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
title: str = "",
|
||||
content: str = "",
|
||||
) -> str:
|
||||
"""Update an existing note. Only pass fields that should change.
|
||||
note_id: UUID of the note (required)
|
||||
If you need to preserve existing content, call get_note first.
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if content:
|
||||
updates["content"] = content
|
||||
return json.dumps({
|
||||
"action": "update_record",
|
||||
"table": "notes",
|
||||
"data": {"id": note_id, "updates": updates},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def delete_note(note_id: str) -> str:
|
||||
"""Delete a note permanently by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "delete_record",
|
||||
"table": "notes",
|
||||
"data": {"id": note_id},
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class NoteAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "note_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages notes: list, get, create, update, delete"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [list_notes, get_note, create_note, update_note, delete_note]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
158
app/agents/project_agent.py
Normal file
158
app/agents/project_agent.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Project agent — full lifecycle management (list, get, create, update, archive, delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a project management assistant. You help users create, find,\n"
|
||||
"update, and archive projects in their workspace.\n\n"
|
||||
"Rules:\n"
|
||||
" - status must be one of: active, archived\n"
|
||||
" - client_id is optional; link to a client only when explicitly mentioned\n"
|
||||
" - ai_summary is populated only when the user asks for a project summary;\n"
|
||||
" derive it from context data — do not fabricate content\n"
|
||||
" - Use list_projects for scoped queries; list_all_projects only when the\n"
|
||||
" user wants a complete cross-client view including archived projects\n"
|
||||
" - get_project requires a project UUID; resolve the ID first by calling\n"
|
||||
" list_projects if you only have a project name\n"
|
||||
" - Prefer archiving (update_project status=archived) over deletion;\n"
|
||||
" only call delete_project when the user explicitly confirms deletion."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_projects(
|
||||
client_id: str = "",
|
||||
include_archived: int = 0,
|
||||
) -> str:
|
||||
"""List projects, optionally filtered by client_id.
|
||||
include_archived: 1 to include archived projects, 0 for active only (default).
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "projects",
|
||||
"filters": {
|
||||
"clientId": client_id or None,
|
||||
"includeArchived": bool(include_archived),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def list_all_projects() -> str:
|
||||
"""List every project regardless of client or status.
|
||||
Use only when the user wants a complete cross-client overview.
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "list_all",
|
||||
"table": "projects",
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def get_project(project_id: str) -> str:
|
||||
"""Fetch a single project by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "get",
|
||||
"table": "projects",
|
||||
"data": {"id": project_id},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def create_project(
|
||||
name: str,
|
||||
client_id: str = "",
|
||||
) -> str:
|
||||
"""Create a new project.
|
||||
name: human-readable project name (required)
|
||||
client_id: optional UUID of the owning client
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "projects",
|
||||
"data": {
|
||||
"name": name,
|
||||
"clientId": client_id or None,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
name: str = "",
|
||||
client_id: str = "",
|
||||
status: str = "",
|
||||
ai_summary: str = "",
|
||||
) -> str:
|
||||
"""Update a project. Only pass fields that should change.
|
||||
project_id: UUID of the project (required)
|
||||
status: active | archived
|
||||
ai_summary: AI-generated summary text (populate only when explicitly requested)
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
if name:
|
||||
updates["name"] = name
|
||||
if client_id:
|
||||
updates["clientId"] = client_id
|
||||
if status:
|
||||
updates["status"] = status
|
||||
if ai_summary:
|
||||
updates["aiSummary"] = ai_summary
|
||||
return json.dumps({
|
||||
"action": "update_record",
|
||||
"table": "projects",
|
||||
"data": {"id": project_id, "updates": updates},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def delete_project(project_id: str) -> str:
|
||||
"""Permanently delete a project and orphan its tasks.
|
||||
IMPORTANT: prefer update_project(status='archived') unless the user
|
||||
has explicitly confirmed they want permanent deletion.
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "delete_record",
|
||||
"table": "projects",
|
||||
"data": {"id": project_id},
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class ProjectAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "project_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages projects: list, get, create, update, archive, delete"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [
|
||||
list_projects,
|
||||
list_all_projects,
|
||||
get_project,
|
||||
create_project,
|
||||
update_project,
|
||||
delete_project,
|
||||
]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Task agent — create, update, list, and suggest tasks."""
|
||||
"""Task agent — full CRUD for tasks and task comments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,40 +13,121 @@ from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a task management assistant (PM-oriented). Help the user create, "
|
||||
"update, list, and suggest tasks.\n"
|
||||
"You are a task management assistant for a project workspace.\n"
|
||||
"You create, update, list, and track tasks and their comments.\n\n"
|
||||
"Rules:\n"
|
||||
" - priority must be one of: low, medium, high, urgent\n"
|
||||
" - infer priority from context clues (deadlines, urgency language, dependencies)\n"
|
||||
" - due_date as ISO 8601 string when provided\n"
|
||||
" - context fields beyond user_profile are optional; use them when present\n"
|
||||
"Use the available tools to act, then confirm what was done in plain language."
|
||||
" - status must be one of: todo, in_progress, done\n"
|
||||
" - priority must be one of: high, medium, low\n"
|
||||
" - due_date is a Unix timestamp in milliseconds; convert human dates\n"
|
||||
" - assignees is a JSON-encoded array of strings (e.g. '[\"Alice\",\"Bob\"]')\n"
|
||||
" - project_id is optional; link to a project when the user mentions one\n"
|
||||
" - is_ai_suggested: 1 only when proactively proposing a task the user\n"
|
||||
" did not explicitly request; 0 otherwise\n"
|
||||
" - is_approved defaults to 0; set to 1 only when the user confirms\n"
|
||||
" - Use list_tasks_due_today for 'what's due today' queries\n"
|
||||
" - For update_task, use -1 for integer fields you do not want to change\n"
|
||||
" - Always confirm the action in plain, user-friendly language."
|
||||
)
|
||||
|
||||
|
||||
# ── Task tools ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
async def list_tasks(
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
search: str = "",
|
||||
order_by: str = "",
|
||||
) -> str:
|
||||
"""List tasks, optionally filtered by project_id, status (todo|in_progress|done),
|
||||
a search string, or an order_by field name (dueDate|priority|createdAt)."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "tasks",
|
||||
"filters": {
|
||||
"projectId": project_id or None,
|
||||
"status": status or None,
|
||||
"search": search or None,
|
||||
"orderBy": order_by or None,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def create_task(
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "todo",
|
||||
priority: str = "medium",
|
||||
due_date: str = "",
|
||||
assignees: str = "[]",
|
||||
due_date: int = 0,
|
||||
project_id: str = "",
|
||||
is_ai_suggested: int = 0,
|
||||
is_approved: int = 0,
|
||||
) -> str:
|
||||
"""Create a new task. priority: low | medium | high | urgent. due_date: ISO 8601."""
|
||||
"""Create a new task.
|
||||
title: task title (required)
|
||||
description: optional details
|
||||
status: todo | in_progress | done (default: todo)
|
||||
priority: high | medium | low (default: medium)
|
||||
assignees: JSON-encoded array of assignee names, e.g. '["Alice"]'
|
||||
due_date: Unix timestamp in milliseconds; 0 means no due date
|
||||
project_id: optional UUID of the parent project
|
||||
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
||||
is_approved: 0 until the user confirms; 1 when confirmed
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "tasks",
|
||||
"data": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"description": description or None,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"due_date": due_date,
|
||||
"assignee": assignees,
|
||||
"dueDate": due_date or None,
|
||||
"projectId": project_id or None,
|
||||
"isAiSuggested": is_ai_suggested,
|
||||
"isApproved": is_approved,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def update_task(task_id: str, updates: str) -> str:
|
||||
"""Update fields on an existing task. Pass updates as a JSON string, e.g. '{"priority":"high"}'."""
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
status: str = "",
|
||||
priority: str = "",
|
||||
assignees: str = "",
|
||||
due_date: int = -1,
|
||||
project_id: str = "",
|
||||
is_approved: int = -1,
|
||||
) -> str:
|
||||
"""Update fields on an existing task. Only pass fields you want to change.
|
||||
task_id: the task's UUID (required)
|
||||
due_date: -1 means unchanged; 0 clears the due date; any positive value sets it
|
||||
is_approved: -1 means unchanged; 0 or 1 sets the value
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if description:
|
||||
updates["description"] = description
|
||||
if status:
|
||||
updates["status"] = status
|
||||
if priority:
|
||||
updates["priority"] = priority
|
||||
if assignees:
|
||||
updates["assignee"] = assignees
|
||||
if due_date != -1:
|
||||
updates["dueDate"] = due_date or None
|
||||
if project_id:
|
||||
updates["projectId"] = project_id
|
||||
if is_approved != -1:
|
||||
updates["isApproved"] = is_approved
|
||||
return json.dumps({
|
||||
"action": "update_record",
|
||||
"table": "tasks",
|
||||
@@ -55,35 +136,87 @@ async def update_task(task_id: str, updates: str) -> str:
|
||||
|
||||
|
||||
@tool
|
||||
async def list_tasks(status: str = "", priority: str = "") -> str:
|
||||
"""List tasks. Optionally filter by status (open|done|archived) or priority level."""
|
||||
async def delete_task(task_id: str) -> str:
|
||||
"""Delete a task permanently by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"action": "delete_record",
|
||||
"table": "tasks",
|
||||
"filters": {"status": status, "priority": priority},
|
||||
"data": {"id": task_id},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def suggest_tasks(context: str) -> str:
|
||||
"""Suggest new tasks based on notes or free-form context text."""
|
||||
async def list_tasks_due_today() -> str:
|
||||
"""List all tasks whose due date falls on today's date."""
|
||||
return json.dumps({
|
||||
"action": "suggest",
|
||||
"action": "list_due_today",
|
||||
"table": "tasks",
|
||||
"context": context,
|
||||
})
|
||||
|
||||
|
||||
# ── Task comment tools ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
async def list_task_comments(task_id: str) -> str:
|
||||
"""List all comments on a task by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "taskComments",
|
||||
"filters": {"taskId": task_id},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def add_task_comment(task_id: str, author: str, content: str) -> str:
|
||||
"""Add a comment to a task.
|
||||
task_id: UUID of the task to comment on
|
||||
author: name or ID of the comment author
|
||||
content: comment text
|
||||
"""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "taskComments",
|
||||
"data": {
|
||||
"taskId": task_id,
|
||||
"author": author,
|
||||
"content": content,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def delete_task_comment(comment_id: str) -> str:
|
||||
"""Delete a task comment by its UUID."""
|
||||
return json.dumps({
|
||||
"action": "delete_record",
|
||||
"table": "taskComments",
|
||||
"data": {"id": comment_id},
|
||||
})
|
||||
|
||||
|
||||
# ── Agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@registry.register
|
||||
class TaskAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "task_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages tasks: create, update, list, suggest"
|
||||
return "Manages tasks and comments: list, create, update, delete, due-today, comments"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [create_task, update_task, list_tasks, suggest_tasks]
|
||||
return [
|
||||
list_tasks,
|
||||
create_task,
|
||||
update_task,
|
||||
delete_task,
|
||||
list_tasks_due_today,
|
||||
list_task_comments,
|
||||
add_task_comment,
|
||||
delete_task_comment,
|
||||
]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
|
||||
Reference in New Issue
Block a user