feat(tools): tasks accept description; reject body writes via tools

create_note tool:
- New 'description' parameter accepted and forwarded to the service.
- When status is set (creating a task), 'body' is dropped before the
  service call. Task bodies are owned by the consolidation pipeline.

update_note tool:
- New 'description' parameter; routed through update_fields.
- When the resolved target has is_task=True and 'body' is in the
  arguments, the call errors with a message nudging toward log_work or
  description. Knowledge notes are unaffected.

HTTP routes (POST/PATCH/PUT /api/notes) accept body freely — the
restriction is only at the LLM tool layer.
This commit is contained in:
2026-05-13 12:15:09 -04:00
parent 5fa203019a
commit 103db883ad
2 changed files with 222 additions and 2 deletions
+26 -2
View File
@@ -27,7 +27,8 @@ logger = logging.getLogger(__name__)
),
parameters={
"title": {"type": "string", "description": "The title"},
"body": {"type": "string", "description": "Content in markdown"},
"body": {"type": "string", "description": "Content in markdown. NOTE: when status is set (creating a task), body is ignored — task bodies are auto-maintained from work logs. Use `description` to provide the goal/context for tasks."},
"description": {"type": "string", "description": "User-stated goal or initial context for a task. Read-only context for the auto-summary pipeline. Ignored when status is omitted (knowledge note)."},
"tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the body.'},
"project": {"type": "string", "description": "Optional project name. Only set this if the user explicitly named a project. Do NOT infer a project from the content or context."},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Set to 'todo' to create a task. Omit entirely for a knowledge note."},
@@ -43,6 +44,7 @@ logger = logging.getLogger(__name__)
async def create_note_tool(*, user_id, arguments, **_ctx):
title = arguments.get("title", "Untitled")
body = arguments.get("body", "")
description = arguments.get("description")
tags = arguments.get("tags", [])
if not isinstance(title, str):
return {"success": False, "error": "title must be a string. Call create_note once per item."}
@@ -54,6 +56,11 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
is_task = "status" in arguments and arguments["status"] is not None
status = arguments.get("status", "todo") if is_task else None
# Task bodies are auto-maintained by the consolidation pipeline; drop any
# body argument arriving with a task creation so it never lands in the DB.
if is_task:
body = ""
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
parent_task_name = arguments.get("parent_task")
@@ -80,6 +87,7 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
user_id=user_id,
title=title,
body=body,
description=description,
tags=tags,
status=status,
priority=arguments.get("priority", "none") if is_task else None,
@@ -126,7 +134,8 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the note or task to update"},
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields)"},
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields). REJECTED on tasks — task bodies are auto-maintained from work logs; use `log_work` to record progress or `description` to revise the goal."},
"description": {"type": "string", "description": "Update the user-stated goal/context (tasks). Distinct from `body` (machine-maintained on tasks)."},
"title": {"type": "string", "description": "Optional new title"},
"mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."},
@@ -155,6 +164,19 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
return {"success": False, "error": f"No note found matching '{query}'."}
note = candidates[0]
# Schema-level separation: task bodies are auto-maintained from work logs.
# Reject body writes here rather than silently dropping so the LLM gets
# nudged toward log_work / description.
if note.is_task and arguments.get("body"):
return {
"success": False,
"error": (
"Cannot write to `body` on a task — the body is auto-maintained "
"from work logs. Use the `log_work` tool to record progress, or "
"update `description` to revise the goal."
),
}
update_fields: dict = {}
if new_title:
update_fields["title"] = new_title
@@ -163,6 +185,8 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
update_fields["body"] = note.body + "\n\n" + new_body
else:
update_fields["body"] = new_body
if "description" in arguments:
update_fields["description"] = arguments["description"]
if "status" in arguments:
update_fields["status"] = arguments["status"]
if "priority" in arguments: