fix tools calls
This commit is contained in:
@@ -26,32 +26,137 @@ def _is_uuid(value: str) -> bool:
|
|||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
status: str = "",
|
status: str = "",
|
||||||
|
priority: str = "",
|
||||||
|
assignee: str = "",
|
||||||
search: str = "",
|
search: str = "",
|
||||||
order_by: str = "",
|
order_by: str = "",
|
||||||
|
order_dir: str = "",
|
||||||
|
due_date_from: int = -1,
|
||||||
|
due_date_to: int = -1,
|
||||||
|
created_at_from: int = -1,
|
||||||
|
created_at_to: int = -1,
|
||||||
|
completed_at_from: int = -1,
|
||||||
|
completed_at_to: int = -1,
|
||||||
|
is_ai_suggested: int = -1,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""List tasks, optionally filtered by project_id, status (todo|in_progress|done),
|
"""List tasks with optional filters. Returns up to `limit` results (default 50).
|
||||||
a search string, or an order_by field name (dueDate|priority|createdAt)."""
|
|
||||||
|
project_id: UUID of the project to scope results to.
|
||||||
|
status: filter by status — todo | in_progress | done.
|
||||||
|
priority: filter by priority — high | medium | low.
|
||||||
|
assignee: substring to match against assignee names.
|
||||||
|
search: substring search across title and description.
|
||||||
|
order_by: sort field — dueDate | priority | createdAt | completedAt.
|
||||||
|
order_dir: asc (default) | desc.
|
||||||
|
due_date_from / due_date_to: ms epoch range for dueDate. Use -1 to omit.
|
||||||
|
created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit.
|
||||||
|
completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit.
|
||||||
|
is_ai_suggested: 0 or 1 to filter by AI-suggested flag; -1 = any.
|
||||||
|
limit: max rows to return (default 50). Use with offset to paginate.
|
||||||
|
offset: skip first N rows (default 0).
|
||||||
|
|
||||||
|
Tip — combine *_from and *_to for a closed range; pass only one for open-ended.
|
||||||
|
Tip — prefer count_tasks for "how many" questions to avoid listing rows.
|
||||||
|
Tip — for natural-language windows ("today", "tomorrow", "this week", "last month", etc.)
|
||||||
|
take due_date_from / due_date_to verbatim from the DATE CONTEXT block in the system prompt;
|
||||||
|
do not compute boundaries from the current UTC instant.
|
||||||
|
"""
|
||||||
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
||||||
result = await execute_on_client(
|
filters: dict[str, Any] = {
|
||||||
action="select",
|
"projectId": normalized_project_id or None,
|
||||||
table="tasks",
|
"status": status or None,
|
||||||
filters={
|
"priority": priority or None,
|
||||||
"projectId": normalized_project_id or None,
|
"search": search or None,
|
||||||
"status": status or None,
|
"orderBy": order_by or None,
|
||||||
"search": search or None,
|
"orderDir": order_dir or None,
|
||||||
"orderBy": order_by or None,
|
"limit": limit,
|
||||||
},
|
"offset": offset,
|
||||||
)
|
}
|
||||||
|
if assignee:
|
||||||
|
filters["assignee"] = assignee
|
||||||
|
if due_date_from != -1:
|
||||||
|
filters["dueDateFrom"] = due_date_from
|
||||||
|
if due_date_to != -1:
|
||||||
|
filters["dueDateTo"] = due_date_to
|
||||||
|
if created_at_from != -1:
|
||||||
|
filters["createdAtFrom"] = created_at_from
|
||||||
|
if created_at_to != -1:
|
||||||
|
filters["createdAtTo"] = created_at_to
|
||||||
|
if completed_at_from != -1:
|
||||||
|
filters["completedAtFrom"] = completed_at_from
|
||||||
|
if completed_at_to != -1:
|
||||||
|
filters["completedAtTo"] = completed_at_to
|
||||||
|
if is_ai_suggested != -1:
|
||||||
|
filters["isAiSuggested"] = is_ai_suggested
|
||||||
|
|
||||||
|
result = await execute_on_client(action="select", table="tasks", filters=filters)
|
||||||
rows = result.get("rows", [])
|
rows = result.get("rows", [])
|
||||||
if not rows:
|
if not rows:
|
||||||
return "No tasks found matching the given filters."
|
return "No tasks found matching the given filters."
|
||||||
lines = [
|
lines = [
|
||||||
f"- {r['title']} (status: {r['status']}, priority: {r['priority']}, id: {r['id']})"
|
f"- {r['title']} (status: {r['status']}, priority: {r['priority']}, "
|
||||||
|
f"dueDate: {r.get('dueDate')}, completedAt: {r.get('completedAt')}, id: {r['id']})"
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
return f"Found {len(rows)} task(s):\n" + "\n".join(lines)
|
return f"Found {len(rows)} task(s):\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def count_tasks(
|
||||||
|
project_id: str = "",
|
||||||
|
status: str = "",
|
||||||
|
priority: str = "",
|
||||||
|
assignee: str = "",
|
||||||
|
search: str = "",
|
||||||
|
due_date_from: int = -1,
|
||||||
|
due_date_to: int = -1,
|
||||||
|
created_at_from: int = -1,
|
||||||
|
created_at_to: int = -1,
|
||||||
|
completed_at_from: int = -1,
|
||||||
|
completed_at_to: int = -1,
|
||||||
|
is_ai_suggested: int = -1,
|
||||||
|
) -> str:
|
||||||
|
"""Count tasks matching the given filters without returning rows.
|
||||||
|
|
||||||
|
Use this instead of list_tasks for "how many" questions — it is much cheaper.
|
||||||
|
Same filter parameters as list_tasks (no limit/offset/order_by needed).
|
||||||
|
|
||||||
|
due_date_from / due_date_to: ms epoch range for dueDate. Use -1 to omit.
|
||||||
|
created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit.
|
||||||
|
completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit.
|
||||||
|
Tip — for natural-language windows take due_date_from / due_date_to from the DATE CONTEXT block;
|
||||||
|
do not compute boundaries from the current UTC instant.
|
||||||
|
"""
|
||||||
|
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
||||||
|
filters: dict[str, Any] = {
|
||||||
|
"projectId": normalized_project_id or None,
|
||||||
|
"status": status or None,
|
||||||
|
"priority": priority or None,
|
||||||
|
"search": search or None,
|
||||||
|
}
|
||||||
|
if assignee:
|
||||||
|
filters["assignee"] = assignee
|
||||||
|
if due_date_from != -1:
|
||||||
|
filters["dueDateFrom"] = due_date_from
|
||||||
|
if due_date_to != -1:
|
||||||
|
filters["dueDateTo"] = due_date_to
|
||||||
|
if created_at_from != -1:
|
||||||
|
filters["createdAtFrom"] = created_at_from
|
||||||
|
if created_at_to != -1:
|
||||||
|
filters["createdAtTo"] = created_at_to
|
||||||
|
if completed_at_from != -1:
|
||||||
|
filters["completedAtFrom"] = completed_at_from
|
||||||
|
if completed_at_to != -1:
|
||||||
|
filters["completedAtTo"] = completed_at_to
|
||||||
|
if is_ai_suggested != -1:
|
||||||
|
filters["isAiSuggested"] = is_ai_suggested
|
||||||
|
|
||||||
|
result = await execute_on_client(action="count", table="tasks", filters=filters)
|
||||||
|
return f"Task count: {result.get('count', 0)}"
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def create_task(
|
async def create_task(
|
||||||
title: str,
|
title: str,
|
||||||
@@ -72,6 +177,8 @@ async def create_task(
|
|||||||
due_date: Unix timestamp in milliseconds; 0 means no due date
|
due_date: Unix timestamp in milliseconds; 0 means no due date
|
||||||
project_id: optional UUID of the parent project
|
project_id: optional UUID of the parent project
|
||||||
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
||||||
|
|
||||||
|
completedAt is set automatically when status is 'done'.
|
||||||
"""
|
"""
|
||||||
result = await execute_on_client(
|
result = await execute_on_client(
|
||||||
action="insert",
|
action="insert",
|
||||||
@@ -108,6 +215,10 @@ async def update_task(
|
|||||||
"""Update fields on an existing task. Only pass fields you want to change.
|
"""Update fields on an existing task. Only pass fields you want to change.
|
||||||
task_id: the task's UUID (required)
|
task_id: the task's UUID (required)
|
||||||
due_date: -1 means unchanged; 0 clears the due date; any positive value sets it
|
due_date: -1 means unchanged; 0 clears the due date; any positive value sets it
|
||||||
|
|
||||||
|
completedAt is managed automatically:
|
||||||
|
- setting status to 'done' records the current timestamp
|
||||||
|
- changing status away from 'done' clears completedAt
|
||||||
"""
|
"""
|
||||||
updates: dict[str, Any] = {}
|
updates: dict[str, Any] = {}
|
||||||
if title:
|
if title:
|
||||||
@@ -141,11 +252,12 @@ async def delete_task(task_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def list_tasks_due_today(user_timezone: str = "UTC") -> str:
|
async def list_tasks_due_today(user_timezone: str = "UTC", include_done: bool = False) -> str:
|
||||||
"""List all tasks whose due date falls on today's date.
|
"""List all tasks whose due date falls on today's date.
|
||||||
|
|
||||||
user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York').
|
user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York').
|
||||||
Always pass the user's timezone so 'today' is computed in their local time.
|
Always pass the user's timezone so 'today' is computed in their local time.
|
||||||
|
include_done: set True to also include already-completed tasks due today (default False).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -156,10 +268,13 @@ async def list_tasks_due_today(user_timezone: str = "UTC") -> str:
|
|||||||
start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz)
|
start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz)
|
||||||
start_ms = int(start_dt.timestamp() * 1000)
|
start_ms = int(start_dt.timestamp() * 1000)
|
||||||
end_ms = start_ms + 86_400_000 - 1
|
end_ms = start_ms + 86_400_000 - 1
|
||||||
|
filters: dict[str, Any] = {"dueDateFrom": start_ms, "dueDateTo": end_ms}
|
||||||
|
if not include_done:
|
||||||
|
filters["status"] = "todo"
|
||||||
result = await execute_on_client(
|
result = await execute_on_client(
|
||||||
action="select",
|
action="select",
|
||||||
table="tasks",
|
table="tasks",
|
||||||
filters={"dueDateFrom": start_ms, "dueDateTo": end_ms},
|
filters=filters,
|
||||||
)
|
)
|
||||||
rows = result.get("rows", [])
|
rows = result.get("rows", [])
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -203,7 +318,6 @@ async def add_task_comment(task_id: str, author: str, content: str) -> str:
|
|||||||
)
|
)
|
||||||
row = result.get("row", {})
|
row = result.get("row", {})
|
||||||
row_author = row.get("author", author)
|
row_author = row.get("author", author)
|
||||||
# Electron payloads can vary (taskId vs task_id). Fall back to input task_id.
|
|
||||||
row_task_id = row.get("taskId") or row.get("task_id") or task_id
|
row_task_id = row.get("taskId") or row.get("task_id") or task_id
|
||||||
row_comment_id = row.get("id", "unknown")
|
row_comment_id = row.get("id", "unknown")
|
||||||
return f"Comment added by {row_author} on task {row_task_id} (comment id: {row_comment_id})."
|
return f"Comment added by {row_author} on task {row_task_id} (comment id: {row_comment_id})."
|
||||||
@@ -221,6 +335,7 @@ async def delete_task_comment(comment_id: str) -> str:
|
|||||||
|
|
||||||
TASK_TOOLS: list[Any] = [
|
TASK_TOOLS: list[Any] = [
|
||||||
list_tasks,
|
list_tasks,
|
||||||
|
count_tasks,
|
||||||
create_task,
|
create_task,
|
||||||
update_task,
|
update_task,
|
||||||
delete_task,
|
delete_task,
|
||||||
@@ -232,6 +347,7 @@ TASK_TOOLS: list[Any] = [
|
|||||||
|
|
||||||
TASK_READ_TOOLS: list[Any] = [
|
TASK_READ_TOOLS: list[Any] = [
|
||||||
list_tasks,
|
list_tasks,
|
||||||
|
count_tasks,
|
||||||
list_tasks_due_today,
|
list_tasks_due_today,
|
||||||
list_task_comments,
|
list_task_comments,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,19 +20,127 @@ def _is_uuid(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def list_timelines(project_id: str = "") -> str:
|
async def list_timelines(
|
||||||
"""List timelines. Provide project_id to scope to a specific project."""
|
project_id: str = "",
|
||||||
|
type: str = "",
|
||||||
|
is_completed: int = -1,
|
||||||
|
is_ai_suggested: int = -1,
|
||||||
|
order_by: str = "",
|
||||||
|
order_dir: str = "",
|
||||||
|
date_from: int = -1,
|
||||||
|
date_to: int = -1,
|
||||||
|
created_at_from: int = -1,
|
||||||
|
created_at_to: int = -1,
|
||||||
|
completed_at_from: int = -1,
|
||||||
|
completed_at_to: int = -1,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""List timeline events (milestones, checkpoints, activities) with optional filters.
|
||||||
|
|
||||||
|
project_id: UUID to scope results to a specific project.
|
||||||
|
type: filter by event type — milestone | checkpoint | activity.
|
||||||
|
is_completed: 0 = incomplete only, 1 = completed only, -1 = any (default).
|
||||||
|
is_ai_suggested: 0 or 1 to filter by AI-suggested flag; -1 = any.
|
||||||
|
order_by: sort field — date (default) | createdAt | completedAt.
|
||||||
|
order_dir: asc (default) | desc.
|
||||||
|
date_from / date_to: ms epoch range for the event date. Use -1 to omit.
|
||||||
|
created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit.
|
||||||
|
completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit.
|
||||||
|
limit: max rows to return (default 50). Use with offset to paginate.
|
||||||
|
offset: skip first N rows (default 0).
|
||||||
|
|
||||||
|
Tip — combine *_from and *_to for a closed range; pass only one for open-ended.
|
||||||
|
Tip — prefer count_timelines for "how many" questions to avoid listing rows.
|
||||||
|
Tip — for natural-language windows ("today", "this week", "last month", etc.)
|
||||||
|
take date_from / date_to verbatim from the DATE CONTEXT block in the system prompt;
|
||||||
|
do not compute boundaries from the current UTC instant.
|
||||||
|
"""
|
||||||
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
||||||
result = await execute_on_client(
|
filters: dict[str, Any] = {
|
||||||
action="select",
|
"projectId": normalized_project_id or None,
|
||||||
table="timelines",
|
"orderBy": order_by or None,
|
||||||
filters={"projectId": normalized_project_id or None},
|
"orderDir": order_dir or None,
|
||||||
)
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
if type:
|
||||||
|
filters["type"] = type
|
||||||
|
if is_completed != -1:
|
||||||
|
filters["isCompleted"] = is_completed
|
||||||
|
if is_ai_suggested != -1:
|
||||||
|
filters["isAiSuggested"] = is_ai_suggested
|
||||||
|
if date_from != -1:
|
||||||
|
filters["dateFrom"] = date_from
|
||||||
|
if date_to != -1:
|
||||||
|
filters["dateTo"] = date_to
|
||||||
|
if created_at_from != -1:
|
||||||
|
filters["createdAtFrom"] = created_at_from
|
||||||
|
if created_at_to != -1:
|
||||||
|
filters["createdAtTo"] = created_at_to
|
||||||
|
if completed_at_from != -1:
|
||||||
|
filters["completedAtFrom"] = completed_at_from
|
||||||
|
if completed_at_to != -1:
|
||||||
|
filters["completedAtTo"] = completed_at_to
|
||||||
|
|
||||||
|
result = await execute_on_client(action="select", table="timelines", filters=filters)
|
||||||
rows = result.get("rows", [])
|
rows = result.get("rows", [])
|
||||||
if not rows:
|
if not rows:
|
||||||
return "No timelines found."
|
return "No timeline events found."
|
||||||
lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows]
|
lines = [
|
||||||
return f"Found {len(rows)} timeline(s):\n" + "\n".join(lines)
|
f"- {r['title']} (date: {r['date']}, type: {r.get('type')}, "
|
||||||
|
f"completed: {bool(r.get('isCompleted'))}, completedAt: {r.get('completedAt')}, id: {r['id']})"
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return f"Found {len(rows)} timeline event(s):\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def count_timelines(
|
||||||
|
project_id: str = "",
|
||||||
|
type: str = "",
|
||||||
|
is_completed: int = -1,
|
||||||
|
is_ai_suggested: int = -1,
|
||||||
|
date_from: int = -1,
|
||||||
|
date_to: int = -1,
|
||||||
|
created_at_from: int = -1,
|
||||||
|
created_at_to: int = -1,
|
||||||
|
completed_at_from: int = -1,
|
||||||
|
completed_at_to: int = -1,
|
||||||
|
) -> str:
|
||||||
|
"""Count timeline events matching the given filters without returning rows.
|
||||||
|
|
||||||
|
Use this instead of list_timelines for "how many" questions — it is much cheaper.
|
||||||
|
Same filter parameters as list_timelines (no limit/offset/order_by needed).
|
||||||
|
|
||||||
|
date_from / date_to: ms epoch range for the event date. Use -1 to omit.
|
||||||
|
completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit.
|
||||||
|
Tip — for natural-language windows take date_from / date_to from the DATE CONTEXT block;
|
||||||
|
do not compute boundaries from the current UTC instant.
|
||||||
|
"""
|
||||||
|
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
||||||
|
filters: dict[str, Any] = {"projectId": normalized_project_id or None}
|
||||||
|
if type:
|
||||||
|
filters["type"] = type
|
||||||
|
if is_completed != -1:
|
||||||
|
filters["isCompleted"] = is_completed
|
||||||
|
if is_ai_suggested != -1:
|
||||||
|
filters["isAiSuggested"] = is_ai_suggested
|
||||||
|
if date_from != -1:
|
||||||
|
filters["dateFrom"] = date_from
|
||||||
|
if date_to != -1:
|
||||||
|
filters["dateTo"] = date_to
|
||||||
|
if created_at_from != -1:
|
||||||
|
filters["createdAtFrom"] = created_at_from
|
||||||
|
if created_at_to != -1:
|
||||||
|
filters["createdAtTo"] = created_at_to
|
||||||
|
if completed_at_from != -1:
|
||||||
|
filters["completedAtFrom"] = completed_at_from
|
||||||
|
if completed_at_to != -1:
|
||||||
|
filters["completedAtTo"] = completed_at_to
|
||||||
|
|
||||||
|
result = await execute_on_client(action="count", table="timelines", filters=filters)
|
||||||
|
return f"Timeline event count: {result.get('count', 0)}"
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -40,13 +148,19 @@ async def create_timeline(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
title: str,
|
title: str,
|
||||||
date: int,
|
date: int,
|
||||||
|
type: str = "milestone",
|
||||||
|
is_completed: int = 0,
|
||||||
is_ai_suggested: int = 0,
|
is_ai_suggested: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a project timeline (milestone).
|
"""Create a project timeline event.
|
||||||
project_id: REQUIRED UUID of the parent project
|
project_id: REQUIRED UUID of the parent project
|
||||||
title: descriptive name for the milestone
|
title: descriptive name for the event
|
||||||
date: Unix timestamp in milliseconds
|
date: Unix timestamp in milliseconds for the event date
|
||||||
|
type: milestone (default) | checkpoint | activity
|
||||||
|
is_completed: 1 if already completed, 0 if not (default 0)
|
||||||
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
||||||
|
|
||||||
|
completedAt is set automatically when is_completed is 1.
|
||||||
"""
|
"""
|
||||||
result = await execute_on_client(
|
result = await execute_on_client(
|
||||||
action="insert",
|
action="insert",
|
||||||
@@ -55,11 +169,13 @@ async def create_timeline(
|
|||||||
"projectId": project_id,
|
"projectId": project_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"date": date,
|
"date": date,
|
||||||
|
"type": type,
|
||||||
|
"isCompleted": is_completed,
|
||||||
"isAiSuggested": is_ai_suggested,
|
"isAiSuggested": is_ai_suggested,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
row = result["row"]
|
row = result["row"]
|
||||||
return f"Timeline created: '{row['title']}' (id: {row['id']}, date: {row['date']})"
|
return f"Timeline event created: '{row['title']}' (id: {row['id']}, date: {row['date']}, type: {row.get('type')})"
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -67,38 +183,47 @@ async def update_timeline(
|
|||||||
timeline_id: str,
|
timeline_id: str,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
date: int = -1,
|
date: int = -1,
|
||||||
|
is_completed: int = -1,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update a timeline. Only pass fields that should change.
|
"""Update a timeline event. Only pass fields that should change.
|
||||||
timeline_id: UUID of the timeline (required)
|
timeline_id: UUID of the event (required)
|
||||||
date: -1 means unchanged; any other value sets the new date (ms timestamp)
|
date: -1 means unchanged; any other value sets the new date (ms timestamp)
|
||||||
|
is_completed: 0 = mark incomplete, 1 = mark complete, -1 = unchanged
|
||||||
|
|
||||||
|
completedAt is managed automatically:
|
||||||
|
- setting is_completed to 1 records the current timestamp
|
||||||
|
- setting is_completed to 0 clears completedAt
|
||||||
"""
|
"""
|
||||||
updates: dict[str, Any] = {}
|
updates: dict[str, Any] = {}
|
||||||
if title:
|
if title:
|
||||||
updates["title"] = title
|
updates["title"] = title
|
||||||
if date != -1:
|
if date != -1:
|
||||||
updates["date"] = date
|
updates["date"] = date
|
||||||
|
if is_completed != -1:
|
||||||
|
updates["isCompleted"] = is_completed
|
||||||
result = await execute_on_client(
|
result = await execute_on_client(
|
||||||
action="update",
|
action="update",
|
||||||
table="timelines",
|
table="timelines",
|
||||||
data={"id": timeline_id, "updates": updates},
|
data={"id": timeline_id, "updates": updates},
|
||||||
)
|
)
|
||||||
row = result["row"]
|
row = result["row"]
|
||||||
return f"Timeline updated: '{row['title']}' (id: {row['id']})"
|
return f"Timeline event updated: '{row['title']}' (id: {row['id']})"
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def delete_timeline(timeline_id: str) -> str:
|
async def delete_timeline(timeline_id: str) -> str:
|
||||||
"""Delete a timeline permanently by its UUID."""
|
"""Delete a timeline event permanently by its UUID."""
|
||||||
await execute_on_client(action="delete", table="timelines", data={"id": timeline_id})
|
await execute_on_client(action="delete", table="timelines", data={"id": timeline_id})
|
||||||
return f"Timeline {timeline_id} deleted."
|
return f"Timeline event {timeline_id} deleted."
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def list_timelines_today(user_timezone: str = "UTC") -> str:
|
async def list_timelines_today(user_timezone: str = "UTC", include_completed: bool = True) -> str:
|
||||||
"""List all timeline events (milestones) whose date falls on today.
|
"""List all timeline events whose date falls on today.
|
||||||
|
|
||||||
user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York').
|
user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York').
|
||||||
Always pass the user's timezone so 'today' is computed in their local time.
|
Always pass the user's timezone so 'today' is computed in their local time.
|
||||||
|
include_completed: set False to exclude already-completed events (default True).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -109,20 +234,27 @@ async def list_timelines_today(user_timezone: str = "UTC") -> str:
|
|||||||
start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz)
|
start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz)
|
||||||
start_ms = int(start_dt.timestamp() * 1000)
|
start_ms = int(start_dt.timestamp() * 1000)
|
||||||
end_ms = start_ms + 86_400_000 - 1
|
end_ms = start_ms + 86_400_000 - 1
|
||||||
|
filters: dict[str, Any] = {"dateFrom": start_ms, "dateTo": end_ms}
|
||||||
|
if not include_completed:
|
||||||
|
filters["isCompleted"] = 0
|
||||||
result = await execute_on_client(
|
result = await execute_on_client(
|
||||||
action="select",
|
action="select",
|
||||||
table="timelines",
|
table="timelines",
|
||||||
filters={"dateFrom": start_ms, "dateTo": end_ms},
|
filters=filters,
|
||||||
)
|
)
|
||||||
rows = result.get("rows", [])
|
rows = result.get("rows", [])
|
||||||
if not rows:
|
if not rows:
|
||||||
return "No timeline events today."
|
return "No timeline events today."
|
||||||
lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows]
|
lines = [
|
||||||
|
f"- {r['title']} (date: {r['date']}, type: {r.get('type')}, completed: {bool(r.get('isCompleted'))}, id: {r['id']})"
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
return f"Timeline events today ({len(rows)}):\n" + "\n".join(lines)
|
return f"Timeline events today ({len(rows)}):\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
TIMELINE_TOOLS: list[Any] = [
|
TIMELINE_TOOLS: list[Any] = [
|
||||||
list_timelines,
|
list_timelines,
|
||||||
|
count_timelines,
|
||||||
list_timelines_today,
|
list_timelines_today,
|
||||||
create_timeline,
|
create_timeline,
|
||||||
update_timeline,
|
update_timeline,
|
||||||
@@ -131,5 +263,6 @@ TIMELINE_TOOLS: list[Any] = [
|
|||||||
|
|
||||||
TIMELINE_READ_TOOLS: list[Any] = [
|
TIMELINE_READ_TOOLS: list[Any] = [
|
||||||
list_timelines,
|
list_timelines,
|
||||||
|
count_timelines,
|
||||||
list_timelines_today,
|
list_timelines_today,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.agents.note_agent import NOTE_TOOLS
|
|||||||
from app.agents.project_agent import PROJECT_TOOLS
|
from app.agents.project_agent import PROJECT_TOOLS
|
||||||
from app.agents.task_agent import TASK_TOOLS
|
from app.agents.task_agent import TASK_TOOLS
|
||||||
from app.agents.timeline_agent import TIMELINE_TOOLS
|
from app.agents.timeline_agent import TIMELINE_TOOLS
|
||||||
from app.core.langfuse_client import extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context
|
from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback, langfuse_context
|
||||||
from app.core.llm import get_agent_llm, model_for_agent
|
from app.core.llm import get_agent_llm, model_for_agent
|
||||||
from app.core.memory_middleware import MemoryMiddleware
|
from app.core.memory_middleware import MemoryMiddleware
|
||||||
from app.core.ws_context import clear_tool_result_collector, execute_on_client, set_tool_result_collector
|
from app.core.ws_context import clear_tool_result_collector, execute_on_client, set_tool_result_collector
|
||||||
@@ -56,36 +56,89 @@ def _language_instruction(context: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _datetime_context_injection(context: dict[str, Any]) -> str:
|
def _datetime_context_injection(context: dict[str, Any]) -> str:
|
||||||
"""Build a system-prompt paragraph with current timestamp, user timezone, and format prefs."""
|
"""Build a comprehensive DATE CONTEXT block with pre-computed ms-epoch boundaries for common ranges."""
|
||||||
fp = context.get("format_prefs")
|
fp = context.get("format_prefs")
|
||||||
if not fp or not isinstance(fp, dict):
|
if not fp or not isinstance(fp, dict):
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from datetime import datetime as _dt, timezone as _utc
|
from datetime import datetime as _dt, timezone as _utc, timedelta as _td
|
||||||
|
|
||||||
tz_name: str = str(fp.get("timezone") or "UTC")
|
tz_name: str = str(fp.get("timezone") or "UTC")
|
||||||
now_iso: str = str(fp.get("now_iso") or "")
|
now_iso: str = str(fp.get("now_iso") or "")
|
||||||
date_fmt: str = str(fp.get("date_format") or "dd/MM/yyyy")
|
date_fmt: str = str(fp.get("date_format") or "dd/MM/yyyy")
|
||||||
time_fmt: str = str(fp.get("time_format") or "24h")
|
time_fmt: str = str(fp.get("time_format") or "24h")
|
||||||
|
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
if now_iso:
|
if now_iso:
|
||||||
now_utc = _dt.fromisoformat(now_iso.replace("Z", "+00:00"))
|
now_utc = _dt.fromisoformat(now_iso.replace("Z", "+00:00"))
|
||||||
else:
|
else:
|
||||||
now_utc = _dt.now(_utc.utc)
|
now_utc = _dt.now(_utc.utc)
|
||||||
|
|
||||||
tz = ZoneInfo(tz_name)
|
now_ms = int(now_utc.timestamp() * 1000)
|
||||||
now_local = now_utc.astimezone(tz)
|
now_local = now_utc.astimezone(tz)
|
||||||
today_local = now_local.strftime("%Y-%m-%d")
|
now_local_str = now_local.strftime("%Y-%m-%d %H:%M")
|
||||||
weekday_local = now_local.strftime("%A")
|
weekday_str = now_local.strftime("%A")
|
||||||
|
y, m, d = now_local.year, now_local.month, now_local.day
|
||||||
|
|
||||||
|
def _day(year: int, month: int, day: int) -> tuple[int, int]:
|
||||||
|
s = _dt(year, month, day, tzinfo=tz)
|
||||||
|
e = s + _td(days=1)
|
||||||
|
return int(s.timestamp() * 1000), int(e.timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
def _between(start: "_dt", end_excl: "_dt") -> tuple[int, int]:
|
||||||
|
return int(start.timestamp() * 1000), int(end_excl.timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
today_s, today_e = _day(y, m, d)
|
||||||
|
yd = now_local - _td(days=1)
|
||||||
|
yesterday_s, yesterday_e = _day(yd.year, yd.month, yd.day)
|
||||||
|
tm = now_local + _td(days=1)
|
||||||
|
tomorrow_s, tomorrow_e = _day(tm.year, tm.month, tm.day)
|
||||||
|
|
||||||
|
# ISO week (Mon–Sun)
|
||||||
|
monday = _dt(y, m, d, tzinfo=tz) - _td(days=now_local.weekday())
|
||||||
|
last_monday = monday - _td(weeks=1)
|
||||||
|
next_monday = monday + _td(weeks=1)
|
||||||
|
this_week_s, this_week_e = _between(monday, next_monday)
|
||||||
|
last_week_s, last_week_e = _between(last_monday, monday)
|
||||||
|
next_week_s, next_week_e = _between(next_monday, next_monday + _td(weeks=1))
|
||||||
|
|
||||||
|
# Calendar months
|
||||||
|
this_m_start = _dt(y, m, 1, tzinfo=tz)
|
||||||
|
next_m_start = _dt(y + (m // 12), m % 12 + 1, 1, tzinfo=tz)
|
||||||
|
last_m_start = _dt(y - (1 if m == 1 else 0), 12 if m == 1 else m - 1, 1, tzinfo=tz)
|
||||||
|
next2_m = next_m_start.month % 12 + 1
|
||||||
|
next2_y = next_m_start.year + (1 if next_m_start.month == 12 else 0)
|
||||||
|
next2_m_start = _dt(next2_y, next2_m, 1, tzinfo=tz)
|
||||||
|
this_month_s, this_month_e = _between(this_m_start, next_m_start)
|
||||||
|
last_month_s, last_month_e = _between(last_m_start, this_m_start)
|
||||||
|
next_month_s, next_month_e = _between(next_m_start, next2_m_start)
|
||||||
|
|
||||||
|
# Calendar years
|
||||||
|
this_yr_s, this_yr_e = _between(_dt(y, 1, 1, tzinfo=tz), _dt(y + 1, 1, 1, tzinfo=tz))
|
||||||
|
last_yr_s, last_yr_e = _between(_dt(y - 1, 1, 1, tzinfo=tz), _dt(y, 1, 1, tzinfo=tz))
|
||||||
|
|
||||||
|
sunday = monday + _td(days=6)
|
||||||
|
last_sunday = last_monday + _td(days=6)
|
||||||
|
next_sunday = next_monday + _td(days=6)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"\n\nCurrent instant: {now_utc.isoformat()}. "
|
f"\n\nDATE CONTEXT (timezone: {tz_name}, dateFormat: {date_fmt}, timeFormat: {time_fmt})\n"
|
||||||
f"User local date: {today_local} ({weekday_local}). "
|
f"now_local: {now_local_str} ({weekday_str})\n"
|
||||||
f"Timezone: {tz_name}. "
|
f"now_ms: {now_ms}\n\n"
|
||||||
f"Display preference: dateFormat={date_fmt}, timeFormat={time_fmt}. "
|
f"today [{today_s}, {today_e}] {y:04d}-{m:02d}-{d:02d}\n"
|
||||||
f"When calling tools with date fields, always pass integer Unix milliseconds (ms since epoch, UTC). "
|
f"tomorrow [{tomorrow_s}, {tomorrow_e}] {tm.strftime('%Y-%m-%d')}\n"
|
||||||
f"When calling list_tasks_due_today or list_timelines_today, always pass user_timezone=\"{tz_name}\". "
|
f"yesterday [{yesterday_s}, {yesterday_e}] {yd.strftime('%Y-%m-%d')}\n"
|
||||||
f"When presenting dates to the user in chat, format using the display preference above."
|
f"this_week [{this_week_s}, {this_week_e}] {monday.strftime('%Y-%m-%d')} → {sunday.strftime('%Y-%m-%d')} (Mon–Sun)\n"
|
||||||
|
f"last_week [{last_week_s}, {last_week_e}] {last_monday.strftime('%Y-%m-%d')} → {last_sunday.strftime('%Y-%m-%d')}\n"
|
||||||
|
f"next_week [{next_week_s}, {next_week_e}] {next_monday.strftime('%Y-%m-%d')} → {next_sunday.strftime('%Y-%m-%d')}\n"
|
||||||
|
f"this_month [{this_month_s}, {this_month_e}] {y:04d}-{m:02d}\n"
|
||||||
|
f"last_month [{last_month_s}, {last_month_e}] {last_m_start.strftime('%Y-%m')}\n"
|
||||||
|
f"next_month [{next_month_s}, {next_month_e}] {next_m_start.strftime('%Y-%m')}\n"
|
||||||
|
f"this_year [{this_yr_s}, {this_yr_e}] {y:04d}\n"
|
||||||
|
f"last_year [{last_yr_s}, {last_yr_e}] {y - 1:04d}\n\n"
|
||||||
|
f"When calling list_tasks_due_today or list_timelines_today, always pass user_timezone=\"{tz_name}\".\n"
|
||||||
|
f"When presenting dates, format using dateFormat={date_fmt} and timeFormat={time_fmt}."
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
@@ -123,27 +176,75 @@ def _relational_memory_injection(context: dict[str, Any]) -> str:
|
|||||||
return section
|
return section
|
||||||
|
|
||||||
|
|
||||||
_HOME_SYSTEM_PROMPT = (
|
def _request_context_block(context: dict[str, Any]) -> str:
|
||||||
"You are the home assistant with direct access to all tools: tasks, projects, notes, timelines, and memory tools. "
|
"""Return a small block with per-request scope and resolved project context."""
|
||||||
"Always use tools for factual data retrieval before answering. "
|
parts: list[str] = []
|
||||||
"When the user asks to remember, forget, or update what you know about them, use memory tools. "
|
scope = context.get("scope")
|
||||||
"If context.context.resolved_project_id exists, use it as project_id for scoped list calls. "
|
if scope and isinstance(scope, dict):
|
||||||
"Return markdown and use tags when relevant: <project>[ids]</project>, <task>[ids]</task>, "
|
parts.append(f"scope: {json.dumps(scope, ensure_ascii=True)}")
|
||||||
"<note>[ids]</note>, <timeline>[ids]</timeline>, <chart>{json}</chart>. "
|
resolved = context.get("resolved_project_id")
|
||||||
"When listing tasks or timelines, each id tag must be on its own line with no prefix/suffix text. "
|
if resolved and isinstance(resolved, str):
|
||||||
"Never put titles, priorities, or dates on the same line as <task> or <timeline> tags. "
|
parts.append(f"resolved_project_id: {resolved}")
|
||||||
"For questions about upcoming timelines (e.g. 'prossimi eventi'), include only future items in the current month unless the user asks a different range. "
|
return "\n".join(parts)
|
||||||
"For upcoming tasks, after tag lines add a short recommendation based on due date and priority."
|
|
||||||
)
|
|
||||||
|
|
||||||
_FLOATING_SYSTEM_PROMPT = (
|
|
||||||
"You are the floating assistant with direct access to all tools: tasks, projects, notes, timelines, and memory tools. "
|
_HOME_SYSTEM_PROMPT = """\
|
||||||
"Stay focused on the floating scope in context.scope and answer concisely. "
|
You are the home assistant for adiuvAI with direct access to all tools: tasks, projects, notes, timelines, and memory tools.
|
||||||
"Return plain text only. Do not output XML/HTML-like tags such as <task>, <project>, <note>, <timeline>, or any bracketed id tag wrappers. "
|
Always use tools for factual data retrieval before answering.
|
||||||
"Always use tools for factual data retrieval before answering. "
|
When the user asks to remember, forget, or update what you know about them, use memory tools.
|
||||||
"When the user asks to remember, forget, or update what you know about them, use memory tools. "
|
|
||||||
"If context.context.resolved_project_id exists, use it as project_id for scoped list calls. "
|
# Output format
|
||||||
)
|
Return markdown and use tags when relevant: <project>[ids]</project>, <task>[ids]</task>, <note>[ids]</note>, <timeline>[ids]</timeline>, <chart>{{json}}</chart>.
|
||||||
|
When listing tasks or timelines, each id tag must be on its own line with no prefix/suffix text.
|
||||||
|
Never put titles, priorities, or dates on the same line as <task> or <timeline> tags.
|
||||||
|
For questions about upcoming timelines (e.g. 'prossimi eventi'), include only future items in the current month unless the user asks a different range.
|
||||||
|
For upcoming tasks, after tag lines add a short recommendation based on due date and priority.
|
||||||
|
|
||||||
|
# Date filtering
|
||||||
|
{date_context}
|
||||||
|
|
||||||
|
When filtering tasks/timelines/notes by date, take dueDateFrom / dueDateTo (ms epoch UTC) verbatim from the DATE CONTEXT boundary table above. Do NOT compute boundaries from now_ms yourself.
|
||||||
|
For specific dates not listed, compute local-midnight in the user timezone and convert to UTC ms.
|
||||||
|
For "today" / "tomorrow" queries, prefer list_tasks_due_today / list_timelines_today with user_timezone from DATE CONTEXT.
|
||||||
|
|
||||||
|
# Language
|
||||||
|
{language_instruction}
|
||||||
|
|
||||||
|
# Known people & projects
|
||||||
|
{relational_memory}
|
||||||
|
|
||||||
|
# Behavioral hints
|
||||||
|
{proactive_hints}
|
||||||
|
|
||||||
|
# Request context
|
||||||
|
{request_context}\
|
||||||
|
"""
|
||||||
|
|
||||||
|
_FLOATING_SYSTEM_PROMPT = """\
|
||||||
|
You are the floating assistant for adiuvAI with direct access to all tools: tasks, projects, notes, timelines, and memory tools.
|
||||||
|
Stay focused on the floating scope and answer concisely.
|
||||||
|
Return plain text only. Do not output XML/HTML-like tags such as <task>, <project>, <note>, <timeline>, or any bracketed id tag wrappers.
|
||||||
|
Always use tools for factual data retrieval before answering.
|
||||||
|
When the user asks to remember, forget, or update what you know about them, use memory tools.
|
||||||
|
|
||||||
|
# Date filtering
|
||||||
|
{date_context}
|
||||||
|
|
||||||
|
When filtering by date, take dueDateFrom / dueDateTo (ms epoch UTC) verbatim from the DATE CONTEXT boundary table above. Do NOT compute boundaries from now_ms yourself.
|
||||||
|
For specific dates not listed, compute local-midnight in the user timezone and convert to UTC ms.
|
||||||
|
|
||||||
|
# Language
|
||||||
|
{language_instruction}
|
||||||
|
|
||||||
|
# Known people & projects
|
||||||
|
{relational_memory}
|
||||||
|
|
||||||
|
# Behavioral hints
|
||||||
|
{proactive_hints}
|
||||||
|
|
||||||
|
# Request context
|
||||||
|
{request_context}\
|
||||||
|
"""
|
||||||
|
|
||||||
_FLOATING_DOMAIN_CLASSIFIER_PROMPT = (
|
_FLOATING_DOMAIN_CLASSIFIER_PROMPT = (
|
||||||
"You are a strict domain classifier for websocket floating requests. "
|
"You are a strict domain classifier for websocket floating requests. "
|
||||||
@@ -253,10 +354,18 @@ def _session_id_from_context(context: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _context_for_model(context: dict[str, Any]) -> dict[str, Any]:
|
def _build_system_prompt(name: str, fallback: str, context: dict[str, Any]) -> tuple[str, Any]:
|
||||||
sanitized = dict(context)
|
"""Fetch Langfuse template and compile all per-request slots into one system prompt."""
|
||||||
sanitized.pop("_debug", None)
|
template, prompt_obj = get_prompt_or_fallback(name, fallback)
|
||||||
return sanitized
|
text = compile_prompt(
|
||||||
|
template, prompt_obj,
|
||||||
|
date_context=_datetime_context_injection(context).strip(),
|
||||||
|
language_instruction=_language_instruction(context).strip(),
|
||||||
|
relational_memory=_relational_memory_injection(context).strip(),
|
||||||
|
proactive_hints=_proactive_hints_injection(context).strip(),
|
||||||
|
request_context=_request_context_block(context),
|
||||||
|
)
|
||||||
|
return text, prompt_obj
|
||||||
|
|
||||||
|
|
||||||
_TAG_LINE_RE = re.compile(r"<(task|timeline)>\[[^\]]+\]</\1>")
|
_TAG_LINE_RE = re.compile(r"<(task|timeline)>\[[^\]]+\]</\1>")
|
||||||
@@ -713,17 +822,11 @@ async def _run_single_agent(
|
|||||||
lf = get_langfuse()
|
lf = get_langfuse()
|
||||||
llm = get_agent_llm(agent_name)
|
llm = get_agent_llm(agent_name)
|
||||||
tools = _all_tools_for_user(user_id, trace_id)
|
tools = _all_tools_for_user(user_id, trace_id)
|
||||||
model_context = _context_for_model(context)
|
|
||||||
logger.info("deep_agent: run_single_agent_start trace=%s user=%s", trace_id or "-", user_id)
|
logger.info("deep_agent: run_single_agent_start trace=%s user=%s", trace_id or "-", user_id)
|
||||||
llm_with_tools = llm.bind_tools(tools)
|
llm_with_tools = llm.bind_tools(tools)
|
||||||
messages: list[Any] = [
|
messages: list[Any] = [
|
||||||
SystemMessage(content=system_prompt),
|
SystemMessage(content=system_prompt),
|
||||||
HumanMessage(
|
HumanMessage(content=message),
|
||||||
content=(
|
|
||||||
f"User message:\n{message}\n\n"
|
|
||||||
f"Context:\n{json.dumps({'context': model_context}, ensure_ascii=True)[:3500]}"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
tool_calls_count = 0
|
tool_calls_count = 0
|
||||||
@@ -843,17 +946,11 @@ async def _run_single_agent_stream(
|
|||||||
llm = get_agent_llm(agent_name)
|
llm = get_agent_llm(agent_name)
|
||||||
if tools is None:
|
if tools is None:
|
||||||
tools = _all_tools_for_user(user_id, trace_id)
|
tools = _all_tools_for_user(user_id, trace_id)
|
||||||
model_context = _context_for_model(context)
|
|
||||||
logger.info("deep_agent: run_single_agent_stream_start trace=%s user=%s", trace_id or "-", user_id)
|
logger.info("deep_agent: run_single_agent_stream_start trace=%s user=%s", trace_id or "-", user_id)
|
||||||
llm_with_tools = llm.bind_tools(tools)
|
llm_with_tools = llm.bind_tools(tools)
|
||||||
messages: list[Any] = [
|
messages: list[Any] = [
|
||||||
SystemMessage(content=system_prompt),
|
SystemMessage(content=system_prompt),
|
||||||
HumanMessage(
|
HumanMessage(content=message),
|
||||||
content=(
|
|
||||||
f"User message:\n{message}\n\n"
|
|
||||||
f"Context:\n{json.dumps({'context': model_context}, ensure_ascii=True)[:3500]}"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
tool_calls_count = 0
|
tool_calls_count = 0
|
||||||
@@ -969,13 +1066,7 @@ async def _run_single_agent_stream(
|
|||||||
|
|
||||||
async def run_home(user_id: str, message: str, context: dict[str, Any]) -> str:
|
async def run_home(user_id: str, message: str, context: dict[str, Any]) -> str:
|
||||||
prepared_context = await _prepare_context(message, context)
|
prepared_context = await _prepare_context(message, context)
|
||||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
system_prompt, langfuse_prompt = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, prepared_context)
|
||||||
"home_system", _HOME_SYSTEM_PROMPT
|
|
||||||
)
|
|
||||||
system_prompt += _relational_memory_injection(context)
|
|
||||||
system_prompt += _proactive_hints_injection(context)
|
|
||||||
system_prompt += _datetime_context_injection(context)
|
|
||||||
system_prompt += _language_instruction(context)
|
|
||||||
response = await _run_single_agent(
|
response = await _run_single_agent(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
@@ -990,13 +1081,7 @@ async def run_home(user_id: str, message: str, context: dict[str, Any]) -> str:
|
|||||||
async def run_floating(user_id: str, message: str, context: dict[str, Any]) -> tuple[str, dict[str, str | None]]:
|
async def run_floating(user_id: str, message: str, context: dict[str, Any]) -> tuple[str, dict[str, str | None]]:
|
||||||
prepared_context = await _prepare_context(message, context)
|
prepared_context = await _prepare_context(message, context)
|
||||||
domain = await _infer_floating_domain(message, prepared_context)
|
domain = await _infer_floating_domain(message, prepared_context)
|
||||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||||
"floating_system", _FLOATING_SYSTEM_PROMPT
|
|
||||||
)
|
|
||||||
system_prompt += _relational_memory_injection(context)
|
|
||||||
system_prompt += _proactive_hints_injection(context)
|
|
||||||
system_prompt += _datetime_context_injection(context)
|
|
||||||
system_prompt += _language_instruction(context)
|
|
||||||
response = await _run_single_agent(
|
response = await _run_single_agent(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
@@ -1017,13 +1102,7 @@ async def run_home_stream(
|
|||||||
context: dict[str, Any],
|
context: dict[str, Any],
|
||||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||||
prepared_context = await _prepare_context(message, context)
|
prepared_context = await _prepare_context(message, context)
|
||||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
system_prompt, langfuse_prompt = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, prepared_context)
|
||||||
"home_system", _HOME_SYSTEM_PROMPT
|
|
||||||
)
|
|
||||||
system_prompt += _relational_memory_injection(context)
|
|
||||||
system_prompt += _proactive_hints_injection(context)
|
|
||||||
system_prompt += _datetime_context_injection(context)
|
|
||||||
system_prompt += _language_instruction(context)
|
|
||||||
text_chunks: list[str] = []
|
text_chunks: list[str] = []
|
||||||
async for event in _run_single_agent_stream(
|
async for event in _run_single_agent_stream(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -1053,13 +1132,7 @@ async def run_floating_stream(
|
|||||||
domain = await _infer_floating_domain(message, prepared_context)
|
domain = await _infer_floating_domain(message, prepared_context)
|
||||||
yield "floating_domain", domain
|
yield "floating_domain", domain
|
||||||
|
|
||||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||||
"floating_system", _FLOATING_SYSTEM_PROMPT
|
|
||||||
)
|
|
||||||
system_prompt += _relational_memory_injection(context)
|
|
||||||
system_prompt += _proactive_hints_injection(context)
|
|
||||||
system_prompt += _datetime_context_injection(context)
|
|
||||||
system_prompt += _language_instruction(context)
|
|
||||||
sanitizer = _FloatingStreamSanitizer()
|
sanitizer = _FloatingStreamSanitizer()
|
||||||
emitted_sanitized = False
|
emitted_sanitized = False
|
||||||
raw_chunks: list[str] = []
|
raw_chunks: list[str] = []
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import pytest
|
|||||||
from langchain_core.messages import AIMessage, ToolMessage
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
|
|
||||||
from app.core.deep_agent import (
|
from app.core.deep_agent import (
|
||||||
|
_build_system_prompt,
|
||||||
|
_datetime_context_injection,
|
||||||
_infer_floating_domain,
|
_infer_floating_domain,
|
||||||
_normalize_tagged_list_lines,
|
_normalize_tagged_list_lines,
|
||||||
|
_request_context_block,
|
||||||
run_floating,
|
run_floating,
|
||||||
run_floating_stream,
|
run_floating_stream,
|
||||||
run_home,
|
run_home,
|
||||||
@@ -91,8 +94,12 @@ async def test_run_floating_stream_emits_domain_then_tokens_with_mocked_tool_res
|
|||||||
"floating_domain",
|
"floating_domain",
|
||||||
{"type": "timeline", "id": "tl-1", "section": None},
|
{"type": "timeline", "id": "tl-1", "section": None},
|
||||||
)
|
)
|
||||||
assert ("token", "stream-") in events
|
# _run_single_agent_stream uses ainvoke (not astream); the final token is
|
||||||
assert ("token", "ok") in events
|
# the second LLM response which echoes the tool result.
|
||||||
|
token_events = [e for e in events if e[0] == "token"]
|
||||||
|
assert token_events, "Expected at least one token event"
|
||||||
|
combined = "".join(str(e[1]) for e in token_events)
|
||||||
|
assert "Mock Task" in combined
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -286,3 +293,213 @@ async def test_run_floating_stream_returns_fallback_when_sanitization_would_empt
|
|||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
assert ("token", "No results found.") in events
|
assert ("token", "No results found.") in events
|
||||||
|
|
||||||
|
|
||||||
|
# ── _datetime_context_injection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fp(tz: str, now_iso: str) -> dict:
|
||||||
|
return {"timezone": tz, "now_iso": now_iso, "date_format": "dd/MM/yyyy", "time_format": "24h"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ms(block: str, key: str) -> tuple[int, int]:
|
||||||
|
"""Extract [start, end] from a 'key [start, end]' line in the DATE CONTEXT block."""
|
||||||
|
import re
|
||||||
|
m = re.search(rf"^{key}\s+\[(\d+),\s*(\d+)\]", block, re.MULTILINE)
|
||||||
|
assert m, f"Key '{key}' not found in block:\n{block}"
|
||||||
|
return int(m.group(1)), int(m.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_europe_rome_late_evening():
|
||||||
|
"""22:16 CEST on 2026-04-26 — 'tomorrow' must be 2026-04-27 00:00→23:59:59.999 CEST."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-04-26T20:16:02.155Z")})
|
||||||
|
assert "DATE CONTEXT" in block
|
||||||
|
assert "Europe/Rome" in block
|
||||||
|
|
||||||
|
tz = ZoneInfo("Europe/Rome")
|
||||||
|
today_start = int(datetime(2026, 4, 26, tzinfo=tz).timestamp() * 1000)
|
||||||
|
today_end = int(datetime(2026, 4, 27, tzinfo=tz).timestamp() * 1000) - 1
|
||||||
|
tomorrow_start = today_end + 1
|
||||||
|
tomorrow_end = int(datetime(2026, 4, 28, tzinfo=tz).timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
t_s, t_e = _parse_ms(block, "today")
|
||||||
|
assert t_s == today_start
|
||||||
|
assert t_e == today_end
|
||||||
|
|
||||||
|
tm_s, tm_e = _parse_ms(block, "tomorrow")
|
||||||
|
assert tm_s == tomorrow_start
|
||||||
|
assert tm_e == tomorrow_end
|
||||||
|
|
||||||
|
# Sanity: window is exactly 86 400 000 ms (1 day, CEST has no DST jump on this date)
|
||||||
|
assert today_end - today_start + 1 == 86_400_000
|
||||||
|
assert tomorrow_end - tomorrow_start + 1 == 86_400_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_utc():
|
||||||
|
"""UTC timezone: boundaries are clean UTC midnights."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
block = _datetime_context_injection({"format_prefs": _fp("UTC", "2026-01-15T10:00:00Z")})
|
||||||
|
t_s, t_e = _parse_ms(block, "today")
|
||||||
|
expected_start = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
assert t_s == expected_start
|
||||||
|
assert t_e == expected_start + 86_400_000 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_dst_spring_forward():
|
||||||
|
"""Europe/Rome DST spring-forward 2026-03-29: that day is 23h, not 24h."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-03-29T08:00:00Z")})
|
||||||
|
tz = ZoneInfo("Europe/Rome")
|
||||||
|
day_start = int(datetime(2026, 3, 29, tzinfo=tz).timestamp() * 1000)
|
||||||
|
day_end = int(datetime(2026, 3, 30, tzinfo=tz).timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
t_s, t_e = _parse_ms(block, "today")
|
||||||
|
assert t_s == day_start
|
||||||
|
assert t_e == day_end
|
||||||
|
assert t_e - t_s + 1 == 23 * 3_600_000 # 23-hour day
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_dst_fall_back():
|
||||||
|
"""Europe/Rome DST fall-back 2026-10-25: that day is 25h."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
block = _datetime_context_injection({"format_prefs": _fp("Europe/Rome", "2026-10-25T08:00:00Z")})
|
||||||
|
tz = ZoneInfo("Europe/Rome")
|
||||||
|
day_start = int(datetime(2026, 10, 25, tzinfo=tz).timestamp() * 1000)
|
||||||
|
day_end = int(datetime(2026, 10, 26, tzinfo=tz).timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
t_s, t_e = _parse_ms(block, "today")
|
||||||
|
assert t_s == day_start
|
||||||
|
assert t_e == day_end
|
||||||
|
assert t_e - t_s + 1 == 25 * 3_600_000 # 25-hour day
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_year_boundary():
|
||||||
|
"""Dec 31 → Jan 1: last_year, this_year, next_month cross year boundary correctly."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
block = _datetime_context_injection({"format_prefs": _fp("UTC", "2026-12-31T23:00:00Z")})
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
|
||||||
|
yr_s, yr_e = _parse_ms(block, "this_year")
|
||||||
|
assert yr_s == int(datetime(2026, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||||
|
assert yr_e == int(datetime(2027, 1, 1, tzinfo=tz).timestamp() * 1000) - 1
|
||||||
|
|
||||||
|
ly_s, ly_e = _parse_ms(block, "last_year")
|
||||||
|
assert ly_s == int(datetime(2025, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||||
|
assert ly_e == yr_s - 1
|
||||||
|
|
||||||
|
nm_s, _ = _parse_ms(block, "next_month")
|
||||||
|
assert nm_s == int(datetime(2027, 1, 1, tzinfo=tz).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_context_injection_missing_format_prefs():
|
||||||
|
assert _datetime_context_injection({}) == ""
|
||||||
|
assert _datetime_context_injection({"format_prefs": None}) == ""
|
||||||
|
assert _datetime_context_injection({"format_prefs": "bad"}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _request_context_block ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_request_context_block_scope_and_project():
|
||||||
|
ctx = {"scope": {"type": "task", "id": "t-1"}, "resolved_project_id": "proj-uuid"}
|
||||||
|
block = _request_context_block(ctx)
|
||||||
|
assert "scope" in block
|
||||||
|
assert "resolved_project_id: proj-uuid" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_context_block_empty():
|
||||||
|
assert _request_context_block({}) == ""
|
||||||
|
assert _request_context_block({"scope": None}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _build_system_prompt ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_system_prompt_substitutes_all_slots(monkeypatch):
|
||||||
|
"""All five slots must appear in the compiled output; no raw placeholder remains."""
|
||||||
|
# Patch get_prompt_or_fallback to return None prompt_obj so we use fallback .format() path
|
||||||
|
import app.core.deep_agent as da
|
||||||
|
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda name, fallback: (fallback, None))
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"format_prefs": _fp("Europe/Rome", "2026-04-26T20:16:02.155Z"),
|
||||||
|
"core_memory": {"language": "it"},
|
||||||
|
"relational_memory": ["Alice — client"],
|
||||||
|
"proactive_hints": ["User prefers morning meetings"],
|
||||||
|
"scope": {"type": "task"},
|
||||||
|
"resolved_project_id": "proj-1",
|
||||||
|
}
|
||||||
|
from app.core.deep_agent import _HOME_SYSTEM_PROMPT
|
||||||
|
text, _ = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, ctx)
|
||||||
|
|
||||||
|
# No unresolved placeholders
|
||||||
|
assert "{date_context}" not in text
|
||||||
|
assert "{language_instruction}" not in text
|
||||||
|
assert "{relational_memory}" not in text
|
||||||
|
assert "{proactive_hints}" not in text
|
||||||
|
assert "{request_context}" not in text
|
||||||
|
|
||||||
|
# Content was injected
|
||||||
|
assert "DATE CONTEXT" in text
|
||||||
|
assert "Italian" in text
|
||||||
|
assert "Alice" in text
|
||||||
|
assert "morning meetings" in text
|
||||||
|
assert "proj-1" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_empty_format_prefs(monkeypatch):
|
||||||
|
"""Missing format_prefs must not raise — date_context slot renders empty string."""
|
||||||
|
import app.core.deep_agent as da
|
||||||
|
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda name, fallback: (fallback, None))
|
||||||
|
|
||||||
|
from app.core.deep_agent import _HOME_SYSTEM_PROMPT
|
||||||
|
text, _ = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, {})
|
||||||
|
# Prompt renders without error; date section is empty but structure holds
|
||||||
|
assert "# Date filtering" in text
|
||||||
|
assert "{date_context}" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_message_is_bare_message(monkeypatch):
|
||||||
|
"""After the refactor HumanMessage content must equal the raw user message exactly."""
|
||||||
|
import app.core.deep_agent as da
|
||||||
|
from langchain_core.messages import HumanMessage as LCHumanMessage
|
||||||
|
|
||||||
|
captured: list[list] = []
|
||||||
|
|
||||||
|
class _CaptureLLM:
|
||||||
|
def bind_tools(self, _):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def ainvoke(self, messages):
|
||||||
|
captured.append(list(messages))
|
||||||
|
return AIMessage(content="risposta")
|
||||||
|
|
||||||
|
monkeypatch.setattr(da, "get_prompt_or_fallback", lambda n, f: (f, None))
|
||||||
|
monkeypatch.setattr(da, "get_agent_llm", lambda _: _CaptureLLM())
|
||||||
|
monkeypatch.setattr(da, "_all_tools_for_user", lambda *_: [])
|
||||||
|
monkeypatch.setattr(da, "get_langfuse", lambda: None)
|
||||||
|
monkeypatch.setattr(da, "set_tool_result_collector", lambda _: None)
|
||||||
|
monkeypatch.setattr(da, "clear_tool_result_collector", lambda: None)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
chunks = []
|
||||||
|
ctx = {"format_prefs": _fp("UTC", "2026-04-27T10:00:00Z")}
|
||||||
|
async for ev in da.run_home_stream("u1", "Cosa devo fare domani?", ctx):
|
||||||
|
chunks.append(ev)
|
||||||
|
|
||||||
|
asyncio.get_event_loop().run_until_complete(_run())
|
||||||
|
|
||||||
|
assert captured, "LLM was never called"
|
||||||
|
messages = captured[0]
|
||||||
|
human = next(m for m in messages if isinstance(m, LCHumanMessage))
|
||||||
|
assert human.content == "Cosa devo fare domani?"
|
||||||
|
assert "Context:" not in human.content
|
||||||
|
|||||||
Reference in New Issue
Block a user