WS Gateway:
- WebSocket lifecycle handler with RS256 JWT auth
- Redis bridge: device registry, frame publishing, tool_result routing
- Inbound routing: tool_result→LPUSH, home/floating→chat pub/sub
- Outbound: subscribes to ws:out:{user_id}, forwards to Electron
- Single-worker Dockerfile (long-lived WS connections)
Chat Service:
- Redis consumer: subscribes to chat:request:* pattern
- Redis-based ws_context: tool_call→publish, BRPOP tool_result (30s timeout)
- deep_agent: single-agent runner with home/floating/stream variants
- memory_middleware: core/associative/episodic/proactive memory with Fernet
- Domain agents: task (8 tools), note (5), project (6), timeline (4)
- LLM factory via LiteLLM (100+ providers)
- Output formatter (StreamFormatter)
- POST /chat REST fallback with Traefik header auth
- Multi-worker Dockerfile with 120s timeout for LLM calls
147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
"""Project agent — full lifecycle management (list, get, create, update, archive, delete).
|
|
|
|
Adapted for Chat Service: import from app.ws_context instead of app.core.ws_context.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from langchain_core.tools import tool
|
|
|
|
from app.ws_context import execute_on_client
|
|
|
|
PROJECT_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).
|
|
"""
|
|
result = await execute_on_client(
|
|
action="select",
|
|
table="projects",
|
|
filters={
|
|
"clientId": client_id or None,
|
|
"includeArchived": bool(include_archived),
|
|
},
|
|
)
|
|
rows = result.get("rows", [])
|
|
if not rows:
|
|
return "No projects found."
|
|
lines = [f"- {r['name']} (status: {r['status']}, id: {r['id']})" for r in rows]
|
|
return f"Found {len(rows)} project(s):\n" + "\n".join(lines)
|
|
|
|
|
|
@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.
|
|
"""
|
|
result = await execute_on_client(action="select", table="projects")
|
|
rows = result.get("rows", [])
|
|
if not rows:
|
|
return "No projects found."
|
|
lines = [f"- {r['name']} (status: {r['status']}, id: {r['id']})" for r in rows]
|
|
return f"All projects ({len(rows)}):\n" + "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def get_project(project_id: str) -> str:
|
|
"""Fetch a single project by its UUID."""
|
|
result = await execute_on_client(action="get", table="projects", data={"id": project_id})
|
|
row = result.get("row")
|
|
if not row:
|
|
return f"Project {project_id} not found."
|
|
return (
|
|
f"Project: '{row['name']}' (id: {row['id']}, status: {row['status']}, "
|
|
f"clientId: {row.get('clientId', 'none')})"
|
|
)
|
|
|
|
|
|
@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
|
|
"""
|
|
result = await execute_on_client(
|
|
action="insert",
|
|
table="projects",
|
|
data={"name": name, "clientId": client_id or None},
|
|
)
|
|
row = result["row"]
|
|
return f"Project created: '{row['name']}' (id: {row['id']})"
|
|
|
|
|
|
@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
|
|
result = await execute_on_client(
|
|
action="update",
|
|
table="projects",
|
|
data={"id": project_id, "updates": updates},
|
|
)
|
|
row = result["row"]
|
|
return f"Project updated: '{row['name']}' (id: {row['id']}, status: {row['status']})"
|
|
|
|
|
|
@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.
|
|
"""
|
|
await execute_on_client(action="delete", table="projects", data={"id": project_id})
|
|
return f"Project {project_id} permanently deleted."
|
|
|
|
|
|
PROJECT_TOOLS: list[Any] = [
|
|
list_projects,
|
|
list_all_projects,
|
|
get_project,
|
|
create_project,
|
|
update_project,
|
|
delete_project,
|
|
]
|