Improve tools reliability and add milestone management to ProjectView

tools.py:
- Default tag_mode changed from 'replace' to 'add' — existing tags are
  preserved unless the user explicitly requests replacement
- create_task, create_note, update_note, create_milestone: project and
  milestone lookup now uses get_by_title with fuzzy fallback; returns a
  clear error (with a hint to use list_projects/list_milestones) instead
  of silently creating duplicate projects or milestones via get_or_create
- create_task: project/milestone resolved before note creation so a bad
  project name fails fast without leaving an orphaned task behind
- update_note return value now includes item_type, tags, project_id, and
  an 'updated' summary string so the LLM can confirm what was modified
- research_topic stub now logs an error and returns failure instead of
  silently returning success when reached via wrong code path

ProjectView.vue:
- Milestone headers now show edit (✎) and delete (✕) action buttons on hover
- Edit triggers inline rename input; Enter/blur commits, Escape cancels
- Delete opens a confirmation modal clarifying tasks are unlinked not deleted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:33:13 -05:00
parent 012eb1d46b
commit 6580d16942
2 changed files with 199 additions and 36 deletions
+69 -32
View File
@@ -156,8 +156,8 @@ _CORE_TOOLS = [
},
"tag_mode": {
"type": "string",
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
"enum": ["add", "remove", "replace"],
"description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)",
},
"project": {
"type": "string",
@@ -716,6 +716,28 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": False, "error": "title must be a string. Call create_task once per task."}
if not isinstance(task_body, str):
task_body = ""
# Resolve project and milestone BEFORE creating to fail fast on not-found
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
parent_task_name = arguments.get("parent_task")
pre_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
pre_fields["project_id"] = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
ms = await _gmbt(user_id, proj.id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
pre_fields["milestone_id"] = ms.id
note = await create_note(
user_id=user_id,
title=task_title,
@@ -723,28 +745,16 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
status="todo",
priority=arguments.get("priority", "none"),
due_date=_parse_due_date(arguments.get("due_date")),
project_id=pre_fields.get("project_id"),
milestone_id=pre_fields.get("milestone_id"),
)
suggested = await suggest_tags(user_id, task_title, task_body)
_schedule_embedding(note.id, user_id, task_title, task_body)
# Handle optional project and parent_task assignment
project_name = arguments.get("project")
parent_task_name = arguments.get("parent_task")
update_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
update_fields["parent_id"] = parent_notes[0].id
milestone_name = arguments.get("milestone")
if milestone_name and project_name:
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import get_or_create_milestone
proj = await _gocp(user_id, project_name)
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
update_fields["milestone_id"] = ms.id
if update_fields:
note = await update_note(user_id, note.id, **update_fields)
return {
@@ -771,20 +781,29 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
note_body = ""
if not isinstance(note_tags, list):
note_tags = []
# Resolve project BEFORE creating to fail fast on not-found
project_name = arguments.get("project")
project_id = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
project_id = proj.id
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=note_tags,
project_id=project_id,
)
suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body)
# Handle optional project assignment
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
note = await update_note(user_id, note.id, project_id=proj.id)
return {
"success": True,
"type": "note",
@@ -831,7 +850,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "replace")
tag_mode = arguments.get("tag_mode", "add")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
@@ -847,8 +866,14 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
@@ -859,12 +884,18 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
_schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
item_type = "task" if updated.status is not None else "note"
return {
"success": True,
"type": "note_updated",
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
"data": {
"id": updated.id,
"title": updated.title,
"item_type": item_type,
"status": updated.status,
"tags": updated.tags or [],
"project_id": updated.project_id,
},
"suggested_tags": suggested,
}
@@ -1133,12 +1164,18 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
if not project_name:
return {"success": False, "error": "project is required"}
proj = await _gocp(user_id, project_name)
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
ms = await _cm(
user_id,
proj.id,
@@ -1182,13 +1219,13 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "research_topic":
# Research is always handled upstream in generation_task.py (round 0).
# This fallback exists in case it somehow reaches execute_tool.
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
# Reaching here indicates a code path regression.
topic = arguments.get("topic", "")
logger.error("research_topic reached execute_tool — should have been intercepted upstream in generation_task.py. topic=%r", topic)
return {
"success": True,
"type": "research_pending",
"data": {"topic": topic},
"success": False,
"error": "Research could not be started. Please try again.",
}
elif tool_name == "create_project":