Abdullahcoder54 commited on
Commit
a67367b
·
1 Parent(s): 3e0f53b
Files changed (12) hide show
  1. .gitignore +44 -0
  2. Dockerfile +17 -0
  3. ai_mcp_server.py +229 -0
  4. app.py +0 -0
  5. config/config.py +22 -0
  6. guardrails.py +110 -0
  7. main.py +267 -0
  8. models/conversation.py +33 -0
  9. pyproject.toml +32 -0
  10. requirements.txt +7 -0
  11. todo_agent.py +116 -0
  12. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-code files
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+ *.egg
16
+
17
+ # Editors
18
+ .idea/
19
+ .vscode/
20
+
21
+ # Environments
22
+ .venv/
23
+ env/
24
+ venv/
25
+ *.ini
26
+ .python-version
27
+
28
+ # Logs and databases
29
+ *.log
30
+ *.sqlite3
31
+ *.db
32
+
33
+ # OS
34
+ .DS_Store
35
+ .Trashes
36
+ Thumbs.db
37
+
38
+ # uv specific
39
+ .uv/
40
+
41
+ # dotenv
42
+ .env
43
+ .env.*
44
+ !.env.example # Keep example files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+ # Pehle root user hai by default, to yahan packages install karo
3
+ RUN apt-get update && apt-get install -y libgl1 libglib2.0-0
4
+
5
+ # Ab user add karo aur switch karo
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ WORKDIR /app
11
+
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+ RUN pip install --no-cache-dir openai-chatkit==1.4.0
15
+
16
+ COPY --chown=user . /app
17
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
ai_mcp_server.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ HTTP-only MCP Server for the separated AI service.
4
+ Communicates with the business logic service via HTTP API calls.
5
+ """
6
+ import re
7
+ from typing import Literal, Optional, Any, Dict
8
+ import httpx
9
+ from mcp.server.fastmcp import FastMCP
10
+ from config.config import settings
11
+ import logging
12
+ import os
13
+ import sys
14
+
15
+ # Set up logging
16
+ logger = logging.getLogger(__name__)
17
+
18
+ logger.info("--- MCP SERVER SCRIPT STARTING ---")
19
+ # Load secure service secret from environment variable
20
+ SERVICE_SECRET = settings.BETTER_AUTH_SECRET
21
+ logger.info(f"SERVICE_SECRET loaded: '{bool(SERVICE_SECRET)}'") # Log boolean status, not the secret itself.
22
+
23
+ if not SERVICE_SECRET:
24
+ logger.error("SERVICE_SECRET is NOT SET. Authentication will fail.")
25
+
26
+ # Create MCP server instance
27
+ mcp = FastMCP("separated-task-management-server")
28
+ logger.info("FastMCP instance created.")
29
+
30
+ # Get business service base URL from environment
31
+ BUSINESS_SERVICE_URL = settings.BUSINESS_SERVICE_URL
32
+
33
+ def _get_auth_headers() -> Dict[str, str]:
34
+ if not SERVICE_SECRET:
35
+ logger.error("Attempted to get auth headers but SERVICE_SECRET is not set.")
36
+ return {}
37
+ return {"Authorization": f"Bearer {SERVICE_SECRET}"}
38
+
39
+ def detect_priority_from_text(text: str) -> str:
40
+ text_lower = text.lower()
41
+ high_priority_patterns = [r'\bhigh\s*priority\b', r'\burgent\b', r'\bcritical\b', r'\bimportant\b', r'\basap\b', r'\bhigh\b']
42
+ low_priority_patterns = [r'\blow\s*priority\b', r'\bminor\b', r'\boptional\b', r'\bwhen\s*you\s*have\s*time\b', r'low']
43
+ if any(re.search(p, text_lower) for p in high_priority_patterns):
44
+ return "high"
45
+ if any(re.search(p, text_lower) for p in low_priority_patterns):
46
+ return "low"
47
+ if re.search(r'\bmedium\b|\bnormal\b', text_lower):
48
+ return "medium"
49
+ return "medium"
50
+
51
+ @mcp.tool()
52
+ async def add_task(
53
+ user_id: str,
54
+ title: str,
55
+ description: Optional[str] = None,
56
+ priority: Optional[str] = None,
57
+ due_date: Optional[str] = None,
58
+ ) -> dict:
59
+ """
60
+ Create a new task. This tool is idempotent.
61
+ """
62
+ if not SERVICE_SECRET:
63
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
64
+
65
+ try:
66
+ existing_tasks_response = await list_tasks(user_id=user_id)
67
+ if existing_tasks_response.get("success"):
68
+ for task in existing_tasks_response.get("tasks", []):
69
+ if task.get("title", "").lower() == title.lower():
70
+ return {"success": False, "done": True, "message": "Task already exists."}
71
+ except Exception as e:
72
+ logger.error(f"Failed to check for existing tasks during add_task: {e}")
73
+ return {"success": False, "done": True, "error": "Failed to verify if task exists.", "details": str(e)}
74
+
75
+ if priority is None:
76
+ combined_text = f"{title} {description or ''}"
77
+ priority = detect_priority_from_text(combined_text)
78
+ else:
79
+ priority = priority.lower()
80
+ if priority not in ["low", "medium", "high"]:
81
+ priority = "medium"
82
+
83
+ payload = {"title": title, "description": description, "priority": priority, "completed": False, "due_date": due_date}
84
+ headers = {"Content-Type": "application/json", **_get_auth_headers()}
85
+
86
+ async with httpx.AsyncClient() as client:
87
+ try:
88
+ response = await client.post(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks", json=payload, headers=headers)
89
+ if response.status_code in [200, 201]:
90
+ return {"success": True, "done": True, "message": "Task added successfully."}
91
+ else:
92
+ return {"success": False, "done": True, "error": f"Failed to create task: {response.status_code}", "details": response.text}
93
+ except Exception as e:
94
+ return {"success": False, "done": True, "error": "Failed to create task.", "details": str(e)}
95
+
96
+ @mcp.tool()
97
+ async def list_tasks(user_id: str, status: Literal["all", "pending", "completed"] = "all") -> dict:
98
+ """
99
+ Retrieve tasks.
100
+ """
101
+ if not SERVICE_SECRET:
102
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
103
+ params = {"status": status} if status != "all" else {}
104
+ headers = _get_auth_headers()
105
+ async with httpx.AsyncClient() as client:
106
+ try:
107
+ response = await client.get(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks", params=params, headers=headers)
108
+ if response.status_code == 200:
109
+ result = response.json()
110
+ tasks = result if isinstance(result, list) else result.get("tasks", [])
111
+ task_list = [{"id": t.get("id"), "title": t.get("title"), "description": t.get("description"), "completed": t.get("completed", False), "priority": t.get("priority"), "created_at": t.get("created_at")} for t in tasks]
112
+ return {"success": True, "done": True, "tasks": task_list, "count": len(task_list)}
113
+ else:
114
+ return {"success": False, "done": True, "error": f"Failed to list tasks: {response.status_code}", "details": response.text}
115
+ except Exception as e:
116
+ return {"success": False, "done": True, "error": "Failed to list tasks.", "details": str(e)}
117
+
118
+ @mcp.tool()
119
+ async def complete_task(user_id: str, task_id: int) -> dict:
120
+ """
121
+ Mark a task as complete.
122
+ """
123
+ if not SERVICE_SECRET:
124
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
125
+ async with httpx.AsyncClient() as client:
126
+ try:
127
+ response = await client.patch(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks/{task_id}/complete", json=payload, headers=headers)
128
+ if response.status_code in [200, 204]:
129
+ return {"success": True, "done": True, "message": f"Task {task_id} marked as complete."}
130
+ else:
131
+ return {"success": False, "done": True, "error": f"Failed to complete task: {response.status_code}", "details": response.text}
132
+ except Exception as e:
133
+ return {"success": False, "done": True, "error": "Failed to complete task.", "details": str(e)}
134
+
135
+ @mcp.tool()
136
+ async def delete_task(user_id: str, task_id: int) -> dict:
137
+ """
138
+ Remove a task.
139
+ """
140
+ if not SERVICE_SECRET:
141
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
142
+ headers = _get_auth_headers()
143
+ async with httpx.AsyncClient() as client:
144
+ try:
145
+ response = await client.delete(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks/{task_id}", headers=headers)
146
+ if response.status_code in [200, 204]:
147
+ return {"success": True, "done": True, "message": "Task deleted successfully."}
148
+ else:
149
+ return {"success": False, "done": True, "error": f"Failed to delete task: {response.status_code}", "details": response.text}
150
+ except Exception as e:
151
+ return {"success": False, "done": True, "error": "Failed to delete task.", "details": str(e)}
152
+
153
+ @mcp.tool()
154
+ async def update_task(user_id: str, task_id: int, title: Optional[str] = None, description: Optional[str] = None, priority: Optional[str] = None) -> dict:
155
+ """
156
+ Modify task details.
157
+ """
158
+ if not SERVICE_SECRET:
159
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
160
+ if title is None and description is None and priority is None:
161
+ return {"success": False, "done": True, "error": "At least one of 'title', 'description', or 'priority' must be provided."}
162
+ payload = {k: v for k, v in {"title": title, "description": description, "priority": priority}.items() if v is not None}
163
+ headers = {"Content-Type": "application/json", **_get_auth_headers()}
164
+ async with httpx.AsyncClient() as client:
165
+ try:
166
+ response = await client.put(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks/{task_id}", json=payload, headers=headers)
167
+ if response.status_code in [200, 204]:
168
+ return {"success": True, "done": True, "message": f"Task {task_id} updated successfully."}
169
+ else:
170
+ return {"success": False, "done": True, "error": f"Failed to update task: {response.status_code}", "details": response.text}
171
+ except Exception as e:
172
+ return {"success": False, "done": True, "error": "Failed to update task.", "details": str(e)}
173
+
174
+ @mcp.tool()
175
+ async def set_priority(user_id: str, task_id: int, priority: str) -> dict:
176
+ """
177
+ Set or update a task's priority.
178
+ """
179
+ if not SERVICE_SECRET:
180
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
181
+ priority = priority.lower()
182
+ if priority not in ["low", "medium", "high"]:
183
+ return {"success": False, "done": True, "error": "Priority must be one of: 'low', 'medium', 'high'."}
184
+ payload = {"priority": priority}
185
+ headers = {"Content-Type": "application/json", **_get_auth_headers()}
186
+ async with httpx.AsyncClient() as client:
187
+ try:
188
+ response = await client.put(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks/{task_id}", json=payload, headers=headers)
189
+ if response.status_code in [200, 204]:
190
+ return {"success": True, "done": True, "message": f"Priority for task {task_id} updated successfully."}
191
+ else:
192
+ return {"success": False, "done": True, "error": f"Failed to update priority: {response.status_code}", "details": response.text}
193
+ except Exception as e:
194
+ return {"success": False, "done": True, "error": "Failed to update priority.", "details": str(e)}
195
+
196
+ @mcp.tool()
197
+ async def list_tasks_by_priority(user_id: str, priority: str, status: Literal["all", "pending", "completed"] = "all") -> dict:
198
+ """
199
+ Retrieve tasks filtered by priority.
200
+ """
201
+ if not SERVICE_SECRET:
202
+ return {"success": False, "done": True, "error": "Internal authentication error: Service secret missing."}
203
+ priority = priority.lower()
204
+ if priority not in ["low", "medium", "high"]:
205
+ return {"success": False, "done": True, "error": "Priority must be one of: 'low', 'medium', 'high'."}
206
+ params = {"priority": priority}
207
+ if status != "all":
208
+ params["status"] = status
209
+ headers = _get_auth_headers()
210
+ async with httpx.AsyncClient() as client:
211
+ try:
212
+ response = await client.get(f"{BUSINESS_SERVICE_URL}/api/{user_id}/tasks", params=params, headers=headers)
213
+ if response.status_code == 200:
214
+ result = response.json()
215
+ tasks = result.get("tasks", [])
216
+ task_list = [{"id": t.get("id"), "title": t.get("title"), "priority": t.get("priority"), "completed": t.get("completed", False), "description": t.get("description"), "created_at": t.get("created_at")} for t in tasks]
217
+ return {"success": True, "done": True, "tasks": task_list, "count": len(task_list), "priority": priority, "status": status}
218
+ else:
219
+ return {"success": False, "done": True, "error": f"Failed to list tasks by priority: {response.status_code}", "details": response.text}
220
+ except Exception as e:
221
+ return {"success": False, "done": True, "error": "Failed to list tasks by priority.", "details": str(e)}
222
+
223
+ if __name__ == "__main__":
224
+ try:
225
+ logger.info("MCP script running in __main__")
226
+ mcp.run()
227
+ except Exception as e:
228
+ logger.error("An unhandled exception occurred in ai_mcp_server.py", exc_info=True)
229
+ raise
app.py ADDED
File without changes
config/config.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+ from pydantic import HttpUrl
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ # Load environment variables from .env file
8
+ load_dotenv()
9
+
10
+ class Settings(BaseSettings):
11
+
12
+ GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY")
13
+ BUSINESS_SERVICE_URL: HttpUrl = "https://abdullahcoder54-todo-app.hf.space"
14
+ CONVERSATION_RETENTION_DAYS: int = 7
15
+ BETTER_AUTH_SECRET: str = os.getenv("BETTER_AUTH_SECRET")
16
+
17
+ model_config = {
18
+ "env_file": ".env",
19
+ "case_sensitive": True,
20
+ "extra": "allow"
21
+ }
22
+ settings = Settings()
guardrails.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guardrails for the TodoAgent."""
2
+
3
+ from agents import (
4
+ Agent,
5
+ OpenAIChatCompletionsModel,
6
+ input_guardrail,
7
+ output_guardrail,
8
+ GuardrailFunctionOutput,
9
+ RunContextWrapper, # Needed for guardrail function signature
10
+ Runner, # Needed to run topic_checker_agent
11
+ TResponseInputItem # Correct type for input content in guardrail functions
12
+ )
13
+ from config.config import settings
14
+ from openai import AsyncOpenAI
15
+ import logging
16
+ from typing import Any # Needed for RunContextWrapper[Any]
17
+ from pydantic import BaseModel
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class todo(BaseModel):
22
+ reasoning: str
23
+ is_not_todo: bool
24
+ user_friendly_response: str
25
+
26
+ # A simple, low-cost model to act as a topic checker.
27
+ guardrail_client = AsyncOpenAI(
28
+ api_key=settings.GEMINI_API_KEY,
29
+ base_url="https://openrouter.ai/api/v1"
30
+ )
31
+ guardrail_model = OpenAIChatCompletionsModel(
32
+ model="arcee-ai/trinity-large-preview:free", # Using a common model for topic checking
33
+ openai_client=guardrail_client
34
+ )
35
+
36
+ # This is the "topic checker" agent.
37
+ guardrail_agent = Agent(
38
+ name="TopicChecker",
39
+ model=guardrail_model,
40
+ output_type=todo,
41
+ instructions="""
42
+ Your job is to check whether the user's message is related to managing a to-do list.
43
+
44
+ If the message IS related to task management:
45
+ - Adding a task
46
+ - Deleting a task
47
+ - Updating a task
48
+ - Listing tasks
49
+ - Completing tasks
50
+ - Asking about existing tasks
51
+ - Any clear todo/task management request
52
+
53
+
54
+ If the message is NOT related to task management
55
+ (e.g., math questions, weather, casual conversation, general knowledge):
56
+ - Respond politely and briefly.
57
+ - Explain that you can help only with managing tasks.
58
+ - Guide the user to ask a task-related question.
59
+
60
+ Your response MUST be friendly, clear, and short.
61
+
62
+ Example response for non-task queries:
63
+ "I’m here to help with your to-do list. You can ask me to add, remove, update, or list your tasks."
64
+
65
+ """
66
+ )
67
+
68
+ @input_guardrail(name="TodoInputGuardrail")
69
+ async def is_task_related(
70
+ ctx: RunContextWrapper[Any], # Context provided by Runner
71
+ agent: Agent, # The agent running this guardrail
72
+ user_input: str | list[TResponseInputItem] # The user's input
73
+ ) -> GuardrailFunctionOutput:
74
+ """
75
+ Input guardrail to check if the user's query is related to task management.
76
+ """
77
+ try:
78
+
79
+ logger.info(f"Checking topic for query: '{user_input}'")
80
+
81
+ # Run the topic checker agent. Pass the input directly, and context for traceability.
82
+ result = await Runner.run(guardrail_agent, input=user_input, context=ctx.context)
83
+ logger.info(f"Topic checker result: is_todo={result.final_output.is_not_todo}, reasoning='{result.final_output.reasoning}'")
84
+ return GuardrailFunctionOutput(
85
+ output_info=result.final_output.user_friendly_response,
86
+ tripwire_triggered=result.final_output.is_not_todo,
87
+ )
88
+
89
+ except Exception as e:
90
+ logger.error(f"Error in input guardrail: {e}")
91
+ return GuardrailFunctionOutput(
92
+ tripwire_triggered=False,
93
+ output_info="An error occurred while checking your query.",
94
+ )
95
+
96
+ @output_guardrail(name="TodoOutputGuardrail")
97
+ async def is_response_appropriate(
98
+ ctx: RunContextWrapper[Any], # Context provided by Runner
99
+ agent: Agent, # The agent running this guardrail
100
+ response_content: str | list[TResponseInputItem] # The agent's output
101
+ ) -> GuardrailFunctionOutput:
102
+ """
103
+ Output guardrail to check if the agent's response is appropriate.
104
+ (Placeholder - currently allows all responses.)
105
+ """
106
+ # In a real implementation, you might check for profanity, sensitive data, etc.
107
+ return GuardrailFunctionOutput(
108
+ tripwire_triggered=False,
109
+ output_info=None,
110
+ )
main.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entrypoint for the ChatKit backend with TodoAgent."""
2
+
3
+ from __future__ import annotations
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
7
+ from typing import Optional, Dict, Any
8
+ import logging
9
+ from contextlib import asynccontextmanager
10
+ from uuid import uuid4
11
+
12
+ from chatkit.server import ChatKitServer, StreamingResult
13
+ from chatkit.store import Store, Page
14
+ from chatkit.types import ThreadMetadata, UserMessageItem, ThreadItem, AssistantMessageItem, ThreadItemAddedEvent, ErrorEvent
15
+ import json
16
+ from todo_agent import TodoAgent
17
+
18
+ # Configure logging
19
+ logging.basicConfig(level=logging.INFO)
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class MemoryStore(Store):
24
+ """In-memory implementation of ChatKit Store for development."""
25
+
26
+ def __init__(self):
27
+ self.threads: Dict[str, ThreadMetadata] = {}
28
+ self.items: Dict[str, Dict[str, Any]] = {}
29
+ self.attachments: Dict[str, Any] = {}
30
+
31
+ async def load_threads(self, *, context: Dict[str, Any] | None = None, **kwargs) -> Page:
32
+ """Load all threads for a user, extracting user_id from context."""
33
+ user_id = context.get("user_id") if context else None
34
+ if not user_id:
35
+ # If no user_id is found, return an empty page.
36
+ return Page(data=[], has_more=False)
37
+
38
+ threads = [t for t in self.threads.values() if t.user and t.user.id == user_id]
39
+ return Page(data=threads, has_more=False)
40
+
41
+ async def load_thread(self, thread_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> ThreadMetadata | None:
42
+ """Load a single thread, create if doesn't exist."""
43
+ thread = self.threads.get(thread_id)
44
+ if thread is None:
45
+ # Create a new thread if it doesn't exist to prevent NoneType errors downstream.
46
+ user_id = context.get("user_id", "unknown") if context else "unknown"
47
+ thread = ThreadMetadata(
48
+ id=thread_id,
49
+ user_id=user_id,
50
+ created_at=0, # Using a simple timestamp
51
+ updated_at=0,
52
+ )
53
+ self.threads[thread_id] = thread
54
+ return thread
55
+
56
+ async def save_thread(self, thread: ThreadMetadata, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
57
+ """Save a thread."""
58
+ self.threads[thread.id] = thread
59
+
60
+ async def delete_thread(self, thread_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
61
+ """Delete a thread."""
62
+ self.threads.pop(thread_id, None)
63
+ self.items.pop(thread_id, None)
64
+
65
+ async def load_thread_items(self, thread_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> Page:
66
+ """Load all items for a thread."""
67
+ items = list(self.items.get(thread_id, {}).values())
68
+ return Page(data=items, has_more=False)
69
+
70
+ async def load_item(self, thread_id: str, item_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> ThreadItem | None:
71
+ """Load a single item."""
72
+ return self.items.get(thread_id, {}).get(item_id)
73
+
74
+ async def save_item(self, thread_id: str, item: ThreadItem, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
75
+ """Save an item."""
76
+ if thread_id not in self.items:
77
+ self.items[thread_id] = {}
78
+ self.items[thread_id][item.id] = item
79
+
80
+ async def add_thread_item(self, thread_id: str, item: ThreadItem, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
81
+ """Add an item to a thread."""
82
+ await self.save_item(thread_id, item, context=context)
83
+
84
+ async def delete_thread_item(self, thread_id: str, item_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
85
+ """Delete an item from a thread."""
86
+ if thread_id in self.items:
87
+ self.items[thread_id].pop(item_id, None)
88
+
89
+ async def save_attachment(self, attachment: Any, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
90
+ """Save an attachment."""
91
+ self.attachments[attachment.id] = attachment
92
+
93
+ async def load_attachment(self, attachment_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> Any | None:
94
+ """Load an attachment."""
95
+ return self.attachments.get(attachment_id)
96
+
97
+ async def delete_attachment(self, attachment_id: str, *args, context: Dict[str, Any] | None = None, **kwargs) -> None:
98
+ """Delete an attachment."""
99
+ self.attachments.pop(attachment_id, None)
100
+
101
+
102
+ # Global instances
103
+ todo_agent: Optional[TodoAgent] = None
104
+ chatkit_server: Optional[ChatKitServer] = None
105
+
106
+
107
+ class TodoChatKitServer(ChatKitServer):
108
+ """ChatKit server that wraps the TodoAgent."""
109
+
110
+ def __init__(self, todo_agent: TodoAgent):
111
+ self.todo_agent = todo_agent
112
+ super().__init__(MemoryStore())
113
+
114
+ async def respond(self, thread: ThreadMetadata, input_user_message: UserMessageItem | None, context: Dict[str, Any]):
115
+ """Implement the abstract respond method to handle user messages."""
116
+ try:
117
+ user_id = context.get("user_id", "unknown")
118
+
119
+ # Get the message content
120
+ message_content = ""
121
+ if input_user_message and hasattr(input_user_message, 'content'):
122
+ message_content = input_user_message.content
123
+
124
+ logger.info(f"Processing message from user {user_id}: {message_content}")
125
+
126
+ # Use TodoAgent to generate response
127
+ response = await self.todo_agent.process_message(user_id, message_content)
128
+
129
+ # Create an AssistantMessageItem for the response, ensuring all fields are correctly formatted.
130
+ assistant_item = AssistantMessageItem(
131
+ id=f"msg_{uuid4()}",
132
+ thread_id=thread.id, # Pass the thread_id
133
+ created_at=0, # Pass a timestamp
134
+ content=[{"type": "output_text", "text": response}], # Format content as a list of blocks
135
+ )
136
+
137
+ # Yield ThreadItemAddedEvent to add the assistant message to the thread
138
+ yield ThreadItemAddedEvent(
139
+ type="thread.item.added",
140
+ thread_id=thread.id,
141
+ item=assistant_item
142
+ )
143
+
144
+ except Exception as e:
145
+ logger.error(f"Error in respond(): {e}", exc_info=True)
146
+ yield ErrorEvent(
147
+ type="error",
148
+ error=str(e)
149
+ )
150
+
151
+ async def process(self, payload: bytes | str, context: Dict[str, Any]) -> StreamingResult | dict | str:
152
+ """Process ChatKit events using TodoAgent."""
153
+ try:
154
+ # Parse the incoming payload
155
+ if isinstance(payload, bytes):
156
+ body = json.loads(payload.decode('utf-8'))
157
+ else:
158
+ body = json.loads(payload)
159
+
160
+ logger.info(f"Processing ChatKit event: {body.get('type')}")
161
+
162
+ # Extract user_id from context or payload
163
+ user_id = str(context.get("user_id", body.get("user", {}).get("id", "unknown")))
164
+
165
+ # Call the parent process method which handles ChatKit protocol
166
+ result = await super().process(payload, context)
167
+
168
+ return result
169
+
170
+ except Exception as e:
171
+ logger.error(f"Error in ChatKitServer.process(): {e}", exc_info=True)
172
+ return {"type": "error", "content": str(e)}
173
+
174
+
175
+ @asynccontextmanager
176
+ async def lifespan(app: FastAPI):
177
+ """Initialize agents on startup, cleanup on shutdown."""
178
+ global todo_agent, chatkit_server
179
+
180
+ logger.info("Initializing TodoAgent...")
181
+ todo_agent = TodoAgent()
182
+ logger.info("TodoAgent initialized")
183
+
184
+ logger.info("Initializing ChatKit Server...")
185
+ chatkit_server = TodoChatKitServer(todo_agent)
186
+ logger.info("ChatKit Server initialized")
187
+
188
+ yield
189
+
190
+ logger.info("Shutting down...")
191
+ todo_agent = None
192
+ chatkit_server = None
193
+
194
+
195
+ app = FastAPI(
196
+ title="ChatKit Backend with TodoAgent",
197
+ description="ChatKit backend powered by TodoAgent",
198
+ version="1.0.0",
199
+ lifespan=lifespan
200
+ )
201
+
202
+ app.add_middleware(
203
+ CORSMiddleware,
204
+ allow_origins=["*"],
205
+ allow_credentials=True,
206
+ allow_methods=["*"],
207
+ allow_headers=["*"],
208
+ )
209
+
210
+
211
+ @app.post("/chatkit")
212
+ async def chatkit_endpoint(request: Request) -> Response:
213
+ """Proxy the ChatKit web component payload to the server implementation."""
214
+ global chatkit_server
215
+
216
+ if chatkit_server is None:
217
+ logger.error("ChatKit server not initialized")
218
+ return JSONResponse({"error": "Server not initialized"}, status_code=500)
219
+
220
+ try:
221
+ payload = await request.body()
222
+
223
+ # Extract user_id from the request if available
224
+ context = {"request": request}
225
+ try:
226
+ import json
227
+ body = json.loads(payload.decode('utf-8'))
228
+ if "user" in body and "id" in body["user"]:
229
+ context["user_id"] = str(body["user"]["id"])
230
+ except Exception as e:
231
+ logger.debug(f"Could not extract user_id from payload: {e}")
232
+
233
+ result = await chatkit_server.process(payload, context)
234
+
235
+ if isinstance(result, StreamingResult):
236
+ return StreamingResponse(result, media_type="text/event-stream")
237
+ if hasattr(result, "json"):
238
+ return Response(content=result.json, media_type="application/json")
239
+ return JSONResponse(result)
240
+
241
+ except Exception as e:
242
+ logger.error(f"Error in chatkit_endpoint: {e}", exc_info=True)
243
+ return JSONResponse({"error": str(e)}, status_code=500)
244
+
245
+
246
+ @app.get("/")
247
+ async def health_check():
248
+ """Health check endpoint."""
249
+ return {
250
+ "service": "chatkit-todo-assistant",
251
+ "initialized": chatkit_server is not None
252
+ }
253
+
254
+ @app.get("/health")
255
+ async def health():
256
+ return{"status": "ok"}
257
+
258
+ @app.post("/chatkit/api")
259
+ async def chatkit_api_endpoint(request: Request) -> Response:
260
+ """Alias for /chatkit endpoint for compatibility."""
261
+ return await chatkit_endpoint(request)
262
+
263
+
264
+
265
+ if __name__ == "__main__":
266
+ import uvicorn
267
+ uvicorn.run(app, host="0.0.0.0", port=8000)
models/conversation.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
3
+ from pydantic import BaseModel, Field
4
+ import uuid
5
+
6
+ class Message(BaseModel):
7
+ role: str
8
+ content: str
9
+
10
+ class Conversation(BaseModel):
11
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
12
+ user_id: str
13
+ messages: List[Message] = Field(default_factory=list)
14
+ created_at: datetime = Field(default_factory=datetime.utcnow)
15
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
16
+
17
+ def add_message(self, role: str, content: str):
18
+ self.messages.append(Message(role=role, content=content))
19
+ self.updated_at = datetime.utcnow()
20
+
21
+ def to_dict(self):
22
+ return {
23
+ "id": self.id,
24
+ "user_id": self.user_id,
25
+ "messages": [msg.model_dump() for msg in self.messages],
26
+ "created_at": self.created_at.isoformat(),
27
+ "updated_at": self.updated_at.isoformat(),
28
+ }
29
+
30
+ @classmethod
31
+ def from_dict(cls, data: dict):
32
+ data["messages"] = [Message(**msg) for msg in data["messages"]]
33
+ return cls(**data)
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "ai-service"
3
+ version = "0.1.0"
4
+ description = "AI Agent Service for Todo List App"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "uvicorn[standard]>=0.32.0",
10
+ "openai-agents>=0.3.2",
11
+ "python-mcp>=1.0.0",
12
+ "httpx>=0.27.2",
13
+ "pydantic>=2.10",
14
+ "pydantic-settings>=2.6.1",
15
+ "openai-chatkit>=1.4.0"
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=61.0"]
20
+ build-backend = "setuptools.build_meta"
21
+
22
+ [tool.setuptools]
23
+ py-modules = ["app", "todo_agent", "ai_mcp_server", "main"]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["."]
27
+ include = ["config*", "models*"]
28
+
29
+ [tool.uv.workspace]
30
+ members = [
31
+ "ai-service",
32
+ ]
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.32.0
3
+ openai-agents==0.2.9
4
+ python-mcp==1.0.0
5
+ httpx==0.27.2
6
+ pydantic==2.10
7
+ pydantic-settings==2.6.1
todo_agent.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional
2
+ from agents import (
3
+ Agent,
4
+ Runner,
5
+ RunConfig,
6
+ OpenAIChatCompletionsModel,
7
+ ModelSettings,
8
+ set_tracing_disabled,
9
+ enable_verbose_stdout_logging,
10
+ InputGuardrailTripwireTriggered # Import the exception
11
+ )
12
+ from agents.mcp import MCPServerStdio
13
+ from guardrails import is_task_related # Import the guardrail function
14
+ import logging
15
+ from openai import AsyncOpenAI
16
+ import json
17
+ import sys
18
+ import os
19
+ from config.config import settings
20
+
21
+ set_tracing_disabled(disabled=True)
22
+ enable_verbose_stdout_logging()
23
+ logger = logging.getLogger(__name__)
24
+
25
+ class TodoAgent:
26
+ def __init__(self):
27
+ self.client = AsyncOpenAI(
28
+ api_key=settings.GEMINI_API_KEY,
29
+ base_url="https://openrouter.ai/api/v1"
30
+ )
31
+ model = OpenAIChatCompletionsModel(
32
+ model="z-ai/glm-4.5-air:free",
33
+ openai_client=self.client
34
+ )
35
+ self.config = RunConfig(model=model, model_provider=self.client)
36
+ self.mcp_server = MCPServerStdio(
37
+ name="Todo Management MCP Server",
38
+ params={"command": sys.executable, "args": ["-m", "ai_mcp_server"]},
39
+ client_session_timeout_seconds=30.0
40
+ )
41
+ self.agent = Agent(
42
+ name="TodoAssistant",
43
+ instructions="""
44
+
45
+ You are a Todo Management Assistant.
46
+
47
+ YOUR RESPONSIBILITIES:
48
+ - Add tasks when the user asks to create a todo.
49
+ - List tasks when the user asks to see todos.
50
+ - Update tasks when the user asks to modify a todo.
51
+ - Delete tasks when the user asks to remove a todo.
52
+ - Answer questions about existing tasks clearly.
53
+
54
+ YOUR TOOLS:
55
+ 1. ADD_TASK - Adds a new task to the todo list.
56
+
57
+ 2. LIST_TASKS - Lists all current tasks in the todo list.
58
+
59
+ 3. UPDATE_TASK - Updates an existing task.
60
+
61
+ 4. DELETE_TASK - Deletes a task from the todo list.
62
+
63
+ 5. GET_TASK - Retrieves details of a specific task.
64
+
65
+ CRITICAL EXECUTION RULES:
66
+
67
+ - When a tool call succeeds, STOP immediately.
68
+ - Do NOT repeat or retry the same tool call.
69
+ - Do NOT perform multiple actions for a single request.
70
+ - Your job ends after the correct tool is called and a brief confirmation is returned.
71
+
72
+
73
+ TASK HANDLING RULES:
74
+ 1. A task consists of a clear title and optional details.
75
+ 2. Never create duplicate tasks with the same title.
76
+ 3. If a task already exists, inform the user instead of adding it again.
77
+ 4. When listing tasks, show them in a clear and readable format.
78
+ 5. When a task is added, updated, or deleted, confirm the action briefly.
79
+ 6. If the request is unclear, ask a short clarification question.
80
+ 7. Perform only the action the user requested — no extra actions.
81
+
82
+ GENERAL BEHAVIOR:
83
+ - Be concise and practical.
84
+ - Do not invent tasks.
85
+ - Do not repeat actions.
86
+ - Always reflect the current state of the todo list.
87
+
88
+ Your goal is to keep the user's todo list accurate and easy to manage.
89
+ """,
90
+ mcp_servers=[self.mcp_server],
91
+ model_settings=ModelSettings(parallel_tool_calls=False),
92
+ input_guardrails=[is_task_related] # Add the input guardrail here
93
+ )
94
+
95
+ async def process_message(self, user_id: str, message: str) -> str:
96
+ """
97
+ Processes a user message and returns only the final string response,
98
+ as required by the simple, non-streaming backend template.
99
+ """
100
+ await self.mcp_server.connect()
101
+
102
+ try:
103
+ result = await Runner.run(
104
+ self.agent,
105
+ input=f"[USER_ID: {user_id}] {message}",
106
+ run_config=self.config,
107
+
108
+ )
109
+ final_response = result.final_output if result.final_output else "I was able to process your request."
110
+
111
+ except InputGuardrailTripwireTriggered as e:
112
+ logger.warning(f"Input guardrail tripped for user {user_id}. Message: '{message}'")
113
+ final_response = e.guardrail_result.output.output_info
114
+
115
+ logger.info(f"Agent for user {user_id} produced final response: {final_response}")
116
+ return final_response
uv.lock ADDED
The diff for this file is too large to render. See raw diff