rename from checkpoint to timeline agent
This commit is contained in:
@@ -69,7 +69,7 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern
|
||||
|---|---|
|
||||
| `tasks` | id, projectId, title, description, status (todo\|in_progress\|done), priority (high\|medium\|low), assignee (JSON array string), dueDate (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) |
|
||||
| `projects` | id, clientId, name, status (active\|archived), aiSummary, createdAt (ms) |
|
||||
| `checkpoints` | id, projectId (required), title, date (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) |
|
||||
| `timelines` | id, projectId (required), title, date (ms), isAiSuggested (0\|1), isApproved (0\|1), createdAt (ms) |
|
||||
| `notes` | id, projectId, title, content (markdown), createdAt (ms), updatedAt (ms) |
|
||||
| `taskComments` | id, taskId, author, content, createdAt (ms) |
|
||||
| `clients` | id, parentId, name, industry, createdAt (ms) |
|
||||
@@ -141,11 +141,11 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern
|
||||
- `update_project(project_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation
|
||||
- `delete_project(project_id)`: `execute_on_client(action="delete", ...)` → return confirmation
|
||||
|
||||
- [x] **`app/agents/checkpoint_agent.py` (4 tools):**
|
||||
- `list_checkpoints(project_id)`: `execute_on_client(action="select", table="checkpoints", filters={projectId})` → format + return
|
||||
- `create_checkpoint(project_id, title, date, ...)`: `execute_on_client(action="insert", table="checkpoints", data={...})` → return confirmation + id
|
||||
- `update_checkpoint(checkpoint_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation
|
||||
- `delete_checkpoint(checkpoint_id)`: `execute_on_client(action="delete", ...)` → return confirmation
|
||||
- [x] **`app/agents/timeline_agent.py` (4 tools):**
|
||||
- `list_timelines(project_id)`: `execute_on_client(action="select", table="timelines", filters={projectId})` → format + return
|
||||
- `create_timeline(project_id, title, date, ...)`: `execute_on_client(action="insert", table="timelines", data={...})` → return confirmation + id
|
||||
- `update_timeline(timeline_id, ...)`: build updates → `execute_on_client(action="update", ...)` → return confirmation
|
||||
- `delete_timeline(timeline_id)`: `execute_on_client(action="delete", ...)` → return confirmation
|
||||
|
||||
- [x] **`app/agents/note_agent.py` (5 tools):**
|
||||
- `list_notes(project_id)`: `execute_on_client(action="select", table="notes", filters={projectId})` → format + return
|
||||
@@ -154,7 +154,7 @@ Tools must use **camelCase** field names (Drizzle maps them to snake_case intern
|
||||
- `update_note(note_id, ...)`: build updates → `execute_on_client(action="update", ...)` → then vector_upsert for updated content → return confirmation
|
||||
- `delete_note(note_id)`: `execute_on_client(action="delete", ...)` → return confirmation
|
||||
|
||||
- **Files:** `app/agents/task_agent.py`, `app/agents/project_agent.py`, `app/agents/checkpoint_agent.py`, `app/agents/note_agent.py`
|
||||
- **Files:** `app/agents/task_agent.py`, `app/agents/project_agent.py`, `app/agents/timeline_agent.py`, `app/agents/note_agent.py`
|
||||
- **Outcome:** All 23 tools query real user data via WS. LLM sees actual rows, not action descriptors.
|
||||
|
||||
### Step B.3 — Bidirectional WebSocket handler
|
||||
@@ -282,7 +282,7 @@ Cloud Agent:
|
||||
- `device_id` str — identifies which Electron install this config belongs to
|
||||
- `name` str
|
||||
- `directory_paths` JSON — list of absolute paths on the device
|
||||
- `data_types` JSON — which tables to extract to: `["tasks", "notes", "checkpoints", "projects"]`
|
||||
- `data_types` JSON — which tables to extract to: `["tasks", "notes", "timelines", "projects"]`
|
||||
- `prompt_template` text — user-configured via Chatbot Journey
|
||||
- `file_extensions` JSON — e.g. `[".eml", ".txt", ".pdf", ".md"]`
|
||||
- `schedule_cron` str — e.g. `"0 */6 * * *"` (every 6h)
|
||||
@@ -429,7 +429,7 @@ Cloud Agent:
|
||||
- `POST /api/v1/agents/journey/message`:
|
||||
- Body: `{ session_id, message }`
|
||||
- AI processes user's answer, asks follow-up questions (max 5 turns)
|
||||
- System prompt: "You are configuring a data extraction agent for a freelancer. Ask about file format, what data to extract (tasks, notes, checkpoints), naming conventions, priority rules, and any special mapping. After 3-5 questions, generate a detailed prompt_template."
|
||||
- System prompt: "You are configuring a data extraction agent for a freelancer. Ask about file format, what data to extract (tasks, notes, timelines), naming conventions, priority rules, and any special mapping. After 3-5 questions, generate a detailed prompt_template."
|
||||
- When AI determines enough context: `{ session_id, message: "Here's your configuration...", done: true, prompt_template: "..." }`
|
||||
- The `prompt_template` is a structured instruction for the extraction LLM (e.g. "Extract tasks from email. Subject becomes task title. If body contains 'urgent' or 'ASAP', set priority to 'high'. Extract due dates if mentioned.")
|
||||
- **Electron note:** `toCamelCase` converts the response → Electron reads `promptTemplate` from the final message and auto-fills the agent config panel. User clicks "Save & apply" which calls `agent.local.update` / `agent.cloud.update` tRPC mutation.
|
||||
|
||||
@@ -201,9 +201,9 @@ adiuva-api/
|
||||
- Tools (8): `list_tasks(project_id, status, search, order_by)`, `create_task(title, description, status, priority, assignees, due_date, project_id, is_ai_suggested, is_approved)`, `update_task(task_id, ...)`, `delete_task(task_id)`, `list_tasks_due_today()`, `list_task_comments(task_id)`, `add_task_comment(task_id, author, content)`, `delete_task_comment(comment_id)`
|
||||
- status: `todo|in_progress|done`; priority: `high|medium|low`; assignees: JSON-encoded string; due_date: ms timestamp
|
||||
- Accepts flexible context; sentinel `-1` for optional integer update fields
|
||||
- [x] `app/agents/checkpoint_agent.py` — `@registry.register`:
|
||||
- Description: "Manages project checkpoints (milestones): list, create, update, delete"
|
||||
- Tools (4): `list_checkpoints(project_id)`, `create_checkpoint(project_id, title, date, is_ai_suggested, is_approved)`, `update_checkpoint(checkpoint_id, ...)`, `delete_checkpoint(checkpoint_id)`
|
||||
- [x] `app/agents/timeline_agent.py` — `@registry.register`:
|
||||
- Description: "Manages project timelines (milestones): list, create, update, delete"
|
||||
- Tools (4): `list_timelines(project_id)`, `create_timeline(project_id, title, date, is_ai_suggested, is_approved)`, `update_timeline(timeline_id, ...)`, `delete_timeline(timeline_id)`
|
||||
- `project_id` is required for create; date is a ms timestamp; supports AI-suggestion + approval workflow
|
||||
- [x] `app/agents/project_agent.py` — `@registry.register`:
|
||||
- Description: "Manages projects: list, get, create, update, archive, delete"
|
||||
@@ -215,7 +215,7 @@ adiuva-api/
|
||||
- content is Markdown; `get_note` should be called before update to preserve existing content
|
||||
- [x] `app/agents/__init__.py`: imports all four agent modules to trigger `@registry.register` decorators
|
||||
- [x] Unit tests per agent with mocked LLM (registration, names, tool counts, handle(), direct tool invocation)
|
||||
- **Outcome:** Four domain-specific agents matching the UI data model (Tasks, Checkpoints, Projects, Notes), all registered and tested.
|
||||
- **Outcome:** Four domain-specific agents matching the UI data model (Tasks, Timelines, Projects, Notes), all registered and tested.
|
||||
|
||||
### Step 7 — Storage Layer ✅
|
||||
- [x] `app/storage/blob_store.py`:
|
||||
|
||||
10
README.md
10
README.md
@@ -83,7 +83,7 @@ Adiuva Cloud API is the FastAPI backend that powers the **Adiuva Electron deskto
|
||||
## Key Features
|
||||
|
||||
1. **LLM-powered orchestration** — GPT-4o-mini classifies user intent and routes to the appropriate domain agent.
|
||||
2. **4 specialized AI agents** — Tasks (8 tools), Projects (6 tools), Checkpoints (4 tools), Notes (5 tools), all powered by GPT-4o via LangChain.
|
||||
2. **4 specialized AI agents** — Tasks (8 tools), Projects (6 tools), Timelines (4 tools), Notes (5 tools), all powered by GPT-4o via LangChain.
|
||||
3. **Execution plans & playbooks** — Server-side prompt template registry; clients receive only opaque template IDs, never raw prompts.
|
||||
4. **E2E encrypted cloud storage** — The backend never decrypts user data; SHA-256 checksum verification uses constant-time comparison to prevent timing attacks.
|
||||
5. **Cloud vector store** — Pinecone or Qdrant with user-isolated namespaces and encrypted blob payloads.
|
||||
@@ -449,7 +449,7 @@ The agent system uses a registry pattern with LangChain tool-calling agents powe
|
||||
|---|---|---|---|
|
||||
| **TaskAgent** | `task_agent` | 8 | Full task and comment CRUD. Status: `todo` / `in_progress` / `done`. Priority: `high` / `medium` / `low`. Tools: `list_tasks`, `create_task`, `update_task`, `delete_task`, `list_tasks_due_today`, `list_task_comments`, `add_task_comment`, `delete_task_comment` |
|
||||
| **ProjectAgent** | `project_agent` | 6 | Project lifecycle management. Status: `active` / `archived`. Prefers archiving over deletion. Tools: `list_projects`, `list_all_projects`, `get_project`, `create_project`, `update_project`, `delete_project` |
|
||||
| **CheckpointAgent** | `checkpoint_agent` | 4 | Project milestones. Requires `project_id` for creation. Supports AI-suggestion and approval workflows. Tools: `list_checkpoints`, `create_checkpoint`, `update_checkpoint`, `delete_checkpoint` |
|
||||
| **TimelineAgent** | `timeline_agent` | 4 | Project milestones. Requires `project_id` for creation. Supports AI-suggestion and approval workflows. Tools: `list_timelines`, `create_timeline`, `update_timeline`, `delete_timeline` |
|
||||
| **NoteAgent** | `note_agent` | 5 | Markdown note management. Optionally linked to projects. Tools: `list_notes`, `get_note`, `create_note`, `update_note`, `delete_note` |
|
||||
|
||||
All agents use the model configured by `LLM_MODEL` (default: GPT-4o) with `temperature=0` via LiteLLM. Tools return JSON action descriptors that the Electron client interprets and applies locally.
|
||||
@@ -504,7 +504,7 @@ Source: `app/core/orchestrator.py`, `app/core/execution_plan.py`
|
||||
|
||||
### Built-in Templates (6)
|
||||
|
||||
`tpl_task_agent_default`, `tpl_checkpoint_agent_default`, `tpl_project_agent_default`, `tpl_note_agent_default`, `tpl_task_extract_from_project`, `tpl_note_weekly_summary`
|
||||
`tpl_task_agent_default`, `tpl_timeline_agent_default`, `tpl_project_agent_default`, `tpl_note_agent_default`, `tpl_task_extract_from_project`, `tpl_note_weekly_summary`
|
||||
|
||||
### Built-in Playbooks (2)
|
||||
|
||||
@@ -643,7 +643,7 @@ Source: `app/marketplace/`
|
||||
- Plugin ID must match `^[a-z0-9-]+$`
|
||||
- Permissions must be from the allowed set only
|
||||
- No binary blobs in the manifest
|
||||
- **Allowed permissions:** `read:tasks`, `write:tasks`, `read:projects`, `write:projects`, `read:notes`, `write:notes`, `read:checkpoints`, `write:checkpoints`, `read:calendar`, `write:calendar`
|
||||
- **Allowed permissions:** `read:tasks`, `write:tasks`, `read:projects`, `write:projects`, `read:notes`, `write:notes`, `read:timelines`, `write:timelines`, `read:calendar`, `write:calendar`
|
||||
- `get_pending(db)` — Lists plugins awaiting review.
|
||||
- `submit_review(db, plugin_id, reviewer_id, decision, notes)` — Records the review decision.
|
||||
|
||||
@@ -734,7 +734,7 @@ adiuva-api/
|
||||
│ ├── agents/ # LLM-powered domain agents
|
||||
│ │ ├── task_agent.py # Task & comment CRUD (8 tools)
|
||||
│ │ ├── project_agent.py # Project lifecycle (6 tools)
|
||||
│ │ ├── checkpoint_agent.py # Milestones (4 tools)
|
||||
│ │ ├── timeline_agent.py # Milestones (4 tools)
|
||||
│ │ └── note_agent.py # Markdown notes (5 tools)
|
||||
│ │
|
||||
│ ├── core/ # Orchestration engine
|
||||
|
||||
@@ -169,7 +169,7 @@ Supported entity types (matching Electron component types):
|
||||
- `task` — TaskRow component (`TaskItem`: id, title, status, priority, assignee, dueDate, projectId, ...)
|
||||
- `project` — Project card (id, name, clientId, status)
|
||||
- `note` — Note card (id, title, createdAt, projectId)
|
||||
- `checkpoint` — Checkpoint card (GanttCheckpoint: id, title, date, projectId, isAiSuggested, isApproved)
|
||||
- `timeline` — Timeline card (GanttTimeline: id, title, date, projectId, isAiSuggested, isApproved)
|
||||
|
||||
**Table block** — buffered, validated:
|
||||
```json
|
||||
@@ -178,7 +178,7 @@ Supported entity types (matching Electron component types):
|
||||
|
||||
**Timeline block** — buffered, validated (renders via GanttChart component):
|
||||
```json
|
||||
{ "type": "timeline", "checkpoints": [{ "id": "...", "title": "...", "date": 1234567890 }] }
|
||||
{ "type": "timeline", "timelines": [{ "id": "...", "title": "...", "date": 1234567890 }] }
|
||||
```
|
||||
|
||||
### Changes
|
||||
@@ -192,13 +192,13 @@ Supported entity types (matching Electron component types):
|
||||
- `chart` -> buffers until JSON complete, validates `chartType` against allowed set, yields `WsStreamBlock`
|
||||
- `entity_ref` -> looks up data from `agent.tool_results`, serializes full entity, yields `WsStreamBlock`
|
||||
- `table` -> buffers, validates headers/rows structure, yields `WsStreamBlock`
|
||||
- `timeline` -> buffers, validates checkpoint objects, yields `WsStreamBlock`
|
||||
- `timeline` -> buffers, validates timeline objects, yields `WsStreamBlock`
|
||||
- Invalid blocks are logged and skipped (never crash the stream)
|
||||
- `FloatingFormatter`:
|
||||
- Receives `agent_name` from orchestrator
|
||||
- Maps agent name to domain (deterministic, by code — no LLM):
|
||||
- `task_agent` -> `"tasks"`
|
||||
- `checkpoint_agent` -> `"checkpoints"`
|
||||
- `timeline_agent` -> `"timelines"`
|
||||
- `note_agent` -> `"notes"`
|
||||
- `project_agent` -> `"projects"`
|
||||
- Yields `WsFloatingDomain` immediately
|
||||
|
||||
@@ -37,12 +37,12 @@ _SEED_PLUGINS = [
|
||||
{
|
||||
"id": "plugin-slack-notify",
|
||||
"name": "Slack Notifier",
|
||||
"description": "Post task and checkpoint updates to Slack channels.",
|
||||
"description": "Post task and timeline updates to Slack channels.",
|
||||
"version": "1.2.0",
|
||||
"author_name": "Adiuva",
|
||||
"category": "communication",
|
||||
"price_cents": 499,
|
||||
"permissions": json.dumps(["read:tasks", "read:checkpoints"]),
|
||||
"permissions": json.dumps(["read:tasks", "read:timelines"]),
|
||||
"status": "approved",
|
||||
"s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip",
|
||||
"install_count": 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Import all agent modules to trigger @registry.register decorators."""
|
||||
|
||||
from app.agents import checkpoint_agent, note_agent, project_agent, task_agent
|
||||
from app.agents import timeline_agent, note_agent, project_agent, task_agent
|
||||
|
||||
__all__ = ["checkpoint_agent", "note_agent", "project_agent", "task_agent"]
|
||||
__all__ = ["timeline_agent", "note_agent", "project_agent", "task_agent"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Checkpoint agent — project milestone management (list, create, update, delete)."""
|
||||
"""Timeline agent — project milestone management (list, create, update, delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,43 +13,43 @@ from app.core.llm import get_llm
|
||||
from app.core.ws_context import execute_on_client
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a project checkpoint assistant. Checkpoints are milestone dates that\n"
|
||||
"You are a project timeline assistant. Timelines 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_ai_suggested: 1 when proactively proposing a timeline, 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"
|
||||
" - For update_timeline, use -1 for integer fields you do not want to change\n"
|
||||
" - Listing without a project_id returns all timelines 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."""
|
||||
async def list_timelines(project_id: str = "") -> str:
|
||||
"""List timelines. Provide project_id to scope to a specific project."""
|
||||
result = await execute_on_client(
|
||||
action="select",
|
||||
table="checkpoints",
|
||||
table="timelines",
|
||||
filters={"projectId": project_id or None},
|
||||
)
|
||||
rows = result.get("rows", [])
|
||||
if not rows:
|
||||
return "No checkpoints found."
|
||||
return "No timelines found."
|
||||
lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows]
|
||||
return f"Found {len(rows)} checkpoint(s):\n" + "\n".join(lines)
|
||||
return f"Found {len(rows)} timeline(s):\n" + "\n".join(lines)
|
||||
|
||||
|
||||
@tool
|
||||
async def create_checkpoint(
|
||||
async def create_timeline(
|
||||
project_id: str,
|
||||
title: str,
|
||||
date: int,
|
||||
is_ai_suggested: int = 0,
|
||||
is_approved: int = 0,
|
||||
) -> str:
|
||||
"""Create a project checkpoint (milestone).
|
||||
"""Create a project timeline (milestone).
|
||||
project_id: REQUIRED UUID of the parent project
|
||||
title: descriptive name for the milestone
|
||||
date: Unix timestamp in milliseconds
|
||||
@@ -58,7 +58,7 @@ async def create_checkpoint(
|
||||
"""
|
||||
result = await execute_on_client(
|
||||
action="insert",
|
||||
table="checkpoints",
|
||||
table="timelines",
|
||||
data={
|
||||
"projectId": project_id,
|
||||
"title": title,
|
||||
@@ -68,18 +68,18 @@ async def create_checkpoint(
|
||||
},
|
||||
)
|
||||
row = result["row"]
|
||||
return f"Checkpoint created: '{row['title']}' (id: {row['id']}, date: {row['date']})"
|
||||
return f"Timeline created: '{row['title']}' (id: {row['id']}, date: {row['date']})"
|
||||
|
||||
|
||||
@tool
|
||||
async def update_checkpoint(
|
||||
checkpoint_id: str,
|
||||
async def update_timeline(
|
||||
timeline_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)
|
||||
"""Update a timeline. Only pass fields that should change.
|
||||
timeline_id: UUID of the timeline (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
|
||||
"""
|
||||
@@ -92,30 +92,30 @@ async def update_checkpoint(
|
||||
updates["isApproved"] = is_approved
|
||||
result = await execute_on_client(
|
||||
action="update",
|
||||
table="checkpoints",
|
||||
data={"id": checkpoint_id, "updates": updates},
|
||||
table="timelines",
|
||||
data={"id": timeline_id, "updates": updates},
|
||||
)
|
||||
row = result["row"]
|
||||
return f"Checkpoint updated: '{row['title']}' (id: {row['id']})"
|
||||
return f"Timeline updated: '{row['title']}' (id: {row['id']})"
|
||||
|
||||
|
||||
@tool
|
||||
async def delete_checkpoint(checkpoint_id: str) -> str:
|
||||
"""Delete a checkpoint permanently by its UUID."""
|
||||
await execute_on_client(action="delete", table="checkpoints", data={"id": checkpoint_id})
|
||||
return f"Checkpoint {checkpoint_id} deleted."
|
||||
async def delete_timeline(timeline_id: str) -> str:
|
||||
"""Delete a timeline permanently by its UUID."""
|
||||
await execute_on_client(action="delete", table="timelines", data={"id": timeline_id})
|
||||
return f"Timeline {timeline_id} deleted."
|
||||
|
||||
|
||||
@registry.register
|
||||
class CheckpointAgent(ChatAgent):
|
||||
class TimelineAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "checkpoint_agent"
|
||||
return "timeline_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages project checkpoints (milestones): list, create, update, delete"
|
||||
return "Manages project timelines (milestones): list, create, update, delete"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [list_checkpoints, create_checkpoint, update_checkpoint, delete_checkpoint]
|
||||
return [list_timelines, create_timeline, update_timeline, delete_timeline]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = get_llm()
|
||||
@@ -107,7 +107,7 @@ and produce a detailed prompt_template that a separate AI will use as its instru
|
||||
|
||||
Ask concise, focused questions one at a time. Cover these topics (not necessarily in this order):
|
||||
1. The type and format of the source content.
|
||||
2. Which data types to extract: tasks, notes, checkpoints, and/or projects.
|
||||
2. Which data types to extract: tasks, notes, timelines, and/or projects.
|
||||
3. How fields should be mapped (e.g. email subject → task title).
|
||||
4. Priority or status rules (e.g. "urgent" keyword → high priority).
|
||||
5. Any special handling, date extraction, or exclusions.
|
||||
@@ -121,7 +121,7 @@ these exact markers on their own lines:
|
||||
|
||||
The prompt_template must be a self-contained instruction for an AI that receives a document/email/message \
|
||||
and must return a JSON array of records in this shape:
|
||||
[{{ "table": "<tasks|notes|checkpoints|projects>", "data": {{ <field: value> }} }}, ...]
|
||||
[{{ "table": "<tasks|notes|timelines|projects>", "data": {{ <field: value> }} }}, ...]
|
||||
|
||||
Rules for the generated template:
|
||||
- Be explicit about field names (camelCase: title, status, priority, dueDate, projectId, content, etc.).
|
||||
|
||||
@@ -53,7 +53,7 @@ _INSERT_TIMEOUT: int = 30
|
||||
# ── Allowed tables & extraction schema hints ───────────────────────────────
|
||||
|
||||
_ALLOWED_TABLES: frozenset[str] = frozenset(
|
||||
{"tasks", "notes", "checkpoints", "projects", "taskComments"}
|
||||
{"tasks", "notes", "timelines", "projects", "taskComments"}
|
||||
)
|
||||
|
||||
# Field descriptions fed to the extraction LLM as concise schema references.
|
||||
@@ -65,7 +65,7 @@ _TABLE_SCHEMAS: dict[str, str] = {
|
||||
"assignee (JSON array string), dueDate (ms timestamp int), projectId (str)"
|
||||
),
|
||||
"notes": "title (str, required), content (str, markdown), projectId (str)",
|
||||
"checkpoints": (
|
||||
"timelines": (
|
||||
"title (str, required), projectId (str, required), date (ms timestamp int)"
|
||||
),
|
||||
"projects": "name (str, required), clientId (str)",
|
||||
|
||||
@@ -159,9 +159,9 @@ def _register_builtin_templates() -> None:
|
||||
"list, and track tasks. Use correct status values (todo, in_progress, "
|
||||
"done) and priority values (high, medium, low) from the workspace model."
|
||||
),
|
||||
"tpl_checkpoint_agent_default": (
|
||||
"You are a project checkpoint assistant. Help the user create and manage "
|
||||
"milestone checkpoints on their projects. Every checkpoint requires a "
|
||||
"tpl_timeline_agent_default": (
|
||||
"You are a project timeline assistant. Help the user create and manage "
|
||||
"milestone timelines on their projects. Every timeline requires a "
|
||||
"project_id and a date expressed as a Unix timestamp in milliseconds."
|
||||
),
|
||||
"tpl_project_agent_default": (
|
||||
@@ -182,7 +182,7 @@ def _register_builtin_templates() -> None:
|
||||
"tpl_note_weekly_summary": (
|
||||
"Generate a weekly project summary note from the provided workspace data. "
|
||||
"Include: tasks completed this week, tasks due soon, active projects, "
|
||||
"and upcoming checkpoints. Format the output as clean Markdown."
|
||||
"and upcoming timelines. Format the output as clean Markdown."
|
||||
),
|
||||
}
|
||||
for tid, text in _tpls.items():
|
||||
|
||||
@@ -27,7 +27,7 @@ _VALID_CHART_TYPES = {"area", "bar", "line", "pie", "radar", "radial"}
|
||||
# Map agent name → floating domain
|
||||
_AGENT_DOMAIN: dict[str, str] = {
|
||||
"task_agent": "tasks",
|
||||
"checkpoint_agent": "checkpoints",
|
||||
"timeline_agent": "timelines",
|
||||
"note_agent": "notes",
|
||||
"project_agent": "projects",
|
||||
}
|
||||
@@ -171,8 +171,8 @@ class HomeFormatter:
|
||||
)
|
||||
|
||||
if block_type == "timeline":
|
||||
if not isinstance(obj.get("checkpoints"), list):
|
||||
logger.warning("HomeFormatter: timeline missing checkpoints — skipping")
|
||||
if not isinstance(obj.get("timelines"), list):
|
||||
logger.warning("HomeFormatter: timeline missing timelines — skipping")
|
||||
return None
|
||||
return WsStreamBlock(
|
||||
request_id=self.request_id,
|
||||
|
||||
@@ -29,8 +29,8 @@ ALLOWED_PERMISSIONS: frozenset[str] = frozenset(
|
||||
"write:projects",
|
||||
"read:notes",
|
||||
"write:notes",
|
||||
"read:checkpoints",
|
||||
"write:checkpoints",
|
||||
"read:timelines",
|
||||
"write:timelines",
|
||||
"read:calendar",
|
||||
"write:calendar",
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class WsAgentComplete(BaseModel):
|
||||
class WsFloatingScope(BaseModel):
|
||||
"""Scope for a floating request — narrows the agent to a specific entity."""
|
||||
|
||||
type: Literal["task", "project", "note", "checkpoint"]
|
||||
type: Literal["task", "project", "note", "timeline"]
|
||||
id: str | None = None
|
||||
|
||||
|
||||
@@ -325,7 +325,7 @@ class WsFloatingDomain(BaseModel):
|
||||
|
||||
type: Literal[WsFrameType.floating_domain] = WsFrameType.floating_domain
|
||||
request_id: str
|
||||
domain: Literal["tasks", "checkpoints", "notes", "projects"]
|
||||
domain: Literal["tasks", "timelines", "notes", "projects"]
|
||||
|
||||
|
||||
# ── Agent Catalog ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -129,12 +129,12 @@ _SEED_PLUGINS = [
|
||||
Plugin(
|
||||
id="plugin-slack-notify",
|
||||
name="Slack Notifier",
|
||||
description="Post task and checkpoint updates to Slack channels.",
|
||||
description="Post task and timeline updates to Slack channels.",
|
||||
version="1.2.0",
|
||||
author_name="Adiuva",
|
||||
category="communication",
|
||||
price_cents=499,
|
||||
permissions=json.dumps(["read:tasks", "read:checkpoints"]),
|
||||
permissions=json.dumps(["read:tasks", "read:timelines"]),
|
||||
status="approved",
|
||||
s3_package_key="plugins/plugin-slack-notify/1.2.0/package.zip",
|
||||
install_count=0,
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
import app.agents # noqa: F401 — triggers @registry.register decorators
|
||||
from app.agents.checkpoint_agent import CheckpointAgent
|
||||
from app.agents.timeline_agent import TimelineAgent
|
||||
from app.agents.note_agent import NoteAgent
|
||||
from app.agents.project_agent import ProjectAgent
|
||||
from app.agents.task_agent import TaskAgent
|
||||
@@ -110,12 +110,12 @@ class TestAgentRegistration:
|
||||
def test_all_agents_registered(self) -> None:
|
||||
names = {a["name"] for a in registry.list_agents()}
|
||||
assert {
|
||||
"task_agent", "checkpoint_agent", "project_agent", "note_agent"
|
||||
"task_agent", "timeline_agent", "project_agent", "note_agent"
|
||||
}.issubset(names)
|
||||
|
||||
def test_registry_returns_correct_types(self) -> None:
|
||||
assert isinstance(registry.get("task_agent"), TaskAgent)
|
||||
assert isinstance(registry.get("checkpoint_agent"), CheckpointAgent)
|
||||
assert isinstance(registry.get("timeline_agent"), TimelineAgent)
|
||||
assert isinstance(registry.get("project_agent"), ProjectAgent)
|
||||
assert isinstance(registry.get("note_agent"), NoteAgent)
|
||||
|
||||
@@ -336,94 +336,94 @@ class TestTaskAgentTools:
|
||||
assert "c1" in result
|
||||
|
||||
|
||||
# ── CheckpointAgent ───────────────────────────────────────────────────
|
||||
# ── TimelineAgent ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckpointAgent:
|
||||
class TestTimelineAgent:
|
||||
def test_name(self) -> None:
|
||||
assert CheckpointAgent().get_name() == "checkpoint_agent"
|
||||
assert TimelineAgent().get_name() == "timeline_agent"
|
||||
|
||||
def test_description(self) -> None:
|
||||
assert CheckpointAgent().get_description() == "Manages project checkpoints (milestones): list, create, update, delete"
|
||||
assert TimelineAgent().get_description() == "Manages project timelines (milestones): list, create, update, delete"
|
||||
|
||||
def test_get_tools_count(self) -> None:
|
||||
assert len(CheckpointAgent().get_tools()) == 4
|
||||
assert len(TimelineAgent().get_tools()) == 4
|
||||
|
||||
def test_tool_names(self) -> None:
|
||||
names = {t.name for t in CheckpointAgent().get_tools()}
|
||||
assert names == {"list_checkpoints", "create_checkpoint", "update_checkpoint", "delete_checkpoint"}
|
||||
names = {t.name for t in TimelineAgent().get_tools()}
|
||||
assert names == {"list_timelines", "create_timeline", "update_timeline", "delete_timeline"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_no_tool_calls(self) -> None:
|
||||
with patch("app.agents.checkpoint_agent.get_llm") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("No checkpoints found.")
|
||||
result = await CheckpointAgent().handle("list checkpoints", {})
|
||||
assert result == "No checkpoints found."
|
||||
with patch("app.agents.timeline_agent.get_llm") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("No timelines found.")
|
||||
result = await TimelineAgent().handle("list timelines", {})
|
||||
assert result == "No timelines found."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_with_create_tool_call(self) -> None:
|
||||
with patch("app.agents.checkpoint_agent.get_llm") as mock_cls:
|
||||
with patch("app.agents.timeline_agent.get_llm") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm_with_tool_call(
|
||||
"create_checkpoint",
|
||||
"create_timeline",
|
||||
{"project_id": "p1", "title": "MVP Launch", "date": 1700000000000},
|
||||
"Checkpoint 'MVP Launch' created.",
|
||||
"Timeline 'MVP Launch' created.",
|
||||
)
|
||||
result = await CheckpointAgent().handle("add MVP checkpoint", {})
|
||||
assert result == "Checkpoint 'MVP Launch' created."
|
||||
result = await TimelineAgent().handle("add MVP timeline", {})
|
||||
assert result == "Timeline 'MVP Launch' created."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_empty_context(self) -> None:
|
||||
with patch("app.agents.checkpoint_agent.get_llm") as mock_cls:
|
||||
with patch("app.agents.timeline_agent.get_llm") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await CheckpointAgent().handle("show milestones", {})
|
||||
result = await TimelineAgent().handle("show milestones", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestCheckpointAgentTools:
|
||||
class TestTimelineAgentTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_checkpoints_no_project(self) -> None:
|
||||
from app.agents.checkpoint_agent import list_checkpoints
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
async def test_list_timelines_no_project(self) -> None:
|
||||
from app.agents.timeline_agent import list_timelines
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"rows": []}
|
||||
result = await list_checkpoints.ainvoke({})
|
||||
result = await list_timelines.ainvoke({})
|
||||
call_kwargs = m.call_args.kwargs
|
||||
assert call_kwargs["action"] == "select"
|
||||
assert call_kwargs["table"] == "checkpoints"
|
||||
assert call_kwargs["table"] == "timelines"
|
||||
assert call_kwargs["filters"]["projectId"] is None
|
||||
assert result == "No checkpoints found."
|
||||
assert result == "No timelines found."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_checkpoints_with_project(self) -> None:
|
||||
from app.agents.checkpoint_agent import list_checkpoints
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
async def test_list_timelines_with_project(self) -> None:
|
||||
from app.agents.timeline_agent import list_timelines
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"rows": []}
|
||||
await list_checkpoints.ainvoke({"project_id": "p1"})
|
||||
await list_timelines.ainvoke({"project_id": "p1"})
|
||||
assert m.call_args.kwargs["filters"]["projectId"] == "p1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_checkpoint(self) -> None:
|
||||
from app.agents.checkpoint_agent import create_checkpoint
|
||||
async def test_create_timeline(self) -> None:
|
||||
from app.agents.timeline_agent import create_timeline
|
||||
fake_row = {"id": "cp1", "title": "Beta release", "date": 1700000000000}
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"row": fake_row}
|
||||
result = await create_checkpoint.ainvoke({
|
||||
result = await create_timeline.ainvoke({
|
||||
"project_id": "p1", "title": "Beta release", "date": 1700000000000,
|
||||
})
|
||||
call_kwargs = m.call_args.kwargs
|
||||
assert call_kwargs["action"] == "insert"
|
||||
assert call_kwargs["table"] == "checkpoints"
|
||||
assert call_kwargs["table"] == "timelines"
|
||||
assert call_kwargs["data"]["projectId"] == "p1"
|
||||
assert call_kwargs["data"]["title"] == "Beta release"
|
||||
assert call_kwargs["data"]["date"] == 1700000000000
|
||||
assert "Beta release" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_checkpoint_ai_suggested(self) -> None:
|
||||
from app.agents.checkpoint_agent import create_checkpoint
|
||||
async def test_create_timeline_ai_suggested(self) -> None:
|
||||
from app.agents.timeline_agent import create_timeline
|
||||
fake_row = {"id": "cp1", "title": "Review", "date": 1700000000000}
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"row": fake_row}
|
||||
await create_checkpoint.ainvoke({
|
||||
await create_timeline.ainvoke({
|
||||
"project_id": "p1", "title": "Review", "date": 1700000000000, "is_ai_suggested": 1,
|
||||
})
|
||||
call_kwargs = m.call_args.kwargs
|
||||
@@ -431,12 +431,12 @@ class TestCheckpointAgentTools:
|
||||
assert call_kwargs["data"]["isApproved"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_checkpoint_approve(self) -> None:
|
||||
from app.agents.checkpoint_agent import update_checkpoint
|
||||
async def test_update_timeline_approve(self) -> None:
|
||||
from app.agents.timeline_agent import update_timeline
|
||||
fake_row = {"id": "c1", "title": "MVP", "isApproved": 1}
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"row": fake_row}
|
||||
result = await update_checkpoint.ainvoke({"checkpoint_id": "c1", "is_approved": 1})
|
||||
result = await update_timeline.ainvoke({"timeline_id": "c1", "is_approved": 1})
|
||||
call_kwargs = m.call_args.kwargs
|
||||
assert call_kwargs["action"] == "update"
|
||||
assert call_kwargs["data"]["id"] == "c1"
|
||||
@@ -444,23 +444,23 @@ class TestCheckpointAgentTools:
|
||||
assert "c1" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_checkpoint_empty_updates(self) -> None:
|
||||
from app.agents.checkpoint_agent import update_checkpoint
|
||||
async def test_update_timeline_empty_updates(self) -> None:
|
||||
from app.agents.timeline_agent import update_timeline
|
||||
fake_row = {"id": "c1", "title": "MVP"}
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"row": fake_row}
|
||||
await update_checkpoint.ainvoke({"checkpoint_id": "c1"})
|
||||
await update_timeline.ainvoke({"timeline_id": "c1"})
|
||||
assert m.call_args.kwargs["data"]["updates"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_checkpoint(self) -> None:
|
||||
from app.agents.checkpoint_agent import delete_checkpoint
|
||||
with patch("app.agents.checkpoint_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
async def test_delete_timeline(self) -> None:
|
||||
from app.agents.timeline_agent import delete_timeline
|
||||
with patch("app.agents.timeline_agent.execute_on_client", new_callable=AsyncMock) as m:
|
||||
m.return_value = {"deleted": True}
|
||||
result = await delete_checkpoint.ainvoke({"checkpoint_id": "c1"})
|
||||
result = await delete_timeline.ainvoke({"timeline_id": "c1"})
|
||||
call_kwargs = m.call_args.kwargs
|
||||
assert call_kwargs["action"] == "delete"
|
||||
assert call_kwargs["table"] == "checkpoints"
|
||||
assert call_kwargs["table"] == "timelines"
|
||||
assert call_kwargs["data"]["id"] == "c1"
|
||||
assert "c1" in result
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ class TestPlanCache:
|
||||
|
||||
class TestModuleSingletons:
|
||||
def test_template_registry_has_all_agent_defaults(self) -> None:
|
||||
for agent in ("task_agent", "checkpoint_agent", "project_agent", "note_agent"):
|
||||
for agent in ("task_agent", "timeline_agent", "project_agent", "note_agent"):
|
||||
assert template_registry.has(f"tpl_{agent}_default"), (
|
||||
f"Missing template: tpl_{agent}_default"
|
||||
)
|
||||
|
||||
@@ -94,13 +94,13 @@ async def test_orchestrate_v3_uses_default_registry_when_none():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrate_v3_get_called_with_agent_name():
|
||||
agent = _FixedAgent("checkpoint_agent")
|
||||
reg = _make_registry("checkpoint_agent", agent)
|
||||
agent = _FixedAgent("timeline_agent")
|
||||
reg = _make_registry("timeline_agent", agent)
|
||||
|
||||
with patch("app.core.orchestrator.classify_intent", AsyncMock(return_value="checkpoint_agent")):
|
||||
with patch("app.core.orchestrator.classify_intent", AsyncMock(return_value="timeline_agent")):
|
||||
await orchestrate_v3(user_id="u-2", message="schedule", context={}, reg=reg)
|
||||
|
||||
reg.get.assert_called_once_with("checkpoint_agent")
|
||||
reg.get.assert_called_once_with("timeline_agent")
|
||||
|
||||
|
||||
# ── orchestrate_v3_stream ─────────────────────────────────────────────
|
||||
|
||||
@@ -115,7 +115,7 @@ async def test_home_formatter_table_block():
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_formatter_timeline_block():
|
||||
req_id = "req-7"
|
||||
timeline_json = '{"type": "timeline", "checkpoints": [{"id": "c1", "title": "M1", "date": 123}]}'
|
||||
timeline_json = '{"type": "timeline", "timelines": [{"id": "c1", "title": "M1", "date": 123}]}'
|
||||
formatter = HomeFormatter(request_id=req_id, tool_results=[])
|
||||
frames = await collect(formatter, _stream(("task_agent", timeline_json)))
|
||||
|
||||
@@ -156,11 +156,11 @@ async def test_floating_formatter_domain_emitted_first():
|
||||
async def test_floating_formatter_text_only():
|
||||
req_id = "pop-2"
|
||||
formatter = FloatingFormatter(request_id=req_id)
|
||||
tokens = [("checkpoint_agent", ""), ("checkpoint_agent", "Summary")]
|
||||
tokens = [("timeline_agent", ""), ("timeline_agent", "Summary")]
|
||||
frames = await collect(formatter, _stream(*tokens))
|
||||
|
||||
assert isinstance(frames[0], WsFloatingDomain)
|
||||
assert frames[0].domain == "checkpoints"
|
||||
assert frames[0].domain == "timelines"
|
||||
text_frames = [f for f in frames if isinstance(f, WsStreamText)]
|
||||
assert len(text_frames) == 1
|
||||
assert text_frames[0].chunk == "Summary"
|
||||
|
||||
@@ -213,7 +213,7 @@ def test_stream_block_timeline():
|
||||
frame = WsStreamBlock(
|
||||
request_id="r1",
|
||||
block_type="timeline",
|
||||
data={"checkpoints": [{"id": "c1", "title": "Launch", "date": 1700000000}]},
|
||||
data={"timelines": [{"id": "c1", "title": "Launch", "date": 1700000000}]},
|
||||
)
|
||||
assert frame.block_type == "timeline"
|
||||
|
||||
@@ -270,7 +270,7 @@ def test_floating_domain_tasks():
|
||||
assert frame.domain == "tasks"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain", ["tasks", "checkpoints", "notes", "projects"])
|
||||
@pytest.mark.parametrize("domain", ["tasks", "timelines", "notes", "projects"])
|
||||
def test_floating_domain_valid_domains(domain: str):
|
||||
frame = WsFloatingDomain(request_id="r1", domain=domain) # type: ignore[arg-type]
|
||||
assert frame.domain == domain
|
||||
|
||||
Reference in New Issue
Block a user