fix tools calls
This commit is contained in:
@@ -26,32 +26,137 @@ def _is_uuid(value: str) -> bool:
|
||||
async def list_tasks(
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
priority: str = "",
|
||||
assignee: str = "",
|
||||
search: 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:
|
||||
"""List tasks, optionally filtered by project_id, status (todo|in_progress|done),
|
||||
a search string, or an order_by field name (dueDate|priority|createdAt)."""
|
||||
"""List tasks with optional filters. Returns up to `limit` results (default 50).
|
||||
|
||||
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 ""
|
||||
result = await execute_on_client(
|
||||
action="select",
|
||||
table="tasks",
|
||||
filters={
|
||||
"projectId": normalized_project_id or None,
|
||||
"status": status or None,
|
||||
"search": search or None,
|
||||
"orderBy": order_by or None,
|
||||
},
|
||||
)
|
||||
filters: dict[str, Any] = {
|
||||
"projectId": normalized_project_id or None,
|
||||
"status": status or None,
|
||||
"priority": priority or None,
|
||||
"search": search or None,
|
||||
"orderBy": order_by or None,
|
||||
"orderDir": order_dir 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", [])
|
||||
if not rows:
|
||||
return "No tasks found matching the given filters."
|
||||
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
|
||||
]
|
||||
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
|
||||
async def create_task(
|
||||
title: str,
|
||||
@@ -72,6 +177,8 @@ async def create_task(
|
||||
due_date: Unix timestamp in milliseconds; 0 means no due date
|
||||
project_id: optional UUID of the parent project
|
||||
is_ai_suggested: 1 if proactively suggested, 0 if user-requested
|
||||
|
||||
completedAt is set automatically when status is 'done'.
|
||||
"""
|
||||
result = await execute_on_client(
|
||||
action="insert",
|
||||
@@ -108,6 +215,10 @@ async def update_task(
|
||||
"""Update fields on an existing task. Only pass fields you want to change.
|
||||
task_id: the task's UUID (required)
|
||||
due_date: -1 means unchanged; 0 clears the due date; any positive value sets it
|
||||
|
||||
completedAt is managed automatically:
|
||||
- setting status to 'done' records the current timestamp
|
||||
- changing status away from 'done' clears completedAt
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
if title:
|
||||
@@ -141,11 +252,12 @@ async def delete_task(task_id: str) -> str:
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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.
|
||||
include_done: set True to also include already-completed tasks due today (default False).
|
||||
"""
|
||||
try:
|
||||
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_ms = int(start_dt.timestamp() * 1000)
|
||||
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(
|
||||
action="select",
|
||||
table="tasks",
|
||||
filters={"dueDateFrom": start_ms, "dueDateTo": end_ms},
|
||||
filters=filters,
|
||||
)
|
||||
rows = result.get("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_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_comment_id = row.get("id", "unknown")
|
||||
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] = [
|
||||
list_tasks,
|
||||
count_tasks,
|
||||
create_task,
|
||||
update_task,
|
||||
delete_task,
|
||||
@@ -232,6 +347,7 @@ TASK_TOOLS: list[Any] = [
|
||||
|
||||
TASK_READ_TOOLS: list[Any] = [
|
||||
list_tasks,
|
||||
count_tasks,
|
||||
list_tasks_due_today,
|
||||
list_task_comments,
|
||||
]
|
||||
|
||||
@@ -20,19 +20,127 @@ def _is_uuid(value: str) -> bool:
|
||||
|
||||
|
||||
@tool
|
||||
async def list_timelines(project_id: str = "") -> str:
|
||||
"""List timelines. Provide project_id to scope to a specific project."""
|
||||
async def list_timelines(
|
||||
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 ""
|
||||
result = await execute_on_client(
|
||||
action="select",
|
||||
table="timelines",
|
||||
filters={"projectId": normalized_project_id or None},
|
||||
)
|
||||
filters: dict[str, Any] = {
|
||||
"projectId": normalized_project_id or None,
|
||||
"orderBy": order_by 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", [])
|
||||
if not rows:
|
||||
return "No timelines found."
|
||||
lines = [f"- {r['title']} (date: {r['date']}, id: {r['id']})" for r in rows]
|
||||
return f"Found {len(rows)} timeline(s):\n" + "\n".join(lines)
|
||||
return "No timeline events found."
|
||||
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
|
||||
@@ -40,13 +148,19 @@ async def create_timeline(
|
||||
project_id: str,
|
||||
title: str,
|
||||
date: int,
|
||||
type: str = "milestone",
|
||||
is_completed: int = 0,
|
||||
is_ai_suggested: int = 0,
|
||||
) -> str:
|
||||
"""Create a project timeline (milestone).
|
||||
"""Create a project timeline event.
|
||||
project_id: REQUIRED UUID of the parent project
|
||||
title: descriptive name for the milestone
|
||||
date: Unix timestamp in milliseconds
|
||||
title: descriptive name for the event
|
||||
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
|
||||
|
||||
completedAt is set automatically when is_completed is 1.
|
||||
"""
|
||||
result = await execute_on_client(
|
||||
action="insert",
|
||||
@@ -55,11 +169,13 @@ async def create_timeline(
|
||||
"projectId": project_id,
|
||||
"title": title,
|
||||
"date": date,
|
||||
"type": type,
|
||||
"isCompleted": is_completed,
|
||||
"isAiSuggested": is_ai_suggested,
|
||||
},
|
||||
)
|
||||
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
|
||||
@@ -67,38 +183,47 @@ async def update_timeline(
|
||||
timeline_id: str,
|
||||
title: str = "",
|
||||
date: int = -1,
|
||||
is_completed: int = -1,
|
||||
) -> str:
|
||||
"""Update a timeline. Only pass fields that should change.
|
||||
timeline_id: UUID of the timeline (required)
|
||||
"""Update a timeline event. Only pass fields that should change.
|
||||
timeline_id: UUID of the event (required)
|
||||
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] = {}
|
||||
if title:
|
||||
updates["title"] = title
|
||||
if date != -1:
|
||||
updates["date"] = date
|
||||
if is_completed != -1:
|
||||
updates["isCompleted"] = is_completed
|
||||
result = await execute_on_client(
|
||||
action="update",
|
||||
table="timelines",
|
||||
data={"id": timeline_id, "updates": updates},
|
||||
)
|
||||
row = result["row"]
|
||||
return f"Timeline updated: '{row['title']}' (id: {row['id']})"
|
||||
return f"Timeline event updated: '{row['title']}' (id: {row['id']})"
|
||||
|
||||
|
||||
@tool
|
||||
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})
|
||||
return f"Timeline {timeline_id} deleted."
|
||||
return f"Timeline event {timeline_id} deleted."
|
||||
|
||||
|
||||
@tool
|
||||
async def list_timelines_today(user_timezone: str = "UTC") -> str:
|
||||
"""List all timeline events (milestones) whose date falls on today.
|
||||
async def list_timelines_today(user_timezone: str = "UTC", include_completed: bool = True) -> str:
|
||||
"""List all timeline events whose date falls on today.
|
||||
|
||||
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.
|
||||
include_completed: set False to exclude already-completed events (default True).
|
||||
"""
|
||||
try:
|
||||
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_ms = int(start_dt.timestamp() * 1000)
|
||||
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(
|
||||
action="select",
|
||||
table="timelines",
|
||||
filters={"dateFrom": start_ms, "dateTo": end_ms},
|
||||
filters=filters,
|
||||
)
|
||||
rows = result.get("rows", [])
|
||||
if not rows:
|
||||
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)
|
||||
|
||||
|
||||
TIMELINE_TOOLS: list[Any] = [
|
||||
list_timelines,
|
||||
count_timelines,
|
||||
list_timelines_today,
|
||||
create_timeline,
|
||||
update_timeline,
|
||||
@@ -131,5 +263,6 @@ TIMELINE_TOOLS: list[Any] = [
|
||||
|
||||
TIMELINE_READ_TOOLS: list[Any] = [
|
||||
list_timelines,
|
||||
count_timelines,
|
||||
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.task_agent import TASK_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.memory_middleware import MemoryMiddleware
|
||||
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:
|
||||
"""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")
|
||||
if not fp or not isinstance(fp, dict):
|
||||
return ""
|
||||
try:
|
||||
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")
|
||||
now_iso: str = str(fp.get("now_iso") or "")
|
||||
date_fmt: str = str(fp.get("date_format") or "dd/MM/yyyy")
|
||||
time_fmt: str = str(fp.get("time_format") or "24h")
|
||||
|
||||
tz = ZoneInfo(tz_name)
|
||||
if now_iso:
|
||||
now_utc = _dt.fromisoformat(now_iso.replace("Z", "+00:00"))
|
||||
else:
|
||||
now_utc = _dt.now(_utc.utc)
|
||||
|
||||
tz = ZoneInfo(tz_name)
|
||||
now_ms = int(now_utc.timestamp() * 1000)
|
||||
now_local = now_utc.astimezone(tz)
|
||||
today_local = now_local.strftime("%Y-%m-%d")
|
||||
weekday_local = now_local.strftime("%A")
|
||||
now_local_str = now_local.strftime("%Y-%m-%d %H:%M")
|
||||
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 (
|
||||
f"\n\nCurrent instant: {now_utc.isoformat()}. "
|
||||
f"User local date: {today_local} ({weekday_local}). "
|
||||
f"Timezone: {tz_name}. "
|
||||
f"Display preference: dateFormat={date_fmt}, timeFormat={time_fmt}. "
|
||||
f"When calling tools with date fields, always pass integer Unix milliseconds (ms since epoch, UTC). "
|
||||
f"When calling list_tasks_due_today or list_timelines_today, always pass user_timezone=\"{tz_name}\". "
|
||||
f"When presenting dates to the user in chat, format using the display preference above."
|
||||
f"\n\nDATE CONTEXT (timezone: {tz_name}, dateFormat: {date_fmt}, timeFormat: {time_fmt})\n"
|
||||
f"now_local: {now_local_str} ({weekday_str})\n"
|
||||
f"now_ms: {now_ms}\n\n"
|
||||
f"today [{today_s}, {today_e}] {y:04d}-{m:02d}-{d:02d}\n"
|
||||
f"tomorrow [{tomorrow_s}, {tomorrow_e}] {tm.strftime('%Y-%m-%d')}\n"
|
||||
f"yesterday [{yesterday_s}, {yesterday_e}] {yd.strftime('%Y-%m-%d')}\n"
|
||||
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:
|
||||
return ""
|
||||
@@ -123,27 +176,75 @@ def _relational_memory_injection(context: dict[str, Any]) -> str:
|
||||
return section
|
||||
|
||||
|
||||
_HOME_SYSTEM_PROMPT = (
|
||||
"You are the home assistant with direct access to all tools: tasks, projects, notes, timelines, and memory tools. "
|
||||
"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. "
|
||||
"If context.context.resolved_project_id exists, use it as project_id for scoped list calls. "
|
||||
"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."
|
||||
)
|
||||
def _request_context_block(context: dict[str, Any]) -> str:
|
||||
"""Return a small block with per-request scope and resolved project context."""
|
||||
parts: list[str] = []
|
||||
scope = context.get("scope")
|
||||
if scope and isinstance(scope, dict):
|
||||
parts.append(f"scope: {json.dumps(scope, ensure_ascii=True)}")
|
||||
resolved = context.get("resolved_project_id")
|
||||
if resolved and isinstance(resolved, str):
|
||||
parts.append(f"resolved_project_id: {resolved}")
|
||||
return "\n".join(parts)
|
||||
|
||||
_FLOATING_SYSTEM_PROMPT = (
|
||||
"You are the floating assistant with direct access to all tools: tasks, projects, notes, timelines, and memory tools. "
|
||||
"Stay focused on the floating scope in context.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. "
|
||||
"If context.context.resolved_project_id exists, use it as project_id for scoped list calls. "
|
||||
)
|
||||
|
||||
_HOME_SYSTEM_PROMPT = """\
|
||||
You are the home assistant for adiuvAI with direct access to all tools: tasks, projects, notes, timelines, and memory tools.
|
||||
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.
|
||||
|
||||
# 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 = (
|
||||
"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
|
||||
|
||||
|
||||
def _context_for_model(context: dict[str, Any]) -> dict[str, Any]:
|
||||
sanitized = dict(context)
|
||||
sanitized.pop("_debug", None)
|
||||
return sanitized
|
||||
def _build_system_prompt(name: str, fallback: str, context: dict[str, Any]) -> tuple[str, Any]:
|
||||
"""Fetch Langfuse template and compile all per-request slots into one system prompt."""
|
||||
template, prompt_obj = get_prompt_or_fallback(name, fallback)
|
||||
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>")
|
||||
@@ -713,17 +822,11 @@ async def _run_single_agent(
|
||||
lf = get_langfuse()
|
||||
llm = get_agent_llm(agent_name)
|
||||
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)
|
||||
llm_with_tools = llm.bind_tools(tools)
|
||||
messages: list[Any] = [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(
|
||||
content=(
|
||||
f"User message:\n{message}\n\n"
|
||||
f"Context:\n{json.dumps({'context': model_context}, ensure_ascii=True)[:3500]}"
|
||||
)
|
||||
),
|
||||
HumanMessage(content=message),
|
||||
]
|
||||
|
||||
tool_calls_count = 0
|
||||
@@ -843,17 +946,11 @@ async def _run_single_agent_stream(
|
||||
llm = get_agent_llm(agent_name)
|
||||
if tools is None:
|
||||
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)
|
||||
llm_with_tools = llm.bind_tools(tools)
|
||||
messages: list[Any] = [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(
|
||||
content=(
|
||||
f"User message:\n{message}\n\n"
|
||||
f"Context:\n{json.dumps({'context': model_context}, ensure_ascii=True)[:3500]}"
|
||||
)
|
||||
),
|
||||
HumanMessage(content=message),
|
||||
]
|
||||
|
||||
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:
|
||||
prepared_context = await _prepare_context(message, context)
|
||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
||||
"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)
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, prepared_context)
|
||||
response = await _run_single_agent(
|
||||
user_id=user_id,
|
||||
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]]:
|
||||
prepared_context = await _prepare_context(message, context)
|
||||
domain = await _infer_floating_domain(message, prepared_context)
|
||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
||||
"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)
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||
response = await _run_single_agent(
|
||||
user_id=user_id,
|
||||
system_prompt=system_prompt,
|
||||
@@ -1017,13 +1102,7 @@ async def run_home_stream(
|
||||
context: dict[str, Any],
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
prepared_context = await _prepare_context(message, context)
|
||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
||||
"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)
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("home_system", _HOME_SYSTEM_PROMPT, prepared_context)
|
||||
text_chunks: list[str] = []
|
||||
async for event in _run_single_agent_stream(
|
||||
user_id=user_id,
|
||||
@@ -1053,13 +1132,7 @@ async def run_floating_stream(
|
||||
domain = await _infer_floating_domain(message, prepared_context)
|
||||
yield "floating_domain", domain
|
||||
|
||||
system_prompt, langfuse_prompt = get_prompt_or_fallback(
|
||||
"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)
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||
sanitizer = _FloatingStreamSanitizer()
|
||||
emitted_sanitized = False
|
||||
raw_chunks: list[str] = []
|
||||
|
||||
@@ -10,8 +10,11 @@ import pytest
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from app.core.deep_agent import (
|
||||
_build_system_prompt,
|
||||
_datetime_context_injection,
|
||||
_infer_floating_domain,
|
||||
_normalize_tagged_list_lines,
|
||||
_request_context_block,
|
||||
run_floating,
|
||||
run_floating_stream,
|
||||
run_home,
|
||||
@@ -91,8 +94,12 @@ async def test_run_floating_stream_emits_domain_then_tokens_with_mocked_tool_res
|
||||
"floating_domain",
|
||||
{"type": "timeline", "id": "tl-1", "section": None},
|
||||
)
|
||||
assert ("token", "stream-") in events
|
||||
assert ("token", "ok") in events
|
||||
# _run_single_agent_stream uses ainvoke (not astream); the final token is
|
||||
# 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
|
||||
@@ -286,3 +293,213 @@ async def test_run_floating_stream_returns_fallback_when_sanitization_would_empt
|
||||
events.append(event)
|
||||
|
||||
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